Automatic setup for extensions

How an extension fetches its own API key and webhook signing secret at install, so no merchant ever copies or pastes a secret into it.

With automatic setup, an extension gets its credentials straight from Throttle. When a merchant installs it, Throttle sends your service a signed notice. Your service then fetches its own API key and webhook signing secret, and the merchant never sees either value.

Manual setup is deprecated. Manual setup means showing the merchant the signing secret in the install wizard and having them paste it, with an API key, into your extension. It still works, but it will be removed in a future release. New extensions should use automatic setup, and existing ones should migrate.

Why

  • No secrets in a merchant’s hands. Nothing is displayed, copied or pasted, so nothing ends up in a chat, a ticket or a screenshot.
  • The first delivery verifies. Your service holds the signing secret before Throttle pings it, so the first signed delivery doesn’t fail while a human finishes copying.
  • Recoverable. If your datastore is lost, you can fetch the credentials again. You don’t have to ask every merchant to reinstall.

How it works

  1. You generate client credentials for your extension once, and give Throttle an install callback URL.
  2. On every install, Throttle POSTs extension.install_callback to that URL with a signed token in X-Throttle-Install-Token. The callback carries identifiers only, never secrets.
  3. Your service verifies the token, then fetches the signing secret and mints an API key, authenticating with HTTP Basic clientId:clientSecret.

1. Generate client credentials

In the dashboard, open your catalog extension and find the Client credentials card. Enter the install callback URL and choose Generate client credentials. Or call the API:

curl -X POST https://api.usethrottle.dev/api/v1/extensions/<extension id>/client-credentials \
  -H "Authorization: Bearer <sk_ key with extensions:write>" \
  -H "content-type: application/json" \
  -d '{ "installCallbackUrl": "https://your-service.example.com/throttle/install-callback" }'
# → { "data": { "clientId": "...", "clientSecret": "...", "installCallbackUrl": "..." } }
  • The client secret is shown once. Throttle stores only its SHA-256 digest. Put it in your deploy’s secret store, never in a committed config file. The client id is not secret.
  • The callback URL must start with https://. Any other scheme is refused with 422 invalid_callback_url.
  • You can set the callback URL only while generating credentials. If you leave it out, the URL already on file is kept.
  • Generating again rotates the pair, with no grace period. The old client secret stops working immediately, so deploy the new one in the same step.
  • The caller must be a dashboard user with application:extensions:write, or a secret (sk_) key with the extensions:write scope. An extension can’t issue credentials for itself.

Once the extension has client credentials, the install wizard stops showing merchants the signing secret. It still shows the install-time API key once.

Throttle sends the install callback only when the extension has both client credentials and a callback URL. Without a callback URL, find new installs with GET /api/v1/extensions/me/installations.

2. Verify the install token

The callback is a POST with a JSON body and an X-Throttle-Install-Token header. Trust the token, not the body. The body repeats the token’s claims for convenience, but anyone can send JSON.

CheckValue
AlgorithmRS256, with a kid in the header
Public keys (JWKS)https://api.usethrottle.dev/.well-known/extension-jwks.json
issthrottle
audYour extension id
subThe installation id
exp5 minutes after iat

The token also carries installationId, workspaceId, applicationId, environmentId, environmentKind (production or non_production), installSequence and version. The body adds type (extension.install_callback), extensionId, versionId and installedAt.

Store installSequence with the install. A reinstall reuses the installation id and increases the sequence, and you need the stored value to discard a late extension.uninstalled from an earlier install.

The callback URL must resolve to a public address. Throttle checks this once per install, before the first attempt. If the host resolves to a private, loopback or link-local address, or doesn’t resolve, Throttle doesn’t send the callback and doesn’t retry.

3. Fetch the signing secret and API key

Each route below takes Authorization: Basic base64(clientId:clientSecret). Client credentials work on these four routes only. Anything else, including the wrong method on one of these paths, gets 403.

RouteWhat it does
GET /api/v1/installations/:id/webhook-secretReads the signing secret. Safe to repeat: it doesn’t change the secret.
POST /api/v1/installations/:id/api-keyMints a new API key with the install’s scopes. The key it replaces keeps working for 24 hours.
POST /api/v1/installations/:id/rotate-webhook-secretIssues a new signing secret. See Rotation.
GET /api/v1/extensions/me/installationsLists your installs, for recovery.

Fetch the signing secret as soon as the token verifies, and store it before you do anything else, including minting the API key. Reading the secret is what tells the install wizard your setup is complete, and the wizard then sends extension.ping to your webhook within about a second. If the secret isn’t stored by the time that ping arrives, your webhook can’t verify it and answers 401, and the merchant sees the webhook as not reachable.

