Extensions

Extension Events

Extensions can subscribe to Throttle events by declaring a webhookUrl and an eventSubscriptions list in their manifest. Throttle creates a managed webhook endpoint for each install and delivers signed events to your URL with the same retry and dead-letter mechanics as standard webhooks.

Use this reference for backend and hybrid applications after your first private install, then validate retries and replays with the testing guide.

Payload envelope

Every delivery is a JSON POST with a stable top-level envelope. The event-specific payload lives under data.

Delivery envelope
{
  "id":          "evt_01HZX...",
  "type":        "order.created",
  "version":     "1",
  "workspaceId": "6659b411-9cd7-40ac-9a73-1e7801d89f55",
  "environmentId": "8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0",
  "environmentKind": "production",
  "createdAt":   "2026-05-31T10:00:00.000Z",
  "data": {
    "orderId":   "ord_01HZX...",
    "cartId":    "cart_01HZX...",
    "status":    "pending",
    "currency":  "USD",
    "total":     12900
  }
}
  • Headers: X-Throttle-Signature, X-Throttle-Event-Id, X-Throttle-Event-Type, Content-Type: application/json.
  • Envelope fields: id (unique event ID), type, version (payload-schema version, currently "1"), workspaceId, environmentId, environmentKind (production or non_production; omitted only when the environment could not be resolved, so treat absent as unknown rather than as production), createdAt, data.
  • Additive fields are not a version bump. Throttle adds envelope fields and data fields without changing version, announced in the changelog but not ahead of the first delivery that carries them. Parse the envelope with a schema that strips unknown keys. A .strict() envelope schema rejects every delivery the moment a field is added, and if your handler reports that with the same 401 as a bad signature, the failure is indistinguishable from a rotated secret. Extension starter versions before PR #11 shipped exactly that schema; update and redeploy.
  • Amounts: all money fields are integer minor units (e.g. cents for USD).
  • Environment isolation: an install only receives events from the same workspace environment. The same event is never delivered across environments.
Typed payloads
The data shape for every event type is typed in @usethrottle/webhook-types . Import the ThrottleEvent discriminated union or ThrottleEventEnvelope generic and narrow on type to get the exact shape per event.

Verify signatures

Each delivery includes X-Throttle-Signature in the format t=<unix-seconds>,v1=<hex-hmac-sha256> . The signed payload is <t>.<rawBody> — identical to the standard webhook signature scheme. The signing secret is the per-install webhookSigningSecret returned at install time.

Node.js verifier
// Node.js — import from the subpath entry (uses node:crypto)
import { verifyWebhook } from '@usethrottle/extension-bridge/webhook';
// Express / Fastify / plain Node
app.post('/webhooks/throttle', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body.toString('utf8');   // ← raw string, BEFORE JSON.parse
  const signature = req.headers['x-throttle-signature'] as string;
  const secret = process.env.EXTENSION_WEBHOOK_SECRET!; // from install response
  const valid = verifyWebhook(rawBody, signature, secret);
  if (!valid) {
    return res.status(400).json({ error: 'Invalid signature' });
  }
  const event = JSON.parse(rawBody);
  console.log('Received event:', event.id, event.type);
  res.status(200).send();
});
Edge / Web Crypto verifier
// Cloudflare Workers / Deno / Bun — use verify.ts (Web Crypto API, async)
import { verifyWebhook } from '@usethrottle/extension-bridge/verify';
export default {
  async fetch(request: Request): Promise<Response> {
    const rawBody = await request.text();
    const signature = request.headers.get('x-throttle-signature') ?? '';
    const secret = env.EXTENSION_WEBHOOK_SECRET;
    const valid = await verifyWebhook(rawBody, signature, secret);
    if (!valid) return new Response('Bad signature', { status: 400 });
    const event = JSON.parse(rawBody);
    // handle event
    return new Response('ok');
  },
};
Use the raw body
Signature verification fails if your framework parses and re-serializes JSON before you verify. Capture the raw request body string first, verify, then parse.
Clock-skew tolerance
verifyWebhook rejects deliveries whose t timestamp is more than 300 seconds (5 minutes) from the current clock. Pass toleranceSeconds to override.
Two v1 digests during a rotation window
For 24 hours after the secret is rotated the header reads t=…,v1=<outgoing>,v1=<new>. Check every v1 against the secret you hold and accept if any matches — @usethrottle/extension-bridge 1.1.1+ and the starter's verifier do. A parser that keeps only the last v1 verifies once it holds the new secret, and not before.

Rotate the signing secret

Either side can rotate: your extension, authenticated as the installation, or the merchant from the dashboard. The old secret keeps verifying for graceSeconds (default 86400, max 604800) while every delivery is signed with both; 0 revokes it now — the leaked-secret case.

Rotate as the installation
# Rotate, keeping the old secret verifying for 24h (default). 0 = revoke now.
curl -X POST https://api.usethrottle.dev/api/v1/installations/$INSTALL_ID/rotate-webhook-secret \
  -H "X-API-Key: $INSTALLATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "graceSeconds": 86400 }'

