Skip to content
Docs / Webhooks

Webhooks

A webhook is Flik telling your server, server to server, that a payment finished — so you know to go and read it. Flik's recommended integration fulfils on that read rather than on the delivery; the webhook is what covers a payer who never returns to your site. If you do take one, the panel alongside has working signature verification, and that is the part you must not skip.

Two flows, one answer

Read the payment back — that is the integration we recommend GET /api/checkout-sessions/{id} and GET /api/payment-links/{id} are the answer to act on. They are authenticated, you make them when you want them, and they tell you where the payment stands now rather than where it stood when something was sent. Build that first; most integrations need nothing else, because the redirect brings the ids back with the customer.

A webhook earns its place for the payers who never come back — the tab closed at the bank, the phone that started on a laptop. Treat it as a prompt to read rather than as the answer: verify the signature, then make the same GET and fulfil on that. A periodic sweep over what you are still waiting on does the same job with no public endpoint at all.

So a completed payment can reach you two ways, and they answer different questions. The webhook tells you the payment happened. The redirect tells you the customer is back on your site and waiting to be told something.

The webhook The redirect
Goes to Your server, at the webhookUrl you set The customer's browser, at the redirectUrl you set
Carries A signed statement that the payment completed, sent from Flik's servers because it did An id, appended as ?transactionId= — plus &checkoutSessionId= for a session — which payment to ask about, not evidence about it. Anyone can put an id in a URL
Arrives when The payment completes, whether or not the customer's browser survives the trip Only if the customer comes back. They can close the tab at their bank, lose signal leaving a banking app, or pay on their phone having started on a laptop
Your handler Check the signature, then read the payment back and fulfil on what the read says Read the session, then render from what it says — paid, still pending, or failed. Never render "paid" off the arrival alone
Both end at the same read Whichever prompted you, the fulfilment decision comes from GET /api/checkout-sessions/{id}. A webhook is a true record of a moment; the read is where things stand now, which is not the same thing once a payment has been refunded. The same call reconciles a day, or catches you up after an outage on your side.

Your webhook endpoint

You set the endpoint per payment, as webhookUrl on the checkout session or payment link you create. There is nothing to register account-wide: whatever URL you pass is where that payment's events go, and a session created without the field notifies nobody.

Per payment rather than one endpoint per account, because the caller usually knows more than the account does — a platform taking payments on behalf of several sellers can route each seller's events to a different receiver, and a staging deployment can point at itself without touching where live payments notify. The cost is a field you have to remember, and it cannot be added after the fact. Set it in whatever wraps your create call rather than at each call site, so forgetting it is not possible.

The URL must be HTTPS and reachable from the public internet. Because it is public, anyone can post to it — which is what the signature is for. Test and live payments are signed with the same secret, and the payload does not say which it was — read the session or link back and check testMode. If you want them handled by different code, give test sessions a different webhookUrl rather than splitting one receiver by traffic.

The payload

Flik posts application/json. The body is small on purpose: it tells you which transaction changed, not what the transaction now looks like. Read the transaction back if you need its current state.

This is the shape for sessions and payment links Transactions created through POST /api/checkout-sessions and POST /api/payment-links get the body below. A transaction created the older way — through POST /api/transaction, or from a payment square or the portal — still gets the original two-field body, { "transactionId", "foreignTransactionId" }, with no event and no signature. That is deliberate: integrations built against it keep working untouched. If you run both, branch on whether event is present.
Request body
{
  "event": "checkout_session.completed",
  "createdAt": "2026-09-12T04:13:22Z",
  "checkoutSessionId": "cs_7Hk2pQr4TnVxWy9BdLmE",
  "transactionId": "0b0f1e2d-3c4b-5a69-8778-96a5b4c3d2e1",
  "foreignTransactionId": "order-5591"
}
Field Type Description
event string What happened. See Events below.
createdAt string ISO 8601, UTC. When the event happened — not when this delivery was attempted, so it stays the same if the same event arrives twice.
checkoutSessionId string or null The cs_ id, when the payment came from a checkout session — read it back with that. null for a payment link, which has no session: use transactionId there, since a link's id is its transaction id.
transactionId string UUID of the transaction, and the key to deduplicate on — it identifies the payment whichever resource created it.
foreignTransactionId string or null Whatever you passed as foreignTransactionId when you created the session, or null. Pass your order id there and the webhook arrives already knowing which order it belongs to.

Events

One event today, and it is final: Flik sends it when a payment succeeds. A session that expires unpaid sends nothing — expiry is a status you read, not an event. Anything that arrives here can be confirmed through the API afterwards — see session statuses.

Event Sent when What to do
checkout_session.completed The customer paid. The session reached completed and the funds are on their way to your settlement account. Read the payment back, then fulfil. This is the only event that means paid.
There is no failed event A declined card is not an outcome, it is a moment. The session is still live and most customers simply try again — a different card, the right bank, a corrected number — often within seconds. An event called failed would invite you to cancel the order, release the stock and email the customer about a payment that is about to succeed, so Flik does not send one. If you want to see attempts as they happen, read the session: status reports the last one. What you act on is checkout_session.completed, and it is final.

More events will be added. When they are, your endpoint starts receiving event values it has never seen. Your handler must ignore anything it does not recognise and still return 2xx — do not throw, do not 400, do not page someone. Write that branch now, while there is only one to match on:

Ignore what you don't know
switch (event.event) {
  case "checkout_session.completed":
    await fulfil(event.transactionId);
    break;

  default:
    // An event type Flik added after this code shipped. Not an error.
    break;
}

Verifying the signature

Your endpoint is a public URL that grants goods when it is called. Anyone who guesses it can post a checkout_session.completed body at you. The signature is what separates a real Flik delivery from that.

Every delivery that Flik can sign carries a Flik-Signature header. If the signing secret cannot be resolved at the moment of sending, the delivery still goes out — unsigned, with no header at all. Reject a delivery with no signature rather than treating a missing header as nothing to check; that is the whole point of verifying.

Header
Flik-Signature: t=1757646000,v1=5f2c...

t is a unix timestamp in seconds. v1 is an HMAC-SHA256 digest, hex encoded, of the string t + . + the raw request body, keyed with your webhook signing secret. The scheme is versioned so that if the algorithm ever changes, deliveries can carry both v1 and its successor while you migrate — read the value by name, not by position.

The signing secret

Flik issues one signing secret per organisation, with the prefix whsec_, and it signs every delivery to every webhookUrl you set — test and live alike. Read it off the Webhook Signing section of API Keys in the Flik portal. It is not your client secret and it is not a bearer token: its only job is signing. Store it wherever you keep your other secrets.

Unlike an API key, the secret is shown in full every time you open that page, not once at creation. There is nothing to lose, so mislaying it never forces a rotation — which matters, because rotating replaces the secret outright with no overlap period. From the moment you rotate, every delivery is signed with the new secret and nothing is still signing with the old one, so a delivery already in flight fails verification and your endpoint has until the next payment to be holding the new value. Rotate when the secret has leaked, not when you have misplaced it, and rotate when you can deploy straight afterwards.

What to check

  1. Parse the header. Split on the comma, then each part on its first =, and pull out t and v1 by name. If either is missing, reject.
  2. Recompute the digest over the raw request body — the exact bytes that arrived, before any JSON parsing. See below; this is where integrations go wrong.
  3. Compare in constant time. crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP. A plain == returns faster the earlier it finds a mismatched character, and that timing difference is enough to let an attacker guess a valid signature one character at a time.
  4. Reject anything older than five minutes. The timestamp is inside the signed string, so it cannot be edited without breaking the signature. Without this check, a valid request captured once can be replayed against you forever.
Verify before you trust any of it Do the signature check first, before you parse the JSON, read transactionId, look up an order or write anything to a database. Until it passes, the body is a string a stranger sent you.

The raw body, not a re-serialised object

The single most common webhook bug: a framework parses the JSON before your handler runs, and you sign JSON.stringify(req.body) instead of what Flik actually sent. It looks identical and it is not. Re-serialising changes whitespace, can reorder keys, and rewrites how numbers and non-ASCII characters are spelled. The digest is over bytes, so any of that breaks it — and it breaks it consistently, which is why it reads as "signature verification never works" rather than as a flaky bug.

Keep the bytes. In Express, mount express.raw() on the webhook route and make sure no global express.json() runs ahead of it. In Flask, use request.get_data(), not request.json. In PHP, read php://input, not $_POST. Parse the JSON after the signature has passed, from the same bytes you verified.

No cURL tab on this one Verification is code that runs inside your server when a request arrives — there is no request for you to make, so there is nothing to express as a cURL command. The panel offers Node, Python and PHP.

Responding

Return a 2xx as soon as the signature checks out and you have recorded the event somewhere durable. Flik waits five seconds for your answer. Any other status, or no answer in time, counts as a failed delivery and alerts Flik's team.

Whether a failed delivery is retried depends on which rail the payment settled on, so do not build on the retry. A payment completed by card is delivered from a queue, which attempts it up to three times in total before setting it aside for us to look at. A payment settled on the open banking rail is delivered inline and is not retried at all: one attempt, and a failure is only alerted. In both cases a 4xx that is not 408 or 429 is treated as final — your endpoint has answered, and repeating the request reproduces the same answer.

So do not fulfil the order on the request path. Sending a confirmation email, calling your ERP, generating a PDF — all of that belongs on a queue or a background job. If it runs inline, one slow downstream system turns into a timeout, and a timeout is a delivery you did not get.

For the same reason, reconcile. Retries run out and one rail has none, so a daily read of anything still unfulfilled on your side — GET /api/checkout-sessions/{id} or GET /api/payment-links/{id} — is what catches an outage on your end, not the webhook.

Duplicate deliveries

The same event can still arrive twice: a payment can complete through more than one path on Flik's side, and each one that finds your webhookUrl posts. Treat that as normal.

Make the handler idempotent, keyed on transactionId. Insert it into a table with a unique constraint before you do the work; if the insert conflicts, you have already handled this event, so return 200 and stop. Checking "have I seen this?" with a read, then acting, then writing is not enough — two concurrent retries can both pass the read.

Testing webhooks locally

Flik cannot reach localhost. Run a tunnel — ngrok, Cloudflare Tunnel, or whatever your team already uses — and pass the public HTTPS URL it gives you as the webhookUrl on the sessions you create while developing. That URL usually changes each time you restart the tunnel, which matters less here than it would with a registered endpoint: the address travels with the session, so a stale one strands only the sessions created with it and never touches where your live payments notify.

Webhooks fire in test mode exactly as they do in live, signed with the same secret and the same scheme, so you can develop and verify the whole path without moving money. See Testing.