The wizard waits up to 15 seconds for that read. If you haven’t read the secret by then, the ping still goes out. If it fails with 401, the merchant sees that your extension “may still be finishing setup”, with a Test webhook again button.

Mint API keys only in response to a request you have verified, such as a verified install token or a verified delivery. Each call issues a new key, so an unauthenticated trigger would let anyone who can guess an installation id rotate your keys.

On GNU systems, base64 wraps long output, which breaks the header and returns 401. Use base64 -w 0, or pipe through tr -d '\n'.

Rotation and the 24-hour grace period

  • Signing secret. POST .../rotate-webhook-secret takes an optional graceSeconds (default 86400, maximum 604800). During the grace period, every delivery carries two v1= digests in X-Throttle-Signature: the old secret’s first and the new one’s last. Accept a delivery if any digest matches. graceSeconds: 0 revokes the old secret at once, which is the right choice for a leaked secret.
  • Who rotated. The dashboard has no button for this, but the secret can be rotated through the API by your service, or by a dashboard user or API key with the right permission. Whoever rotates, Throttle sends your webhook a signed extension.webhook_secret_rotated event with installationId, endpointId, rotatedAt and previousSecretExpiresAt. When it arrives, read the secret again with GET .../webhook-secret.
  • Rotated with no grace. After a rotation with graceSeconds: 0, deliveries carry only the new secret’s digest, including the extension.webhook_secret_rotated event itself, so your stored secret stops verifying everything. When no stored secret verifies a delivery, read the secret again once with GET .../webhook-secret and retry before you answer 401. The read doesn’t change the secret, so it is safe on unverified input.
  • API key. Each mint grace-expires the previous key after graceSeconds, with the same default of 24 hours and maximum of 7 days. The response carries previousKeyExpiresAt.
  • Client secret. Generating new client credentials has no grace period (see step 1).

Retries

Throttle tries the callback 3 times in total.

  • A 2xx answer ends the callback.
  • A 5xx, a network error or a timeout (30 seconds per attempt) is retried after a short backoff of 0.5 s, then 1 s.
  • Any other answer below 500, including every 4xx, stops at once and is never retried.

Answer 5xx for a failure a retry could fix, such as a failure to fetch the JWKS. Answer 401 only for a missing or invalid token.

An install never fails because of the callback. If all 3 attempts fail, the install still succeeds and you recover as described below.

If a callback never arrives

GET /api/v1/extensions/me/installations lists every install of your extension, with cursor pagination (limit up to 100, default 50). It returns inactive installs too, so filter on status: "active". Then fetch credentials for each install you don’t already have.

The 404 cases

You get 404 fromBecause
webhook-secret, api-key or rotate-webhook-secretThe installation doesn’t exist, belongs to another extension, or is no longer active (uninstalled, suspended or taken down). Throttle doesn’t say which. Drop the credentials you cached for it.
GET /api/v1/extensions/me/installationsThe request didn’t use client credentials. This route answers 404 to every other credential.

Other answers worth handling: 401 means the client id or secret is wrong. 403 means a route or method that client credentials can’t use. 409 no_webhook_endpoint means the installed version declares no webhook URL, so there is no signing secret.

Your own callback endpoint answering 404, for example because of a wrong path, is a 4xx. Throttle stops after that first attempt.

Minimal example

@usethrottle/extension-bridge has no helper for the install token or the credential routes, so this uses jose and plain fetch. It uses the bridge only to verify deliveries, through verifyWebhook from @usethrottle/extension-bridge/verify (Web Crypto). Under Node, @usethrottle/extension-bridge/webhook gives the synchronous version.

import { createRemoteJWKSet, jwtVerify } from 'jose';
import { verifyWebhook } from '@usethrottle/extension-bridge/verify';

const API = 'https://api.usethrottle.dev';
const JWKS = createRemoteJWKSet(new URL(`${API}/.well-known/extension-jwks.json`));

interface Env {
  THROTTLE_EXTENSION_ID: string;
  THROTTLE_CLIENT_ID: string;
  THROTTLE_CLIENT_SECRET: string; // from your secret store
}

interface Install {
  installationId: string;
  workspaceId: string;
  environmentId: string;
  installSequence: number;
  signingSecret: string;
  apiKey?: string;
}

interface Store {
  put(install: Install): Promise<void>;
  /** Installs in one workspace and environment. */
  inScope(workspaceId: string, environmentId: string): Promise<Install[]>;
  delete(installationId: string): Promise<void>;
}

const basic = (env: Env) => `Basic ${btoa(`${env.THROTTLE_CLIENT_ID}:${env.THROTTLE_CLIENT_SECRET}`)}`;