# → { "data": { "installationId": "…", "endpointId": "…",
#              "signingSecret": "whsec_…",             # shown once
#              "previousSecretExpiresAt": "2026-09-11T09:15:02.101Z" } }

When the merchant rotates, you are told: a signed extension.webhook_secret_rotated is delivered to the endpoint — dual-signed, so it verifies with the secret you still hold. It never carries a secret. On receipt, fetch the new one with GET /api/v1/installations/:id/webhook-secret and store it before previousSecretExpiresAt; that read also reports the deadline while a window is open.

extension.webhook_secret_rotated
{
  "id": "evt_01J9…",
  "type": "extension.webhook_secret_rotated",
  "version": "1",
  "createdAt": "2026-09-10T09:15:02.118Z",
  "environmentId": "env_…",
  "environmentKind": "production",
  "workspaceId": "ws_…",
  "data": {
    "installationId": "62e32355-72ed-4bf6-b738-fcd554a3e851",
    "extensionId": "ext_…",
    "endpointId": "wep_…",
    "rotatedAt": "2026-09-10T09:15:02.101Z",
    "previousSecretExpiresAt": "2026-09-11T09:15:02.101Z"
  }
}
  • Like extension.uninstalled, it is delivered to the installation's own endpoint without a subscription and cannot be listed in eventSubscriptions.
  • previousSecretExpiresAt is null when the old secret was revoked immediately — fetch the new secret at once; deliveries in between will have failed and will be retried.
  • Rotating again inside a window replaces the outgoing secret: only two are ever live.

Process idempotently

Extension event delivery is at-least-once. Retries use the same backoff schedule as standard webhooks: [5m, 15m, 1h, 6h, 24h] (max five attempts). After the fifth attempt, the delivery is marked dead_letter. Always store each event.id before applying side effects.

Idempotency guard
// Store event IDs before processing side effects to handle at-least-once delivery.
// Example with a database:
const eventId = event.id;
const alreadyProcessed = await db.query(
  'SELECT 1 FROM processed_events WHERE event_id = $1',
  [eventId],
);
if (alreadyProcessed.rowCount > 0) {
  return res.status(200).json({ status: 'duplicate' });
}
// Process the event (idempotent side effects here)
await processOrder(event.data);
// Mark as processed
await db.query(
  'INSERT INTO processed_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
  [eventId, event.type],
);

Delivery inspection and replay

Use the installation delivery routes to inspect past attempts and replay dead-lettered events. The delivery list and replay require the extensions:read and extensions:install scopes respectively.

Delivery management
# Inspect deliveries for an installation
GET /api/v1/installations/:id/deliveries?limit=50&offset=0
# Replay a dead-lettered delivery
POST /api/v1/installations/:id/deliveries/:deliveryId/replay
# Send a verification ping to the installation's webhook URL.
# The ping is signed with the installation's signing secret, like every delivery.
POST /api/v1/installations/:id/test
A 401 on the install-time ping is a sequencing problem, not a broken endpoint
The dashboard pings your webhook right after install, signed with the secret it just minted — before an operator could have handed that secret to your service. A correctly implemented endpoint therefore answers 401 once. Recover without a human: on a verification failure, fetch the current secret with GET /api/v1/installations/:id/webhook-secret (authenticated as the installation), cache it, and re-verify. The ping then passes on the merchant's retry, and a rotated secret heals the same way.

You are told when your endpoint stops accepting deliveries

Two signals go to the publisher of the extension and to the admins of the installing application, by email and in the dashboard inbox, without anyone watching a delivery log.

  • Unauthorized streak. The moment an endpoint has answered three consecutive deliveries with 401 or 403 — on any attempt, so retries of one event count — an alert names the endpoint, the last event type and the status. It fires once per streak. Retrying does not fix an authorization failure: the receiver is verifying with a stale secret or rejecting an envelope field it does not recognise. Deliveries keep retrying and then dead-letter; replay them once the endpoint accepts again.
  • Signed probe. Every active installation's endpoint receives an extension.ping envelope two minutes after each Throttle deploy and every six hours. It is built by the same code as a real delivery — same body shape, headers and signature — so an endpoint that rejects it will reject real events. Any response other than 2xx, or no response, is reported at most once per endpoint per day. Your handler should verify it like any event and answer 2xx; there is nothing to process. It is a reserved type: it never appears in the event catalog and cannot be subscribed to.

Auto-suspend on repeated failures

If an extension's webhook endpoint fails all five delivery attempts repeatedly, Throttle may suspend the installation ( status: "suspended"). A suspended installation stops receiving new events. Resume via POST /api/v1/extensions/:id/suspend (which actually toggles — use it to re-activate) or reinstall the extension, which reactivates the same install row and endpoint (the signing secret is preserved) and increments installSequence.

Installation lifecycle: extension.uninstalled

When a merchant uninstalls your extension, or staff take it down, Throttle sends one signed delivery to that installation's webhook URL. It is the only signal you get that the installation has ended, and it needs nothing from you in advance: no subscription, no scope, no API key. It is sent after the installation reads uninstalled, and it is retried on the normal ladder if your endpoint does not answer 2xx.

