Webhooks
Webhooks deliver real-time events from Frame to your server. Subscribe to an event code, register an HTTPS URL, verify the signature on each delivery, and run your handler idempotently. This page covers the integration surface end to end — the conceptual model (event lifecycle, delivery guarantees, retry semantics) lives in events and webhooks.
Most merchants manage endpoints from the Frame Dashboard. The same surface is available via API — useful when you have multiple environments to provision, or when you want to manage endpoints from infrastructure code.
What you need
| Requirement | Details |
|---|---|
| HTTPS endpoint | Public URL Frame can POST to. HTTP is rejected. Self-signed certs are rejected. |
| Secret key | A Frame secret key (sk_sandbox_* or sk_production_*) to manage endpoints via the API. |
| Event codes | A list of events your endpoint should receive. See the event catalog below. |
Register an endpoint
POST/v1/webhook_endpointscurl --request POST \
--url https://api.framepayments.com/v1/webhook_endpoints \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"url": "https://api.example.com/frame/webhooks",
"description": "Production webhook receiver",
"event_codes": [
"charge_intent.succeeded",
"charge.refunded",
"invoice.paid",
"customer.subscription.updated"
]
}'
The response includes a signing secret — a 32-character string scoped to this endpoint. Frame returns it once, on create or rotation. Store it securely on your server side; the dashboard never displays it again. You'll use this secret to verify every incoming delivery.
{
"id": "we_abc123",
"url": "https://api.example.com/frame/webhooks",
"description": "Production webhook receiver",
"event_codes": ["charge_intent.succeeded", "charge.refunded", "invoice.paid", "customer.subscription.updated"],
"secret": "whsec_8f3e9c2a4b6d1f7e0a5c9b8d2e4f6a1c"
}
If you lose the secret, rotate it — you cannot retrieve the existing value.
Verify the signature
Every delivery carries an X-Frame-Signature header of the form sha256=<hex>. Compute the HMAC-SHA256 of the raw request body using your endpoint's signing secret, then compare against the header value with a constant-time comparison.
Node.js
import crypto from 'crypto'
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
const provided = signatureHeader.replace(/^sha256=/, '')
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex')
)
}
Ruby
require 'openssl'
def verify_signature(raw_body, signature_header, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
provided = signature_header.sub(/^sha256=/, '')
OpenSSL.fixed_length_secure_compare(expected, provided)
end
Python
import hmac, hashlib
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
provided = signature_header.removeprefix('sha256=')
return hmac.compare_digest(expected, provided)
Important: verify against the raw request body, not the parsed JSON. Body parsers re-serialize and break the hash. Frame's signature is over the bytes Frame sent.
Process events idempotently
Frame retries failed deliveries up to 3 times. Treat your handler as at-least-once: dedup on the event id field so a retried delivery doesn't double-charge the customer in your accounting system. See idempotency for the recommended pattern.
async function handleWebhook(req, res) {
if (!verifySignature(req.rawBody, req.headers['x-frame-signature'], SECRET)) {
return res.status(401).end()
}
const event = JSON.parse(req.rawBody)
if (await alreadyProcessed(event.id)) {
return res.status(200).end()
}
await processEvent(event)
await markProcessed(event.id)
res.status(200).end()
}
Respond with 2xx within 30 seconds. Anything else — non-2xx, timeout, connection error — counts as a failure and triggers a retry. Long-running work should be queued; the webhook handler just acknowledges receipt.
Manage endpoints
List
GET/v1/webhook_endpointsPaginated list of all endpoints registered to the authenticated merchant. Standard ?page=N&per_page=M query params.
Retrieve
GET/v1/webhook_endpoints/{id}Returns a single endpoint by ID. The secret field is not included on retrieve — only on create and rotate.
Update
PATCH/v1/webhook_endpoints/{id}curl --request PATCH \
--url https://api.framepayments.com/v1/webhook_endpoints/we_abc123 \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"event_codes": ["charge_intent.succeeded", "charge.refunded", "invoice.paid", "customer.subscription.updated", "invoice.overdue"]
}'
event_codes is a replacement, not a merge — the array you send is the new full subscription set. URL and description update independently.
Delete
DELETE/v1/webhook_endpoints/{id}Soft-deletes the endpoint. Frame immediately stops delivering to the URL. Returns a deletion stub:
{ "id": "we_abc123", "object": "webhook_endpoint", "deleted": true }
Rotate the signing secret
POST/v1/webhook_endpoints/{id}/rotate_secretcurl --request POST \
--url https://api.framepayments.com/v1/webhook_endpoints/we_abc123/rotate_secret \
--header "Authorization: Bearer $FRAME_SECRET_KEY"
The response returns the endpoint with a fresh secret. The previous secret is invalidated immediately — any delivery in flight signed with the old secret will fail verification on your receiver. Configure your verifier with the new secret before continuing to process events.
A common rotation pattern: deploy the new secret as a second valid verifier alongside the current one, rotate via the API, then remove the old verifier after the first new-secret delivery lands.
Event catalog
The complete list of subscribable event codes Frame supports. Endpoints with event_codes referencing values not in this catalog are rejected on create or update. The authoritative source is Webhook::EventCodes::EVENT_CODES in frame; this page tracks it.
Account
account.activated, account.created, account.disabled, account.restricted, account.unrestricted
Billing
billing.charge.succeeded, billing.credits_expiration, billing.credits_usage, billing.invoice.generated, billing.subscriber_overage, billing.subscriber_usage, billing.threshold.approaching
Capability
capability.activated, capability.disabled, capability.ineligible, capability.reactivated, capability.requested
Charge
charge.authorized, charge.captured, charge.disputed, charge.expired, charge.failed, charge.pending, charge.refunded, charge.reversed, charge.succeeded
Charge dispute
charge.dispute.closed_lost, charge.dispute.closed_won, charge.dispute.created, charge.dispute.evidence_requested, charge.dispute.evidence_submitted, charge.dispute.updated
Charge intent
charge_intent.authorized, charge_intent.canceled, charge_intent.confirmed, charge_intent.created, charge_intent.disputed, charge_intent.expired, charge_intent.failed, charge_intent.payment_action_required, charge_intent.payment_failed, charge_intent.refunded, charge_intent.remaining_voided, charge_intent.requires_3d_secure, charge_intent.requires_account_or_customer, charge_intent.requires_action, charge_intent.requires_confirmation, charge_intent.requires_payment_method, charge_intent.reversed, charge_intent.succeeded
Customer
customer.created, customer.deleted, customer.identity_verification.created, customer.identity_verification.updated, customer.updated
Customer subscription
Subscriptions owned by a Customer (the merchant's end-user). Account-owned subscriptions use the parallel subscription.* namespace below.
customer.subscription.activated, customer.subscription.canceled, customer.subscription.created, customer.subscription.incomplete, customer.subscription.past_due, customer.subscription.renewal.completed, customer.subscription.renewal.failed, customer.subscription.renewal.processing, customer.subscription.terminated, customer.subscription.unpaid, customer.subscription.updated
Invoice
invoice.created, invoice.deleted, invoice.issued, invoice.line_item.created, invoice.line_item.deleted, invoice.line_item.updated, invoice.overdue, invoice.paid, invoice.updated, invoice.voided
Merchant
merchant.created
Payment link
payment_link.created
Payment method
payment_method.detached, payment_method.updated
Payout
payout.created, payout.failed, payout.processing, payout.succeeded
Product
product.created, product.deleted, product.updated
Refund
refund.created, refund.failed
Subscription
Subscriptions owned by an Account (a marketplace seller/creator on your platform). Customer-owned subscriptions use the customer.subscription.* namespace above.
subscription.activated, subscription.canceled, subscription.incomplete, subscription.past_due, subscription.renewal.completed, subscription.renewal.failed, subscription.renewal.processing, subscription.terminated, subscription.unpaid
Transfer
transfer.canceled, transfer.created, transfer.disputed, transfer.expired, transfer.failed, transfer.incomplete, transfer.pending, transfer.processing, transfer.refunded, transfer.requires_3d_secure, transfer.requires_account_or_customer, transfer.requires_capture, transfer.requires_confirmation, transfer.requires_payment_method, transfer.reversed, transfer.succeeded
Transfer billing agreement
transfer_billing_agreement.created, transfer_billing_agreement.updated
Transfer fee plan
transfer_fee_plan.created
Delivery semantics
Frame's webhook delivery model:
- Transport: HTTPS POST with
Content-Type: application/json. - Signature:
X-Frame-Signature: sha256=<hex>HMAC of the raw body using the endpoint's secret. - Retries: Up to 3 attempts on non-2xx responses, with exponential backoff. After 3 failures the message is dropped (it does not retry beyond that window).
- Ordering: Best-effort, not strict. Two related events (e.g.
charge_intent.succeededandinvoice.paid) may arrive in either order. Your handler should be order-independent. - At-least-once: A successfully-processed event may still be retried if Frame didn't observe your 2xx response cleanly. Dedup on
event.id.
Testing
In sandbox, Frame fires the same events on the same payloads as production. Use a tunnel (ngrok, Cloudflare Tunnel) to expose your local handler to Frame's deliveries, or stand up an endpoint in your staging environment.
For unit testing your signature verifier without a live delivery: compute sha256=<hmac> over a fixture body using the endpoint's secret and inject it as X-Frame-Signature.
Gotchas
Symptom: signature verification fails on every delivery. Why: most often, your framework re-serialized the body before your handler saw it, or you compared against the parsed JSON instead of the raw bytes. Fix: in Express, mount express.raw({ type: 'application/json' }) on the webhook route before any JSON middleware. In Rails, use request.raw_post. Frame's signature is byte-exact.
Symptom: 400 on endpoint create with the message "contains invalid codes." Why: the code isn't in the catalog above. Frame validates the event_codes array against Webhook::EventCodes::EVENT_CODES; any value not in that list is rejected. Fix: check the spelling and the resource prefix against the catalog above — every code listed there is subscribable.
Symptom: events fire but no delivery arrives. Why: a previous endpoint with the same URL was soft-deleted but still in your dashboard, or the event code isn't in your subscription set. Fix: list active endpoints (GET /v1/webhook_endpoints) and confirm both the URL and the event_codes array match what you expect.
Symptom: you lost the signing secret. Why: secrets are returned only on create and rotate — there's no read-secret endpoint. Fix: rotate the secret via POST /v1/webhook_endpoints/{id}/rotate_secret, store the new value, update your verifier. Any in-flight deliveries signed with the old secret will fail; expect a brief verification-failure window during rotation.
Symptom: your endpoint sometimes processes the same event twice. Why: expected — at-least-once delivery. Fix: dedup on event.id before applying side effects. See the idempotency concept.
Next steps
- Events and webhooks — conceptual model, lifecycle, when events fire.
- Idempotency — handler patterns for at-least-once delivery.
- API reference: webhook endpoints — full operation reference generated from
openapi.yaml.