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.
{
"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(productionornon_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
datafields without changingversion, 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.
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 — 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();
}); // 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');
},
}; verifyWebhook rejects deliveries whose t timestamp is more than 300 seconds (5 minutes)
from the current clock. Pass toleranceSeconds to override.
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, 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.
{
"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 ineventSubscriptions. -
previousSecretExpiresAtisnullwhen 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.
// 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.
# 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 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
401or403— 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.pingenvelope 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.
{
"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"
}
} -
reasonismerchantfor a dashboard or API uninstall andstaff_takedownwhen 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
installationIdand 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.
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.
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