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.
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,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/testAuto-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 to get a fresh install row with a fresh endpoint.
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.addedworkspace.payment_method.backup_setworkspace.payment_method.backup_usedworkspace.payment_method.default_changedworkspace.payment_method.removedworkspace.platform_charge.failedworkspace.platform_charge.succeededworkspace.trial_expired
Required scope: carts:read
cart.abandonedcart.checkout_startedcart.convertedcart.createdcart.discount_appliedcart.discount_removedcart.expiredcart.item_addedcart.item_removedcart.item_updatedcart.mergedcart.shipping_clearedcart.shipping_selectedcart.tax_recomputedcart.updatedcheckout.session.completedcheckout.session.created
Required scope: customers:read
customer.createdcustomer.deletedcustomer.payment_method_addedcustomer.payment_method_removedcustomer.updated
Required scope: discounts:read
discount.applieddiscount.removed
Required scope: fulfillment_digital:read
fulfillment.digital.delivered
Required scope: fulfillments:read
fulfillment.cancelledfulfillment.completedfulfillment.createdfulfillment.shipment.deliveredfulfillment.shipment.shipped
Required scope: invoices:read
invoice.past_due
Required scope: merchant:read
extension.published_publicextension.review.approvedextension.review.auto_approvedextension.review.changes_requestedextension.review.comment_addedextension.review.rejectedextension.review.submittedextension.unpublished
Required scope: orders:read
order.cancelledorder.closedorder.completedorder.createdorder.sync_conflictorder.sync_failedorder.updated
Required scope: payment_disputes:read
payment.dispute_clearedpayment.disputed
Required scope: payment_refunds:read
payment.partially_refundedpayment.refund_failedpayment.refunded
Required scope: payments:read
payment.authorization_failedpayment.authorizedpayment.capture_failedpayment.capturedpayment.failedpayment.pendingpayment.vaultedpayment.void_failedpayment.voided
Required scope: shipping_tax:read
shipping_tax.provider_connection.unhealthy
Required scope: subscriptions:read
subscription.activatedsubscription.cancelledsubscription.create_failedsubscription.createdsubscription.past_duesubscription.pausedsubscription.payment_failedsubscription.plan_change_scheduledsubscription.plan_changedsubscription.renewedsubscription.resumedsubscription.trial_blockedsubscription.updated