Webhooks
Webhooks are how Flint tells your backend that something happened. When a payment succeeds, a refund settles, or a subscription renews, Flint sends a signed HTTP POST to a URL you control and retries until your server confirms receipt. A webhook survives an abandoned browser redirect and is the right trigger for discrete business events. Account readiness is different: poll authoritative state, and use readiness webhooks to fetch sooner.
Use webhooks to:
- mark an order paid and kick off fulfillment
- record refund and dispute outcomes
- react to subscription lifecycle changes: renewals, payment failures, cancellations
- keep entitlements, inventory, or an internal ledger in sync
To receive events, register an endpoint, verify signatures, and make your handler safe to retry. For the complete list of event types Flint sends, see the event catalog.
How webhooks work#
Something happens on your account, such as a payment succeeding, a refund settling, or a subscription renewing. Flint records it as a webhook event (whev_...) and delivers it:
- event created moves to delivery attempted on signed HTTP POST
- delivery attempted moves to delivered on 2xx within 10 seconds
- delivery attempted moves to waiting to retry on anything else
- waiting to retry moves to delivery attempted on backoff, up to 8 more attempts over 72 hours
Every delivery is an HTTPS POST with a JSON body. Flint waits 10 seconds for a response, never follows redirects, and counts any 2xx as success. Delivery is at least once and in no guaranteed order, so a good handler deduplicates (Step 6) and treats each event as a signal to fetch fresh state rather than as the state itself (Retries and ordering). The full contract, every header, every envelope field, and the retry schedule are on the Webhook delivery reference page.
What you need#
- A test API key (
flint_test_...). Create one on the dashboard's API keys page, or use the API setup flow to provision the merchant and mint its first sandbox key programmatically. - A web server you can add a route to. The examples below show Node with Express, Python with Flask, and Go with the standard library.
- For local development, a tunnel tool such as ngrok or cloudflared.
Webhook URLs must be HTTPS and must resolve to a publicly routable address. Flint rejects localhost, private IPs, and internal hostnames both when you register the URL and again at delivery time, so it can never reach your dev machine directly. Step 2 shows the tunnel setup that solves this.
The event envelope#
Every delivery carries the same headers and the same JSON envelope. Only data changes with the event type. A handler needs three things from it:
webhook_event_id(also thewebhook-idheader) is your deduplication key. It is identical on every retry and resend of the same event.event_typesays what happened, so you can route the delivery without parsing the body. Every type is in the event catalog.datais the payload. Its shape depends on the event type.
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"payload_version": 1,
"api_version": "2026-02-01",
"mode": "test",
"merchant_id": "mer_1kmn0aExample",
"created_at": "2026-07-03T14:53:30Z",
"request": {
"id": "2f7c5a9d-6e4b-4f8a-9b73-1d8e4c6a0f25",
"idempotency_key": "confirm-payment-1kmn0a-001"
},
"data": {
"payment_intent": {
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded",
"amount_money": {"amount": 2500, "currency": "USD"},
"order_id": "ord_1kmn0aExample"
}
}
}
Ignore fields you don't recognize, ignore event_type values you don't handle, and return 2xx for those events so Flint does not retry them. The delivery headers and every envelope field are documented on the reference page.
Step 1: write a minimal handler#
Start with a route that does two things correctly: it keeps the raw request body available for signature verification, and it responds fast. Everything else layers on top of those two behaviors. The raw-body rule is universal to signed webhooks; if a body parser consumes the payload first, verification fails the way it does on any provider, and the signature failure reference catalogs those failure modes.
import express from "express";
const app = express();
// Raw body on this route only: signature verification needs the exact bytes.
app.post("/webhooks/flint", express.raw({ type: "application/json" }), (req, res) => {
console.log("received", req.headers["x-flint-event-type"]);
res.sendStatus(200);
});
app.listen(3000);
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/flint")
def flint_webhook():
raw_body = request.get_data() # exact bytes, before any JSON parsing
print("received", request.headers.get("X-Flint-Event-Type"))
return "", 200
package main
import (
"io"
"log"
"net/http"
)
func main() {
http.HandleFunc("/webhooks/flint", func(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body) // exact bytes, needed for verification
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.Println("received", r.Header.Get("X-Flint-Event-Type"), len(rawBody), "bytes")
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
Signature verification needs the exact bytes Flint sent. Global body-parsing middleware (such as express.json()) consumes and reparses the body before your code runs, which silently breaks verification. Register a raw-body parser on the webhook route only. And if your framework applies CSRF protection to all POST routes, exempt this one: Flint cannot send your CSRF token.
Step 2: expose it over public HTTPS#
In production, your webhook endpoint is a normal route on your domain and this step is free. In development, put a tunnel in front of your local server and give Flint the tunnel URL.
The Flint CLI skips this step. flint listen --forward-to http://localhost:3000/webhooks/flint streams your sandbox's events and posts them to your local server with a real signature, so you do not need a tunnel or a registered endpoint while developing. You still complete Steps 3 through 6 before going live.
ngrok http 3000
Copy the https:// forwarding URL it prints. Your endpoint URL is that host plus your route, for example https://abc123.ngrok-free.app/webhooks/flint.
cloudflared tunnel --url http://localhost:3000
Copy the https:// URL it prints. Your endpoint URL is that host plus your route, for example https://random-words.trycloudflare.com/webhooks/flint.
Free-tier tunnel URLs change every time the tunnel restarts. When yours does, update the registered URL with PATCH /v1/webhook-endpoints/{webhook_endpoint_id} instead of registering a new endpoint each time.
Step 3: register the endpoint#
Register the URL and choose which events it receives. Replace url with your own endpoint (the tunnel URL from Step 2 while developing):
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-endpoint-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": [
"checkout_session.completed",
"order.paid",
"refund.updated"
],
"description": "Fulfillment and refund sync",
"enabled": true
}'
{
"data": {
"webhook_endpoint_id": "whep_1kmn0aExample",
"url": "https://example.com/webhooks/flint",
"secret": "whsec_IZuqE1Q8V+xP3VgZ5vHrJBFM1WJ6+DpvV35+yRHKKc8=",
"enabled_events": [
"checkout_session.completed",
"order.paid",
"refund.updated"
],
"description": "Fulfillment and refund sync",
"enabled": true,
"created_at": "2026-07-03T14:53:12Z",
"updated_at": "2026-07-03T14:53:12Z"
}
}
This response is the only time Flint shows the secret. Store it in your secret manager before doing anything else. If you lose it, you don't need a new endpoint: rotate the secret and store the replacement.
Set api_version to "default" to follow your merchant default, or to a supported date such as "2026-02-01" to pin the handler. If omitted on creation, it follows the default. A request's Flint-Version header does not select the webhook endpoint version.
Omitting enabled_events subscribes the endpoint to every event type, including types launched later. That's convenient while exploring, but in production subscribe to the events you handle and widen the list with PATCH when you need more. Event matching is exact: order.* and * wildcard subscriptions are not supported today. GET /v1/webhook-event-types returns every subscribable type, and the event catalog describes each one.
You can also register and manage endpoints in the dashboard under Developers, then Webhooks. The dashboard shows the secret once at creation, exactly like the API.
Step 4: verify the signature#
Anyone who discovers your endpoint URL can send it fake events. Signature verification is what makes a delivery trustworthy: an HMAC that only Flint, holding your endpoint's secret, could have produced. New integrations should verify the Standard Webhooks headers with a library (shown below). If you maintain a legacy verifier, X-Flint-Signature signs the same body with the same secret. Verify one scheme completely before touching the payload.
The header contains a timestamp and one or more signatures:
X-Flint-Signature: t=1783090410,v1=39a949d856b98d47d3d546a7f098ca2b...
tis a Unix timestamp in seconds, set when the delivery attempt was made. Retries are signed fresh, so a retry never carries a stale timestamp.- Each
v1is an HMAC-SHA256 signature, hex encoded. There is normally one; during the 24 hours after a secret rotation there are two, one per secret. A match against anyv1value means the delivery is authentic.
The signed payload is the timestamp, a period, and the raw request body: {timestamp}.{raw_body}. The HMAC key is your whole whsec_... secret string, exactly as issued.
import crypto from "node:crypto";
export function verifyFlintSignature(rawBody, signatureHeader, secrets, toleranceSeconds = 300) {
const parts = String(signatureHeader ?? "").split(",").map((part) => part.trim());
const timestamp = Number(parts.find((part) => part.startsWith("t="))?.slice(2));
const signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3));
if (!Number.isFinite(timestamp) || signatures.length === 0) {
throw new Error("Malformed signature header");
}
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
throw new Error("Timestamp outside tolerance");
}
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
const valid = secrets.some((secret) => {
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
return signatures.some(
(signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
if (!valid) {
throw new Error("Signature mismatch");
}
}
import hashlib
import hmac
import time
def verify_flint_signature(raw_body: bytes, signature_header: str, secrets: list, tolerance_seconds: int = 300) -> None:
parts = [part.strip() for part in (signature_header or "").split(",")]
timestamps = [part[2:] for part in parts if part.startswith("t=")]
signatures = [part[3:] for part in parts if part.startswith("v1=")]
if not timestamps or not timestamps[0].isdigit() or not signatures:
raise ValueError("Malformed signature header")
timestamp = int(timestamps[0])
if abs(time.time() - timestamp) > tolerance_seconds:
raise ValueError("Timestamp outside tolerance")
signed_payload = f"{timestamp}.".encode() + raw_body
for secret in secrets:
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, signature) for signature in signatures):
return
raise ValueError("Signature mismatch")
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyFlintSignature(rawBody []byte, signatureHeader string, secrets []string, tolerance time.Duration) error {
var timestamp int64
var signatures [][]byte
for _, part := range strings.Split(signatureHeader, ",") {
part = strings.TrimSpace(part)
if value, ok := strings.CutPrefix(part, "t="); ok {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return errors.New("malformed signature header")
}
timestamp = parsed
}
if value, ok := strings.CutPrefix(part, "v1="); ok {
if decoded, err := hex.DecodeString(value); err == nil {
signatures = append(signatures, decoded)
}
}
}
if timestamp == 0 || len(signatures) == 0 {
return errors.New("malformed signature header")
}
age := time.Since(time.Unix(timestamp, 0))
if age < 0 {
age = -age
}
if age > tolerance {
return errors.New("timestamp outside tolerance")
}
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", timestamp)
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range signatures {
if hmac.Equal(signature, expected) {
return nil
}
}
}
return errors.New("signature mismatch")
}
Verification needs nothing beyond your runtime's built-in crypto library, so there is no dependency to add. If you already use an SDK, its verifyWebhook method does the same checks.
How verification works#
If you're implementing in another language, this is the whole algorithm:
The timestamp check is your replay protection: without it, anyone who captures one legitimate delivery can replay it forever. Never skip it or make the tolerance unbounded, and keep your server clock synced with NTP. Retried deliveries are re-signed with a fresh timestamp, so a five-minute tolerance never rejects a legitimate retry.
Test your verifier#
The values below are internally consistent, so you can use them as a fixture in your test suite before pointing Flint at your endpoint. With the secret from Step 3's example response, whsec_IZuqE1Q8V+xP3VgZ5vHrJBFM1WJ6+DpvV35+yRHKKc8=, timestamp 1783090410, and this raw body (one line, no trailing newline):
{"webhook_event_id":"whev_0testvector00000000000000","event_type":"payment_intent.succeeded","payload_version":1,"mode":"test","merchant_id":"mer_0testvector00000000000000","created_at":"2026-07-03T14:53:30Z","request":null,"data":{"payment_intent":{"payment_intent_id":"pi_0testvector00000000000000","status":"succeeded","amount_money":{"amount":2500,"currency":"USD"},"order_id":"ord_0testvector00000000000000"}}}
a correct implementation accepts exactly these headers:
X-Flint-Signature: t=1783090410,v1=43dc86d86ff71f6556d2547293592e076e2bb7c6d7d01a1b28f12ae8f5b6ddeb
webhook-signature: v1,2TP/l2XMEWGJ0n3KmLWR88LAzbI+EGG6ux7jqpSNP1s=
Disable the timestamp tolerance check when running this fixture; the timestamp is fixed in the past.
Step 5: send a test event#
Prove the whole pipeline works before wiring up real payments. The test-events endpoint synthesizes an event of any type and delivers it to your endpoint immediately:
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints/whep_1kmn0aExample/test-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-test-payment-intent-1" \
-d '{"event_type": "payment_intent.succeeded"}'
{
"data": {
"webhook_event_id": "whev_1kmn0aExample",
"webhook_delivery_id": "wdel_1kmn0aExample",
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 1,
"attempt_status": "delivered",
"delivery_status": "delivered",
"delivery_trigger": "test_event",
"status_code": 200,
"duration_milliseconds": 326,
"started_at": "2026-07-03T14:53:30Z",
"completed_at": "2026-07-03T14:53:30Z"
}
}
Meanwhile, your endpoint receives a delivery with real signatures and this body:
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"payload_version": 1,
"mode": "test",
"merchant_id": "mer_1kmn0aExample",
"created_at": "2026-07-03T14:53:30Z",
"request": {
"id": "2f7c5a9d-6e4b-4f8a-9b73-1d8e4c6a0f25",
"idempotency_key": "webhook-test-payment-intent-1"
},
"test": true,
"data": {
"event_type": "payment_intent.succeeded",
"payment_intent_id": "pi_1kmn0aExample",
"test": true
}
}
Two things to know about test events:
- The
datapayload is a small fixture with a reference ID, not a full resource object. Real events carry the complete payload. Test events prove your transport, verification, and routing; use real flows to test your business logic. - Test events are delivered once and never retried. If
attempt_statuscomes backfailed, the response includes the status code and response excerpt from your server; Monitor, Debug, and Resend shows how to dig deeper.
Run test events through the same code path as everything else, and branch on the envelope's mode and test fields before touching state you care about. To drive real end-to-end events (test cards, refunds, subscription renewals), see Testing.
Step 6: deduplicate and process asynchronously#
Duplicates are a normal part of webhook delivery, not an error: a retry can race a slow 2xx, and a manual resend redelivers an event on purpose. Every delivery of the same event carries the same webhook-id and legacy X-Flint-Webhook-ID, so deduplication is one check: record each processed ID with a unique constraint, or make the event ID the idempotency key of the write it triggers.
The finished handler puts the pieces in order: verify, deduplicate, enqueue, respond.
app.post("/webhooks/flint", express.raw({ type: "application/json" }), async (req, res) => {
const secrets = [
process.env.FLINT_WEBHOOK_SECRET,
process.env.FLINT_WEBHOOK_PREVIOUS_SECRET, // set only during rotation
].filter(Boolean);
try {
verifyFlintSignature(req.body, req.headers["x-flint-signature"], secrets);
} catch {
return res.sendStatus(400);
}
const eventId = req.headers["x-flint-webhook-id"];
if (await alreadyProcessed(eventId)) {
return res.sendStatus(200); // duplicate delivery, already handled
}
const event = JSON.parse(req.body.toString("utf8"));
switch (event.event_type) {
case "order.paid":
await jobs.enqueue("fulfill-order", { orderId: event.data.order_id });
break;
case "refund.updated":
await jobs.enqueue("sync-refund", { refundId: event.data.refund_id });
break;
default:
break; // unhandled event types still get a 200
}
await markProcessed(eventId);
return res.sendStatus(200);
});
import json
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/flint")
def flint_webhook():
raw_body = request.get_data()
secrets = [
secret
for secret in (
os.environ.get("FLINT_WEBHOOK_SECRET"),
os.environ.get("FLINT_WEBHOOK_PREVIOUS_SECRET"), # set only during rotation
)
if secret
]
try:
verify_flint_signature(raw_body, request.headers.get("X-Flint-Signature", ""), secrets)
except ValueError:
return "", 400
event_id = request.headers.get("X-Flint-Webhook-ID", "")
if already_processed(event_id):
return "", 200 # duplicate delivery, already handled
event = json.loads(raw_body)
if event["event_type"] == "order.paid":
jobs.enqueue("fulfill-order", order_id=event["data"]["order_id"])
elif event["event_type"] == "refund.updated":
jobs.enqueue("sync-refund", refund_id=event["data"]["refund_id"])
# Unhandled event types still return 200.
mark_processed(event_id)
return "", 200
func flintWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
secrets := []string{os.Getenv("FLINT_WEBHOOK_SECRET")}
if previous := os.Getenv("FLINT_WEBHOOK_PREVIOUS_SECRET"); previous != "" {
secrets = append(secrets, previous) // set only during rotation
}
if err := VerifyFlintSignature(rawBody, r.Header.Get("X-Flint-Signature"), secrets, 5*time.Minute); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
eventID := r.Header.Get("X-Flint-Webhook-ID")
if alreadyProcessed(eventID) {
w.WriteHeader(http.StatusOK) // duplicate delivery, already handled
return
}
var event struct {
EventType string `json:"event_type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
switch event.EventType {
case "order.paid":
enqueue("fulfill-order", event.Data)
case "refund.updated":
enqueue("sync-refund", event.Data)
default:
// Unhandled event types still get a 200.
}
markProcessed(eventID)
w.WriteHeader(http.StatusOK)
}
Respond within the 10-second budget by doing only verification, deduplication, and a durable enqueue inline; run fulfillment, emails, and third-party calls from the queue. This matters more than it looks: subscription renewals and payouts arrive in bursts, and a queue absorbs the burst at your own pace. Return 2xx only after the work is safely recorded. If you crash before recording it, the missing 2xx means Flint redelivers, which is exactly what you want.
Verify with a standard webhooks library#
Every delivery also carries Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature), so if your team already uses the spec's libraries you can verify Flint deliveries without writing any crypto. Pass your whsec_... secret to the library unchanged; the libraries handle the prefix and key decoding themselves.
The two schemes sign the same body with the same secret but differ in the details, so verify one of them fully rather than mixing pieces of both:
X-Flint-Signature | webhook-signature | |
|---|---|---|
| Format | t={timestamp},v1={signature} | v1,{signature}, space separated when multiple |
| Signed payload | {timestamp}.{raw_body} | {webhook_event_id}.{timestamp}.{raw_body} |
| Signature encoding | Hex | Base64 |
| HMAC key | The whole whsec_... string as UTF-8 bytes | The secret after whsec_, base64 decoded (libraries do this for you) |
| Timestamp | t= value inside the header | The webhook-timestamp header |
import { Webhook } from "standard-webhooks";
const webhook = new Webhook(process.env.FLINT_WEBHOOK_SECRET);
// Throws when the signature is invalid or the timestamp is stale.
const event = webhook.verify(req.body, {
"webhook-id": req.headers["webhook-id"],
"webhook-timestamp": req.headers["webhook-timestamp"],
"webhook-signature": req.headers["webhook-signature"],
});
from standardwebhooks import Webhook
webhook = Webhook(os.environ["FLINT_WEBHOOK_SECRET"])
# Raises WebhookVerificationError when invalid.
event = webhook.verify(raw_body, dict(request.headers))
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
wh, err := standardwebhooks.NewWebhook(os.Getenv("FLINT_WEBHOOK_SECRET"))
if err != nil {
log.Fatal(err)
}
// Returns an error when the signature is invalid or the timestamp is stale.
if err := wh.Verify(rawBody, r.Header); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
Pick one scheme and verify it completely. There is no security benefit to checking both; they authenticate the same delivery with the same secret.
Retries and ordering#
A failed delivery retries on a backoff schedule, up to 9 attempts over about three days, each signed fresh with a new timestamp. After the last failure the event is marked failed, but it is never lost: it stays queryable and you can resend it once your handler is fixed. The retry schedule lists every delay.
Events fan out independently, so a retry of an older event can arrive after a newer one. Treat a webhook as a signal that something changed, not as the change itself. When your logic needs current state, fetch the resource. The delivery order section shows what an interleaved sequence looks like and which timestamps to compare if you mirror state locally.
Which event should trigger fulfillment?#
Several events fire around a successful payment. Pick one as your fulfillment trigger; subscribing to all of them gives you three chances to double-fulfill.
Whichever you choose, the webhook, never the browser redirect, is the source of truth for fulfillment. A buyer can close the tab before your success page loads, and anyone can request your success URL directly. The Checkout Sessions guide walks through this flow end to end, and Choosing the right event covers which event level to build each job on.
Account readiness: poll first, fetch sooner on events#
For onboarding and later compliance remediation, GET /v1/onboarding/state is authoritative in both sandbox and live mode. Read it after every browser handoff and poll with bounded exponential backoff while review is pending. Start at 2 seconds, then 4, 8, and 15 seconds, then every 30 seconds for up to 15 minutes. Keep the workflow resumable after that bound instead of polling forever.
Subscribe to merchant.readiness.updated as an accelerator. On receipt, enqueue a fresh state fetch and return 2xx; do not apply the event payload as a patch. Delivery is at least once, ordering is not guaranteed, and Flint may emit a readiness event after a processor account write even when the normalized public state has no visible change. Duplicate and no-visible-change events are valid fetch triggers.
Sandbox processor account updates emit real merchant.readiness.updated events with mode = "test". A test event sent through the test-events endpoint is still useful, but it proves only signing, delivery, parsing, and your fetch trigger. Use an actual sandbox onboarding or remediation transition to rehearse the complete state loop.
Rotate your signing secret#
Rotate when a secret may have leaked, when someone with access to it leaves, or on whatever schedule your security policy sets. Rotation is zero-downtime: for 24 hours after rotating, Flint signs every delivery with both the old and new secrets, and a match against either one verifies.
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints/whep_1kmn0aExample/rotate-secret \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-rotate-001"
{
"data": {
"secret": "whsec_8496JEjvXj6YMU2eGg1GYA+/M1pundqxxvRNasV25u4="
}
}
A clean rollout:
- Call
rotate-secretand store the new secret. Your running handler keeps verifying without a deploy, because deliveries still carry a signature from the old secret during the overlap. - Deploy with
FLINT_WEBHOOK_SECRETset to the new value andFLINT_WEBHOOK_PREVIOUS_SECRETset to the old one. The Step 6 handler already accepts both. - After 24 hours, remove
FLINT_WEBHOOK_PREVIOUS_SECRET.
Monitor, debug, and resend#
Every delivery Flint makes is recorded, along with what your server sent back. When something isn't arriving, the answer is almost always in these records rather than in your logs.
List recent events and deliveries#
curl "https://api.withflintpay.com/v1/webhook-events?webhook_endpoint_id=whep_1kmn0aExample&include=test_events" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"event_origin": "business_event",
"resource_type": "payment_intent",
"resource_id": "pi_1kmn0aExample",
"test": false,
"created_at": "2026-07-03T14:59:52Z"
}
]
}
Webhook events are canonical records and do not have a single delivery status. One event can have several child deliveries with different outcomes. webhook_endpoint_id filters to events with a delivery for that endpoint, and delivery_status=failed filters to events with at least one failed delivery. You can also filter by event_type, creation time, or resource attribution. Synthetic test events are hidden unless you pass include=test_events.
Fetching a single event with GET /v1/webhook-events/{webhook_event_id} also returns the event's payload when your API key has read scope for the resource inside it. The payload uses the older of your request's served API version and the event's recorded version. Its envelope's api_version identifies that shape. Events recorded at an older version keep their original shape when you request a newer version.
The webhook.event frames on GET /v1/webhook-events/stream follow the same version rule. The list route omits payloads.
List the concrete deliveries before inspecting or resending one:
curl https://api.withflintpay.com/v1/webhook-events/whev_1kmn0aExample/deliveries \
-H "Authorization: Bearer YOUR_API_KEY"
Each delivery includes its webhook_delivery_id, endpoint, status, automatic attempt count, latest error, and recommended action.
Inspect delivery attempts#
Each attempt records what your server said, which is usually the whole diagnosis:
curl https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample/attempts \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"webhook_delivery_id": "wdel_1kmn0aExample",
"webhook_event_id": "whev_1kmn0aExample",
"attempt_number": 1,
"delivery_trigger": "automatic_delivery",
"status": "failed",
"status_code": 500,
"error_message": "Webhook endpoint returned HTTP 500.",
"error_summary": "The webhook endpoint returned a server error.",
"diagnostic_category": "http_error",
"retryable": true,
"recommended_action": "Inspect the endpoint logs for this delivery, fix the handler, then resend the webhook event.",
"response_body_excerpt": "Internal Server Error",
"response_content_type": "text/plain; charset=UTF-8",
"started_at": "2026-07-03T14:59:52Z",
"completed_at": "2026-07-03T14:59:52Z",
"duration_milliseconds": 326
}
]
}
response_body_excerpt is the beginning of whatever your server returned, and diagnostic_category classifies transport failures. Together they cover the common cases:
| What you see | Likely cause | Fix |
|---|---|---|
status_code in the 3xx range | Your server redirected (trailing slash, http to https, www). Flint never follows redirects. | Register the exact final URL. |
status_code 400 | Your handler rejected the signature, usually because middleware consumed the raw body. | Verify against the exact raw bytes (Step 1); confirm the secret belongs to this endpoint. |
status_code 401 or 403 | Auth or CSRF middleware intercepted the request before your handler ran. | Exempt the webhook route. |
status_code 404 | The registered URL doesn't match a deployed route. | Compare the registered URL with your router. |
status_code 5xx | Your handler crashed; the excerpt usually shows the error. | Fix the handler, then resend. |
diagnostic_category: "timeout" | No response within 10 seconds. | Respond after enqueueing, not after processing (Step 6). |
diagnostic_category: "dns_error" | The hostname no longer resolves. Tunnels expire. | Check DNS, restart the tunnel, update the URL. |
diagnostic_category: "tls_error" | Invalid, expired, or self-signed certificate. | Serve a valid TLS certificate. |
diagnostic_category: "connection_error" | Nothing is listening at the address. | Confirm the server or tunnel is running. |
diagnostic_category: "blocked_target" | The URL resolves to a private or local address. | Use a publicly routable HTTPS URL. |
Resend a delivery#
After fixing your handler, redeliver one concrete endpoint delivery on demand:
curl -X POST https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample/resend \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"reason_message": "handler fixed, redelivering"}'
{
"data": {
"webhook_event_id": "whev_1kmn0aExample",
"webhook_delivery_id": "wdel_1kmn0aExample",
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 2,
"attempt_status": "delivered",
"delivery_status": "delivered",
"delivery_trigger": "manual_resend",
"status_code": 200,
"duration_milliseconds": 341
}
}
Resends carry fresh signatures and the same webhook_event_id. That second part matters: if you resend an event your system already processed, your deduplication check will skip it, which is correct. To force reprocessing, clear your processed marker for that event ID first.
The dashboard provides the same controls under Developers, then Webhooks: per-endpoint delivery history, attempt details, test events, and resends.
Common mistakes#
- Verifying a parsed body. Parsing and re-serializing JSON almost never reproduces the original bytes, so the signature won't match. Verify the raw body, then parse.
- Fulfilling from the browser redirect. Redirects get abandoned and success URLs can be opened by anyone. The webhook is the durable, authenticated signal.
- Assuming events arrive in order. Retries interleave with new events. Fetch current state from the API when order matters.
- Rejecting event types you don't handle. A non-2xx response makes Flint retry that delivery for three days and then mark it failed, polluting the delivery history you monitor. Return
200and move on. - Doing slow work before responding. Anything past 10 seconds is a failed attempt, even if your handler eventually finishes. Enqueue, respond, process.
- Skipping the timestamp check. Without it, one captured delivery can be replayed forever. Five minutes of tolerance costs you nothing; retries are always re-signed fresh.
- Losing the signing secret. It's shown once, at creation and rotation. If it's gone, rotate; don't go looking for it.
Production checklist#
- Signature verification runs against the raw body with a bounded timestamp tolerance. New integrations prefer the Standard Webhooks headers.
- Deduplication keys off
webhook-idorwebhook_event_idbefore any side effect runs. - The handler returns
2xxonly after a durable write or enqueue, and returns it within 10 seconds. - Unhandled event types get a
200, not an error. enabled_eventsis narrowed to what you handle.- Secrets live in a secret manager, with a rotation plan and a
FLINT_WEBHOOK_PREVIOUS_SECRETslot ready. - Something alerts on
GET /v1/webhook-events?delivery_status=failedso exhausted retries don't go unnoticed. - A production endpoint is registered with your live key; see Going live.
Next steps#
- Webhook delivery: the delivery contract, headers, envelope fields, and retry schedule.
- Webhook event catalog: every event type Flint can send.
- Webhooks API Reference: every endpoint and field for managing webhooks.
- Testing: drive real events end to end with test cards.
- Idempotency: retry-safe writes on the request side.
- Checkout sessions: the fulfillment flow webhooks complete.
Upgrade with two endpoints#
Webhook endpoints can follow the merchant default or pin a dated API version. Set api_version to "default" to upgrade with the merchant default, or to a supported date to upgrade the handler separately. Omitting api_version when creating an endpoint follows the default; omitting it on an update leaves the setting unchanged. Existing dated pins stay pinned. Each delivery uses the older of the endpoint's version and the event's recorded version; an upgrade never rewrites an old event into a newer shape.
- Keep your existing endpoint and consumer active. Create a second endpoint with the same event subscriptions and the target
api_version, pointing to a separate handler URL. - Verify signatures with each endpoint's own signing secret. Read the delivered envelope's
api_versionand accept older event shapes during retries. - Run the new handler without business side effects while you compare its results with the existing handler. Use test-mode events before switching live processing.
- Switch business processing to the new handler once its results are correct. Share deduplication by
webhook_event_idacross both handlers so duplicate deliveries cannot repeat a payment, fulfillment, or notification action. - Keep the old handler available during your rollback window. Stop its business processing after the switch, and retire the old endpoint once you have checked pending deliveries and retries.
After you change an existing endpoint’s version, the Versions screen offers rollback for 72 hours while the previous version remains supported. A newly created endpoint has no previous version to restore. Pending retries use the endpoint's version at delivery time. Rolling back the endpoint does not undo business actions your handler already performed.
