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
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 |
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.
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.
{
"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. |
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:
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.
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
-
Parse the header. Split on the comma, then each
part on its first
=, and pull outtandv1by name. If either is missing, reject. - 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.
-
Compare in constant time.
crypto.timingSafeEqualin Node,hmac.compare_digestin Python,hash_equalsin 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. - 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.
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.
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.