Skip to main content
This page assumes an integration path has already been picked. If not, start with Choose an Integration. The examples use Hosted Checkout with Node.js and Express. The structure is the same in any language or framework.
Signature verification, deduplication, and the order model on this page underlie every kind of payment. The code does not transfer directly, though: subscription renewals are recorded and fulfilled per subscriptionId + invoiceId, which is not the same logic as matching a one-time order on merchantReferenceId + sessionId.The examples themselves are one-time payments. Recurring billing is in Subscriptions, and discounts are in Discounts and promotion codes.

The three endpoints required

What to store on the local order

At minimum these fields. Without them nothing can be debugged later. Separate payment attempts, refunds, and reconciliation evidence.
For an installation that already has merchant_orders, apply the Session-event version column before deploying the handler:
paymentAttempts.insertIfAbsent() is not a read-then-write helper. It must let the clink_order_id primary key arbitrate concurrent first deliveries:
When this returns no row, read the winner and validate both merchant_order_id and clink_session_id before any update. A different owner returns manual_review; never lock that other merchant order or overwrite its attempt. refunds rows are immutable. insertIfAbsent() below means this SQL, followed by a read of the stored row when nothing was returned:
An existing refund_id is an exact replay only when its merchant order, Clink Order, Decimal amount, normalized currency, and status all match. Never use DO UPDATE: a different immutable value is evidence for manual reconciliation, not a correction to the old row. reconciliation_cases.insertIfAbsent() uses the same INSERT ... ON CONFLICT (dedupe_key) DO NOTHING pattern.
This example uses decimal.js for money arithmetic:
An existing Decimal library is equally valid. Keep money as database NUMERIC values and application-layer Decimal values — never binary floating point.
Refund-basis scope: the automatic refund logic below applies only to Hosted Checkout payments where the payment uses a single cash funding source and the successful Order’s amountTotal is the authoritative refundable cash amount. The public Order contract exposes amountTotal and paymentCurrency, but not a separate cashAmount for balance/points plus cash splits.If mixed funding is enabled, do not substitute originalAmount or total amountTotal. Obtain an authoritative refundable-cash field from the backend contract first; until then, route those orders to manual reconciliation instead of running the automated refund-status calculation.
A deterministic data conflict returns manual_review; it is not thrown as a retryable error. Before that result is returned, the same transaction writes an immutable reconciliation_cases row and a deduplicated manual_reconciliation Outbox task. Only then may the Webhook be marked processed and acknowledged. The Worker opens or updates the operations case and alert using the stable case key.
Never use the local insert time to decide which attempt is current. received_at is when the merchant database wrote the row — shaped by push order, retries, and processing delay, and unrelated to when Clink created the Order. It exists for diagnosis.Exactly one source carries Order creation order: the outer event.created of an order.created event. On every other order.* event, event.created is when that status occurred, not when the Order was created; data.object carries no Order creation time, and GET /order/{orderId} does not return one today.So order.created must be subscribed. All four are required: order.created, order.next_action, order.succeeded, order.failed.
Why one table is not enough. A single Session can produce several Orders — a declined first card followed by a successful second card is two Orders. Write every order.* event into the merchant order row and a late failure from the first attempt overwrites the success of the second. So store one row per payment attempt, keyed by obj.orderId, and derive the merchant order’s payment status by aggregating those attempts.
The SQL below uses snake_case column names; the JavaScript examples use camelCase properties throughout (clinkOrderId, clinkSessionStatus, paymentStatus, attemptCreatedAt). They refer to the same fields — leave the mapping to your ORM or query layer.Every tx.* / db.* repository method in this page is therefore assumed to return camelCase objects. When bypassing that layer with raw node-postgres SQL, alias every consumed column explicitly, as claimTask() does below. Never return a mixed object such as camelCase business IDs with a snake_case claim_token.
Snapshot the product at purchase time. After a price change, what this customer actually paid must still be recoverable. Payment status and fulfillment status are separate columns because they go out of sync: money arrives, fulfillment fails, and those orders have to be findable and retryable. Session lifecycle is separate too. session.complete advances clinkSessionStatus to completed, while session.expired advances it to expired; neither event decides payment, refund, fulfillment, or Attempt state. clinkSessionLastEventCreated stores the terminal Session event’s Unix-millisecond version so a late older terminal event cannot overwrite a newer one. Build customer-facing copy from the Session status, the aggregate payment status, and whether a pending/action-required attempt still exists.

1. Create the order and the Session

