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.

Legacy and canonical families both fire

transfer.* is the canonical family for charge and payout money movement. The legacy charge_intent.* / charge.* / payout.* families still fire in parallel for the same underlying transactions — they aren't being removed, but they also aren't additional events. Subscribing to both families for the same transaction type means your endpoint receives two events per transaction. New integrations should subscribe to transfer.* only; see webhooks — event catalog for the full code list by family.

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.