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… |
|---|---|
One fixed webhook URL / one channel | The app needs many picos under your root |
The sender can call | 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
Create a channel with the
oauth-webhooktag (in KRL or the Channels tab).Create credentials for that channel in the UI —
client_idis the channel ECI; copy the client_secret (shown once).The integrator calls
POST /oauth/tokenwithgrant_type=client_credentials.The integrator calls
/sky/*withAuthorization: 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. stripe, github) 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_rx,tx_rx,subscription)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 |
|---|---|
| The channel ECI (one OAuth client per webhook channel) |
| Generated when you click Create credentials; shown once; stored hashed in the engine |
Access token | Minted at |
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:
Create credentials — copies
client_id(ECI) andclient_secret. Store the secret securely; it cannot be viewed again.Rotate credentials — issues a new secret and revokes existing tokens for that channel. Use this if a secret may have leaked.
Revoke credentials — removes the stored secret and revokes tokens. The channel stays Bearer-locked; minting fails until new credentials are created.
Get token — exchange the secret for a token in the UI (same as calling
/oauth/token), with a chosen lifetime.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 |
|---|---|
| 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… |
|---|---|
Client Credentials | The token was minted for this 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 |
|---|---|
| This channel always requires Bearer (from the moment the tag is set) |
| All channels on |
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 |
|---|---|
|
|
| Create credentials; returns |
| Remove stored secret and revoke tokens |
| Revoke outstanding access tokens for the channel |
|
|
|
|
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 meshOAuthThe 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 yetIf 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 createChannelSecret, revokeChannelSecret, 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-webhookUseful 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).