The order model, Session creation, and webhook handling on this page are shared by every kind of payment — one-time, subscription, and discounted payments all build on them.The examples themselves are one-time payments. Recurring billing is in Subscriptions, and discounts are in Discounts and promotion codes.
The three endpoints you need
What to store on your own order
At minimum these fields. Without them you cannot debug anything later.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 so you can see where each field goes.- Read the price from your 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 your order number. Clink does not use it for idempotency — the same value twice gives you two Sessions. Preventing duplicate orders is your job.referenceCustomerIdis your 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 your own order status and show it.
pending. Show “confirming payment” and poll a few times rather than declaring failure.
If it stays pending, your 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.
On Node, the SDK does this for you:
Other languages: computing the HMAC yourself
Other languages: computing the HMAC yourself
The rule is simple enough to implement anywhere:Three headers carry it:
X-Clink-Timestamp is the timestamp, X-Clink-Signature is the signature, and X-Clink-SignType is currently always SHA256.The full handler
refund.* object has no merchantReferenceId, only orderId and refundMerchantOrderId. Look up your local order by the clinkOrderId you 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 you will see the same event repeatedly. Deduplicate on
event.id and return 200 for repeats.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. An order already
paid or refunded must not be moved back to pending by an older event arriving late.5
Return 2xx only after writing
A 200 means “handled”. Returning 200 before persisting loses the event permanently.
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. If you already have a public address — a preview deployment, a Vercel or Netlify URL, your own 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 when yours does.
Testing verification without paying
A tunnel plus a real payment proves the whole chain, but paying with a card every time you tweak your verification logic is slow. Build a signed request yourself and send it locally: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 your 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.