The server can also use @clink-ai/clink-typescript-sdk, which handles the auth headers and ships type definitions. The direct-API version above is here to show where each field goes.
Things that bite people:
  • Read the price from the local database. Never trust an amount sent by the frontend, or a customer can edit the request and buy a membership for one dollar.
  • Amounts are in the major currency unit. USD 19.99 is 19.99. Sending 1999 charges USD 1999 — a factor of 100. JPY, KRW, and IDR have no decimals and take integers only.
  • Do not multiply amounts as floats. In JavaScript 0.1 * 3 is 0.30000000000000004, and Clink checks that the line items sum exactly to the total, so that drift is rejected. Store prices as integer minor units, multiply, and convert once; use a decimal library for anything more involved.
  • merchantReferenceId is the merchant order number. Clink does not use it for idempotency — the same value twice produces two Sessions. Preventing duplicate orders is the merchant’s responsibility.
  • referenceCustomerId is the merchant user ID. Send it and Clink links later Sessions for the same person to the same Clink customer.

2. The return page

The customer comes back to successUrl. This page does one thing: read the local order status and show it.
The webhook may not have arrived yet when the customer returns, so the order can still be pending. Show “confirming payment” and poll a few times rather than declaring failure. If it stays pending, the backend can call GET /checkout/session/{id} to sync the status directly.

3. Receiving webhooks

This is the part that decides whether money is real. Get it right.

Verifying the signature

Clink signs with HMAC SHA-256 over timestamp + "." + raw request body. A complete verification does three things, and skipping any one of them means it is not verified: check signType, check timestamp freshness, and compare the signature in constant time. Here is the whole thing:
X-Clink-Timestamp is a Unix millisecond timestamp, which is why it compares directly against Date.now(). The 5-minute window is this example’s choice — the platform defines no required value. A wider window leaves a captured request replayable for longer; a narrower one is more sensitive to clock drift. Keep the server on NTP.
Both the freshness check and the HMAC must run before JSON.parse, against the raw body. Parsing and re-serializing changes key order and whitespace, and the signature will never match. In Express that means express.raw(), not express.json().
The time window stops replays, not duplicates. Clink’s own retries arrive inside it, so the same event still arrives several times. Deduplicate separately and atomically on event.id — see the next section.
ClinkWebhook.verifyAndGet() in @clink-ai/clink-typescript-sdk@1.0.1 only does the HMAC step — one of the three things above:
  • It does not check X-Clink-SignType
  • It does not check timestamp freshness
  • It compares signatures with ===, not a constant-time comparison
The first two can be screened before calling it. The third is inside the SDK and cannot be worked around.
Until the SDK offers a constant-time comparison, use the local implementation above for production integrations.

The full handler

webhook_events is not only a dedup table — it doubles as a pending queue. It needs at least these columns:
Never mark an event processed when its dependency does not exist.refund.succeeded can perfectly well arrive before order.succeeded. Returning early with a 200 at that point records the event as handled — Clink stops delivering it, and that refund is never applied.Keep a missing dependency pending, queue a reprocess task, and let the worker retry once it exists. Set processed only after either the business work is complete or a deterministic conflict and its manual-review task have been durably quarantined in the same transaction.
handleEvent returns 'done', 'deferred', or 'manual_review'. manual_review is terminal only because the conflict evidence and its operations task have already been persisted in the same transaction. The order of steps inside the transaction is fixed. Refunds and order events follow the same sequence, and the identical lock order is what keeps them from deadlocking:
1

Insert and deduplicate webhook_events

2

Locate the merchant order and lock the row with SELECT ... FOR UPDATE

3

Validate required identifiers and the merchant order's Session

4

Insert-or-read payment_attempts, then validate its merchant and Session owner

5

Re-read all attempts for that order inside the lock

6

Aggregate and update paymentStatus

7

Write the fulfillment / reconciliation outbox row in the same transaction

8

Mark processed, or stay pending when a dependency is missing

The webhook_events unique index does not stop concurrent writes to one order. It guarantees a given event.id is handled once; order.succeeded and another Order’s order.failed are two different events that can arrive together, each reading a stale snapshot and each writing it back.So take SELECT ... FOR UPDATE on the merchant order row before touching attempts, serializing every status update for that order. A status-conditional UPDATE is a useful second layer, but it cannot replace the row lock — it prevents an overwrite, not an aggregate computed from a stale snapshot.The refund branch uses the same lock in the same order; crossing lock orders between the two paths deadlocks.
When ordering cannot be established, do not guess. A succeeded Attempt is globally decisive. Otherwise, a Session-confirmed current Attempt may decide even while its attemptCreatedAt is null. Without either of those safe anchors:
  • Any Attempt whose order.created has not arrived makes the aggregate pending; queue reconcile_session as well as the event’s reprocess_webhook, so GET /checkout/session/{sessionId} can identify the current Order without waiting forever for creation time
  • Two attempts sharing the same known, non-null attemptCreatedAt also queue reconcile_session
