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#

text
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:

PropertyBehavior
ProtocolHTTPS POST with a JSON body
User agentFlint-Webhooks/1.0
Timeout10 seconds per attempt
RedirectsNever followed
SuccessAny 2xx response from your server
FailureAnything else: a non-2xx status, a redirect, a timeout, or a connection error
GuaranteesAt-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#

HTTP
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=
HeaderPurpose
X-Flint-SignatureLegacy proof the delivery came from Flint. Verified in Step 4.
X-Flint-Webhook-IDLegacy event ID (whev_...). Identical on every retry and resend of the same event, which makes it your deduplication key.
X-Flint-Event-TypeThe event type, so you can route the delivery without parsing the body.
X-Flint-Webhook-Endpoint-IDThe endpoint (whep_...) this delivery was sent to.
webhook-id, webhook-timestamp, webhook-signatureThe 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#

JSON
{
  "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"
    }
  }
}
FieldDescription
webhook_event_idUnique event ID. Identical on every retry and resend of this event; also sent as webhook-id and legacy X-Flint-Webhook-ID.
event_typeWhat happened. Every type is listed in the event catalog.
payload_versionEnvelope format version, currently 1.
modetest or live. An endpoint only receives events for the mode of the API key that created it.
merchant_idThe merchant the event belongs to.
created_atWhen the event was created (RFC 3339, UTC).
testPresent, and true, only on synthetic events from the test-events endpoint (Step 5). Real events in your sandbox carry "mode": "test" instead.
dataThe 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.

JavaScript
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);

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.

Bash
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.

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):

Bash
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
  }'
JSON
{
  "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:

text
X-Flint-Signature: t=1783090410,v1=39a949d856b98d47d3d546a7f098ca2b...
  • t is 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 v1 is 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 any v1 value 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.

JavaScript
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");
  }
}

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:

1

Parse the header. Split X-Flint-Signature on commas. The t= element is the timestamp; every v1= element is a candidate signature.

2

Check the timestamp. Reject the delivery if t differs from your clock by more than your tolerance. Five minutes is the recommended window.

3

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.

text
expected = hex( hmac_sha256( secret, "{timestamp}." + raw_body ) )
4

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):

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

text
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:

Bash
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"}'
JSON
{
  "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:

JSON
{
  "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 data payload 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_status comes back failed, 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.

JavaScript
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);
});

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-Signaturewebhook-signature
Formatt={timestamp},v1={signature}v1,{signature}, space separated when multiple
Signed payload{timestamp}.{raw_body}{webhook_event_id}.{timestamp}.{raw_body}
Signature encodingHexBase64
HMAC keyThe whole whsec_... string as UTF-8 bytesThe secret after whsec_, base64 decoded (libraries do this for you)
Timestampt= value inside the headerThe webhook-timestamp header
JavaScript
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"],
});

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:

AttemptDelay after previous failureApproximate elapsed time
1none (immediate)0
230 seconds30 seconds
32 minutes2.5 minutes
410 minutes13 minutes
51 hour1.2 hours
64 hours5 hours
712 hours17 hours
824 hours41 hours
930 hours71 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:

text
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.

EventFires whenChoose it when
order.payment_succeededA payment on an order succeeds, no matter how it was collected: hosted checkout, payment link, invoice, or a direct API paymentYou fulfill orders. One subscription covers every way an order gets paid. The right default for commerce integrations.
checkout_session.completedA hosted checkout session finishes and its payment succeedsYou want session context (which session, which redirect flow) or you only sell through hosted checkout.
payment_intent.succeededA payment intent succeeds, including payments that aren't attached to an orderYou run payment-level integrations: ledgers, reconciliation, custom payment flows.
subscription.payment_succeededA subscription renewal invoice is paidYou maintain subscription entitlements or renewal-specific lifecycle logic.
merchant.readiness.updatedLive account readiness changesYou 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.

Bash
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"
JSON
{
  "data": {
    "secret": "whsec_8496JEjvXj6YMU2eGg1GYA+/M1pundqxxvRNasV25u4="
  }
}

A clean rollout:

  1. Call rotate-secret and 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.
  2. Deploy with FLINT_WEBHOOK_SECRET set to the new value and FLINT_WEBHOOK_PREVIOUS_SECRET set to the old one. The Step 6 handler already accepts both.
  3. 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#

Bash
curl "https://api.withflintpay.com/v1/webhook-events?webhook_endpoint_id=whep_1kmn0aExample&include=test_events" \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "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:

Bash
curl https://api.withflintpay.com/v1/webhook-events/whev_1kmn0aExample/delivery-attempts \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "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 seeLikely causeFix
status_code in the 3xx rangeYour server redirected (trailing slash, http to https, www). Flint never follows redirects.Register the exact final URL.
status_code 400Your 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 403Auth or CSRF middleware intercepted the request before your handler ran.Exempt the webhook route.
status_code 404The registered URL doesn't match a deployed route.Compare the registered URL with your router.
status_code 5xxYour 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:

Bash
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"}'
JSON
{
  "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 200 and 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-id or webhook_event_id before any side effect runs.
  • The handler returns 2xx only after a durable write or enqueue, and returns it within 10 seconds.
  • Unhandled event types get a 200, not an error.
  • enabled_events is narrowed to what you actually handle.
  • Secrets live in a secret manager, with a rotation plan and a FLINT_WEBHOOK_PREVIOUS_SECRET slot ready.
  • Something alerts on GET /v1/webhook-events?status=failed so exhausted retries don't go unnoticed.
  • A production endpoint is registered with your live key; see Going Live.

Next Steps#

Rate this doc