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

RequirementDetails
HTTPS endpointPublic URL Frame can POST to. HTTP is rejected. Self-signed certs are rejected.
Secret keyA Frame secret key (sk_sandbox_* or sk_production_*) to manage endpoints via the API.
Event codesA list of events your endpoint should receive. See the event catalog below.

Register an endpoint

POST/v1/webhook_endpoints
curl --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_endpoints

Paginated 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_secret
curl --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.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.succeeded and invoice.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