A newly inserted unordered Order invalidates an older confirmed pointer. A later order.created only fills that Attempt’s creation time and never rolls its status back.The same applies within one Order when two status events share an event.created: queue reconcile_attempt and converge from GET /order/{orderId}.If the Session answer remains unavailable, stay pending and escalate after the retry policy. received_at, autoincrement ids, Webhook arrival order, and lexical ordering of Clink Order IDs must never break the tie — none of them tracks real creation order.
Keep the three responsibilities separate. Collapsing them is what produces the rollbacks described above:
order.next_action speaks only for the current attempt. A superseded Order must not write it back to the merchant order — in recomputeMerchantOrder above, action_required comes only from the Session-confirmed current, or from ordered[0] when creation time has a unique latest value. An unresolved tie stays pending.By the same token, one failed attempt is not a failed order. If any attempt succeeded, the aggregate is success.
When ordering or fields cannot settle it, read the API. event.created only orders events within one Order. If two events share a timestamp, or no reliable version field is available, call GET /order/{orderId} for that obj.orderId and converge the attempt from the returned statusa server-side read is the soundest way to resolve ordering ambiguity, not the push order and certainly not frontend events.Do that lookup from an outbox task, not synchronously inside the webhook request.
Put a unique index on dedupeKey so queuing the same piece of work twice is rejected by the database rather than piling up duplicate tasks.
refund.succeeded does not mean a full refund. A successful partial refund fires the same event, so setting refunded unconditionally marks partly-refunded orders as fully refunded.Do not re-read the Order the moment the event arrives, either. The refund service updates the refund record and emits the event first, then updates the Order status asynchronously. A lookup at that instant will often still return the pre-refund status.Also note this event means the refund succeeded on Clink’s side, not that the money is back in the customer’s account. A card refund typically takes several more business days, so support messaging should not say “funds received”.

The outbox worker

The webhook only records state and queues a to-do, both in one transaction. A separate worker does the actual work. Claiming and executing must be separate: claim in a short transaction, then make external calls after it commits. Holding a row lock across a slow external API drains the connection pool. Here is the full table. Every default is supplied, so an INSERT only has to carry business fields:
Queuing a task writes only business fields; status, attempt, and next_retry_at come from the defaults:
ON CONFLICT DO NOTHING turns a duplicate insert into a silent skip rather than an exception. That matters inside the webhook transaction — queuing the same work twice should not roll the whole thing back.
Neither reconcile task needs extra columns:
  • reconcile_attempt calls GET /order/{clinkOrderId} using clink_order_id
  • reconcile_session looks the merchant order up by order_id and takes clink_session_id from that row
  • refund_reconcile takes refundId from payload; it reads the latest cumulative refundedAmount again under the merchant-order lock
  • apply_refund_policy takes refundId, orderStatus, refundedAmount, and paymentCurrency from payload
  • manual_reconciliation takes a stable caseKey from payload, loads the immutable evidence row, and opens or updates the operations case and alert idempotently on that key
  • page_outbox_failure carries only the failed task ID/type and allowlisted error name/code; it retries paging durably and never creates another paging task
If a task later needs more arguments — a retry policy, an expected status — put them in the payload JSONB column rather than adding a column per task type.
A task moves through these states:
Retry exhaustion and paging are committed atomically. The guarded update that marks the original task failed and the deduplicated page_outbox_failure insert run in one database transaction. The paging payload contains only failedTaskId, failedTask, errorName, and errorCode; it never copies an exception message, Webhook/API object, HTTP body, customer data, or payment details.page_outbox_failure awaits the alert adapter and becomes succeeded only after delivery. If paging fails, that task returns to pending with capped backoff; it never creates another paging task. The alert adapter uses outbox_failure_alert:{failedTaskId} as its external idempotency key.Infrastructure monitoring is still required for any status = 'failed' task, tasks left pending/processing too long, and page_outbox_failure rows that remain unsuccessful. Durable retry prevents a process crash from silently dropping the page; it does not replace queue health monitoring.
SKIP LOCKED is not an idempotency guarantee. All it does is let concurrent SELECTs step over rows another transaction has locked, so they do not block each other.What actually stops two workers claiming the same task is flipping the status to processing and writing a fresh claim_token in the same statement. The UPDATE ... RETURNING above locks and marks in one step, leaving no window for another worker.Splitting it into SELECT ... FOR UPDATE SKIP LOCKED, commit, then UPDATE reopens that window. If it has to be two steps, they must sit inside the same short transaction.
Every status update must carry the claim_token. This is what prevents a stale worker overwriting state after its lease expired:
  1. Worker A claims the task and holds tokenA
  2. A stalls; the lease expires
  3. Worker B reclaims the same task with tokenB
  4. A comes back and reports success — its WHERE claim_token = tokenA matches nothing, so the row count is 0
  5. B finishes normally, and its status is not overwritten by A
