Skip to content

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#

PropertyBehavior
RequestHTTPS POST with a JSON body
User agentFlint-Webhooks/1.0
SigningA 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
Timeout10 seconds per attempt
RedirectsNever followed
SuccessAny 2xx response
FailureAnything else: another status, a redirect, a timeout, or a connection error
RetriesUp to 9 automatic attempts over about 71 to 86 hours
GuaranteesAt-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#

HTTP
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
HeaderSentPurpose
AuthorizationWhen jwt is enabledBearer <JWT>. Verify it as described in JWT signing.
webhook-signatureWhen hmac is enabledOne 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-idAlwaysThe event ID (whev_...), the same on every retry and resend of the event. It's your deduplication key.
webhook-timestampAlwaysUnix time in seconds when Flint signed this attempt. Equal to the JWT's iat.
Flint-Webhook-Endpoint-IdAlwaysThe 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.

ClaimValue
isshttps://api.withflintpay.com, in live and test mode
audThe endpoint's webhook_endpoint_id, which stays the same when you change its URL
iatWhen Flint signed this attempt, also sent as webhook-timestamp
expiat + 300
jtiThe attempt ID (watt_...), different on every attempt
body_sha256SHA-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 an aud from your own list of endpoint IDs.
  • Check iat and exp with at most 60 seconds of leeway, and reject a token whose lifetime exceeds 300 seconds.
  • Hash the raw body and compare it with body_sha256 in 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, not jti: 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 unknown kid, 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#

JSON
{
  "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.

FieldDescription
webhook_event_idThe event ID. The same on every retry and resend, and sent as the webhook-id header.
event_typeWhat happened, as <resource_type>.<event>. The part before the dot names the resource in data.object.
event_sourceRequired. 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_versionThe 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_versionThe API version data is rendered at, explained below.
modetest or live.
sandbox_idPublic sandbox ID. Present exactly when mode is test; omitted in live mode.
merchant_idThe merchant the event belongs to.
created_atWhen Flint recorded the event, in RFC 3339 UTC. Retries and resends keep this time.
testtrue only for a synthetic event from the test-events endpoint. Real sandbox events have "test": false and "mode": "test".
requestAlways 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.
dataAlways {"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:

JSON
{
  "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 fieldDescription
merchant_idThe merchant the event concerns.
partner_app_install_idThe install the event concerns.
modetest or live. installed_merchants only.
environment_grant_idThe environment grant that authorizes the delivery. installed_merchants only.
sandbox_idThe 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:

Webhook delivery statusStartFinal
  • 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 delivery
  • pending
    Flint is still delivering. next_retry_at says when the next automatic attempt runs, and automatic_attempt_count counts the automatic attempts so far, out of max_attempts.
  • delivered
    Your endpoint answered 2xx. A later resend can still send the event again; the delivery stays delivered whatever the resend's outcome.
  • failed
    Automatic delivery stopped: the ninth automatic attempt failed, or the event's payload expired while retries remained. Resend it after you fix your handler, while the payload is retained.
  • suppressed
    Flint stopped the delivery before sending it, for the suppression_reason listed under suppression reasons.

Retry schedule#

A failed attempt retries on a fixed backoff, up to 9 automatic attempts in total:

AttemptDelay after the previous failureTime since the first attempt, before jitter
1none0
230 seconds30 seconds
32 minutes2.5 minutes
410 minutes12.5 minutes
51 hour1.2 hours
64 hours5.2 hours
712 hours17.2 hours
824 hours41.2 hours
930 hours71.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 pending delivery attempts at once and leaves the schedule alone. A failed resend keeps the same next_retry_at, and a successful one marks the delivery delivered.
  • Resending a failed or suppressed delivery makes one attempt. It becomes delivered if the attempt succeeds and failed if 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 becomes pending and 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 pending deliveries become suppressed with suppression_reason endpoint_inactive, so Flint never sends them.
  • Events that happen while it's inactive create no deliveries for it.
  • Setting it back to active doesn'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
curl https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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:

FieldDescription
error_categoryThe kind of failure, one of the failure categories. Branch your code on this field.
error_summaryA short explanation for people to read.
retryabletrue when sending again without changing your endpoint is likely to succeed. While a delivery is pending, Flint retries on schedule either way.
recommended_actionThe 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:

FieldDescription
status_codeThe HTTP status your endpoint returned. Absent when no response arrived.
duration_millisecondsHow long the attempt took.
response_headersYour 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_excerptThe 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_typeThe 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:

StatusMeaning
pendingScheduled or in flight; not yet answered.
deliveredThe endpoint answered 2xx.
failedThe 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_categoryWhat happenedretryableWhat to do
webhook_timeoutYour endpoint didn't respond within 10 seconds.trueAcknowledge requests quickly and process work asynchronously.
webhook_dnsThe endpoint's hostname didn't resolve.trueCheck the hostname and its DNS records.
webhook_tlsFlint couldn't establish a trusted TLS connection.falseFix the certificate chain and hostname configuration.
webhook_connectionFlint couldn't connect to the endpoint.trueConfirm the server is running and reachable from the public internet.
webhook_blocked_targetThe URL resolves to localhost, a private network, or another address Flint doesn't deliver to.falseUse a public HTTPS URL.
webhook_requestFlint couldn't build or send the request.falseCheck the endpoint URL.
webhook_redirectYour endpoint answered with a 3xx redirect, which Flint doesn't follow.falseRegister the final URL as the endpoint's url.
webhook_http_4xxYour endpoint answered with a 4xx status.falseCheck the response body and your signature verification, then resend once the handler is fixed.
webhook_http_5xxYour endpoint answered with a 5xx status.trueCheck your endpoint's logs for this delivery, fix the handler, then resend.
webhook_payload_renderFlint couldn't prepare the delivery's payload, and retries it automatically.trueRetry delivery, and contact support if Flint still can't prepare it.
webhook_deliveryFlint couldn't classify the failure.trueCheck 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_reasonWhy Flint stopped the delivery
endpoint_inactiveThe endpoint's status is inactive.
endpoint_deletedThe endpoint was deleted.
event_not_allowedThe endpoint can no longer receive the event's type: the type was removed, or it isn't available to partner app endpoints.
authorization_revokedPartner app endpoints only. The app, the merchant's install, or the environment grant that authorized the delivery is no longer active.
partner_app_install_revokedThe 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_missingPartner 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#

RecordKept forAfter it expires
Event payload14 daysThe 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 details14 daysAttempts keep their number, timing, and status code, but lose response_headers and response_body_excerpt.
Events, deliveries, and attempts90 daysThe 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:

A retry arrives after two newer eventsResponse
FlintYour endpointsubscription.created503subscription.payment_succeeded200subscription.activated200subscription.created, retried after 30 seconds200
  1. Flint sends subscription.created to Your endpoint
  2. Your endpoint returns 503 to Flint
  3. Flint sends subscription.payment_succeeded to Your endpoint
  4. Your endpoint returns 200 to Flint
  5. Flint sends subscription.activated to Your endpoint
  6. Your endpoint returns 200 to Flint
  7. Flint sends subscription.created, retried after 30 seconds to Your endpoint
  8. 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.

Webhooks guideRegister an endpoint, verify signatures, and build a handler that survives retries.Webhook eventsEvery event type Flint delivers, with its payload schema.Webhooks APIManage endpoints, list events and deliveries, and resend.

Was this helpful?