Events and webhooks

Webhooks are Frame's mechanism for telling your application that something happened — a transfer succeeded, a subscription renewed, a dispute was created. Each state transition on a Frame resource fires an event; events route to whichever endpoints you've registered to listen for them.

The model

Three primitives:

  • Event — an immutable record of something that happened. Carries an id, a type (the event code like transfer.succeeded), and a data payload (a snapshot of the affected resource).
  • Webhook endpoint — a URL your application owns. Each endpoint subscribes to a specific list of event codes via the event_codes array on the endpoint record.
  • Delivery — Frame's attempt to POST the event payload to the endpoint. Successful deliveries are recorded; failures are retried.

Endpoints belong to a merchant + dev-mode scope. Sandbox events go to sandbox endpoints; production events go to production endpoints.

Endpoint management

Endpoints have a full REST surface — POST /v1/webhook_endpoints to create, PATCH to update the subscribed event codes, DELETE to remove, plus a dedicated rotate_secret action for key rotation. The Frame Dashboard wraps the same API for merchants who'd rather not script it.

See the webhooks integration page for the full CRUD walkthrough, signing-secret handling, and the canonical event catalog.

How events fire

Frame uses two internal mechanisms:

  • StateWebhookRegistry — bound to AASM state machines. State transitions on resources like Transfer, ChargeIntent, Subscription automatically fire the registered event codes for that transition.
  • Notifiable — a model-level concern that fires <prefix>.created / <prefix>.updated events on save callbacks. Used by resources like Dispute, Customer, Invoice.

The choice is internal to Frame; from an integration perspective, both produce the same event shape and you don't need to know which fired.

Event codes

Event codes follow the shape <resource>.<action>:

  • transfer.succeeded, transfer.failed — state transitions on a Transfer.
  • charge.failed, charge.refunded — charge lifecycle.
  • charge.dispute.created, charge.dispute.updated — dispute events (the Dispute model uses the charge.dispute prefix).
  • customer.subscription.activated, subscription.activated — subscription events, dual-prefixed by owner (see subscriptions).
  • invoice.created, invoice.issued, invoice.paid, invoice.overdue + 6 more — invoice lifecycle.
  • billing.invoice.generated, billing.subscriber_overage + 5 more — billing pipeline (see metering).

The full list is registered server-side in Webhook::EventCodes::EVENT_CODES. When you configure a webhook endpoint in the dashboard, pick the specific codes you want delivered — endpoints that don't subscribe to a code don't receive it.

Payload shape

Webhook deliveries are HTTP POST with a JSON body:

{
  "id": "evt_8f3a2c1e...",
  "type": "transfer.succeeded",
  "created": 1717612985,
  "data": {
    "id": "tr_...",
    "amount": 5000,
    "status": "succeeded",
    ...
  }
}

The data field is a snapshot of the affected resource at the moment the event fired, including its metadata. The shape matches the resource's GET response.

Signature verification

Every delivery includes an X-Frame-Signature header. Verify it before trusting the payload:

X-Frame-Signature: sha256=<hex>

The hex digest is HMAC-SHA256(endpoint.secret_key, payload_json). Each endpoint has its own 32-character secret, generated by Frame at endpoint creation time and visible in the dashboard.

Verification, in Node:

import crypto from 'crypto';

function verifyFrameWebhook(rawBody, headerValue, endpointSecret) {
  const [scheme, expectedHex] = headerValue.split('=');
  if (scheme !== 'sha256') throw new Error('Unsupported signature scheme');

  const actualHex = crypto
    .createHmac('sha256', endpointSecret)
    .update(rawBody)
    .digest('hex');

  // Use timing-safe comparison to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expectedHex, 'hex'),
    Buffer.from(actualHex, 'hex')
  );
}

A few non-negotiables:

  • Verify the raw request body, not a parsed-and-re-serialized version. Re-serializing JSON can reorder keys and break the signature match.
  • Use timing-safe comparison. A naïve === comparison leaks signature timing.
  • Store the secret per-endpoint, not globally. Each endpoint has its own; rotate by replacing the endpoint.