A row count of 0 means “my lease is gone”. At that point log it and stop — do not retry the write, do not change the status. Success, failure, and renewal updates all need this condition.Reclaiming must generate a new token; reusing the old one defeats the whole check.
When a worker crashes. If the process dies after claiming but before updating the status, the task is stuck in processing forever.That is what lease_until is for: the claim condition includes status = 'processing' AND lease_until < NOW(), so expired leases are picked up by the next worker.This sample claims one task at a time. It renews once before starting any side effect, then runs a heartbeat every LEASE_MS / 3. A failed renewal marks the local lease lost, aborts HTTP adapters that accept AbortSignal, suppresses all later steps and skips finishTask / failTask. The manual-reconciliation path renews again between opening the desk case and paging the alert.Business idempotency remains mandatory, but it is a final safety net — never a reason to knowingly start work after ownership is gone.worker_id and lease_until also serve diagnosis, showing which host is stuck and for how long.
Both reconcile tasks must actually issue their query — never no-op.reconcile_attempt calls GET /order/{clinkOrderId} and maps the returned status onto an attempt status; reconcile_session calls GET /checkout/session/{sessionId} and treats the returned orderId as the current attempt.Both queries run outside the transaction. Before the query, capture the local version: reconcile_attempt stores the attempt owner, Session ID, status, lastEventCreated, and attemptCreatedAt; reconcile_session stores the Session ID, current pointer/confirmation flag, and an ownership-and-state fingerprint of every attempt. Only once a result is in hand does the transaction open and lock the merchant order — same lock, same order as the webhook handler — then re-read and compare that snapshot. Any mismatch means the API result may be stale: roll back and retry instead of writing it.An unsettled result must throw so the backoff retries it. Never mark the task succeeded just because this attempt could not determine the answer: a GET /order status outside the map, or a GET /checkout/session with no orderId yet, both fall into that category.The current attempt lives in merchant_orders.current_clink_order_id, while current_attempt_confirmed records whether it came from the Session query. A pointer inferred from order.created is not allowed to break a timestamp tie. Do not touch attempt_created_at — that is the real creation time from Clink, and rewriting it corrupts the source data; “raise it to the group maximum” would not reliably break the tie either.Before writing it, look the Session’s orderId up globally inside the lock. No attempt means the Order has not been persisted locally yet, so throw and retry. An existing attempt owned by another merchant or Session is instead a deterministic conflict: persist its manual-review evidence, mark the event processed, and stop automatic retries.
The lease guarantees nothing gets stuck permanently — not that a task runs exactly once. A crash or an expired lease means the task is reclaimed and runs again, so fulfill(), reconcileRefund(), applyRefundPolicy(), and reprocessWebhook() must each be protected by a business idempotency key: the order number for fulfillment, refundId for refund bookkeeping and refund policy, and event.id for event handling. Refund policy must also compare cumulative refundedAmount (or a monotonic refund version) so a delayed older task cannot roll entitlement backward. Where the external API accepts an idempotency key, send it too.
clinkGet is the bodyless GET wrapper around the shared server helper above. It accepts only controlled relative paths, computes a fresh millisecond X-Timestamp, forwards AbortSignal, requires both HTTP success and a { code: 200, data: object } envelope, and raises a sanitized error without copying the Secret Key or response body. Where reconciling refunds is not worth the machinery, there is a simpler rule: sum successful refunds by refundId and compare that Decimal total against refundable_paid_amount. Clink returns no running refunded total for an order, and there is no order.refunded event, so that sum has to be maintained locally anyway.
Never await this worker from the webhook request thread.Run external work before returning 200 and a fulfillment failure turns into a non-2xx, Clink redelivers, dedup on event.id bounces that redelivery straight back to 200 — and that order never ships. Acknowledge as soon as the transaction commits, and leave every external action to the worker’s retries.
The outbox table needs at least status, attempt, next_retry_at, and last_error. Key the worker’s idempotency on eventId or the order number, because it will be re-run.Fulfillment, emails, and third-party calls cannot be rolled back with the transaction, so they must not run inside it. The reverse is equally wrong: calling an external queue with enqueueXxx() from inside the transaction leaves an orphaned task behind whenever that transaction rolls back. The to-do belongs in the same transaction as the business state.
Whether access is revoked should not follow the payment state — refunding half the money might mean cutting service off, prorating it, or changing nothing, depending on what is being sold. Keep that decision in something like applyRefundPolicy rather than spreading it through the webhook handler. The Order status advances only when its rank increases, but every successful refundId records its own apply_refund_policy task inside the reconciliation transaction. Run the policy from the Worker after commit; never call it while holding the merchant-order lock.
Order matching, terminal-state protection, and recording fulfillment apply to every order.* event, not just the success branch.The common mistake is validating carefully on success and then updating straight from the order number on failure. One late order.failed is then enough to mark money already received as failed, and reverse the fulfillment with it.
Refunds need their own branch because the payload differs: a refund.* object has no merchantReferenceId, only orderId and refundMerchantOrderId. Look up the local order by the clinkOrderId stored at payment time.

