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.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:
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:
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.
decimal.js for money arithmetic:NUMERIC values and application-layer Decimal values — never binary floating point.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.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.
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.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
@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.- 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. Sending1999charges 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 * 3is0.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. merchantReferenceIdis 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.referenceCustomerIdis 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 tosuccessUrl. This page does one thing: read the local order status and show it.
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 overtimestamp + "." + 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.
Using the TypeScript SDK
Using the TypeScript SDK
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 full handler
webhook_events is not only a dedup table — it doubles as a pending queue. It needs at least these columns:
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:
Insert and deduplicate webhook_events
Locate the merchant order and lock the row with SELECT ... FOR UPDATE
Validate required identifiers and the merchant order's Session
Insert-or-read payment_attempts, then validate its merchant and Session owner
Re-read all attempts for that order inside the lock
Aggregate and update paymentStatus
Write the fulfillment / reconciliation outbox row in the same transaction
Mark processed, or stay pending when a dependency is missing
attemptCreatedAt is null. Without either of those safe anchors:- Any Attempt whose
order.createdhas not arrived makes the aggregatepending; queuereconcile_sessionas well as the event’sreprocess_webhook, soGET /checkout/session/{sessionId}can identify the current Order without waiting forever for creation time - Two attempts sharing the same known, non-null
attemptCreatedAtalso queuereconcile_session
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.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 status — a 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.dedupeKey so queuing the same piece of work twice is rejected by the database rather than piling up duplicate tasks.
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 anINSERT only has to carry 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.reconcile_attemptcallsGET /order/{clinkOrderId}usingclink_order_idreconcile_sessionlooks the merchant order up byorder_idand takesclink_session_idfrom that rowrefund_reconciletakesrefundIdfrompayload; it reads the latest cumulativerefundedAmountagain under the merchant-order lockapply_refund_policytakesrefundId,orderStatus,refundedAmount, andpaymentCurrencyfrompayloadmanual_reconciliationtakes a stablecaseKeyfrompayload, loads the immutable evidence row, and opens or updates the operations case and alert idempotently on that keypage_outbox_failurecarries only the failed task ID/type and allowlisted error name/code; it retries paging durably and never creates another paging task
payload JSONB column rather than adding a column per task type.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.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.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.
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.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.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
Verify the signature
X-Clink-Signature. Return 401 on mismatch.Deduplicate by event ID
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.Match the order on two fields
merchantReferenceId and sessionId. If only one matches, treat it as an anomaly and do not update anything.Tolerate out-of-order delivery
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.Return 2xx only after writing
Scenarios this design has to survive
Run these before going live. Testing the happy path alone will not surface any of them. Payment attempt orderingrefundId 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:GET /webhook/events returns the currently supported event names.
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:--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 andclinkSessionId must already exist in the local database.
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.