Bootstrapping Extension Credentials
Every credential Throttle mints by default belongs to one install and is shown to the installing merchant, once. That works for an iframe extension a human eventually opens, but it leaves a webhook-only extension with no machine route to its own secrets at all — every delivery fails signature verification until someone copy-pastes a secret out of the dashboard. Client credentials close that gap: a developer issues one pair for their catalog extension, and every install after that bootstraps itself.
The handshake, end to end
Four exchanges, only the first of which involves a person. The thing to hold on to is which way each arrow points and what it carries: Throttle pushes the fact of an install, and your extension pulls the secret — the push is deliberately worthless to anyone who intercepts it.
sequenceDiagram participant M as Merchant in dashboard participant T as Throttle participant X as Your extension M->>T: clicks Install T->>T: create install, mint API key + signing secret T-->>M: apiKey + webhookSigningSecret, displayed once to a human T->>X: POST installCallbackUrl - the fact, identifiers only Note over T,X: X-Throttle-Install-Token, RS256 JWT, sub = installationId X->>T: GET /.well-known/extension-jwks.json T-->>X: public keys X->>X: verify the token, trust its claims and not the body X->>T: GET /installations/:id/webhook-secret, Basic clientId:clientSecret T-->>X: signingSecret, or 404 if the install is not active T->>X: POST your webhookUrl - event + X-Throttle-Signature X->>X: verify the HMAC with the secret it pulled
1. Issue client credentials
From your extension's catalog settings in the dashboard, generate a clientId / clientSecret
pair. This is a one-time, developer-facing action — it is never shown to a merchant. The
route accepts a dashboard user holding the application:extensions:write permission, or a secret
(sk_) API key holding the extensions:write scope — but never the extension's own
client credentials, which are deliberately not on the allow-list that reaches this route.
An extension cannot mint its own credentials. installCallbackUrl must be https://
— a plain http:// URL is rejected with 422 invalid_callback_url.
# Issue client credentials for your catalog extension. A dashboard user or a
# secret API key holding extensions:write — an extension can never mint this
# for itself (not on the extension_client allow-list).
curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/client-credentials \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{ "installCallbackUrl": "https://your-worker.example.com/throttle/install-callback" }'
# → { data: {
# "clientId": "extc_...",
# "clientSecret": "...", ← shown once, never again
# "installCallbackUrl": "https://your-worker.example.com/throttle/install-callback"
# } }
# Calling this again ROTATES the pair — the previous secret stops verifying immediately. 2. Where the client secret goes
A typical extension worker already keeps a wrangler.jsonc
(or equivalent) with a vars block for public values like THROTTLE_EXTENSION_ID. That block is committed to git and
readable by anyone with repo access — correct for an identifier, wrong for a secret. The
client id is fine alongside it; the client secret is not.
# wrangler.jsonc — "vars" is plaintext and checked into git. It is fine for
# public identifiers, wrong for the client secret.
{
"vars": {
"THROTTLE_EXTENSION_ID": "ext_xxx",
"THROTTLE_CLIENT_ID": "extc_..." // public identifier — fine here
}
}
# The client secret goes in the deploy's secret store instead, never in a file
# that gets committed:
wrangler secret put THROTTLE_CLIENT_SECRET
# (Vercel: vercel env add · Fly: fly secrets set · Render: dashboard env group) wrangler
secret put for a Cloudflare Worker, or the equivalent for whatever you deploy to.
Nothing about the existing vars block tells you where the
line is; this is that line.
3. Receive the install callback
Once you have client credentials and an installCallbackUrl on file, every future install of your extension POSTs a signed notice to that URL —
the only human action anywhere in this flow is the merchant clicking Install. The payload
carries identifiers only, never secrets: it tells you an install happened
and who it belongs to, not how to authenticate as it.
{
"type": "extension.install_callback",
"installationId": "inst_01HZX...",
"extensionId": "ext_xxx",
"workspaceId": "...",
"applicationId": "...",
"environmentId": "...",
"environmentKind": "non_production",
"versionId": "ver_xxx",
"version": "1.2.0",
"installSequence": 2,
"installedAt": "2026-09-13T10:00:00.000Z"
}
The request carries an X-Throttle-Install-Token header —
an RS256 JWT signed with the same extension JWKS keypair your bridge already verifies
session tokens against. aud is your extension id, sub is the installation id.
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Same JWKS the extension-session token and OAuth token already verify against.
const JWKS = createRemoteJWKSet(
new URL('https://api.usethrottle.dev/.well-known/extension-jwks.json'),
);
// A Cloudflare Worker reads its "vars" (THROTTLE_EXTENSION_ID) off the "env"
// argument, not process.env — that only works under nodejs_compat, and even
// then this signature would need env threaded in to reach it. Thread env
// through your router/handler rather than reaching for process.env.
interface Env {
THROTTLE_EXTENSION_ID: string;
}
export async function handleInstallCallback(request: Request, env: Env): Promise<Response> {
const token = request.headers.get('X-Throttle-Install-Token');
// Missing token is a genuine, permanent rejection — 401, never retry.
if (!token) return new Response('missing token', { status: 401 });
// The token is authoritative — read identifiers from its verified claims,
// never from the JSON body. The body only repeats them for convenience.
let claims;
try {
({ payload: claims } = await jwtVerify(token, JWKS, {
issuer: 'throttle',
audience: env.THROTTLE_EXTENSION_ID, // aud is the extension id
algorithms: ['RS256'],
}));
} catch {
// A bad signature, expired token, or wrong audience is genuinely invalid
// — 401 is correct, and retrying changes nothing. But createRemoteJWKSet
// throws through this same catch if the JWKS fetch itself fails (a
// transient network error), and a 401 there wastes Throttle's remaining
// retry attempts on a problem a retry would have fixed. Distinguish the
// two if you can; when in doubt on an unfamiliar error, prefer 500.
return new Response('invalid token', { status: 401 });
}
const installationId = claims.sub as string; // sub is the installation id
const { workspaceId, applicationId, environmentId, environmentKind, installSequence } =
claims as Record<string, unknown>;
// Record the install, then pull credentials in the background (below).
await recordInstall({ installationId, workspaceId, applicationId, environmentId, environmentKind, installSequence });
return new Response(null, { status: 204 });
} https://api.usethrottle.dev/.well-known/extension-jwks.json and trust its claims — never the body. A body is just JSON anyone could send;
the signature is what makes workspaceId, applicationId, and the rest trustworthy.
4xx is treated as a permanent rejection and is never
retried. Respond 5xx for a transient failure you want
Throttle to retry, and reserve 4xx for a failure that
retrying would never fix (a bad or missing token). If the callback never arrives (dropped,
never configured, all 3 attempts exhausted, or you lost your own datastore), enumerate your installs instead of waiting for it.
4. Pull your credentials — read the secret, mint the key
Every route below authenticates with the same header: Authorization:
Basic base64(clientId:clientSecret). Nothing else accepts that scheme, and a client
credential can reach only these three routes plus the enumeration route below — anything
else is 403 before the handler runs, and so is the wrong
verb on a route it can reach. But the read and the mint are not interchangeable, and treating them as one call is the mistake this
section exists to prevent.
base64 (the default on Linux and most CI images) inserts a
newline into the header value — BSD/macOS base64 does not,
which is exactly why this ships unnoticed from a Mac. The wrapped header fails validation
and comes back as 401 unauthorized, which reads as a wrong
secret rather than a formatting bug. Pipe through tr -d '\n' (portable) or pass base64 -w 0 on GNU systems.
ensureCredentials() call hands
anyone who can guess an installation id a way to churn your keys from outside — the read is
harmless to run on unauthenticated input, the mint is not.
flowchart TB
D([Delivery arrives, unverified]) -->|you hold no secret yet| R[GET .../webhook-secret]
R -->|read, idempotent| S[signingSecret]
S --> V{Signature verifies?}
V -- no --> J[Reject, act on nothing]
V -- yes --> H[Handle the event]
H --> W{Write back?}
W -- no --> E([Done])
W -- yes --> K[POST .../api-key]
K -->|mint, new key issued| E Read the signing secret
Genuinely readable: the secret lives in cleartext on the installation's webhook endpoint row, so re-reading it changes nothing. Call this as soon as you learn an installation id — before you have verified anything with it — because it is what lets you verify.
# READ — idempotent, safe to call before you have verified anything, because
# you need the secret in order to verify a delivery in the first place.
curl https://api.usethrottle.dev/api/v1/installations/inst_01HZX.../webhook-secret \
-H "Authorization: Basic $(printf '%s:%s' "$THROTTLE_CLIENT_ID" "$THROTTLE_CLIENT_SECRET" | base64 | tr -d '\n')"
# → { data: {
# "installationId": "inst_01HZX...",
# "installSequence": 2,
# "endpointId": "we_01HZY...",
# "url": "https://your-worker.example.com/webhooks/throttle",
# "enabledEvents": ["order.created"],
# "signingSecret": "whsec_...",
# "previousSecretExpiresAt": null
# } } Mint a fresh API key
API keys are hashed at rest, so there is nothing to read back — this route always issues a new key and points the installation at it. Scopes come from the
installation's pinned version; a legacy install with no pinned version (
versionId: null) falls back to the catalog extension's
own current scopes instead. The key it replaces keeps working for graceSeconds (integer, 0 –604800, default 86400 = 24h) via previousKeyExpiresAt, so restarting mid-flight and
re-minting never locks you out. Pass graceSeconds: 0 to
revoke the old key immediately instead — reach for that on a leaked key. Repeated calls are
safe for that reason — each one just grace-expires its predecessor — but each one is also a
real credential rotation, not a lookup, so only call it when you need to.
# MINT — NOT a read. Issues a brand-new API key and grace-expires the one it
# replaces. Call this only after a delivery's signature has verified, and only
# when you actually need to write back to Throttle.
curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_01HZX.../api-key \
-H "Authorization: Basic $(printf '%s:%s' "$THROTTLE_CLIENT_ID" "$THROTTLE_CLIENT_SECRET" | base64 | tr -d '\n')"
# → { data: {
# "installationId": "inst_01HZX...",
# "apiKey": "sk_uat_...", ← shown once
# "keyPrefix": "sk_a1b2c3d4e5f6a7b8",
# "previousKeyExpiresAt": "2026-09-14T10:00:00.000Z"
# } } Rotate a leaked signing secret
If you believe a signing secret has leaked, rotate it with the credential you already
hold — you do not need an API key for this, and minting one you would not otherwise use
is the wrong recovery. The outgoing secret keeps verifying for graceSeconds (integer, 0 –604800, default 86400 = 24h), during which every
delivery carries a digest for each secret, so nothing in flight fails while you store the
new one. Pass graceSeconds: 0 to kill the old secret
immediately — that is the right call for an actual compromise, at the cost of failing
anything already in flight. A signed extension.webhook_secret_rotated event is delivered too,
which is how you learn when the merchant rotates.
# ROTATE — for a signing secret you believe has LEAKED. Issues a new secret and
# keeps the outgoing one verifying for graceSeconds (default 86400 = 24h), so
# deliveries in flight do not start failing before you have stored the new one.
curl -X POST https://api.usethrottle.dev/api/v1/installations/inst_01HZX.../rotate-webhook-secret \
-H "Authorization: Basic $(printf '%s:%s' "$THROTTLE_CLIENT_ID" "$THROTTLE_CLIENT_SECRET" | base64 | tr -d '\n')" \
-H "content-type: application/json" \
-d '{ "graceSeconds": 86400 }'
# → { data: {
# "installationId": "inst_01HZX...",
# "endpointId": "we_01HZY...",
# "signingSecret": "whsec_...", ← the new one
# "previousSecretExpiresAt": "2026-09-15T10:00:00.000Z"
# } } Error codes to distinguish in your handler
Same shape on all four routes — { error: { code, message } }
— but the codes tell you different things about what to do next:
-
401 unauthorized— the Basic credentials themselves are wrong (badclientId/clientSecret, or credentials for an extension with no client secret on file). Check what you stored. -
403 forbidden— the credential is valid, but this request isn't one of the four it may make. The allow-list pairs each path with one method, so the wrong verb on a correct path fails the same way as a wrong path:GETon.../api-keyorPOSTon.../webhook-secretis403, not405. Check the verb before you check the URL. -
404 not_found— one of three things, deliberately indistinguishable: the installation id belongs to a different extension, it doesn't exist, or itsstatusis no longeractive(the merchant uninstalled or suspended it). A credential that may no longer act on an install is told nothing about it — including whether it was ever there. -
409 no_webhook_endpoint(webhook-secret and rotate only) — this install's pinned version declares nowebhookUrl, so there is no signing secret to return. Usually means you're calling this for an installation that never configured a webhook — the most likely first failure you'll actually hit. -
409 version_not_found/409 environment_not_found(api-key only) — the install's pinned version or environment row is missing. A real data-integrity problem, not something a retry fixes.
5. Recover: enumerate your own installs
The install callback is an optimisation, not a dependency — GET
/api/v1/extensions/me/installations lets you list every install that belongs to
your extension at any time, no installation id required. This is your recovery path if a
callback was dropped, never configured, or your own datastore was lost: page through the
list, then pull the signing secret (and mint a key, if you need one) for each install you
don't already have.
Filter on status before you pull. This
route deliberately returns every install, including ones the merchant has uninstalled or
suspended — that is how you learn an install ended, since there is no separate signal. But
the two credential routes refuse anything that isn't active, so pulling for a dead install just spends a request
on a 404. Treat a non-active row as your cue to drop the
credentials you cached for it.
# Recovery path: enumerate every install this extension owns, no installationId
# needed. Use this if a callback was dropped, never configured, or your
# datastore was lost. Cursor-paginated.
curl "https://api.usethrottle.dev/api/v1/extensions/me/installations?limit=50" \
-H "Authorization: Basic $(printf '%s:%s' "$THROTTLE_CLIENT_ID" "$THROTTLE_CLIENT_SECRET" | base64 | tr -d '\n')"
# → { data: [
# {
# "installationId": "inst_01HZX...",
# "extensionId": "ext_xxx",
# "workspaceId": "...",
# "applicationId": "...",
# "environmentId": "...",
# "status": "active",
# "installSequence": 2,
# "versionId": "ver_xxx",
# "installedAt": "2026-09-13T10:00:00.000Z"
# }
# ], "meta": { "requestId": "...", "pagination": { "cursor": null, "hasMore": false } } } 404, not 403, so the
route's existence isn't confirmed to a credential with no business here. The
extension id predicate is baked into every page of the query, so there is no installation
id you could pass, guessed or otherwise, that belongs to a different extension.
The three credentials, and why they rotate differently
By the end of this flow your extension is holding three things with three different owners, and the rotation rules that keep tripping people up are not arbitrary — each one falls out of how the credential is stored.
flowchart LR D1[Held by you] --> CS[clientSecret] CS -->|SHA-256| CSD[(digest at rest)] CSD -->|never readable| CSR[re-issue, old dies at once] I1[Held by one install] --> SS[signingSecret] SS -->|stored as-is| SSD[(cleartext at rest)] SSD -->|readable any time| SSR[rotate, grace window] I2[Held by one install] --> AK[apiKey] AK -->|bcrypt + SHA-256| AKD[(hash at rest)] AKD -->|never readable| AKR[mint new, old grace-expires]
Next steps
- Events
— verify the
X-Throttle-Signatureheader on every delivery, using the secret you just pulled. - Security model — the same RS256 + JWKS verification this callback uses, applied to the iframe session token.
- Installing extensions — what happens on install for extensions that don't use client credentials, and the per-install secrets a human still has to copy there.