All five of these are required

1

Verify the signature

HMAC SHA-256 over the raw body, compared against X-Clink-Signature. Return 401 on mismatch.
2

Deduplicate by event ID

Clink retries failed deliveries, so the same event arrives repeatedly. Put a unique index on event.id, write that row and the business state in one transaction, and let the database settle concurrent copies. Check-then-write does not stop two deliveries arriving at once.
3

Match the order on two fields

Check both merchantReferenceId and sessionId. If only one matches, treat it as an anomaly and do not update anything.
4

Tolerate out-of-order delivery

Clink does not guarantee event order, and there are two distinct cases.A stale event arrives late: an order already paid, refunded, or partial_refunded must not be moved back to pending. Within one Clink Order, compare event.created against the attempt’s last_event_created and drop anything older.A dependency has not arrived yet: refund.succeeded can precede order.succeeded, so the local order does not exist. Keep that event pending and queue a reprocess task — never mark it processed. Once it is marked, Clink stops delivering it and the refund is lost for good.
5

Return 2xx only after writing

A 200 means “handled”. Returning 200 before persisting loses the event permanently.

Scenarios this design has to survive

Run these before going live. Testing the happy path alone will not surface any of them. Payment attempt ordering Payment Attempt ownership and first-insert arbitration Concurrent writes to one order Out-of-order and multiple Orders Session lifecycle Out-of-order refunds Outbox lease race Because an external call may finish exactly as a heartbeat detects loss, business idempotency is still required: merchant order ID for fulfillment, refundId for refund reconciliation/policy, caseKey for manual operations, and event.id for Webhook replay. Outbox exhaustion and paging

Which events to subscribe to

For one-time payments, at least these: Subscription products need their own set of subscription and invoice events — see Subscriptions. The full list is in the Webhook reference, and GET /webhook/events returns the currently supported event names.
Subscribe with full event names. A wildcard like subscription.* is not a value the API accepts.

Developing locally

The webhook URL must be publicly reachable over HTTPS. localhost, loopback, and private IPs are rejected. Where a public address already exists — a preview deployment, a Vercel or Netlify URL, an owned domain — use it. Tunnels are only for purely local development:
If QUIC fails to connect, retry with --protocol http2. Tunnel URLs change on restart, so update the webhook endpoint in the dashboard each time.

Testing verification without paying

A tunnel plus a real payment proves the whole chain, but paying with a card after every tweak to the verification logic is slow. The events below are merchant-owned local fixtures, not events obtained from Clink. The referenced merchant order and clinkSessionId must already exist in the local database.
Expected local results:

Who owns what

Common mistakes

Fulfilling on the return page. successUrl is an ordinary address a customer can type. Only a verified webhook should trigger fulfillment. Fulfilling on every webhook without deduplication. Clink retries, so one purchase becomes three shipments. Treating pending as failure and letting the customer pay again. pending means “not known yet”. Charging again here bills the customer twice. Wait for the webhook or poll GET /order/{id}. Parsing the webhook body with express.json(). The re-serialized body produces a different signature and every event fails verification. A Secret Key in frontend code. Anyone who finds it can take payments and issue refunds on that account. The browser gets a Publishable Key only.

Next

API keys & webhooks

Key rotation, IP restrictions, delivery and retry rules.

Go live

The scenarios to verify before switching to production.