OAuth — Client Credentials (webhooks)

OAuth — Client Credentials (webhooks)

Client Credentials is the OAuth grant for machine-to-machine access to a single webhook channel. A sender that knows the channel ECI and has a valid client secret can mint a short-lived Bearer access token and call /sky/* on that ECI only.

Use this for inbound integrations where each webhook gets its own channel — temperature alerts, custom HTTP hooks, services that can send an Authorization header. For apps that need access across your whole mesh (Home Assistant, dashboards), use Authorization Code Grant instead.

← Back to Identity


When to use Client Credentials

Use Client Credentials when…

Use Authorization Code instead when…

Use Client Credentials when…

Use Authorization Code instead when…

One fixed webhook URL / one channel

The app needs many picos under your root

The sender can call POST /oauth/token

A human signs in and approves access

Scope should stay on one ECI

Scope is the whole mesh (root + descendants)

Client Credentials and Authorization Code can coexist in the same mesh. They share the same token store, but validation differs: a Client Credentials token works only on the ECI it was issued for; an Authorization Code token works on any channel under the registered root.


How it works

  1. Create a channel with the oauth-webhook tag (in KRL or the Channels tab).

  2. Create credentials for that channel in the UI — client_id is the channel ECI; copy the client_secret (shown once).

  3. The integrator calls POST /oauth/token with grant_type=client_credentials.

  4. The integrator calls /sky/* with Authorization: Bearer <access_token>, using the same ECI in the URL.

The ECI stays in the URL for routing (e.g. /sky/event/<eci>/…). The access token is a separate opaque bearer string — it is not the ECI.

After OAuth validates the token, channel policy still applies (allowed domains, events, queries).


Creating a webhook channel

In KRL

Tag the channel at creation time and set policy as usual:

wrangler:createChannel( ["oauth-webhook", "stripe"], { allow: [{domain: "stripe", name: "*"}], deny: [] }, { allow: [], deny: [{rid: "*", name: "*"}] } )

The oauth-webhook tag marks the channel for Client Credentials and turns on Bearer enforcement on /sky/*. Typical webhook channels allow events only (deny all queries), but policy is up to you.

You can add other tags alongside oauth-webhook (e.g. stripegithub) for your own organization.

In the UI

When creating or editing a channel on a pico’s Channels tab, include the oauth-webhook tag.

Eligible channels

Client Credentials applies only to normal application channels tagged oauth-webhook.

These channel types are not eligible:

  • Family channels (parent–child links)

  • Subscription channels (wellknown_rxtx_rxsubscription)

  • System channels


Bearer required from day one

As soon as a channel has the oauth-webhook tag, /sky/* requests for that ECI require a valid Bearer token — even before you create credentials.

That closes the window where someone who learns the ECI could post events without authentication. Until credentials exist, no one can mint a token, so the channel is locked for external callers.

Creating credentials does not “enable” OAuth on the channel; it enables token minting. The requirement is already in place.


Credentials

Field

Value

Field

Value

client_id

The channel ECI (one OAuth client per webhook channel)

client_secret

Generated when you click Create credentials; shown once; stored hashed in the engine

Access token

Minted at POST /oauth/token; prefix oat_

Secrets are not created automatically when the channel is created. A signed-in owner must explicitly create them.

Channels tab — Webhook OAuth panel

Open the channel on the Channels tab (sign in with your passkey first). For oauth-webhook channels you will see Webhook OAuth:

  1. Create credentials — copies client_id (ECI) and client_secret. Store the secret securely; it cannot be viewed again.

  2. Rotate credentials — issues a new secret and revokes existing tokens for that channel. Use this if a secret may have leaked.

  3. Revoke credentials — removes the stored secret and revokes tokens. The channel stays Bearer-locked; minting fails until new credentials are created.

  4. Get token — exchange the secret for a token in the UI (same as calling /oauth/token), with a chosen lifetime.

  5. Revoke tokens — invalidate all outstanding access tokens for this channel without rotating the secret.

Credential management requires a passkey session — the same access you use for the developer UI.


Minting an access token

POST /oauth/token Content-Type: application/json { "grant_type": "client_credentials", "client_id": "<channel-eci>", "client_secret": "<secret>" }

Successful response:

{ "access_token": "oat_…", "token_type": "Bearer", "expires_in": 86400 }

Token lifetime (expires_in)

Optional on the token request. Allowed range: 5 minutes to 90 days, or 0 / "never" for no expiry.

If omitted

Default

If omitted

Default

expires_in not sent

24 hours

The Channels UI offers the same choices (5 minutes through 90 days, or Never).

Rotating credentials or revoking tokens invalidates outstanding access tokens for that channel.


Using the access token

Call the Sky API on /sky/* only — not /c/*.

The ECI in the URL must match the token’s channel:

POST /sky/event/<channel-eci>/my-domain/incoming Authorization: Bearer oat_… Content-Type: application/json { "payload": "…" }

Example query:

GET /sky/query/<channel-eci>/io.picolabs.wrangler/name Authorization: Bearer oat_…

A token minted for one ECI is rejected on any other ECI, even in the same mesh.

Channel event and query policy is evaluated after the token is validated — OAuth does not replace policy; it adds authentication in front of it.


Mesh lock (io.picolabs.oauth)

Installing the optional ruleset io.picolabs.oauth on the root pico requires a Bearer token on every /sky/* request in that mesh. Channels that were previously “open” (policy only, no OAuth) become locked for external callers until they present a valid token.

Webhook channels are unaffected by this change in practice — they already required Bearer because of the oauth-webhook tag. Mesh lock does not add a second authentication step on top of Client Credentials.

One token, not two

When the mesh is locked, each /sky/* request still needs only one Bearer token. The engine accepts either:

Token type

Valid when…

Token type

Valid when…

Client Credentials

The token was minted for this ECI (client_id = channel ECI)

Authorization Code

The token covers the whole mesh (root + descendants)

A webhook caller with a valid Client Credentials token for channel hookEci can call:

GET /sky/query/<hookEci>/… Authorization: Bearer oat_…

…in a locked mesh without also holding an Authorization Code token. The CC token is the Bearer the mesh lock requires.

The same CC token will not work on a different ECI in that mesh — even though the mesh is locked. CC tokens stay single-channel; mesh lock does not widen their scope.

Why both mechanisms coexist

Mechanism

What it controls

Mechanism

What it controls

oauth-webhook tag

This channel always requires Bearer (from the moment the tag is set)

io.picolabs.oauth on root

All channels on /sky/* in the mesh require Bearer

Mesh lock is mainly for Authorization Code apps (Home Assistant, dashboards) and for locking down open channels that would otherwise accept bare ECI URLs. Webhook integrations keep using per-channel Client Credentials; integrators that need mesh-wide access use Authorization Code instead.

Leaked credentials stay compartmentalized: a webhook CC token compromises one channel; an ACG token from the same mesh is a separate credential with broader scope.


Programmatic management (KRL)

Rulesets with io.picolabs.oauth installed can inspect and manage webhook credentials through shared queries. Install the ruleset on the root pico (the usual mesh-lock location). The engine enforces channel eligibility (oauth-webhook tag; not family/subscription/system) and admin scope (the channel must be on the calling pico or in its descendant tree).

Function

Purpose

Function

Purpose

channelStatus(eci)

{ eligible, enabled, clientId, hasSecret, createdAt?, reason? }

createChannelSecret(eci)

Create credentials; returns { client_id, client_secret } once

revokeChannelSecret(eci)

Remove stored secret and revoke tokens

revokeTokens(eci)

Revoke outstanding access tokens for the channel

meshEnabled()

true when this ruleset is installed on the pico

meshRequiresOAuth(eci)

true when /sky/* for that ECI requires Bearer (mesh lock or oauth-webhook tag)

External integrators call POST /oauth/token only — not these queries.

KRL source: packages/pico-engine/krl/io.picolabs.oauth.krl


Usage

1. Install the ruleset

Install io.picolabs.oauth on the root pico from the Rulesets tab (or during provisioning). Other rulesets on picos in the mesh can call these functions via use module or skyQuery / picoQuery to the root.

use module io.picolabs.oauth alias meshOAuth

The alias can be anything; meshOAuth avoids clashing with the engine's internal oauth: module name in documentation.

2. Check whether a channel is set up for webhook OAuth

After creating a channel with the oauth-webhook tag:

use module io.picolabs.oauth alias meshOAuth status = meshOAuth:channelStatus(hook_eci); // status.eligible — channel type supports Client Credentials // status.clientId — same as hook_eci // status.hasSecret — owner has created credentials yet

If eligible is false, see reason (wrong tags, family/subscription channel, etc.).

3. Create credentials from a rule (root or ancestor)

Typically run on the root pico so one admin tree can manage webhook channels on descendants:

use module io.picolabs.oauth alias meshOAuth rule provision_stripe_hook { select when webhook stripe_channel_ready pre { creds = meshOAuth:createChannelSecret(event:attr("eci")); } send direct event:attrs("notify_eci") to "credentials_ready" with creds.put("channel_eci", event:attr("eci")) }

client_secret is returned only once. Persist it securely (encrypted entity, outbound notification, secrets store). You cannot retrieve it again — only rotate with createChannelSecret (rotate) or revokeChannelSecret + create.

4. Rotate or revoke

// New secret; invalidates old secret and all tokens for this channel newCreds = meshOAuth:createChannelSecret(hook_eci); // Remove secret and tokens (channel stays Bearer-locked if tagged oauth-webhook) meshOAuth:revokeChannelSecret(hook_eci); // Invalidate tokens only; secret unchanged meshOAuth:revokeTokens(hook_eci);

5. Call from a child pico via the root

If io.picolabs.oauth is only on the root, a ruleset on a child pico reaches it with picoQuery (same engine, no HTTP) or skyQuery:

use module io.picolabs.wrangler alias wrangler // root_eci — UI or admin channel on the root pico status = wrangler:picoQuery(root_eci, "io.picolabs.oauth", "channelStatus", { "eci": hook_eci });

Use the same pattern for createChannelSecretrevokeChannelSecret, and revokeTokens. The root pico's admin tree covers the child's channel, so credential management from the root is allowed.

6. Check mesh lock before assuming open /sky/*

use module io.picolabs.oauth alias meshOAuth locked = meshOAuth:meshRequiresOAuth(some_eci); // true — caller needs Authorization: Bearer … on /sky/* // false — open channel (policy only) unless this ECI is oauth-webhook

Useful in rulesets that build external URLs or document integration endpoints.


Complete example: create channel and credentials

ruleset myapp.webhooks { meta { use module io.picolabs.wrangler alias wrangler use module io.picolabs.oauth alias meshOAuth } rule on_boot { select when engine_ui dependencies satisfied pre { hook = wrangler:createChannel( ["oauth-webhook", "myapp"], { "allow": [{ "domain": "myapp", "name": "ingest" }], "deny": [] }, { "allow": [], "deny": [{ "rid": "*", "name": "*" }] } ); creds = meshOAuth:createChannelSecret(hook{"id"}); ent:webhook_eci = hook{"id"}; ent:client_id = creds{"client_id"}; ent:client_secret = creds{"client_secret"}; } } }

Requires io.picolabs.oauth on the same pico as this ruleset (usually the root). The integrator then mints tokens with POST /oauth/token and calls /sky/event/<eci>/… with the Bearer token.


Webhooks that cannot send OAuth

Many commercial webhook senders (GitHub, Stripe, etc.) POST to a fixed URL and do not call your token endpoint or send a Bearer header.

Client Credentials protects against ECI / URL leakage — someone who discovers the URL still needs a token. Senders that can attach Authorization: Bearer … should use the token flow.

For senders that only POST a signed body, provider-specific verification (e.g. Stripe HMAC signatures) is a separate concern on top of or instead of Bearer auth, depending on your threat model.


Common mistakes

Using an app_… client ID with Client Credentials

OAuth apps registered in Settings use app_… IDs and require Authorization Code or refresh grants. Webhook client_id is always the channel ECI.

Token on the wrong ECI

The access token is bound to the channel ECI used as client_id. The same token will not work on sibling channels.

Expecting /c/* to accept Bearer

Client Credentials tokens are for /sky/* only. The UI and in-engine loops use passkey sessions on /c/*.

Forgetting credentials after tagging

The channel is Bearer-locked immediately, but no token can be minted until you create credentials.

Losing the client secret

Secrets are shown once. Rotate credentials to issue a new secret (this revokes existing tokens).