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.

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",
  "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.
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.

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
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 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.

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.

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.expired
  • cart.item_added
  • cart.item_removed
  • cart.item_updated
  • cart.merged
  • cart.shipping_cleared
  • cart.shipping_selected
  • cart.tax_recomputed
  • cart.updated
  • checkout.session.completed
  • checkout.session.created

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: merchant:read

  • extension.published_public
  • extension.review.approved
  • extension.review.auto_approved
  • extension.review.changes_requested
  • extension.review.comment_added
  • extension.review.rejected
  • extension.review.submitted
  • extension.unpublished

Required scope: orders:read

  • order.cancelled
  • order.closed
  • order.completed
  • order.created
  • 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.authorization_failed
  • payment.authorized
  • payment.capture_failed
  • payment.captured
  • payment.failed
  • payment.pending
  • payment.vaulted
  • payment.void_failed
  • payment.voided

Required scope: shipping_tax:read

  • shipping_tax.provider_connection.unhealthy

Required scope: subscriptions:read

  • subscription.activated
  • 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