async function throttle(env: Env, method: 'GET' | 'POST', path: string): Promise<any> {
  const res = await fetch(`${API}${path}`, { method, headers: { authorization: basic(env) } });
  // Never put the response body in an error: on success it is a secret.
  if (!res.ok) throw new Error(`Throttle ${method} ${path} answered ${res.status}`);
  return ((await res.json()) as { data: unknown }).data;
}

// POST /throttle/install-callback
export async function handleInstallCallback(request: Request, env: Env, store: Store): Promise<Response> {
  const token = request.headers.get('X-Throttle-Install-Token');
  if (!token) return new Response(null, { status: 401 });

  let claims;
  try {
    ({ payload: claims } = await jwtVerify(token, JWKS, {
      issuer: 'throttle',
      audience: env.THROTTLE_EXTENSION_ID,
      algorithms: ['RS256'],
    }));
  } catch {
    // Also reached when the JWKS fetch fails. Answer 5xx there instead if you
    // can tell the two apart, so Throttle retries.
    return new Response(null, { status: 401 });
  }
  const installationId = claims.sub as string;

  try {
    // Store the secret first: the install wizard pings your webhook about a
    // second after this read, and the ping must verify.
    const { signingSecret } = await throttle(env, 'GET', `/api/v1/installations/${installationId}/webhook-secret`);
    const install: Install = {
      installationId,
      workspaceId: claims.workspaceId as string,
      environmentId: claims.environmentId as string,
      installSequence: claims.installSequence as number,
      signingSecret,
    };
    await store.put(install);
    const { apiKey } = await throttle(env, 'POST', `/api/v1/installations/${installationId}/api-key`);
    await store.put({ ...install, apiKey });
  } catch {
    return new Response(null, { status: 503 }); // Throttle retries a 5xx
  }
  return new Response(null, { status: 204 });
}

// POST /webhooks/throttle
export async function handleWebhook(request: Request, env: Env, store: Store): Promise<Response> {
  const rawBody = await request.text();
  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return new Response(null, { status: 400 });
  }
  const signature = request.headers.get('X-Throttle-Signature') ?? '';

  // Each install has its own secret, so the one that verifies names the install.
  const candidates = await store.inScope(event.workspaceId, event.environmentId);
  let install: Install | undefined;
  for (const candidate of candidates) {
    if (await verifyWebhook(rawBody, signature, candidate.signingSecret)) {
      install = candidate;
      break;
    }
  }
  // Nothing verified: the secret may have been rotated with graceSeconds: 0.
  // Read each candidate's current secret once before rejecting.
  for (const candidate of install ? [] : candidates) {
    try {
      const { signingSecret } = await throttle(env, 'GET', `/api/v1/installations/${candidate.installationId}/webhook-secret`);
      if (signingSecret !== candidate.signingSecret && (await verifyWebhook(rawBody, signature, signingSecret))) {
        install = { ...candidate, signingSecret };
        await store.put(install);
        break;
      }
    } catch {
      // 404: no longer active. Try the next one.
    }
  }
  if (!install) return new Response(null, { status: 401 });

  if (event.type === 'extension.webhook_secret_rotated') {
    const { signingSecret } = await throttle(env, 'GET', `/api/v1/installations/${install.installationId}/webhook-secret`);
    await store.put({ ...install, signingSecret });
  } else if (event.type === 'extension.uninstalled') {
    // A late uninstall from an earlier install of the same id: ignore it.
    if (event.data.installSequence >= install.installSequence) await store.delete(install.installationId);
  }
  return new Response(null, { status: 204 });
}

A delivery names its workspace and environment but not its application, and a workspace can install your extension in more than one application. That is why the example tries the secret of each install in that scope. It reads those two ids from the body before verifying, but acts on nothing until a signature matches.

Migrating from manual setup

  1. Add the two handlers above and deploy them. They do nothing until the client credentials are set.
  2. Generate client credentials with your callback URL, as in step 1. Put the client id and secret in your deploy’s secret store and redeploy. From now on, new installs set themselves up, and the install wizard stops showing merchants the signing secret.
  3. Backfill existing installs. Existing installs never get a callback, because callbacks are sent only on install. Page through GET /api/v1/extensions/me/installations, keep the active rows, and fetch each one’s signing secret. That’s the same secret the merchant pasted, because reading it doesn’t change it. Mint an API key for each install that calls Throttle. The pasted key keeps working for 24 hours, then expires.
  4. Retire the paste form. Once every active install has credentials you fetched yourself, remove the form or environment variable that took pasted values.

For the full protocol, with sequence diagrams and every error code, see Bootstrapping extension credentials in the developer docs.

Last updated October 19, 2026