extension.uninstalled
{
  "id": "evt_01J9…",
  "type": "extension.uninstalled",
  "version": "1",
  "createdAt": "2026-09-09T14:02:11.804Z",
  "environmentId": "env_…",
  "environmentKind": "production",
  "workspaceId": "ws_…",
  "data": {
    "installationId": "62e32355-72ed-4bf6-b738-fcd554a3e851",
    "extensionId": "ext_…",
    "applicationId": "app_…",
    "uninstalledAt": "2026-09-09T14:02:11.790Z",
    "reason": "merchant"
  }
}
  • reason is merchant for a dashboard or API uninstall and staff_takedown when Throttle staff removed the extension from every install at once.
  • Verify it exactly like any other delivery — same secret, same header — then delete the provider credentials and stored configuration for that installationId and stop its queued and scheduled work. This is the hook that lets you honour a deletion obligation.
  • Match on data.installationId, not on the envelope's workspace and environment: a workspace can hold several installations of the same extension across applications.
  • Do not list it in eventSubscriptions. Its read scope (extensions:read) is human-only, so the manifest would be rejected — and the delivery does not depend on a subscription anyway. A merchant can subscribe one of their own workspace endpoints to it for audit.
Installs before 2026-09-09
Installations uninstalled before this delivery existed were never told. If your extension holds provider credentials, reconcile once against GET /api/v1/extension-installations with a key that holds extensions:read, or wait for the credentials to expire — from now on the delivery covers every new uninstall.

Event catalog

Each event type below requires the matching read scope in your extension manifest's scopes list to subscribe to it. Throttle validates this at registration time — if eventSubscriptions references an event whose required scope is not in the manifest's scopes, the create/update call returns 400 insufficient_scopes. Extensions may only request extensionAllowed scopes — workspace events ( workspace.*) require billing:read, which is a Taxonomy B (human) scope and is not grantable to extensions.

Gated at registration, not per delivery
The scope check for event subscriptions happens once when the extension manifest is created or updated. Individual deliveries are not re-checked.

An example delivery for every event — the exact envelope, with fixed ids — is on the webhook payload reference and under example on GET /api/v1/event-types.

Required scope: application_scripts:read

  • script.blocked
  • script.error
  • script.loaded

Required scope: billing:read

  • workspace.payment_method.added
  • workspace.payment_method.backup_set
  • workspace.payment_method.backup_used
  • workspace.payment_method.default_changed
  • workspace.payment_method.removed
  • workspace.platform_charge.failed
  • workspace.platform_charge.succeeded
  • workspace.trial_expired

Required scope: carts:read

  • cart.abandoned
  • cart.checkout_started
  • cart.converted
  • cart.created
  • cart.discount_applied
  • cart.discount_removed
  • cart.item_added
  • cart.item_removed
  • cart.item_updated
  • cart.merged
  • cart.shipping_cleared
  • cart.shipping_selected
  • cart.tax_recomputed
  • cart.updated

Required scope: customers:read

  • customer.created
  • customer.deleted
  • customer.email_verified
  • customer.password_changed
  • customer.payment_method_added
  • customer.payment_method_removed
  • customer.registered
  • customer.sessions_revoked
  • customer.updated

Required scope: discounts:read

  • discount.applied
  • discount.removed

Required scope: extensions:read

  • extension.uninstalled
  • extension.webhook_secret_rotated

Required scope: fulfillment_digital:read

  • fulfillment.digital.delivered

Required scope: fulfillments:read

  • fulfillment.cancelled
  • fulfillment.completed
  • fulfillment.created
  • fulfillment.shipment.delivered
  • fulfillment.shipment.shipped

Required scope: imports:read

  • import.completed

Required scope: invoices:read

  • invoice.past_due

Required scope: order_returns:read

  • return.created
  • return.updated

Required scope: orders:read

  • order.cancelled
  • order.closed
  • order.comp_reversed
  • order.comped
  • order.created
  • order.fulfilled
  • order.held
  • order.hold_released
  • order.sync_conflict
  • order.sync_failed
  • order.updated

Required scope: payment_disputes:read

  • payment.dispute_cleared
  • payment.disputed

Required scope: payment_refunds:read

  • payment.partially_refunded
  • payment.refund_failed
  • payment.refunded
  • subscription.invoice_refunded

Required scope: payments:read

  • payment.authorized
  • payment.captured
  • payment.expired
  • payment.failed
  • payment.pending
  • payment.processing
  • payment.recorded
  • payment.vaulted
  • payment.voided

Required scope: quotes:read

  • quote.accepted
  • quote.comment_added
  • quote.converted
  • quote.declined
  • quote.expired
  • quote.proposed
  • quote.requested
  • quote.revision_requested
  • quote.viewed

Required scope: shipping_tax:read

  • shipping_tax.provider_connection.unhealthy

Required scope: subscriptions:read

  • subscription.activated
  • subscription.backup_pm_used
  • subscription.cancelled
  • subscription.create_failed
  • subscription.created
  • subscription.past_due
  • subscription.paused
  • subscription.payment_failed
  • subscription.plan_change_scheduled
  • subscription.plan_changed
  • subscription.renewed
  • subscription.resumed
  • subscription.trial_blocked
  • subscription.updated