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",
"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,createdAt,data. - 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.
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
POST /api/v1/installations/:id/test 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.
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.
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.payment_method_added -
customer.payment_method_removed -
customer.updated
Required scope: discounts:read
-
discount.applied -
discount.removed
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: invoices:read
-
invoice.past_due
Required scope: order_returns:read
-
return.created -
return.updated
Required scope: orders:read
-
order.cancelled -
order.closed -
order.created -
order.fulfilled -
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
Required scope: payments:read
-
payment.authorized -
payment.captured -
payment.failed -
payment.pending -
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