Webhook delivery
Every Flint webhook uses the same request shape, envelope, and retry schedule, whatever the event type. To build and register a handler, follow the Webhooks guide. For each event type and its data payload, see the event catalog.
Delivery contract#
| Property | Behavior |
|---|---|
| Request | HTTPS POST with a JSON body |
| User agent | Flint-Webhooks/1.0 |
| Signing | A Flint-signed JWT in Authorization (jwt, the default), a Standard Webhooks HMAC in webhook-signature (hmac), or both, set by the endpoint's signing_methods |
| Timeout | 10 seconds per attempt |
| Redirects | Never followed |
| Success | Any 2xx response |
| Failure | Anything else: another status, a redirect, a timeout, or a connection error |
| Retries | Up to 9 automatic attempts over about 71 to 86 hours |
| Guarantees | At-least-once delivery, in no guaranteed order |
Endpoint URLs must use HTTPS. Sandbox endpoints and partner endpoints restricted to test mode may point at a host that isn't live yet. Live endpoints and partner endpoints that include live mode must resolve to public IP addresses when you register or change them. HTTPS, localhost, and private IP checks still apply in test mode. Before every attempt, Flint resolves the hostname and checks every address: a DNS failure reports webhook_dns, and a private or local address reports webhook_blocked_target.
An endpoint belongs to the mode and sandbox of the API key that created it, and receives only that environment's events.
Delivery headers#
POST /webhooks/flint HTTP/1.1
Content-Type: application/json
User-Agent: Flint-Webhooks/1.0
Authorization: Bearer <Flint-signed JWT>
Flint-Webhook-Endpoint-Id: whep_1kmn0aExample
webhook-id: whev_1kmn0aExample
webhook-timestamp: 1783090410
| Header | Sent | Purpose |
|---|---|---|
Authorization | When jwt is enabled | Bearer <JWT>. Verify it as described in JWT signing. |
webhook-signature | When hmac is enabled | One or more space-separated v1,<signature> values in Standard Webhooks format. During a secret rotation's overlap there are two, one per secret. Verify with the HMAC examples. |
webhook-id | Always | The event ID (whev_...), the same on every retry and resend of the event. It's your deduplication key. |
webhook-timestamp | Always | Unix time in seconds when Flint signed this attempt. Equal to the JWT's iat. |
Flint-Webhook-Endpoint-Id | Always | The endpoint (whep_...) this delivery is for. Use it to pick the HMAC secret when one URL serves several endpoints. It isn't signed, so for JWT verify aud against your configured endpoint IDs rather than trusting this header. |
Header names are case-insensitive. The event type is in the body's event_type; route on it after you verify the signature.
JWT signing#
New endpoints default to "signing_methods": ["jwt"]. Each attempt carries a fresh ES256 JWT with header typ: flint-webhook+jwt and a kid naming a key in Flint's JWKS at https://api.withflintpay.com/.well-known/jwks.json.
| Claim | Value |
|---|---|
iss | https://api.withflintpay.com, in live and test mode |
aud | The endpoint's webhook_endpoint_id, which stays the same when you change its URL |
iat | When Flint signed this attempt, also sent as webhook-timestamp |
exp | iat + 300 |
jti | The attempt ID (watt_...), different on every attempt |
body_sha256 | SHA-256 of the raw request body, base64url without padding |
The token has no sub. To verify a delivery:
- Require
ES256,typ: flint-webhook+jwt, the issuer above, and anaudfrom your own list of endpoint IDs. - Check
iatandexpwith at most 60 seconds of leeway, and reject a token whose lifetime exceeds 300 seconds. - Hash the raw body and compare it with
body_sha256in constant time before you parse the JSON. Do this even when a gateway in front of you verifies the JWT, because verifying the token alone doesn't check the body. - Deduplicate on
webhook_event_id, notjti: every retry has a new token and attempt ID. - Honor the JWKS
Cache-Control, cache keys for at most 24 hours, refetch at most once a minute for an unknownkid, and never pin a key ID.
The Step 4 verifiers in the Webhooks guide, with the Node and PHP SDKs or without one, implement all of these.
With ["jwt", "hmac"], each delivery carries both Authorization and webhook-signature. Flint never sends a JWT endpoint a delivery without its JWT, and never falls back to HMAC alone. If Flint can't sign, a test event or resend returns 503 SERVICE_UNAVAILABLE, and an automatic delivery waits and tries again without using one of its 9 attempts. To move an existing endpoint between methods, change its signing methods.
Event body#
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"event_source": "merchant",
"payload_version": 2,
"api_version": "2026-09-07",
"mode": "test",
"sandbox_id": "test_1kmn0aExample",
"merchant_id": "mer_1kmn0aExample",
"created_at": "2026-07-03T14:53:30Z",
"test": false,
"request": {
"id": "req_01KWM638WGDX5RZ6THJCQBZCEV",
"idempotency_key": "confirm-payment-1kmn0a-001"
},
"data": {
"object": {
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded",
"amount_money": {"amount": 2500, "currency": "USD"},
"order_id": "ord_1kmn0aExample"
}
}
}
The object above is abbreviated. A delivery carries the whole resource as its GET returns it, without expansions and apart from the few caller-specific fields the event payload guide lists under fields webhooks leave out.
| Field | Description |
|---|---|
webhook_event_id | The event ID. The same on every retry and resend, and sent as the webhook-id header. |
event_type | What happened, as <resource_type>.<event>. The part before the dot names the resource in data.object. |
event_source | Required. merchant for a merchant endpoint, partner_app for your app's own lifecycle events, or installed_merchants for resource events from merchants that installed your app. |
payload_version | The envelope format: 2 for every current event. 1 only for an event queued before 2026-10-16 and delivered afterwards, which keeps its previous data shape. |
api_version | The API version data is rendered at, explained below. |
mode | test or live. |
sandbox_id | Public sandbox ID. Present exactly when mode is test; omitted in live mode. |
merchant_id | The merchant the event belongs to. |
created_at | When Flint recorded the event, in RFC 3339 UTC. Retries and resends keep this time. |
test | true only for a synthetic event from the test-events endpoint. Real sandbox events have "test": false and "mode": "test". |
request | Always present. null when no API request caused the event. Otherwise the originating request's id, which is the request_id its response returned, and its idempotency_key if it sent one. Look the request up in your request logs. |
data | Always {"object": ...}, where object is the affected resource. |
Which API version a delivery uses#
Every event records the API version it was created at. Each attempt renders data at the older of that version and the endpoint's api_version at the moment of the attempt; an endpoint set to "default" uses the merchant's default version then. Flint never converts an event to a newer version than it was recorded at.
Because each attempt chooses again, a retry or resend that follows a change to the endpoint's api_version can arrive at a different version from the first attempt, with the same webhook_event_id. Read api_version before you parse data. API versions and upgrades covers moving endpoints between versions.
Partner app envelope#
Endpoints owned by a partner app receive the same data in a partner envelope. It has partner_app_id instead of mode, merchant_id, and request, and a source object that says which merchant and install the event concerns. The required top-level event_source is partner_app or installed_merchants:
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"event_source": "installed_merchants",
"payload_version": 2,
"api_version": "2026-09-07",
"partner_app_id": "papp_1kmn0aExample",
"created_at": "2026-07-03T14:53:30Z",
"test": false,
"source": {
"merchant_id": "mer_1kmn0aExample",
"partner_app_install_id": "pinst_1kmn0aExample",
"mode": "test",
"environment_grant_id": "egrt_1kmn0aExample",
"sandbox_id": "test_1kmn0aExample"
},
"data": {
"object": {
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded"
}
}
}
source field | Description |
|---|---|
merchant_id | The merchant the event concerns. |
partner_app_install_id | The install the event concerns. |
mode | test or live. installed_merchants only. |
environment_grant_id | The environment grant that authorizes the delivery. installed_merchants only. |
sandbox_id | The sandbox the event came from. installed_merchants events in test mode only. |
Unknown fields and event types#
Flint adds event types and resource fields over time, and an endpoint subscribed with ["*"] receives new types automatically. Ignore fields you don't recognize, and answer 2xx to event types you don't handle so Flint doesn't retry them.
Delivery status#
Each event gets one delivery per subscribed endpoint (wdel_...), and the status lives on the delivery, not the event:
- pending moves to delivered on 2xx
- pending moves to failed on ninth attempt fails
- pending moves to suppressed on endpoint unavailable
- failed moves to delivered on resend
- suppressed moves to delivered on resend
status on webhook deliveryRetry schedule#
A failed attempt retries on a fixed backoff, up to 9 automatic attempts in total:
| Attempt | Delay after the previous failure | Time since the first attempt, before jitter |
|---|---|---|
| 1 | none | 0 |
| 2 | 30 seconds | 30 seconds |
| 3 | 2 minutes | 2.5 minutes |
| 4 | 10 minutes | 12.5 minutes |
| 5 | 1 hour | 1.2 hours |
| 6 | 4 hours | 5.2 hours |
| 7 | 12 hours | 17.2 hours |
| 8 | 24 hours | 41.2 hours |
| 9 | 30 hours | 71.2 hours |
Each delay is a minimum: Flint adds 0 to 20% at random, and a retry can start several seconds later still, longer when Flint is busy. The ninth attempt lands between about 71 and 86 hours after the first, plus that pickup time. Every attempt is signed fresh with a new timestamp, so a retry passes your timestamp check however late it runs.
Manual attempts don't use up the schedule:
- Resending a
pendingdelivery attempts at once and leaves the schedule alone. A failed resend keeps the samenext_retry_at, and a successful one marks the deliverydelivered. - Resending a
failedorsuppresseddelivery makes one attempt. It becomesdeliveredif the attempt succeeds andfailedif it doesn't; automatic retries don't restart. - Creating a delivery with
POST /v1/webhook-deliveries, for an event the endpoint has no delivery for, attempts at once. If that attempt fails, the delivery becomespendingand the automatic schedule starts.
Attempt numbers count every attempt, manual or automatic, so a resend between two retries takes the next number. Only automatic attempts count toward the 9, and automatic_attempt_count tracks them.
Inactive and deleted endpoints#
Setting an endpoint's status to inactive stops delivery to it immediately:
- In the same update, its
pendingdeliveries becomesuppressedwithsuppression_reasonendpoint_inactive, so Flint never sends them. - Events that happen while it's inactive create no deliveries for it.
- Setting it back to
activedoesn't restart suppressed deliveries or backfill missed events. Resend the suppressed deliveries you need, and create deliveries for events that have none, while their payloads are retained. Or read current state from the API.
Deleting an endpoint suppresses its pending deliveries the same way, with suppression_reason endpoint_deleted. Its past deliveries keep their webhook_endpoint_id.
Delivery diagnostics#
Flint records why each attempt failed and why a delivery was suppressed. These calls return them:
A delivery that's three attempts into its schedule after your server answered 503:
curl https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": {
"webhook_delivery_id": "wdel_1kmn0aExample",
"webhook_event_id": "whev_1kmn0aExample",
"webhook_endpoint_id": "whep_1kmn0aExample",
"status": "pending",
"automatic_attempt_count": 3,
"max_attempts": 9,
"next_retry_at": "2026-07-03T15:07:12Z",
"last_response_code": 503,
"error_category": "webhook_http_5xx",
"error_summary": "The webhook endpoint returned a server error.",
"retryable": true,
"recommended_action": "Inspect the endpoint logs for this delivery, fix the handler, then resend the webhook event.",
"created_at": "2026-07-03T14:53:30Z",
"updated_at": "2026-07-03T14:56:00Z"
},
"request_id": "req_01KWM638WGDX5RZ6THJCQBZCEV"
}
The Webhooks guide walks through debugging and recovering with these calls.
Resend and create refusals#
A resend while another attempt for the same delivery is running returns 409 OPERATION_IN_PROGRESS, with no reason. details[0] names the webhook_delivery and its ID. Wait for Retry-After: 5, then retry the same request with the same Idempotency-Key, as remediation.retry: same_request directs. The refusal isn't retained against the key.
Resending or creating a delivery after the event's 14-day payload retention returns 409 ACTION_NOT_ALLOWED with reason payload_expired and retry none. Read the resource with GET for its current state. Flint can't resend the expired event.
Creating a delivery for an endpoint that doesn't subscribe to the event returns 409 ACTION_NOT_ALLOWED with reason configuration_required. Add the event type to the endpoint's enabled_events; for partner endpoints, also check event_sources and installed_merchant_modes, then retry. An inactive endpoint returns ACTION_NOT_ALLOWED with reason unsupported_state; activate it first.
Failure fields#
Deliveries, attempts, and timeline attempts explain a failure with the same four fields:
| Field | Description |
|---|---|
error_category | The kind of failure, one of the failure categories. Branch your code on this field. |
error_summary | A short explanation for people to read. |
retryable | true when sending again without changing your endpoint is likely to succeed. While a delivery is pending, Flint retries on schedule either way. |
recommended_action | The next step to resolve the failure, for people to read. |
On an attempt, these fields are present only when its status is failed. On a delivery, they describe the most recent failed attempt, and they're absent before any attempt and when the delivery is delivered or suppressed. A delivery also reports the latest HTTP status in last_response_code.
Attempts, including the one a resend or test event returns, also record your server's response:
| Field | Description |
|---|---|
status_code | The HTTP status your endpoint returned. Absent when no response arrived. |
duration_milliseconds | How long the attempt took. |
response_headers | Your response headers, keyed by lowercase name, such as {"content-type": "application/json"}. Repeated values are joined with , , and sensitive values such as cookies and credentials read [REDACTED]. |
response_body_excerpt | The first 4 KB of the response body. JSON bodies are re-encoded with sensitive values replaced by [REDACTED], and a longer body ends with [TRUNCATED]. |
response_content_type | The response's Content-Type. |
The three response_ fields are returned only when your API key can read the resource the event is about, and response_headers and response_body_excerpt are removed after 14 days. Timeline attempts carry status_code, duration_milliseconds, and the failure fields, but not the response details.
Attempt status#
WebhookDeliveryAttempt.status, WebhookDeliveryAction.attempt_status, and webhook attempt timeline entries use the extensible message_attempt_status vocabulary. Webhooks emit these values:
| Status | Meaning |
|---|---|
pending | Scheduled or in flight; not yet answered. |
delivered | The endpoint answered 2xx. |
failed | The endpoint returned a non-2xx status, timed out, or couldn't be reached; see error_category. |
Handle an unknown attempt status without failing to read the record. WebhookDelivery.status uses the closed message_status vocabulary and remains pending, delivered, failed, or suppressed. The delivery_status in action responses uses the same closed message_status vocabulary and the same values.
Failure categories#
error_category | What happened | retryable | What to do |
|---|---|---|---|
webhook_timeout | Your endpoint didn't respond within 10 seconds. | true | Acknowledge requests quickly and process work asynchronously. |
webhook_dns | The endpoint's hostname didn't resolve. | true | Check the hostname and its DNS records. |
webhook_tls | Flint couldn't establish a trusted TLS connection. | false | Fix the certificate chain and hostname configuration. |
webhook_connection | Flint couldn't connect to the endpoint. | true | Confirm the server is running and reachable from the public internet. |
webhook_blocked_target | The URL resolves to localhost, a private network, or another address Flint doesn't deliver to. | false | Use a public HTTPS URL. |
webhook_request | Flint couldn't build or send the request. | false | Check the endpoint URL. |
webhook_redirect | Your endpoint answered with a 3xx redirect, which Flint doesn't follow. | false | Register the final URL as the endpoint's url. |
webhook_http_4xx | Your endpoint answered with a 4xx status. | false | Check the response body and your signature verification, then resend once the handler is fixed. |
webhook_http_5xx | Your endpoint answered with a 5xx status. | true | Check your endpoint's logs for this delivery, fix the handler, then resend. |
webhook_payload_render | Flint couldn't prepare the delivery's payload, and retries it automatically. | true | Retry delivery, and contact support if Flint still can't prepare it. |
webhook_delivery | Flint couldn't classify the failure. | true | Check that the endpoint is available, then retry delivery. |
Suppression reasons#
A suppressed delivery was stopped before Flint sent it. Flint checks each delivery again right before every attempt, so a delivery can be suppressed after earlier attempts failed. Suppressed deliveries have suppression_reason and no failure fields.
suppression_reason | Why Flint stopped the delivery |
|---|---|
endpoint_inactive | The endpoint's status is inactive. |
endpoint_deleted | The endpoint was deleted. |
event_not_allowed | The endpoint can no longer receive the event's type: the type was removed, or it isn't available to partner app endpoints. |
authorization_revoked | Partner app endpoints only. The app, the merchant's install, or the environment grant that authorized the delivery is no longer active. |
partner_app_install_revoked | The app access that authorized a merchant endpoint's install link has ended. Flint stops deliveries before deleting the endpoint; after deletion, pending deliveries use endpoint_deleted. Reinstalling the app doesn't restore the old endpoint. |
scope_missing | Partner app endpoints only. The app, the install, or the environment grant no longer includes the scope the event type requires. |
To deliver an event suppressed with endpoint_inactive, set the endpoint's status back to active and resend the delivery while the event's payload is retained.
Retention#
| Record | Kept for | After it expires |
|---|---|---|
| Event payload | 14 days | The event keeps api_version, mode, merchant_id, request without its idempotency_key, and its other metadata. GET /v1/webhook-events/{webhook_event_id} no longer returns data, payload_version, or a partner event's source. Resending or creating a delivery for it returns 409 ACTION_NOT_ALLOWED with reason payload_expired and retry none. Read the resource with GET for its current state; Flint can't resend an event after its 14-day payload retention. |
| Attempt response details | 14 days | Attempts keep their number, timing, and status code, but lose response_headers and response_body_excerpt. |
| Events, deliveries, and attempts | 90 days | The records are deleted, and looking one up returns 404. |
A delivery still pending when its event's payload expires stops retrying. It becomes failed with the failure fields of its last attempt, or suppressed with endpoint_inactive or endpoint_deleted if its endpoint is inactive or deleted.
Delivery order#
Events fan out independently, and a failed delivery retries while newer events keep flowing, so an older event can arrive after newer ones:
- Flint sends subscription.created to Your endpoint
- Your endpoint returns 503 to Flint
- Flint sends subscription.payment_succeeded to Your endpoint
- Your endpoint returns 200 to Flint
- Flint sends subscription.activated to Your endpoint
- Your endpoint returns 200 to Flint
- Flint sends subscription.created, retried after 30 seconds to Your endpoint
- Your endpoint returns 200 to Flint
Treat a webhook as a signal that something changed, not as the change itself. When your logic needs current state, fetch the resource: GET /v1/subscriptions/{subscription_id} reflects everything that has happened to the subscription, whatever has reached your endpoint so far.
Don't order events by comparing timestamps. Two changes can share a second, and a retry can carry an older snapshot than one you already processed. If you mirror state locally, fetch the current resource on every event and write what the API returns.
