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. Browser redirects get abandoned and polling burns requests; a webhook survives both, which makes it the right trigger for anything your backend must do exactly once.
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
This guide covers receiving events: registering an endpoint, verifying signatures, and building a handler that survives retries. For the complete list of event types Flint sends, see the event catalog.
How Webhooks Work#
payment succeeds, refund settles, subscription renews, ...
|
v
Flint creates a webhook event (whev_...)
|
v
signed HTTP POST to your endpoint
|
+-- 2xx within 10 seconds --> delivered
|
+-- anything else ----------> retried with backoff,
up to 8 more times over ~72 hours
Every delivery follows the same contract:
| Property | Behavior |
|---|---|
| Protocol | HTTPS POST with a JSON body |
| User agent | Flint-Webhooks/1.0 |
| Timeout | 10 seconds per attempt |
| Redirects | Never followed |
| Success | Any 2xx response from your server |
| Failure | Anything else: a non-2xx status, a redirect, a timeout, or a connection error |
| Guarantees | At-least-once delivery, in no guaranteed order |
The last row shapes every good handler. At-least-once means you will occasionally receive the same event twice, so handlers deduplicate (Step 6). No ordering guarantee means an event can arrive after a newer one, so handlers treat events as signals to fetch fresh state rather than as the state itself (Retries and Ordering).
What You Need#
- A test API key (
flint_test_...). Create one on the dashboard's API keys page, or entirely over the API with the API setup flow. - 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.
Delivery headers#
POST /webhooks/flint HTTP/1.1
Content-Type: application/json
User-Agent: Flint-Webhooks/1.0
X-Flint-Event-Type: payment_intent.succeeded
X-Flint-Webhook-ID: whev_1kmn0aExample
X-Flint-Webhook-Endpoint-ID: whep_1kmn0aExample
X-Flint-Signature: t=1783090410,v1=d2eb22206ed2470e912f9e4465586273c16748df664b5ee4ba2700a4694d92a9
webhook-id: whev_1kmn0aExample
webhook-timestamp: 1783090410
webhook-signature: v1,PLz/mlH/ybZHvKsfeKbKu8qA7eC6OjCoXuXAWqb4WFM=
| Header | Purpose |
|---|---|
X-Flint-Signature | Legacy proof the delivery came from Flint. Verified in Step 4. |
X-Flint-Webhook-ID | Legacy event ID (whev_...). Identical on every retry and resend of the same event, which makes it your deduplication key. |
X-Flint-Event-Type | The event type, so you can route the delivery without parsing the body. |
X-Flint-Webhook-Endpoint-ID | The endpoint (whep_...) this delivery was sent to. |
webhook-id, webhook-timestamp, webhook-signature | The preferred identity and signature headers for new integrations, in Standard Webhooks format. See Verify with a Standard Webhooks Library. |
The user agent is always Flint-Webhooks/1.0, which is useful when your infrastructure filters inbound traffic.
Event 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",
"data": {
"payment_intent": {
"payment_intent_id": "pi_1kmn0aExample",
"status": "succeeded",
"amount_money": {"amount": 2500, "currency": "USD"},
"order_id": "ord_1kmn0aExample"
}
}
}
| Field | Description |
|---|---|
webhook_event_id | Unique event ID. Identical on every retry and resend of this event; also sent as webhook-id and legacy X-Flint-Webhook-ID. |
event_type | What happened. Every type is listed in the event catalog. |
payload_version | Envelope format version, currently 1. |
mode | test or live. An endpoint only receives events for the mode of the API key that created it. |
merchant_id | The merchant the event belongs to. |
created_at | When the event was created (RFC 3339, UTC). |
test | Present, and true, only on synthetic events from the test-events endpoint (Step 5). Real events in your sandbox carry "mode": "test" instead. |
data | The event payload. Its shape depends on the event type; the example above is payment_intent.succeeded. |
Two forward-compatibility rules keep integrations from breaking as the platform grows: new event types appear over time (endpoints subscribed to everything receive them automatically), and existing payloads gain new fields. 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.
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.
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.
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.payment_succeeded",
"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.payment_succeeded",
"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.
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 actually 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 SDK dependency to add. The Node SDK manages endpoints over the API but does not wrap verification.
How verification works#
If you're implementing in another language, this is the whole algorithm:
Parse the header. Split X-Flint-Signature on commas. The t= element is the timestamp; every v1= element is a candidate signature.
Check the timestamp. Reject the delivery if t differs from your clock by more than your tolerance. Five minutes is the recommended window.
Compute the expected signature. Concatenate the timestamp, a period, and the raw body bytes, then compute HMAC-SHA256 over that string using your whsec_... secret as the key, and hex encode the result.
expected = hex( hmac_sha256( secret, "{timestamp}." + raw_body ) )
Compare in constant time. Compare expected against every v1 candidate using a constant-time comparison. Any match means the delivery is authentic. During secret rotation, repeat for each secret you hold.
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","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=39a949d856b98d47d3d546a7f098ca2bdeb9c44392b85387216159d1583b5c51
webhook-signature: v1,5yOBgJjiaiCACEtFF9nKss8Z//DTa0GOQHJVXYGY3hg=
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" \
-d '{"event_type": "payment_intent.succeeded"}'
{
"data": {
"webhook_event_id": "whev_1kmn0aExample",
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 1,
"attempt_status": "delivered",
"event_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",
"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.payment_succeeded":
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.payment_succeeded":
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.payment_succeeded":
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#
The retry schedule#
A delivery attempt fails on a non-2xx status, a redirect, a connection error, or a 10-second timeout. Failed deliveries retry on a fixed backoff schedule, up to 9 total attempts spanning about three days:
| Attempt | Delay after previous failure | Approximate elapsed time |
|---|---|---|
| 1 | none (immediate) | 0 |
| 2 | 30 seconds | 30 seconds |
| 3 | 2 minutes | 2.5 minutes |
| 4 | 10 minutes | 13 minutes |
| 5 | 1 hour | 1.2 hours |
| 6 | 4 hours | 5 hours |
| 7 | 12 hours | 17 hours |
| 8 | 24 hours | 41 hours |
| 9 | 30 hours | 71 hours |
Each delay gets up to 20 percent of random jitter added, so treat the elapsed times as approximate. Every attempt is signed fresh with a new timestamp, so retries always pass your tolerance check.
While retries remain, the event's status is pending with a next_retry_at. After the ninth failure it becomes failed and automatic delivery stops, but the event is never silently lost: it stays queryable, and you can resend it manually after fixing your handler.
Delivery order is not guaranteed#
Events fan out independently, and a failed delivery retries while newer events keep flowing. A sequence like this is normal:
happened on Flint arrived at your endpoint
------------------ -------------------------
1 subscription.created 1 subscription.payment_succeeded
2 subscription.payment_ 2 subscription.activated
succeeded 3 subscription.created
3 subscription.activated (first delivery failed; this is its retry)
Treat a webhook as a signal that something changed, not as the change itself. When your logic needs current state, fetch the resource: a GET /v1/orders/{order_id} reflects everything that has happened to that order regardless of what has arrived at your endpoint. The envelope's created_at lets you sequence events locally if you must, but fetching fresh state is simpler and harder to get wrong.
Lifecycle payloads include the resource ID, current public status, and resource_updated_at or an equivalent freshness timestamp. Some subresource events also include event-specific timestamps such as payment_attempt_updated_at; for invoice payment-attempt terminal events, resource_updated_at matches the payment-attempt transition time. If you mirror state locally, compare those timestamps before overwriting a newer row, and fetch the resource before irreversible work when stale data would be costly.
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.
| Event | Fires when | Choose it when |
|---|---|---|
order.payment_succeeded | A payment on an order succeeds, no matter how it was collected: hosted checkout, payment link, invoice, or a direct API payment | You fulfill orders. One subscription covers every way an order gets paid. The right default for commerce integrations. |
checkout_session.completed | A hosted checkout session finishes and its payment succeeds | You want session context (which session, which redirect flow) or you only sell through hosted checkout. |
payment_intent.succeeded | A payment intent succeeds, including payments that aren't attached to an order | You run payment-level integrations: ledgers, reconciliation, custom payment flows. |
subscription.payment_succeeded | A subscription renewal invoice is paid | You maintain subscription entitlements or renewal-specific lifecycle logic. |
merchant.readiness.updated | Live account readiness changes | You control platform onboarding, payouts, or payment acceptance based on account readiness. Sandbox account updates keep sandbox capability state in sync but do not emit merchant readiness webhooks. |
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 Webhook Events covers event altitude in more depth.
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 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",
"webhook_endpoint_id": "whep_1kmn0aExample",
"event_type": "payment_intent.succeeded",
"status": "failed",
"automatic_attempt_count": 9,
"max_attempts": 9,
"last_response_code": 500,
"last_error": "Webhook endpoint returned HTTP 500.",
"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:59:52Z"
}
]
}
An event's status is pending while deliveries and retries are still scheduled, delivered once your server returns 2xx, and failed when all attempts are exhausted. Filter by status=failed to find what needs attention, by event_type, by created_after and created_before, or by resource_type and resource_id to answer "did the webhook for this order fire?". 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 delivered payload when your API key has read scope for the resource inside it.
Inspect delivery attempts#
Each attempt records what your server said, which is usually the whole diagnosis:
curl https://api.withflintpay.com/v1/webhook-events/whev_1kmn0aExample/delivery-attempts \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"webhook_delivery_attempt_id": "watt_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 an event#
After fixing your handler, redeliver any event on demand:
curl -X POST https://api.withflintpay.com/v1/webhook-events/whev_1kmn0aExample/resend \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"reason": "handler fixed, redelivering"}'
{
"data": {
"webhook_event_id": "whev_1kmn0aExample",
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 2,
"attempt_status": "delivered",
"event_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.
Everything in this section is also in the dashboard 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 actually 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?status=failedso exhausted retries don't go unnoticed. - A production endpoint is registered with your live key; see Going Live.
Next Steps#
- 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.