Retries

Failed deliveries retry up to 3 times (Webhook::Message::MAX_RETRIES). Frame considers a delivery failed if your endpoint returns a non-2xx response or times out.

The retry schedule is Sidekiq's default exponential backoff. After 3 attempts the message is marked permanently failed; Frame doesn't keep retrying indefinitely.

Implication for handlers: your endpoint will sometimes see the same event twice. Dedupe on event.id (see idempotency — webhook handlers).

Implication for ops: if your endpoint goes down for an extended outage, you'll lose events. There's no infinite-retry queue; once an event blows through its 3 retries, it's gone. For critical events, consider polling the relevant list endpoint after recovery to backfill.

Webhook handler reliability patterns

The handler-side patterns that consistently work:

  • Respond fast. Acknowledge with a 2xx response in under a few seconds; queue the actual work for async processing. Frame's delivery timeout is short; slow handlers get marked failed even when the work eventually succeeds.
  • Dedupe by event ID. Multiple deliveries of the same event will land; track which IDs you've processed.
  • Verify signatures every time. Unsigned-but-accepted is a security hole; an attacker who guesses your endpoint URL can post arbitrary payloads.
  • Persist before processing. Write the event to your DB (keyed by event.id) inside the transaction that applies the business logic; the dedup-check and the side-effect happen atomically.
  • Log everything. Webhook handlers are async + remote; without good logs, debugging a missed event is brutal.

Listing your endpoints

curl --request GET \
  --url https://api.framepayments.com/v1/webhook_endpoints \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"

Returns the merchant's configured endpoints with their URLs, subscribed event codes, and status. Useful for an "are my webhooks set up correctly" smoke test in your deployment pipeline; the integration page covers create / update / delete / rotate_secret in depth.

Testing webhooks

Frame's dashboard has a "send test event" feature — pick an event code, target endpoint, and Frame fires a synthetic payload your handler can react to. Useful for verifying signature validation and routing logic before real events flow.

For local development, point an endpoint at a tunnel (ngrok, Cloudflare Tunnel) into your dev machine. Test mode uses sandbox-specific endpoints, so production traffic isn't affected.

Gotchas

Symptom: signature verification fails on every request. Why: most often, you're verifying against a parsed-then-stringified body instead of the raw bytes. JSON re-serialization isn't guaranteed to produce the byte-for-byte original. Fix: capture the raw request body at the framework level (Express's express.raw({ type: 'application/json' }), Next.js's raw body handler, etc.) and verify against that.

Symptom: your endpoint times out occasionally and Frame reports failed deliveries. Why: handler is doing too much synchronously. Fix: respond 2xx immediately, then process the event off the request thread (queue, background job, etc.).

Symptom: you missed a window of events while your endpoint was down. Why: 3-retry cap means extended outages drop events. Fix: on recovery, query the relevant resource list endpoints (GET /v1/transfers, GET /v1/invoices) filtered by updated_at in the missed window; backfill. Set up monitoring to catch endpoint outages quickly so the missed window is small.

Symptom: you tried to subscribe an endpoint to an event code and got a 400 with "contains invalid codes". Why: the event_codes array on create and update is allowlist-validated against Webhook::EventCodes::EVENT_CODES. Typos and forward-references to events that haven't been added to the allowlist yet both surface the same way. Fix: check the event catalog — every code there is currently subscribable.

Symptom: you receive the same event ID twice. Why: delivery retry — your first response was slow or non-2xx. Fix: dedupe on event.id in your handler. This is expected; not a bug.

Reference

For the full endpoint CRUD API, see GET/v1/webhook_endpoints and the rest of the Webhook endpoints resource. For the per-endpoint signing-secret rotation flow, see POST/v1/webhook_endpoints/{id}/rotate_secret. For idempotent handling, see idempotency.