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, atype(the event code liketransfer.succeeded), and adatapayload (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_codesarray 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 likeTransfer,ChargeIntent,Subscriptionautomatically fire the registered event codes for that transition.Notifiable— a model-level concern that fires<prefix>.created/<prefix>.updatedevents on save callbacks. Used by resources likeDispute,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 (theDisputemodel uses thecharge.disputeprefix).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.