# Integrate with an AI Agent
Source: https://docs.clinkbill.com/agent-integration
Hand a prompt to the agent already writing the code and let it do the integration.
When an AI coding agent is already building the project, it can wire Clink in instead of a step-by-step walkthrough.
Clink publishes an open-source integration skill, [clink-integ-skills](https://github.com/clinkbillcom/clink-integ-skills), containing the integration rules, an offline CLI, and prompts written for agents. Install it, send the prompt, and let the agent work.
This path suits projects where an agent is already editing the code. For hand-written integrations, [Hosted Checkout](/build-integration) is more direct.
Both paths produce the same result — the agent just does the typing. What it writes still has to be understood and verified.
## What the agent does
* Surveys the project structure, entry points, routes, and how environment variables are injected, before deciding how to integrate
* Scans the existing pricing page or product data, generates `clink-catalog.json`, and imports it as Clink products and prices
* Writes the server-side checkout, subscription, and webhook endpoints
* Registers the webhook endpoint and syncs the signing secret into the project environment
* Hands back curl examples, a start command, and verification results
## What it does not cover
**Decide the business logic.** What is sold, how it is priced, and the refund policy are merchant decisions.
**Prove that payments actually work.** Someone has to open the `checkoutUrl`, pay with a test card, and confirm the local order became paid and fulfillment ran. An agent saying "integration complete" is not evidence that any of that happened.
**Go to production.** The skill works in the sandbox by default; switching to production goes through [Go Live](/go-live).
## Prepare a disposable key
The agent reads and writes project files, runs commands, and may send their contents to a model provider along the way. Provision its key on the assumption that it could leak:
* Sandbox keys only (`sk_uat_`). A production key should never reach anywhere the agent can read.
* Initialize a fresh key for this integration rather than reusing the one the team works with day to day.
* Once the integration is signed off, revoke that key under **Developers > API Keys** and issue a separate one for production.
* Treat the webhook signing key the same way: register the production endpoint separately and take a new key with it.
If the agent writes a key into source in plaintext, moving it to an environment variable afterwards is not enough — that revision is already in git history and in the model's context. Revoke it and issue a new one instead of just deleting the line.
## Step 1: Have the agent install the skill
The easiest way is to tell the agent directly:
```text theme={null}
Install clink-integ-skills from: https://github.com/clinkbillcom/clink-integ-skills
```
Or install it into the local skills directory manually:
```bash theme={null}
mkdir -p ~/.codex/skills
git clone https://github.com/clinkbillcom/clink-integ-skills.git /tmp/clink-integ-skills
cp -R /tmp/clink-integ-skills ~/.codex/skills/clink-integ-skills
```
The skill needs no extra runtime dependencies — `clink-integ-cli` is bundled inside at `vendor/clink-integ-cli/clink-integ-cli`.
The skill follows the Codex skill format and lives under `~/.codex/skills/`. For agents that do not support that format, ask it to read the GitHub repository directly instead; the skill's own prompts include that fallback.
## Step 2: Send it the prompt
Copy this whole block to the agent:
```text theme={null}
Use $clink-integ-skills to integrate ClinkBill payments into this website project.
Goal: complete a ClinkBill sandbox test payment integration as automatically as possible.
Choose the authentication path based on the environment:
- Prefer the offline CLI bundle inside this skill: `vendor/clink-integ-cli/clink-integ-cli`. Do not install `clink-integ-cli` from GitHub or npm during normal execution.
- Prefer an existing `CLINK_SECRET_KEY`, or one I provide manually, and save it to the CLI profile with `clink auth secret set --api-key env:CLINK_SECRET_KEY --env sandbox`.
- Only run `clink login` if you are on a local desktop environment, have no Secret Key already, can open a browser, and Playwright has been provisioned offline. It opens the Dashboard login page for me to complete by hand so the CLI can read or create a Secret Key.
- If you are running in a cloud IDE, low-code editor, sandbox, or any environment without a usable browser, do not block on `clink login`. Ask me to log in to the ClinkBill Dashboard myself and hand you the Secret Key, then write it only into a secure server-side environment variable, platform Secret, or local `.env`.
- In a browserless environment, ask me only for `CLINK_SECRET_KEY`. Do not ask for `CLINK_WEBHOOK_SIGNING_KEY` up front — the CLI can manage webhook endpoints with the Secret Key, so the webhook signing key should be generated and saved by your own `clink webhook endpoint ensure --save-secret` run, then written by you into the platform Secret.
Requirements:
1. Survey the project structure, start command, server entry point, route locations, environment variable mechanism, order/purchase entry points, and whether the webhook route can read the raw body — before deciding how to integrate. Also decide whether this project actually needs recurring billing and promotion codes; if it does not, do not implement the subscription or coupon APIs. If it does need subscriptions, create a recurring price, map subscription status to entitlements, and subscribe using full event names (subscription.created, subscription.trialing, subscription.activated, subscription.past_due, subscription.incomplete_expired, subscription.cancelled, subscription.updated.plan_changed, subscription.updated.plan_change_canceled, subscription.updated.renewed, subscription.updated.cancel_at_period_end_set, subscription.updated.cancel_at_period_end_revoked, invoice.open, invoice.paid, invoice.void) — never a wildcard like subscription.*.
2. If the project has no trustworthy backend, do not put `CLINK_SECRET_KEY` or the webhook signing key in frontend code, and do not let the browser call the Clink sandbox API directly.
3. You must implement or verify the checkout session server endpoint, subscription server endpoint, webhook receiving endpoint, a local start/verify procedure, curl examples, and automated tests or a smoke test.
4. If the site already has a pricing page, paid products, or subscription plans, scan them in this order — running API / pricing page DOM / hydrated JSON, then source and config, and only then ask me — generate `clink-catalog.json`, and create the Clink products and prices with `clink catalog validate/plan/import`. Do not make me copy productId or priceId by hand.
5. Every product in `clink-catalog.json` must carry exactly one image source: `imageId`, `imageUrl`, or `imageFile`. URLs go in `imageUrl`, local public/static assets go in `imageFile`, and URLs must never go in `imageId`. When running catalog commands, add `--project-root . --public-dir public` if the project has a public/static directory.
6. Real keys may only be written to local environment variables or platform Secrets — never into source, README files, frontend variables, test fixtures, or your final reply.
7. After every successful `clink webhook endpoint ensure --save-secret`, sync the latest webhook signing key into the project runtime and restart the service. For projects with a local `.env`, prefer `--sync-env-file `; otherwise webhook signature verification will fail.
8. If a public HTTPS domain is available, configure the webhook endpoint against it directly. A cloudflared tunnel is only needed for purely local `localhost` / `127.0.0.1` development.
9. Do not describe webhook endpoint management as Dashboard-only. `clink dashboard webhook ensure` is only a compatibility alias — prefer `clink webhook endpoint ensure`.
10. The webhook handler must match the local order on both `merchantReferenceId` and `sessionId`. If the two fields point to different local orders, reject, quarantine, or escalate — never rely on one field alone.
11. Clearly distinguish local mocks, signed simulated webhooks, a real sandbox checkout session, and the real webhook that follows a completed sandbox test payment.
12. If nobody has opened `checkoutUrl` and completed a sandbox test payment, do not describe "real checkout session created plus simulated webhook passed" as a completed end-to-end payment. Even when a real webhook returns 200, you must still confirm the local order is paid/completed and that credits, entitlements, shipment, download access, or other fulfillment has actually happened.
When you are done, hand back the architecture survey, the list of changed files, an explanation of new API routes and services, the environment variables and a `.env.example`, a one-command start procedure, curl examples, a summary of CLI verification results, the webhook endpoint, the tunnel or local URL, test results, and whatever steps are left for me to do by hand.
```
Partway through, the agent asks for a **sandbox Secret Key**. Initialize one on the **Developers** page of the [sandbox dashboard](https://uat-dashboard.clinkbill.com) — it starts with `sk_uat_`.
Give it the sandbox key only, never a production key. And do not let the agent write keys into source or commit them — the prompt above forbids this, but verify it independently.
## Step 3: Verify the result
**Do not skip this.** "Integration complete" from an agent and payments actually working are different claims.
Confirm the browser cannot reach any `sk_` key or the webhook signing secret.
Open the `checkoutUrl` the agent returns and pay in full with test card `4242 4242 4242 4242`. Without a real payment, nothing is proven.
The row in the local database should be paid, and shipping, top-up, or entitlement logic should have actually run.
This is the part an agent is most likely to get wrong. Five things have to be right:
* The signature is verified against the **raw request body**, not one that was parsed and re-serialized
* `X-Clink-SignType` is checked against `SHA256`
* The business object is read from **`event.data.object`**, not `event.data`
* Events are deduplicated by `event.id`
* Orders are matched on both `merchantReferenceId` and `sessionId`
Watch the middle two especially — getting them wrong is invisible against simulated events and only surfaces on a real payment. Check each against [Hosted Checkout](/build-integration).
Before production, go through [Go Live](/go-live) line by line.
## Common situations
**The agent says it is done, but nobody paid.** That only means a Session was created. Ask it to separate "session created" from "payment succeeded" — the skill requires that distinction.
**The project has no backend.** A purely static site cannot integrate, because the Secret Key must live on a server. Have the agent add a minimal backend route or serverless function first.
**The agent asks for a productId to be pasted by hand.** Where the site already has a pricing page, it should scan that and use `clink catalog import` rather than requiring IDs to be copied manually.
## Next
Understand the code the agent wrote, especially the webhook half.
The checklist before switching to production.
# TypeScript SDK
Source: https://docs.clinkbill.com/api-reference/SDK
The official Node.js library for the Clink API.
The Clink Node.js SDK provides convenient access to the Clink API from applications written in server-side JavaScript or TypeScript. It includes TypeScript definitions for all request parameters and response fields.
View the package on npmjs.com
## Installation
Install the package with your preferred package manager:
```bash npm theme={null}
npm install @clink-ai/clink-typescript-sdk
```
```bash yarn theme={null}
yarn add @clink-ai/clink-typescript-sdk
```
```bash pnpm theme={null}
pnpm add @clink-ai/clink-typescript-sdk
```
## Quick Start
To start using the SDK, you need to initialize the client with your API Key. You can find your secret keys in the **Developers** section of your Clink dashboard.
**Security Note:** The SDK is intended for server-side use only. Never expose your **Secret API keys** in client-side code (browsers) or public repositories.
```typescript theme={null}
import { ClinkPayClient } from '@clink-ai/clink-typescript-sdk';
const client = new ClinkPayClient({
apiKey: 'YOUR_API_KEY',
env: 'sandbox',
});
async function main() {
const session = await client.createCheckoutSession({
customerEmail: 'customer@example.com',
originalAmount: 19.99,
originalCurrency: 'USD',
uiMode: 'hostedPage',
priceDataList: [
{ name: 'Test item', quantity: 1, unitAmount: 19.99, currency: 'USD' },
],
successUrl: 'https://merchant.example.com/success',
cancelUrl: 'https://merchant.example.com/cancel',
allowPromotionCodes: true,
});
console.log(session.sessionId);
console.log(session.url);
}
main();
```
For [Elements](/elements), create the session with `uiMode: 'elements'` and provide `returnUrl`, for example `https://YOUR_DOMAIN/complete.html?session_id={ELEMENTS_SESSION_ID}`. Clink replaces `{ELEMENTS_SESSION_ID}` with the created session ID.
For the embedded iframe from [`@clink-ai/clink-js`](/api-reference/javascript_sdk), use `uiMode: 'hostedPage'` instead — that SDK mounts the hosted checkout page as-is.
## API Overview
* `createCheckoutSession(options)`: Create a checkout session and get the redirect `url`.
* `getCheckoutSession(sessionId)`: Retrieve checkout session details.
* `getOrder(orderId)`: Retrieve order details.
* `getRefund(refundId)`: Retrieve refund details.
* `getSubscription(subscriptionId)`: Retrieve subscription details.
* `getInvoice(invoiceId)`: Retrieve subscription invoice details.
* `customerPortalSession(options)`: Create a customer portal session and get the access link.
All methods are asynchronous and throw on errors.
## Error Handling
When the API returns a non-success status code (4xx or 5xx), the SDK throws an error. You should wrap your API calls in `try/catch` blocks.
```typescript theme={null}
import { ClinkPayClient, ClinkApiError } from '@clink-ai/clink-typescript-sdk';
const client = new ClinkPayClient({ apiKey: 'YOUR_API_KEY', env: 'sandbox' });
async function demo() {
try {
const order = await client.getOrder('order_123');
console.log(order.status);
} catch (e) {
if (e instanceof ClinkApiError) {
const { code, message } = e;
// your code here
}
// handle other errors
}
}
demo();
```
## Verifying webhooks
`ClinkWebhook` computes and compares the HMAC for you, so you do not have to write it yourself.
```typescript theme={null}
import { ClinkWebhook, ClinkWebhookSignatureError } from '@clink-ai/clink-typescript-sdk';
const webhook = new ClinkWebhook({ signatureKey: process.env.CLINK_WEBHOOK_SIGNING_KEY });
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
// The SDK checks the HMAC only — screen these two before calling it
if (headers['x-clink-signtype'] !== 'SHA256') return reject401();
const ts = Number(headers['x-clink-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return reject401();
try {
const event = webhook.verifyAndGet({
timestamp: headers['x-clink-timestamp'],
body: rawBody, // the raw body, before JSON parsing
headerSignature: headers['x-clink-signature'],
});
// event.id, event.type, event.data.object
} catch (e) {
if (e instanceof ClinkWebhookSignatureError) {
// reject with 401
}
}
```
**`verifyAndGet()` in `1.0.1` verifies the HMAC and nothing else.** It does not check `X-Clink-SignType`, does not check timestamp freshness, and compares signatures with `===` rather than a constant-time comparison.
The first two are screened above. For the third, compute the HMAC yourself with `crypto.timingSafeEqual` — see [Hosted Checkout](/build-integration).
Verifying the signature is also not the same as being safe to act on. Deduplicate on `event.id` with a unique index before doing any work.
`verifySignature()` returns a boolean instead of throwing, and `signature()` generates one — useful for building signed test events locally.
## References
* [Detailed Example for APIs](https://www.npmjs.com/package/@clink-ai/clink-typescript-sdk?activeTab=readme)
# Clink CLI
Source: https://docs.clinkbill.com/api-reference/clink_cli
Use clink-cli to initialize customer wallets, manage card links, pay, refund, and inspect risk rules from the command line.
`clink-cli` is a command-line client for Clink customer wallet APIs. Use it to initialize customer wallet profiles, open card setup or management pages, make payments, create refunds, and inspect risk rule settings.
This is not the tool used to set up a merchant integration. Webhook registration, signed event simulation, and catalog import belong to `clink-integ-cli`, which ships with [clink-integ-skills](https://github.com/clinkbillcom/clink-integ-skills). Both tools use commands starting with `clink`, so check which one you installed. See [Hosted Checkout](/build-integration).
**Security Note:** The CLI can store customer credentials in your local profile. Do not commit `~/.clink-cli/config.json` or share values such as `customer-api-key`.
View the package on npmjs.com
## Requirements
* Node.js `>=20`
* Access to the target Clink API environment
The default base URL is:
```text theme={null}
https://api.clinkbill.com
```
## Installation
Install the CLI globally:
```bash theme={null}
npm install -g @clink-ai/clink-cli
```
Or run it without a global install:
```bash theme={null}
npx clink-cli --help
```
Every command starts with:
```bash theme={null}
clink-cli [subcommand] [options]
```
## Quick Start
### 1. Initialize your wallet
Run this once to create or activate a customer wallet and save credentials locally:
```bash theme={null}
clink-cli wallet init --email user@example.com --name "Alice"
```
Check the saved profile:
```bash theme={null}
clink-cli wallet status --format pretty
```
The local config file is stored at:
```text theme={null}
~/.clink-cli/config.json
```
### 2. Open card pages
Get the raw binding link:
```bash theme={null}
clink-cli card binding-link
```
Open the add-card page:
```bash theme={null}
clink-cli card setup-link --open
```
Open the manage-card page:
```bash theme={null}
clink-cli card modify-link --open
```
### 3. Check saved payment methods
List cached payment methods:
```bash theme={null}
clink-cli card list --format pretty
```
Get one cached payment method:
```bash theme={null}
clink-cli card get --payment-instrument-id pi_xxx
```
Notes:
* Card add, update, and delete actions happen on the web page, not directly in the CLI.
* `card list` and `card get` read local cached data.
* `card binding-link`, `card setup-link`, and `card modify-link` refresh the local payment method cache.
### 4. Make a payment
Pay with merchant mode:
```bash theme={null}
clink-cli pay \
--merchant-id merchant_xxx \
--amount 10.00 \
--currency USD \
--payment-instrument-id pi_xxx
```
Pay with session mode:
```bash theme={null}
clink-cli pay --session-id sess_xxx --payment-instrument-id pi_xxx
```
If `--payment-instrument-id` is omitted, `pay` uses the default cached payment method.
### 5. Refund an order
Create a full refund:
```bash theme={null}
clink-cli refund create --order-id order_xxx
```
Check refund status:
```bash theme={null}
clink-cli refund get --refund-id rfd_xxx
```
### 6. Check risk rules
Get current risk rule settings:
```bash theme={null}
clink-cli risk-rule get --format pretty
```
Open the risk rule page:
```bash theme={null}
clink-cli risk-rule link --open
```
## Common Usage
Use a named profile:
```bash theme={null}
clink-cli wallet init --profile buyer-2 --email user2@example.com --name "Bob"
clink-cli wallet status --profile buyer-2 --format pretty
```
Override the base URL:
```bash theme={null}
clink-cli wallet status --base-url https://uat-api.clinkbill.com
```
Print requests without executing them:
```bash theme={null}
clink-cli pay \
--merchant-id merchant_xxx \
--amount 10.00 \
--currency USD \
--payment-instrument-id pi_xxx \
--dry-run
```
## Commands
Command groups:
* `clink-cli wallet init`
* `clink-cli wallet status`
* `clink-cli card binding-link`
* `clink-cli card setup-link`
* `clink-cli card modify-link`
* `clink-cli card list`
* `clink-cli card get --payment-instrument-id `
* `clink-cli risk-rule get`
* `clink-cli risk-rule link`
* `clink-cli pay --merchant-id --amount --currency `
* `clink-cli pay --session-id `
* `clink-cli refund create --order-id `
* `clink-cli refund get --refund-id `
* `clink-cli config set `
* `clink-cli config get`
* `clink-cli config unset `
Show help:
```bash theme={null}
clink-cli --help
clink-cli wallet --help
clink-cli card --help
clink-cli refund --help
```
## Configuration
Useful config commands:
```bash theme={null}
clink-cli config get
clink-cli config set base-url https://uat-api.clinkbill.com
clink-cli config set customer-id cus_xxx --profile buyer-2
clink-cli config set customer-api-key sk_uat_xxx --profile buyer-2
clink-cli config unset customer-api-key --profile buyer-2
```
Supported config keys:
* `base-url`
* `customer-id`
* `customer-api-key`
* `default-open-links`
* `email`
* `name`
Resolution order:
1. Command flags
2. Environment variables
3. Saved profile config
Environment variables:
* `CLINK_BASE_URL`
* `CLINK_CUSTOMER_ID`
* `CLINK_CUSTOMER_API_KEY`
## Global Options
* `--format `
* `--dry-run`
* `--open`
* `--profile `
* `--base-url `
* `--customer-id `
* `--customer-api-key `
* `--timeout `
* `--help`
# Activate Promotion Code
Source: https://docs.clinkbill.com/api-reference/endpoint/activate-promotion-code
PUT /promotion-code/{promotionCodeId}/active
Activate a promotion code.
# Advance Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/advance-test-clock
POST /subscription/test-clocks/{clockId}/advance
Advance a test clock to the target frozen timestamp
# Cancel Subscription
Source: https://docs.clinkbill.com/api-reference/endpoint/cancel-subscription
POST /subscription/{id}/cancel
Cancel a subscription either immediately or at the end of the current billing period.
# Cancel Subscription Update
Source: https://docs.clinkbill.com/api-reference/endpoint/cancel-subscription-update
POST /subscription/{id}/update/cancel
Cancel a pending subscription plan update that has not taken effect yet.
# Complete Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/complete-test-clock
POST /subscription/test-clocks/{clockId}/complete
Complete a test clock and stop further simulated execution
# Confirm Subscription Update
Source: https://docs.clinkbill.com/api-reference/endpoint/confirm-subscription-update
POST /subscription/{id}/update/confirm
Confirm a subscription upgrade or downgrade using the target price snapshot returned by the preview API. Immediate updates may require payment; period-end updates return success when no immediate payment is required.
# Create Agent Payment Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-agent-payment-session
POST /order/payment-session
Create agent payment session
# Create Checkout Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-checkout-session
POST /checkout/session
Create a new checkout session for payment processing
# Create Coupon
Source: https://docs.clinkbill.com/api-reference/endpoint/create-coupon
POST /coupon
Create a coupon and optionally create promotion codes for it.
# Customer Portal Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-customer-portal
POST /billing/session
Create a new customer portal session for billing management
# Create Payment
Source: https://docs.clinkbill.com/api-reference/endpoint/create-payment
POST /payment
Create a one-time payment with an existing payment instrument, or omit paymentInstrumentId for payment methods that support automatic payment instrument creation. Use either productId and priceId, or amount and currency. To charge in a different payment currency, provide paymentCurrency and Clink will apply the available fixed multi-currency price or automatic currency conversion.
# Create Payment Instrument
Source: https://docs.clinkbill.com/api-reference/endpoint/create-payment-instrument
POST /payment-instrument
Create a new payment instrument for a customer. The request resolves or creates the customer first, then stores the payment instrument under that customer.
# Create Price
Source: https://docs.clinkbill.com/api-reference/endpoint/create-price
POST /price
Create a price for an existing product under your current merchant account.
# Create Product
Source: https://docs.clinkbill.com/api-reference/endpoint/create-product
POST /product
Create a product with one or more price configurations. Product names can include localized values for multiple languages.
# Create Promotion Code
Source: https://docs.clinkbill.com/api-reference/endpoint/create-promotion-code
POST /promotion-code/{id}
Create a promotion code for a coupon.
# Create Refund
Source: https://docs.clinkbill.com/api-reference/endpoint/create-refund
POST /refund
Create a refund for an existing order. The refundMerchantOrderId is used for idempotency.
# Create Subscription
Source: https://docs.clinkbill.com/api-reference/endpoint/create-subscription
POST /subscription
Create a subscription and initiate its first payment. Provide an existing payment instrument, or omit paymentInstrumentId for payment methods that support automatic payment instrument creation.
# Create Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/create-test-clock
POST /subscription/test-clocks
Create a new test clock for a subscription
# Create Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/create-webhook-endpoint
POST /webhook/endpoints
Create a webhook endpoint for the current merchant. The endpoint URL must use HTTPS and resolve to a public host.
# Deactivate Promotion Code
Source: https://docs.clinkbill.com/api-reference/endpoint/deactivate-promotion-code
PUT /promotion-code/{promotionCodeId}/deactivate
Deactivate a promotion code.
# Delete Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/delete-webhook-endpoint
DELETE /webhook/endpoints/{id}
Delete a webhook endpoint. Deleted endpoints stop receiving webhook events.
# Disable Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/disable-webhook-endpoint
POST /webhook/endpoints/{id}/disable
Disable a webhook endpoint. Disabled endpoints are saved but do not receive webhook events.
# Enable Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/enable-webhook-endpoint
POST /webhook/endpoints/{id}/enable
Enable a webhook endpoint.
# Ensure Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/ensure-webhook-endpoint
PUT /webhook/endpoints/ensure
Create or update a webhook endpoint by URL. This endpoint is designed for idempotent setup flows where applications need to safely create or reconcile a webhook endpoint. For existing endpoints, Clink does not return the stored plaintext signing secret unless the secret is rotated.
# Get Agent Payment Session
Source: https://docs.clinkbill.com/api-reference/endpoint/get-agent-payment-session
GET /order/payment-session/{sessionId}
Get agent payment session
# Get Checkout Session
Source: https://docs.clinkbill.com/api-reference/endpoint/get-checkout-session
GET /checkout/session/{id}
Retrieve details of an existing checkout session
# Get Coupon
Source: https://docs.clinkbill.com/api-reference/endpoint/get-coupon
GET /coupon/{couponId}
Get detailed information about a specific coupon, including its promotion codes.
# Get Invoice
Source: https://docs.clinkbill.com/api-reference/endpoint/get-invoice
GET /subscription/invoice/{id}
Get detailed information about a specific invoice
# Get Order
Source: https://docs.clinkbill.com/api-reference/endpoint/get-order
GET /order/{id}
Get detailed information about a specific order
# Get Price
Source: https://docs.clinkbill.com/api-reference/endpoint/get-price
GET /price/{id}
Get price information under your current merchant account based on the price ID
# Get Price List
Source: https://docs.clinkbill.com/api-reference/endpoint/get-price-list
GET /price
Get all price information under your current merchant account
# Get Product
Source: https://docs.clinkbill.com/api-reference/endpoint/get-product
GET /product/{id}
Get product information under your current merchant account based on the product ID
# Get Product List
Source: https://docs.clinkbill.com/api-reference/endpoint/get-product-list
GET /product
Get all product information under your current merchant account
# Get Promotion Code
Source: https://docs.clinkbill.com/api-reference/endpoint/get-promotion-code
GET /promotion-code/{id}
Get detailed information about a specific promotion code.
# Get Refund
Source: https://docs.clinkbill.com/api-reference/endpoint/get-refund
GET /refund/{id}
Get detailed information about a specific refund
# Get Subscription
Source: https://docs.clinkbill.com/api-reference/endpoint/get-subscription
GET /subscription/{id}
Get detailed information about a specific subscription
# Get Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/get-test-clock
GET /subscription/test-clocks/{clockId}
Retrieve details of a specific test clock
# Get Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/get-webhook-endpoint
GET /webhook/endpoints/{id}
Get a webhook endpoint by ID.
# List Coupons
Source: https://docs.clinkbill.com/api-reference/endpoint/list-coupons
GET /coupon
List coupons under your current merchant account with optional filters.
# List Orders
Source: https://docs.clinkbill.com/api-reference/endpoint/list-orders
GET /order
List orders under your current merchant account. You can filter orders by subscription ID, customer ID, or merchant reference ID.
# List Promotion Codes by Coupon
Source: https://docs.clinkbill.com/api-reference/endpoint/list-promotion-codes-by-coupon
GET /promotion-code/coupon/{couponId}
List promotion codes attached to a coupon.
# List Test Clocks
Source: https://docs.clinkbill.com/api-reference/endpoint/list-test-clocks
GET /subscription/test-clocks/list
List all active test clocks under your current merchant account
# List Webhook Endpoints
Source: https://docs.clinkbill.com/api-reference/endpoint/list-webhook-endpoints
GET /webhook/endpoints
Get webhook endpoints under the current merchant account.
# List Webhook Events
Source: https://docs.clinkbill.com/api-reference/endpoint/list-webhook-events
GET /webhook/events
Return supported webhook events and event aliases. Use event names in webhook endpoint management requests; numeric event codes are returned for reference only.
# Preview Subscription Update
Source: https://docs.clinkbill.com/api-reference/endpoint/preview-subscription-update
POST /subscription/{id}/update/preview
Preview a subscription upgrade or downgrade before confirming it. The target price must be a recurring price owned by the current merchant and must support the subscription payment currency.
# Refresh Wallet QR Code
Source: https://docs.clinkbill.com/api-reference/endpoint/refresh-wallet-qrcode
POST /payment/{orderId}/qrcode/refresh
Refresh the wallet QR code for an existing order. Currently only supports CASHAPP wallet payments.
# Rename Coupon
Source: https://docs.clinkbill.com/api-reference/endpoint/rename-coupon
PUT /coupon/{couponId}/name
Update the display name of a coupon.
# Rotate Webhook Signing Secret
Source: https://docs.clinkbill.com/api-reference/endpoint/rotate-webhook-signing-secret
POST /webhook/endpoints/{id}/rotate-secret
Rotate the signing secret for a webhook endpoint. The previous secret stops working immediately.
# Update Price
Source: https://docs.clinkbill.com/api-reference/endpoint/update-price
PUT /price/{id}
Update a price under your current merchant account based on the price ID.
# Update Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/update-webhook-endpoint
PATCH /webhook/endpoints/{id}
Update URL, events, description, or enabled status for a webhook endpoint. Omitted fields remain unchanged.
# Upload Product Image
Source: https://docs.clinkbill.com/api-reference/endpoint/upload-product-image
POST /product/image/upload
Upload a product image as multipart form data. The file must be an image and must not exceed 5 MB.
# Introduction
Source: https://docs.clinkbill.com/api-reference/introduction
API Reference and Integration Guide
The Clink API is currently in development, and available features are limited.
## Base Endpoints
The Clink API follows REST principles and requires HTTPS for all requests to ensure data security, integrity, and privacy.
API endpoints for different environments:
```http theme={null}
https://uat-api.clinkbill.com
```
```http theme={null}
https://api.clinkbill.com
```
## Authentication
Clink uses API keys in request headers for security. A dynamic timestamp is also required — in production it must be within 2 minutes of platform time (the sandbox is more lenient). Generate it when you make the call rather than relying on the tolerance window.
```json theme={null}
{
"headers": {
"X-API-Key": "sk_uat_*********************",
"X-Timestamp": "${currentMillisecondsTimestamp}"
}
}
```
## Response codes
We use standard HTTP status codes to indicate the outcome of API requests:
2xx codes indicate successful requests
4xx codes indicate client-side errors
5xx codes indicate server-side issues
| Status | Description |
| ------ | ----------------------------------------------- |
| 200 | Request successful |
| 400 | Invalid parameters or request format |
| 401 | Missing or invalid API key |
| 403 | Insufficient permissions for requested resource |
| 404 | Requested resource not found |
| 429 | Rate limit exceeded |
| 500 | Internal server error |
# JavaScript SDK
Source: https://docs.clinkbill.com/api-reference/javascript_sdk
Launch Clink Checkout from browser applications with redirect or embedded flows.
The Clink JavaScript SDK helps you launch Clink Checkout in browser-based applications. It supports both full-page redirects and embedded checkout, and initializes with a publishable key.
**Security Note:** Use a **publishable key** in browser code. Never expose your **Secret API keys** on the client side.
View the package on npmjs.com
Create a checkout session on your backend before launching checkout.
Learn how hosted checkout sessions work end to end.
## Installation
Install the SDK from npm with your preferred package manager:
```bash npm theme={null}
npm install @clink-ai/clink-js
```
```bash yarn theme={null}
yarn add @clink-ai/clink-js
```
```bash pnpm theme={null}
pnpm add @clink-ai/clink-js
```
If you need a browser global build, the package also ships a UMD bundle. After self-hosting `dist/index.umd.js`, the SDK is exposed as `Clink.loadClink(...)` on `window`.
## Initialization
Initialize the SDK with your publishable key:
```ts theme={null}
import { loadClink } from '@clink-ai/clink-js';
const clink = await loadClink('pk_uat_xxxxxxxxx', {
checkoutEnvironment: 'sandbox',
locale: 'en-US',
});
```
The SDK syntactically accepts `pk_test_*`, `pk_uat_*`, and `pk_prod_*`. The keys you actually get from the dashboard are `pk_uat_*` in sandbox and `pk_prod_*` in production.
If you already know the final checkout host, you can skip bootstrap by passing `checkoutBaseUrl` directly:
```ts theme={null}
import { loadClink } from '@clink-ai/clink-js';
const clink = await loadClink('pk_prod_xxxxxxxxx', {
checkoutBaseUrl: 'https://checkout.clinkbill.com',
});
```
### Init Options
* `checkoutEnvironment`: `sandbox` or `production`. Used when `checkoutBaseUrl` is not provided.
* `checkoutBaseUrl`: Checkout host used directly by the SDK. When set, bootstrap is skipped.
* `locale`: Forwarded during bootstrap.
* `origin`: Override the current site origin.
* `fetchImpl`: Custom `fetch` implementation for non-browser runtimes.
When `checkoutBaseUrl` is omitted, the SDK resolves bootstrap in this order:
1. `checkoutEnvironment`
2. `CLINK_ENV`
`CLINK_ENV=sandbox` maps to `https://uat-api.clinkbill.com/api/sdk/bootstrap`, and `CLINK_ENV=production` maps to `https://api.clinkbill.com/api/sdk/bootstrap`.
## Redirect Checkout
Use redirect checkout when you want Clink to take over the full page flow.
```ts theme={null}
import { loadClink } from '@clink-ai/clink-js';
const clink = await loadClink('pk_uat_xxxxxxxxx', {
checkoutEnvironment: 'sandbox',
});
document
.getElementById('checkout-button')
?.addEventListener('click', async () => {
await clink.redirectToCheckout({
// Preferred when your backend returns an opaque session token
sessionParam: 'sess_xxx#token_xxx',
replace: false,
});
});
```
`redirectToCheckout` accepts:
* `sessionParam`: Preferred when available.
* `sessionId`: Used when you only have the session ID.
* `replace`: Uses `window.location.replace(...)` instead of `assign(...)`.
When both `sessionParam` and `sessionId` are provided, `sessionParam` wins.
## Embedded Checkout
Use embedded checkout when you want the payment flow to stay inside your page.
Create the Session with `uiMode: "hostedPage"`. The SDK only needs your backend to return a usable `checkoutUrl` — it does not inspect or rewrite `uiMode`.
`uiMode: "elements"` belongs to the separate [`@clink-ai/clink-elements`](/elements) package, which mounts composable payment components rather than a full checkout iframe. Pick one per checkout page.
```ts theme={null}
import { loadClink } from '@clink-ai/clink-js';
const clink = await loadClink('pk_uat_xxxxxxxxx', {
checkoutEnvironment: 'sandbox',
});
const embedded = await clink.initEmbeddedCheckout({
async fetchSession() {
const response = await fetch('/api/clink/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uiMode: 'hostedPage',
successUrl: `${window.location.origin}/complete.html`,
cancelUrl: `${window.location.origin}/cart`,
}),
});
return await response.json();
// {
// sessionId: 'sess_xxx',
// checkoutUrl: 'https://checkout.clinkbill.com/pay/sess_xxx%23token_xxx',
// orderId: 'order_xxx'
// }
},
onEvent(event) {
console.log(event.type, event.payload);
},
async pollStatus({ sessionId, orderId, attempt }) {
const response = await fetch(
`/api/clink/checkout-status?sessionId=${sessionId}`,
);
const result = await response.json();
if (result.state === 'pending' || result.state === 'payment') {
return null;
}
return {
state: result.state,
payload: {
orderId,
attempt,
},
};
},
});
embedded.mount('#clink-checkout');
```
`fetchSession` must create a checkout session on your backend and return the final checkout URL. Create it with `uiMode: 'hostedPage'` — the SDK mounts the returned URL as-is and does not rewrite its query parameters, nor does it require any particular `uiMode`.
### Embedded Options
* `fetchSession`: Required. Must resolve `{ sessionId, checkoutUrl, orderId? }`.
* `onEvent`: Receives all checkout lifecycle events.
* `autoResize`: Automatically applies iframe height updates. Default: `true`.
* `autoDestroyOnComplete`: Automatically destroys the embedded instance after a successful payment. Default: `true`.
* `pollStatus`: Optional polling hook for terminal state detection.
* `pollIntervalMs`: Poll interval in milliseconds. Default: `2000`.
### Embedded Instance API
* `mount(container)`: Mount into a CSS selector or `HTMLElement`.
* `unmount()`: Remove the iframe but keep the instance reusable.
* `destroy()`: Fully dispose the instance.
* `on(type, handler)`: Subscribe to a specific event type.
* `getState()`: Returns `{ mounted, destroyed }`.
## Events and States
Embedded checkout can emit the following events:
| Event | Description |
| --------------- | -------------------------------------------------------- |
| `ready` | The checkout iframe is ready or has finished loading. |
| `resize` | The iframe requests a height update. |
| `state_change` | Checkout state changed. |
| `complete` | A terminal state was reached. |
| `hosted_return` | The hosted checkout returned control to the parent page. |
| `error` | SDK or polling error. |
Possible embedded states are:
* `payment`
* `pending`
* `success`
* `cancelled`
* `error`
* `expired`
Semantics to keep in mind:
* `complete`: The checkout reached a terminal payment state, either from the checkout page itself or via `pollStatus`.
* `hosted_return`: A hosted success or cancel page returned control to the parent page. Use this for UI cleanup or navigation.
* `error`: An SDK or polling failure, not necessarily a payment terminal state.
## Bootstrap Environment
The SDK supports fixing the remote bootstrap environment with `CLINK_ENV`:
* `CLINK_ENV=sandbox` → `https://uat-api.clinkbill.com/api/sdk/bootstrap`
* `CLINK_ENV=production` → `https://api.clinkbill.com/api/sdk/bootstrap`
`checkoutEnvironment` uses the same values: `sandbox` and `production`.
Priority order:
1. `loadClink(..., { checkoutBaseUrl })`
2. `loadClink(..., { checkoutEnvironment })`
3. `CLINK_ENV`
## Error Handling
The SDK throws `ClinkError` for validation, bootstrap, and embedded checkout failures.
```ts theme={null}
import {
CLINK_ERROR_CODES,
ClinkError,
loadClink,
} from '@clink-ai/clink-js';
try {
const clink = await loadClink('pk_uat_xxxxxxxxx', {
checkoutEnvironment: 'sandbox',
});
await clink.redirectToCheckout({
sessionId: 'sess_xxx',
});
} catch (error) {
if (error instanceof ClinkError) {
if (error.code === CLINK_ERROR_CODES.INVALID_PUBLIC_KEY) {
console.error('Invalid publishable key');
}
}
}
```
Common error codes include:
* `INVALID_PUBLIC_KEY`
* `INVALID_CHECKOUT_ENV`
* `BOOTSTRAP_REQUEST_FAILED`
* `INVALID_BOOTSTRAP_RESPONSE`
* `INVALID_REDIRECT_PARAMS`
* `INVALID_EMBEDDED_OPTIONS`
* `INVALID_SESSION_ID`
* `SESSION_ID_FETCH_FAILED`
* `EMBEDDED_CHECKOUT_DISABLED`
* `CONTAINER_NOT_FOUND`
* `NOT_IN_BROWSER`
## References
* [Create Checkout Session](/api-reference/endpoint/create-checkout-session)
* [Checkout Session Guide](/guides/payments/checkout_session)
# customer.verify
Source: https://docs.clinkbill.com/api-reference/webhook/customer.verify
WEBHOOK customer.verify
Webhook notification triggered when customer verification is required
# dispute
Source: https://docs.clinkbill.com/api-reference/webhook/dispute
WEBHOOK dispute
Webhook notification triggered when dispute status changes. When a dispute reaches won or lost, an additional dispute.closed event is sent.
# invoice
Source: https://docs.clinkbill.com/api-reference/webhook/invoice
WEBHOOK invoice
Webhook notification triggered when an invoice is created or updated
# order
Source: https://docs.clinkbill.com/api-reference/webhook/order
WEBHOOK order
Handles order lifecycle events.
For `order.succeeded`:
1. Find the merchant account using `data.object.customerEmail`.
2. Create a missing account and return `account.created`, or return `account.reloaded` after confirming the successful-payment notification for an existing account.
3. Map `data.object.amountTotal` to `data.amount` and `data.object.paymentCurrency` to `data.currency`.
Handle retries idempotently using the event `id` or `orderId`, and return the original result for duplicate events. Other order events may return an empty HTTP 200 response.
# refund
Source: https://docs.clinkbill.com/api-reference/webhook/refund
WEBHOOK refund
Webhook notification triggered when refund is created or updated
# session
Source: https://docs.clinkbill.com/api-reference/webhook/session
WEBHOOK session
Webhook notification triggered when session is completed or expired
# subscription
Source: https://docs.clinkbill.com/api-reference/webhook/subscription
WEBHOOK subscription
Webhook notification triggered when a subscription is created or updated
# Hosted Checkout
Source: https://docs.clinkbill.com/build-integration
What to write on the backend, the frontend, and the webhook handler.
This page assumes an integration path has already been picked. If not, start with [Choose an Integration](/choose-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](/subscriptions), and discounts are in [Discounts and promotion codes](/promotions).
## The three endpoints required
| Endpoint | Does | Does not |
| --------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------- |
| `POST /api/checkout/create` | Validates the item and amount, creates the local order, then creates a Clink Session | Let the frontend decide the price |
| `GET /api/orders/:id` | Lets the return page check order status | Pass Clink's raw response through |
| `POST /api/webhooks/clink` | Receives events, verifies signatures, updates orders, triggers fulfillment | Trust the request body before verifying |
## 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.**
```sql theme={null}
-- Merchant order: one row per checkout
CREATE TABLE merchant_orders (
id TEXT PRIMARY KEY, -- the merchant order number
customer_id TEXT NOT NULL, -- the user in the merchant system
product_snapshot JSONB NOT NULL, -- name, unit price, quantity, as of purchase time
original_amount NUMERIC(18,4) NOT NULL,
original_currency TEXT NOT NULL,
refundable_paid_clink_order_id TEXT, -- successful Order that established the refund basis
refundable_paid_amount NUMERIC(18,4), -- authoritative refundable cash amount
refundable_paid_currency TEXT, -- payment/refund currency for that cash amount
clink_session_id TEXT, -- sessionId returned by Clink
clink_session_status TEXT NOT NULL DEFAULT 'open'
CHECK (clink_session_status IN ('open', 'completed', 'expired')),
clink_session_last_event_created BIGINT,
current_clink_order_id TEXT, -- current payment attempt, inferred or Session-confirmed
current_attempt_confirmed BOOLEAN NOT NULL DEFAULT FALSE, -- true only after a Session query
payment_status TEXT NOT NULL, -- aggregated from the attempts below
fulfillment_status TEXT NOT NULL, -- kept separate from payment status
refunded_amount NUMERIC(18,4) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_refundable_payment_basis CHECK (
(refundable_paid_clink_order_id IS NULL AND refundable_paid_amount IS NULL AND refundable_paid_currency IS NULL)
OR
(refundable_paid_clink_order_id IS NOT NULL AND refundable_paid_amount IS NOT NULL AND refundable_paid_currency IS NOT NULL)
),
CONSTRAINT ck_clink_session_event_created CHECK (
clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0
)
);
-- Payment attempt: one row per Clink Order
CREATE TABLE payment_attempts (
clink_order_id TEXT PRIMARY KEY, -- obj.orderId
merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id),
clink_session_id TEXT NOT NULL,
status TEXT NOT NULL, -- pending / action_required / succeeded / failed
attempt_created_at BIGINT, -- only ever from order.created's event.created
last_event_created BIGINT NOT NULL, -- status-event version, compared within one Order only
failure_code TEXT,
failure_message TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- local arrival time, audit only
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Ordering reads attempt_created_at only; unknown ordering sorts last
CREATE INDEX idx_attempts_order
ON payment_attempts (merchant_order_id, attempt_created_at DESC NULLS LAST);
-- Composite ownership key: pointers and refunds cannot pair one merchant
-- order with another merchant order's Clink Order.
ALTER TABLE payment_attempts
ADD CONSTRAINT uq_payment_attempt_owner UNIQUE (merchant_order_id, clink_order_id);
ALTER TABLE merchant_orders
ADD CONSTRAINT fk_current_attempt_owner
FOREIGN KEY (id, current_clink_order_id)
REFERENCES payment_attempts (merchant_order_id, clink_order_id),
ADD CONSTRAINT fk_refundable_attempt_owner
FOREIGN KEY (id, refundable_paid_clink_order_id)
REFERENCES payment_attempts (merchant_order_id, clink_order_id);
-- Successful refund: immutable business fields, one row per Clink refundId
CREATE TABLE refunds (
refund_id TEXT PRIMARY KEY,
merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id),
clink_order_id TEXT NOT NULL REFERENCES payment_attempts(clink_order_id),
amount NUMERIC(18,4) NOT NULL CHECK (amount > 0),
currency TEXT NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
status TEXT NOT NULL CHECK (status = 'success'),
first_event_id TEXT NOT NULL, -- audit only; not rewritten on an exact replay
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_refund_attempt_owner
FOREIGN KEY (merchant_order_id, clink_order_id)
REFERENCES payment_attempts (merchant_order_id, clink_order_id)
);
CREATE INDEX idx_refunds_success
ON refunds (merchant_order_id, status);
-- Durable evidence for conflicts that automation must not retry or overwrite
CREATE TABLE reconciliation_cases (
dedupe_key TEXT PRIMARY KEY, -- stable business key, not a random task id
event_id TEXT NOT NULL,
merchant_order_id TEXT NOT NULL REFERENCES merchant_orders(id),
clink_order_id TEXT,
refund_id TEXT,
reason TEXT NOT NULL,
evidence JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
For an installation that already has `merchant_orders`, apply the Session-event version column before deploying the handler:
```sql theme={null}
ALTER TABLE merchant_orders
ADD COLUMN IF NOT EXISTS clink_session_last_event_created BIGINT;
ALTER TABLE merchant_orders
DROP CONSTRAINT IF EXISTS ck_clink_session_event_created;
ALTER TABLE merchant_orders
ADD CONSTRAINT ck_clink_session_event_created CHECK (
clink_session_last_event_created IS NULL OR clink_session_last_event_created >= 0
);
```
`paymentAttempts.insertIfAbsent()` is not a read-then-write helper. It must let the `clink_order_id` primary key arbitrate concurrent first deliveries:
```sql theme={null}
INSERT INTO payment_attempts
(clink_order_id, merchant_order_id, clink_session_id, status,
attempt_created_at, last_event_created, failure_code, failure_message)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (clink_order_id) DO NOTHING
RETURNING *;
```
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:
```sql theme={null}
INSERT INTO refunds
(refund_id, merchant_order_id, clink_order_id, amount, currency, status, first_event_id)
VALUES
($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (refund_id) DO NOTHING
RETURNING *;
```
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.
```sql theme={null}
INSERT INTO reconciliation_cases
(dedupe_key, event_id, merchant_order_id, clink_order_id, refund_id, reason, evidence)
VALUES
($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (dedupe_key) DO NOTHING;
```
This example uses `decimal.js` for money arithmetic:
```bash theme={null}
npm install decimal.js
```
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
```javascript Node.js (direct API) theme={null}
const CLINK_API = 'https://uat-api.clinkbill.com/api';
function assertClinkPath(path) {
let decodedPath = '';
try {
decodedPath = decodeURIComponent(String(path).split(/[?#]/, 1)[0]);
} catch {
throw new Error('Clink API path must be a controlled relative path');
}
if (
typeof path !== 'string' ||
!path.startsWith('/') ||
path.startsWith('//') ||
path.includes('\\') ||
decodedPath.startsWith('//') ||
decodedPath.split('/').includes('..')
) {
throw new Error('Clink API path must be a controlled relative path');
}
}
function throwSanitizedClinkError(method, path, detail) {
// Only method, controlled path, HTTP status, and numeric envelope code are
// allowed into the error. Never copy the key, response body, or json.msg.
throw new Error(`Clink ${method} ${path} failed${detail ? `: ${detail}` : ''}`);
}
async function clinkApiRequest(path, { method, body, signal } = {}) {
assertClinkPath(path);
if (method !== 'GET' && method !== 'POST') {
throw new Error('Clink API method must be GET or POST');
}
const apiKey = process.env.CLINK_SECRET_KEY;
if (!apiKey) throw new Error('CLINK_SECRET_KEY is not configured');
const headers = {
'X-API-Key': apiKey,
'X-Timestamp': String(Date.now()),
Accept: 'application/json',
};
const options = { method, headers, signal };
if (method === 'POST') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(`${CLINK_API}${path}`, options);
} catch (err) {
if (err?.name === 'AbortError') throw err;
throwSanitizedClinkError(method, path, 'request error');
}
let json;
try {
json = await res.json();
} catch (err) {
if (err?.name === 'AbortError') throw err;
throwSanitizedClinkError(method, path, 'invalid JSON response');
}
const dataIsObject =
json?.data !== null && typeof json?.data === 'object' && !Array.isArray(json.data);
if (!res.ok || json?.code !== 200 || !dataIsObject) {
const details = [];
if (Number.isInteger(res.status)) details.push(`HTTP ${res.status}`);
if (Number.isInteger(json?.code)) details.push(`code ${json.code}`);
throwSanitizedClinkError(method, path, details.join(', ') || 'invalid response envelope');
}
return json.data;
}
async function clinkRequest(path, body, { signal } = {}) {
return clinkApiRequest(path, { method: 'POST', body, signal });
}
async function clinkGet(path, { signal } = {}) {
return clinkApiRequest(path, { method: 'GET', signal });
}
app.post('/api/checkout/create', async (req, res) => {
const { productId, quantity } = req.body;
const user = req.user;
// Price comes from the local database. Never from the request body.
const product = await db.products.findById(productId);
// Prices are stored as integer minor units and converted once
const unitAmount = product.unitAmountMinor / 100; // 1999 -> 19.99
const amount = (product.unitAmountMinor * quantity) / 100;
const order = await db.merchantOrders.create({
customerId: user.id,
productSnapshot: { name: product.name, unitAmount, quantity },
originalAmount: amount,
originalCurrency: 'USD',
paymentStatus: 'created',
fulfillmentStatus: 'none',
});
const session = await clinkRequest('/checkout/session', {
customerEmail: user.email,
referenceCustomerId: user.id,
originalAmount: amount,
originalCurrency: 'USD',
merchantReferenceId: order.id,
uiMode: 'hostedPage',
priceDataList: [
{ name: product.name, quantity, unitAmount, currency: 'USD' },
],
successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
});
await db.merchantOrders.update(order.id, {
clinkSessionId: session.sessionId,
clinkSessionStatus: 'open',
paymentStatus: 'pending',
});
res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
});
```
```javascript Node.js (server SDK) theme={null}
import { ClinkPayClient } from '@clink-ai/clink-typescript-sdk';
const client = new ClinkPayClient({
apiKey: process.env.CLINK_SECRET_KEY,
env: 'sandbox',
});
app.post('/api/checkout/create', async (req, res) => {
const { productId, quantity } = req.body;
const user = req.user;
const product = await db.products.findById(productId);
const unitAmount = product.unitAmountMinor / 100;
const amount = (product.unitAmountMinor * quantity) / 100;
const order = await db.merchantOrders.create({
customerId: user.id,
productSnapshot: { name: product.name, unitAmount, quantity },
originalAmount: amount,
originalCurrency: 'USD',
paymentStatus: 'created',
fulfillmentStatus: 'none',
});
const session = await client.createCheckoutSession({
customerEmail: user.email,
referenceCustomerId: user.id,
originalAmount: amount,
originalCurrency: 'USD',
merchantReferenceId: order.id,
uiMode: 'hostedPage',
priceDataList: [
{ name: product.name, quantity, unitAmount, currency: 'USD' },
],
successUrl: `https://your-site.com/pay/result?orderId=${order.id}`,
cancelUrl: `https://your-site.com/pay/cancel?orderId=${order.id}`,
});
await db.merchantOrders.update(order.id, {
clinkSessionId: session.sessionId,
clinkSessionStatus: 'open',
paymentStatus: 'pending',
});
res.json({ merchantOrderId: order.id, checkoutUrl: session.url });
});
```
The server can also use [`@clink-ai/clink-typescript-sdk`](/api-reference/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.
```javascript theme={null}
const orderId = new URLSearchParams(location.search).get('orderId');
const order = await fetch(`/api/orders/${orderId}`).then((r) => r.json());
if (order.paymentStatus === 'paid') {
showSuccess();
} else if (order.paymentStatus === 'pending') {
showPending(); // "Confirming payment" — check again in a few seconds
} else {
showFailed();
}
```
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:
```javascript theme={null}
import crypto from 'node:crypto';
const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
function verifyWebhook(rawBody, headers) {
// 1. The algorithm must be SHA256 — stop here otherwise
if (headers['x-clink-signtype'] !== 'SHA256') return false;
// 2. The timestamp is Unix milliseconds. Reject stale or non-numeric values
const ts = Number(headers['x-clink-timestamp']);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return false;
// 3. HMAC over the raw body — nothing has been JSON.parse'd yet
const expected = crypto
.createHmac('sha256', process.env.CLINK_WEBHOOK_SIGNING_KEY)
.update(`${headers['x-clink-timestamp']}.${rawBody}`)
.digest('hex');
// 4. Constant-time compare. Screen the length first — timingSafeEqual
// throws when the two buffers differ in size
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(headers['x-clink-signature'] ?? '', 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
`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.
```javascript theme={null}
import { ClinkWebhook, ClinkWebhookSignatureError } from '@clink-ai/clink-typescript-sdk';
const webhook = new ClinkWebhook({
signatureKey: process.env.CLINK_WEBHOOK_SIGNING_KEY,
});
function verifyWithSdk(rawBody, headers) {
if (headers['x-clink-signtype'] !== 'SHA256') return null;
const ts = Number(headers['x-clink-timestamp']);
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) return null;
try {
return webhook.verifyAndGet({
timestamp: headers['x-clink-timestamp'],
body: rawBody,
headerSignature: headers['x-clink-signature'],
});
} catch (e) {
if (e instanceof ClinkWebhookSignatureError) return null;
throw e;
}
}
```
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:
```sql theme={null}
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- event.id; names the webhook_events_pkey constraint
type TEXT NOT NULL,
payload TEXT NOT NULL, -- exact raw event body, needed when reprocessing
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'processed', 'failed')),
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
```javascript theme={null}
// Note: express.raw(), not express.json()
app.post(
'/api/webhooks/clink',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(rawBody, req.headers)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(rawBody);
try {
await db.transaction(async (tx) => {
// 1. Persist and deduplicate. event.id carries a unique index,
// so only one concurrent delivery gets past this line
await tx.webhookEvents.insert({
id: event.id,
type: event.type,
payload: rawBody,
status: 'pending',
});
// 2. Handle it. Both normal completion and a durably quarantined
// conflict are terminal; a missing dependency stays pending.
const outcome = await handleEvent(event, tx);
if (outcome === 'done' || outcome === 'manual_review') {
await tx.webhookEvents.update(event.id, { status: 'processed' });
} else {
// outcome === 'deferred': the dependency does not exist yet.
// Queue a reprocess task and leave the event pending
await tx.outbox.insert({
eventId: event.id,
task: 'reprocess_webhook',
dedupeKey: `reprocess_webhook:${event.id}`,
});
}
});
} catch (err) {
// Duplicate delivery: match ONLY the webhook_events constraint
if (isDuplicateEvent(err)) return res.status(200).send('ok');
// An unexpected or transient failure. Deterministic conflicts never
// reach this branch: their manual-review evidence commits with the
// event. Here the transaction rolled back, so Clink should retry.
logger.error({ err, eventId: event.id }, 'webhook failed');
return res.status(500).send('retry later');
}
// 3. Acknowledge only once the event and its reprocess task are durable
res.status(200).send('ok');
}
);
// Match the constraint by name. "Is it a unique violation?" is too broad.
function isDuplicateEvent(err) {
return err.code === '23505' && err.constraint === 'webhook_events_pkey';
}
```
**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:
```javascript theme={null}
import Decimal from 'decimal.js';
const ATTEMPT_STATUS = {
'order.created': 'pending',
'order.next_action': 'action_required',
'order.succeeded': 'succeeded',
'order.failed': 'failed',
};
const TERMINAL = new Set(['succeeded', 'failed']);
// Single-attempt status -> merchant order status
const ATTEMPT_TO_ORDER = {
succeeded: 'paid',
failed: 'payment_failed',
action_required: 'action_required',
pending: 'pending',
};
function tryNormalizeCurrency(value) {
const currency = String(value ?? '').trim().toUpperCase();
return /^[A-Z]{3}$/.test(currency) ? currency : null;
}
function tryPositiveDecimal(value) {
try {
const amount = new Decimal(String(value));
return amount.isFinite() && amount.isPositive() ? amount : null;
} catch {
return null;
}
}
// Manual-review evidence is deliberately allowlisted. Never copy a whole
// Webhook/API object: it may contain customerEmail, metadata, or payment data.
function attemptEvidence(attempt) {
if (!attempt) return null;
return {
clinkOrderId: attempt.clinkOrderId,
merchantOrderId: attempt.merchantOrderId,
clinkSessionId: attempt.clinkSessionId,
status: attempt.status,
attemptCreatedAt: attempt.attemptCreatedAt ?? null,
lastEventCreated: attempt.lastEventCreated,
};
}
function orderEvidence(order) {
if (!order) return null;
return {
orderId: order.orderId ?? null,
sessionId: order.sessionId ?? null,
merchantReferenceId: order.merchantReferenceId ?? null,
status: order.status ?? null,
amountTotal: order.amountTotal == null ? null : String(order.amountTotal),
paymentCurrency: order.paymentCurrency ?? null,
failureCode: order.failureCode ?? null,
};
}
function refundEvidence(refund) {
if (!refund) return null;
return {
refundId: refund.refundId ?? null,
merchantOrderId: refund.merchantOrderId ?? null,
clinkOrderId: refund.clinkOrderId ?? refund.orderId ?? null,
amount: refund.amount == null ? (refund.refundAmount == null ? null : String(refund.refundAmount)) : String(refund.amount),
currency: refund.currency ?? refund.refundCurrency ?? null,
status: refund.status ?? null,
};
}
function sessionEvidence(session) {
if (!session) return null;
return {
sessionId: session.sessionId ?? null,
orderId: session.orderId ?? null,
merchantReferenceId: session.merchantReferenceId ?? null,
status: session.status ?? null,
};
}
// Persist both the case and the notification task before returning a terminal
// manual_review result. Neither insert updates the first evidence on a replay.
async function recordManualReview(tx, review) {
await tx.reconciliationCases.insertIfAbsent({
dedupeKey: review.dedupeKey,
eventId: review.eventId,
merchantOrderId: review.merchantOrderId,
clinkOrderId: review.clinkOrderId ?? null,
refundId: review.refundId ?? null,
reason: review.reason,
evidence: review.evidence,
status: 'pending',
});
await tx.outbox.insert({
eventId: review.eventId,
task: 'manual_reconciliation',
orderId: review.merchantOrderId,
clinkOrderId: review.clinkOrderId ?? null,
payload: { caseKey: review.dedupeKey },
dedupeKey: `manual_reconciliation:${review.dedupeKey}`,
});
return 'manual_review';
}
function recordOrderOwnershipReview(tx, merchantOrder, event, incoming, storedAttempt, reason) {
const orderKey = incoming.clinkOrderId ?? `event:${event.id}`;
return recordManualReview(tx, {
dedupeKey: `order_ownership_conflict:${merchantOrder.id}:${orderKey}`,
eventId: event.id,
merchantOrderId: merchantOrder.id,
clinkOrderId: incoming.clinkOrderId ?? null,
reason,
evidence: {
reason,
eventId: event.id,
eventType: event.type,
eventCreated: Number.isFinite(event.created) ? event.created : null,
incoming: {
merchantOrderId: merchantOrder.id,
clinkSessionId: incoming.clinkSessionId ?? null,
clinkOrderId: incoming.clinkOrderId ?? null,
},
storedMerchantOrder: {
merchantOrderId: merchantOrder.id,
clinkSessionId: merchantOrder.clinkSessionId,
currentClinkOrderId: merchantOrder.currentClinkOrderId ?? null,
},
storedAttempt: attemptEvidence(storedAttempt),
},
});
}
function recordSessionOwnershipReview(tx, merchantOrder, event, session, reason) {
const incomingSessionId = session?.sessionId ?? null;
return recordManualReview(tx, {
dedupeKey:
`session_review:${merchantOrder.id}:${reason}:${event.type}:${incomingSessionId ?? 'missing'}`,
eventId: event.id,
merchantOrderId: merchantOrder.id,
clinkOrderId: session?.orderId ?? null,
reason,
evidence: {
reason,
eventId: event.id,
eventType: event.type,
eventCreated: Number.isFinite(event.created) ? event.created : null,
incoming: {
merchantOrderId: session?.merchantReferenceId ?? null,
clinkSessionId: incomingSessionId,
clinkOrderId: session?.orderId ?? null,
status: session?.status ?? null,
},
storedMerchantOrder: {
merchantOrderId: merchantOrder.id,
clinkSessionId: merchantOrder.clinkSessionId,
clinkSessionStatus: merchantOrder.clinkSessionStatus,
clinkSessionLastEventCreated:
merchantOrder.clinkSessionLastEventCreated ?? null,
},
},
});
}
const SESSION_STATUS_BY_EVENT = {
'session.complete': 'completed',
'session.expired': 'expired',
};
async function handleSessionTerminal(event, session, tx) {
if (!session || typeof session !== 'object' || Array.isArray(session)) {
return 'deferred';
}
// Without a merchant reference there is no safe owner row for a durable case.
if (!session.merchantReferenceId) return 'deferred';
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(
session.merchantReferenceId,
);
if (!merchantOrder) return 'deferred';
if (!session.sessionId) {
return recordSessionOwnershipReview(
tx,
merchantOrder,
event,
session,
'session_event_missing_required_identifier',
);
}
if (merchantOrder.clinkSessionId !== session.sessionId) {
return recordSessionOwnershipReview(
tx,
merchantOrder,
event,
session,
'session_event_ownership_conflict',
);
}
const nextStatus = SESSION_STATUS_BY_EVENT[event.type];
if (!nextStatus) return 'deferred';
if (!Number.isSafeInteger(event.created) || event.created < 0) {
return recordSessionOwnershipReview(
tx,
merchantOrder,
event,
session,
'session_event_invalid_created',
);
}
if (session.status != null && session.status !== nextStatus) {
return recordSessionOwnershipReview(
tx,
merchantOrder,
event,
session,
'session_event_status_mismatch',
);
}
const previousCreated = merchantOrder.clinkSessionLastEventCreated ?? null;
if (previousCreated != null && event.created < previousCreated) {
return 'done';
}
if (previousCreated === event.created) {
if (merchantOrder.clinkSessionStatus === nextStatus) return 'done';
return recordSessionOwnershipReview(
tx,
merchantOrder,
event,
session,
'session_terminal_same_version_conflict',
);
}
await tx.merchantOrders.update(merchantOrder.id, {
clinkSessionStatus: nextStatus,
clinkSessionLastEventCreated: event.created,
});
merchantOrder.clinkSessionStatus = nextStatus;
merchantOrder.clinkSessionLastEventCreated = event.created;
// Session lifecycle never changes the payment aggregate, refund status,
// fulfillment status, or an Attempt terminal state.
return 'done';
}
// Call only while holding the merchant-order row lock. The first successful
// Order establishes the refund basis; exact replays are harmless, but a
// different Order, amount, or currency is a duplicate-payment/data conflict.
async function persistRefundablePaymentBasis(tx, merchantOrder, payment, context) {
const existing = {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
};
const incoming = {
clinkOrderId: payment.clinkOrderId ?? null,
amount: payment.amountTotal == null ? null : String(payment.amountTotal),
currency: payment.paymentCurrency ?? null,
};
const dedupeKey = context.dedupeKey ??
`refund_basis_conflict:${merchantOrder.id}:${payment.clinkOrderId ?? 'missing'}`;
const amount = tryPositiveDecimal(payment.amountTotal);
const currency = tryNormalizeCurrency(payment.paymentCurrency);
const successfulAttempt = payment.clinkOrderId
? await tx.paymentAttempts.findByClinkOrderId(payment.clinkOrderId)
: null;
if (
!successfulAttempt ||
successfulAttempt.merchantOrderId !== merchantOrder.id ||
successfulAttempt.clinkSessionId !== merchantOrder.clinkSessionId ||
successfulAttempt.status !== 'succeeded'
) {
return recordManualReview(tx, {
dedupeKey,
eventId: context.eventId,
merchantOrderId: merchantOrder.id,
clinkOrderId: payment.clinkOrderId ?? null,
refundId: context.refundId ?? null,
reason: 'refund_basis_attempt_ownership_conflict',
evidence: {
storedBasis: existing,
incomingPayment: incoming,
storedAttempt: attemptEvidence(successfulAttempt),
},
});
}
if (!payment.clinkOrderId || !amount || !currency) {
return recordManualReview(tx, {
dedupeKey,
eventId: context.eventId,
merchantOrderId: merchantOrder.id,
clinkOrderId: payment.clinkOrderId ?? null,
refundId: context.refundId ?? null,
reason: 'invalid_refund_payment_basis',
evidence: { stored: existing, incoming },
});
}
const isEmpty = existing.clinkOrderId == null && existing.amount == null && existing.currency == null;
if (isEmpty) {
const basis = {
refundablePaidClinkOrderId: payment.clinkOrderId,
refundablePaidAmount: amount.toString(),
refundablePaidCurrency: currency,
};
await tx.merchantOrders.update(merchantOrder.id, basis);
Object.assign(merchantOrder, basis);
return 'stored';
}
const existingAmount = tryPositiveDecimal(existing.amount);
const existingCurrency = tryNormalizeCurrency(existing.currency);
const isExactReplay =
existing.clinkOrderId === payment.clinkOrderId &&
existingAmount?.equals(amount) === true &&
existingCurrency === currency;
if (!isExactReplay) {
return recordManualReview(tx, {
dedupeKey,
eventId: context.eventId,
merchantOrderId: merchantOrder.id,
clinkOrderId: payment.clinkOrderId,
refundId: context.refundId ?? null,
reason: 'refund_payment_basis_conflict',
evidence: { stored: existing, incoming: { ...incoming, amount: amount.toString(), currency } },
});
}
return 'replayed';
}
async function handleEvent(event, tx) {
const obj = event.data?.object;
if (SESSION_STATUS_BY_EVENT[event.type]) {
return handleSessionTerminal(event, obj, tx);
}
if (event.type.startsWith('refund.')) {
return obj ? handleRefund(event, obj, tx) : 'deferred';
}
if (!event.type.startsWith('order.')) return 'done';
const nextStatus = ATTEMPT_STATUS[event.type];
if (!nextStatus) return 'done';
// Without the merchant reference there is no merchant row under which a
// durable case can be filed. Treat it as unresolved input, not a DB error.
if (!obj?.merchantReferenceId) return 'deferred';
// Lock the merchant order. This serializes every event for that order —
// the webhook_events unique index only stops the same event.id, not two
// different events racing on one order
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(obj.merchantReferenceId);
if (!merchantOrder) return 'deferred';
const incomingOwnership = {
clinkOrderId: obj.orderId ?? null,
clinkSessionId: obj.sessionId ?? null,
};
if (!obj.orderId || !obj.sessionId || !Number.isSafeInteger(event.created)) {
return recordOrderOwnershipReview(
tx,
merchantOrder,
event,
incomingOwnership,
null,
'order_event_missing_required_identifier',
);
}
if (merchantOrder.clinkSessionId !== obj.sessionId) {
const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(obj.orderId);
return recordOrderOwnershipReview(
tx,
merchantOrder,
event,
incomingOwnership,
storedAttempt,
'order_event_session_mismatch',
);
}
const attemptResult = await upsertAttempt(tx, {
eventId: event.id, // needed by reconcile_attempt
clinkOrderId: obj.orderId,
merchantOrderId: merchantOrder.id,
clinkSessionId: obj.sessionId,
status: nextStatus,
eventType: event.type,
eventCreated: event.created,
failureCode: obj.failureCode,
failureMessage: obj.failureMessage,
}, merchantOrder, event);
if (attemptResult.outcome === 'manual_review') return attemptResult.outcome;
if (
attemptResult.inserted &&
attemptResult.attempt.attemptCreatedAt == null &&
merchantOrder.currentAttemptConfirmed &&
merchantOrder.currentClinkOrderId !== obj.orderId
) {
// A new unordered Order was not covered by the old Session confirmation.
await tx.merchantOrders.update(merchantOrder.id, {
currentAttemptConfirmed: false,
});
merchantOrder.currentAttemptConfirmed = false;
}
if (event.type === 'order.succeeded') {
// Persist even when order.created has not arrived yet. Do not trust a
// conflicting succeeded event unless the stored attempt actually converged
// to succeeded.
const storedAttempt = attemptResult.attempt;
if (storedAttempt?.status === 'succeeded') {
const basisOutcome = await persistRefundablePaymentBasis(
tx,
merchantOrder,
{
clinkOrderId: obj.orderId,
amountTotal: obj.amountTotal,
paymentCurrency: obj.paymentCurrency,
},
{ eventId: event.id },
);
// The attempt write above and the manual-review evidence now commit
// together. Do not aggregate or throw after quarantining the conflict.
if (basisOutcome === 'manual_review') return basisOutcome;
}
}
// order.created brought a creation time — see if the current attempt moves
if (event.type === 'order.created') {
const pointerOutcome = await maybeAdvanceCurrentAttempt(
tx,
merchantOrder,
obj.orderId,
event.created,
event,
attemptResult.inserted,
);
if (pointerOutcome === 'manual_review') return pointerOutcome;
}
const aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, event.id);
if (aggregateOutcome !== 'done') return aggregateOutcome;
// A same-timestamp status ambiguity still needs its Order query even when
// the merchant aggregate can be kept safe in the meantime.
return attemptResult.deferReason === 'status_timestamp_tie'
? 'deferred'
: 'done';
}
// order.created can infer the current attempt only while creation times are
// unambiguous. A tie invalidates that inference and makes the Session decide.
async function maybeAdvanceCurrentAttempt(
tx,
merchantOrder,
clinkOrderId,
attemptCreatedAt,
event,
isNewAttempt,
) {
const currentId = merchantOrder.currentClinkOrderId;
if (!currentId) {
await tx.merchantOrders.update(merchantOrder.id, {
currentClinkOrderId: clinkOrderId,
currentAttemptConfirmed: false,
});
merchantOrder.currentClinkOrderId = clinkOrderId;
merchantOrder.currentAttemptConfirmed = false;
return;
}
const cur = await tx.paymentAttempts.findByClinkOrderId(currentId);
if (
!cur ||
cur.merchantOrderId !== merchantOrder.id ||
cur.clinkSessionId !== merchantOrder.clinkSessionId
) {
return recordOrderOwnershipReview(
tx,
merchantOrder,
event,
{ clinkOrderId: currentId, clinkSessionId: merchantOrder.clinkSessionId },
cur,
'current_attempt_pointer_ownership_conflict',
);
}
if (
merchantOrder.currentAttemptConfirmed &&
clinkOrderId !== currentId &&
!isNewAttempt
) {
// A delayed order.created for an Attempt already covered by the last
// Session snapshot only fills its creation time; it cannot override that
// newer authoritative Session answer.
return;
}
// The incumbent has no known creation time, or the newcomer is strictly later
if (cur.attemptCreatedAt == null || attemptCreatedAt > cur.attemptCreatedAt) {
await tx.merchantOrders.update(merchantOrder.id, {
currentClinkOrderId: clinkOrderId,
currentAttemptConfirmed: false,
});
merchantOrder.currentClinkOrderId = clinkOrderId;
merchantOrder.currentAttemptConfirmed = false;
return;
}
if (
clinkOrderId !== currentId &&
attemptCreatedAt === cur.attemptCreatedAt
) {
// A different Order has the same creation time. The stored pointer may
// merely reflect arrival order, and even an older Session answer is stale
// once this newly observed Order exists. Force a fresh Session query.
await tx.merchantOrders.update(merchantOrder.id, {
currentAttemptConfirmed: false,
});
merchantOrder.currentAttemptConfirmed = false;
}
}
// Returns { outcome: applied | ignored | deferred | manual_review, attempt }.
// The primary key, not a read-then-insert race, chooses the first owner.
async function upsertAttempt(tx, a, merchantOrder, event) {
let cur = await tx.paymentAttempts.findByClinkOrderId(a.clinkOrderId);
const isCreated = a.eventType === 'order.created';
let inserted = false;
if (!cur) {
const candidate = {
clinkOrderId: a.clinkOrderId,
merchantOrderId: a.merchantOrderId,
clinkSessionId: a.clinkSessionId,
status: a.status,
// Only order.created's event.created represents Order creation time
attemptCreatedAt: isCreated ? a.eventCreated : null,
lastEventCreated: a.eventCreated,
failureCode: a.failureCode ?? null,
failureMessage: a.failureMessage ?? null,
};
const winner = await tx.paymentAttempts.insertIfAbsent(candidate);
cur = winner ?? await tx.paymentAttempts.findByClinkOrderId(a.clinkOrderId);
if (!cur) throw new Error(`attempt ${a.clinkOrderId} disappeared after insert`);
inserted = Boolean(winner);
}
if (
cur.merchantOrderId !== merchantOrder.id ||
cur.clinkSessionId !== a.clinkSessionId
) {
const outcome = await recordOrderOwnershipReview(
tx,
merchantOrder,
event,
{ clinkOrderId: a.clinkOrderId, clinkSessionId: a.clinkSessionId },
cur,
'payment_attempt_ownership_conflict',
);
return { outcome, attempt: cur, inserted };
}
if (inserted) {
// Success is globally decisive even without order.created. Other status
// events need Session reconciliation before they can replace an older result.
const canAggregate = isCreated || a.status === 'succeeded';
return {
outcome: canAggregate ? 'applied' : 'deferred',
attempt: cur,
inserted: true,
deferReason: canAggregate ? null : 'attempt_creation_unknown',
};
}
if (isCreated) {
// A late order.created only fills in the creation time; it never
// drags the status back to pending
if (cur.attemptCreatedAt == null) {
await tx.paymentAttempts.update(a.clinkOrderId, { attemptCreatedAt: a.eventCreated });
return {
outcome: 'applied',
attempt: { ...cur, attemptCreatedAt: a.eventCreated },
inserted: false,
}; // ordering is now known, so re-aggregate
}
return { outcome: 'ignored', attempt: cur, inserted: false };
}
// Within one Order, event.created orders the status events
if (a.eventCreated < cur.lastEventCreated) {
return { outcome: 'ignored', attempt: cur, inserted: false };
}
if (a.eventCreated === cur.lastEventCreated) {
if (a.status === cur.status) {
// A replay is not complete while order.created is still missing.
// Once ordering is known, re-run aggregation even though no row changes.
const creationUnknown = cur.attemptCreatedAt == null && cur.status !== 'succeeded';
return {
outcome: creationUnknown ? 'deferred' : 'ignored',
attempt: cur,
inserted: false,
deferReason: creationUnknown ? 'attempt_creation_unknown' : null,
};
}
// Equal timestamps, different statuses: do not guess from arrival order
await tx.outbox.insert({
eventId: a.eventId,
task: 'reconcile_attempt',
clinkOrderId: a.clinkOrderId,
// One key per ambiguity, not one per Order
dedupeKey: `reconcile_attempt:${a.clinkOrderId}:${a.eventId}`,
});
return {
outcome: 'deferred',
attempt: cur,
inserted: false,
deferReason: 'status_timestamp_tie',
};
}
if (TERMINAL.has(cur.status)) {
return { outcome: 'ignored', attempt: cur, inserted: false }; // terminal never rolls back
}
const update = {
status: a.status,
lastEventCreated: a.eventCreated,
failureCode: a.failureCode ?? null,
failureMessage: a.failureMessage ?? null,
};
await tx.paymentAttempts.update(a.clinkOrderId, update);
// Creation time still missing, so this attempt cannot be treated as latest
const creationUnknown = cur.attemptCreatedAt == null && a.status !== 'succeeded';
return {
outcome: creationUnknown ? 'deferred' : 'applied',
attempt: { ...cur, ...update },
inserted: false,
deferReason: creationUnknown ? 'attempt_creation_unknown' : null,
};
}
async function recomputeMerchantOrder(tx, merchantOrder, eventId) {
// Re-read inside the lock to work from a current snapshot
const attempts = await tx.paymentAttempts.listByMerchantOrder(merchantOrder.id);
const succeededAttempts = attempts.filter((x) => x.status === 'succeeded');
const succeeded = succeededAttempts[0] ?? null;
const unorderedAttempts = attempts.filter((x) => x.attemptCreatedAt == null);
// A persisted pointer inferred from order.created cannot break a timestamp
// tie. Only a pointer confirmed by a Session query can do that.
const current = merchantOrder.currentClinkOrderId
? attempts.find((x) => x.clinkOrderId === merchantOrder.currentClinkOrderId)
: null;
if (merchantOrder.currentClinkOrderId && !current) {
const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(
merchantOrder.currentClinkOrderId,
);
return recordManualReview(tx, {
dedupeKey:
`current_attempt_ownership_conflict:${merchantOrder.id}:${merchantOrder.currentClinkOrderId}`,
eventId,
merchantOrderId: merchantOrder.id,
clinkOrderId: merchantOrder.currentClinkOrderId,
reason: 'current_attempt_pointer_ownership_conflict',
evidence: {
merchantOrderId: merchantOrder.id,
currentClinkOrderId: merchantOrder.currentClinkOrderId,
storedAttempt: attemptEvidence(storedAttempt),
},
});
}
// Sort on attemptCreatedAt only — never receivedAt, an autoincrement id, or orderId
const ordered = attempts
.filter((x) => x.attemptCreatedAt != null)
.sort((a, b) => b.attemptCreatedAt - a.attemptCreatedAt);
const latestIsTied =
ordered.length > 1 &&
ordered[0].attemptCreatedAt === ordered[1].attemptCreatedAt;
const confirmedCurrent = merchantOrder.currentAttemptConfirmed ? current : null;
let paymentStatus;
let needsSessionReconciliation = false;
if (succeededAttempts.length > 0) {
paymentStatus = 'paid'; // one success is enough
} else if (confirmedCurrent) {
// A fresh Session answer may select an Attempt whose order.created has not
// arrived. The confirmed pointer is authoritative for the current Attempt.
paymentStatus = ATTEMPT_TO_ORDER[confirmedCurrent.status] ?? 'pending';
} else if (unorderedAttempts.length > 0) {
paymentStatus = 'pending';
needsSessionReconciliation = true;
} else if (latestIsTied) {
paymentStatus = 'pending';
needsSessionReconciliation = true;
} else if (ordered.length > 0) {
paymentStatus = ATTEMPT_TO_ORDER[ordered[0].status] ?? 'pending';
} else {
paymentStatus = 'pending';
}
// Second line of defence: terminal states are only rewritten by the refund flow
const merchantStatusIsTerminal =
['paid', 'refunded', 'partial_refunded'].includes(merchantOrder.paymentStatus);
if (!merchantStatusIsTerminal && merchantOrder.paymentStatus !== paymentStatus) {
await tx.merchantOrders.updateIfStatus(
merchantOrder.id,
merchantOrder.paymentStatus, // conditional UPDATE, paired with the row lock
{ paymentStatus },
);
merchantOrder.paymentStatus = paymentStatus;
}
if (
paymentStatus === 'paid' &&
!['refunded', 'partial_refunded'].includes(merchantOrder.paymentStatus)
) {
await tx.outbox.insert({
eventId,
task: 'fulfill',
orderId: merchantOrder.id,
clinkOrderId: succeeded.clinkOrderId,
dedupeKey: `fulfill:${merchantOrder.id}`, // unique; duplicate fulfillment stops here
});
}
if (needsSessionReconciliation) {
// One key per concrete ambiguity. Reprocessing the same Webhook is silent,
// while a later new Order gets its own Session query.
await tx.outbox.insert({
eventId,
task: 'reconcile_session',
orderId: merchantOrder.id,
dedupeKey: `reconcile_session:${merchantOrder.id}:${eventId}`,
});
return 'deferred';
}
return 'done';
}
function recordRefundReview(tx, context, reason, evidence) {
const refundKey = context.refundId ?? `event:${context.eventId}`;
return recordManualReview(tx, {
dedupeKey: `refund_conflict:${refundKey}`,
eventId: context.eventId,
merchantOrderId: context.merchantOrderId,
clinkOrderId: context.clinkOrderId,
refundId: context.refundId ?? null,
reason,
evidence,
});
}
function normalizeRefundCandidate(incoming, eventId) {
const amount = tryPositiveDecimal(incoming.amount);
const currency = tryNormalizeCurrency(incoming.currency);
if (
!incoming.refundId ||
!incoming.merchantOrderId ||
!incoming.clinkOrderId ||
!amount ||
!currency
) return null;
return {
refundId: incoming.refundId,
merchantOrderId: incoming.merchantOrderId,
clinkOrderId: incoming.clinkOrderId,
amount: amount.toString(),
currency,
status: 'success',
firstEventId: eventId,
};
}
function isExactRefundReplay(stored, candidate) {
const storedAmount = tryPositiveDecimal(stored?.amount);
return Boolean(stored) &&
stored.merchantOrderId === candidate.merchantOrderId &&
stored.clinkOrderId === candidate.clinkOrderId &&
storedAmount?.equals(new Decimal(candidate.amount)) === true &&
tryNormalizeCurrency(stored.currency) === candidate.currency &&
stored.status === candidate.status;
}
// INSERT ... ON CONFLICT DO NOTHING, then compare the immutable business
// tuple. A conflict never executes UPDATE and therefore never destroys proof.
async function persistRefundOnce(tx, incoming, context) {
const candidate = normalizeRefundCandidate(incoming, context.eventId);
if (!candidate) {
return {
outcome: await recordRefundReview(tx, context, 'invalid_refund_record', {
incoming: refundEvidence(incoming),
}),
refund: null,
};
}
const inserted = await tx.refunds.insertIfAbsent(candidate);
const stored = inserted ?? await tx.refunds.findByRefundId(incoming.refundId);
if (!stored) throw new Error(`refund ${incoming.refundId} disappeared after insert`);
if (!isExactRefundReplay(stored, candidate)) {
return {
outcome: await recordRefundReview(tx, context, 'refund_id_immutable_fields_conflict', {
stored: refundEvidence(stored),
incoming: refundEvidence(candidate),
}),
refund: stored,
};
}
return { outcome: inserted ? 'stored' : 'replayed', refund: stored };
}
async function handleRefund(event, obj, tx) {
if (event.type !== 'refund.succeeded') return 'done';
// Check an existing immutable refund before deferring on a missing attempt.
// Reusing its refundId with an unknown/different Order is deterministic.
const preexistingRefund = obj.refundId
? await tx.refunds.findByRefundId(obj.refundId)
: null;
// A refund event carries only orderId, so find the payment attempt first.
const attempt = await tx.paymentAttempts.findByClinkOrderId(obj.orderId);
if (!attempt) {
if (!preexistingRefund) return 'deferred';
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(
preexistingRefund.merchantOrderId,
);
if (!merchantOrder) throw new Error(`merchant order ${preexistingRefund.merchantOrderId} missing`);
return recordRefundReview(tx, {
eventId: event.id,
merchantOrderId: merchantOrder.id,
clinkOrderId: obj.orderId ?? null,
refundId: obj.refundId,
}, 'refund_id_reused_with_missing_attempt', {
stored: refundEvidence(preexistingRefund),
incoming: refundEvidence({
refundId: obj.refundId,
orderId: obj.orderId ?? null,
refundAmount: obj.refundAmount ?? null,
refundCurrency: obj.refundCurrency ?? null,
status: 'success',
}),
});
}
// Same lock, same lock order as the order events — otherwise the two
// paths deadlock against each other
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(attempt.merchantOrderId);
if (!merchantOrder) return 'deferred';
const reviewContext = {
eventId: event.id,
merchantOrderId: merchantOrder.id,
clinkOrderId: attempt.clinkOrderId,
refundId: obj.refundId,
};
const incomingRefund = {
refundId: obj.refundId,
merchantOrderId: merchantOrder.id,
clinkOrderId: attempt.clinkOrderId,
amount: obj.refundAmount,
currency: obj.refundCurrency,
status: 'success',
};
const candidate = normalizeRefundCandidate(incomingRefund, event.id);
if (!candidate) {
return recordRefundReview(tx, reviewContext, 'invalid_refund_record', {
incoming: refundEvidence(incomingRefund),
});
}
const storedRefund = await tx.refunds.findByRefundId(obj.refundId);
if (storedRefund && !isExactRefundReplay(storedRefund, candidate)) {
return recordRefundReview(tx, reviewContext, 'refund_id_immutable_fields_conflict', {
stored: refundEvidence(storedRefund),
incoming: refundEvidence(candidate),
});
}
const basis = [
merchantOrder.refundablePaidClinkOrderId,
merchantOrder.refundablePaidAmount,
merchantOrder.refundablePaidCurrency,
];
if (basis.every((x) => x == null)) return 'deferred';
if (basis.some((x) => x == null)) {
return recordRefundReview(tx, reviewContext, 'stored_refund_basis_incomplete', {
storedBasis: basis,
incomingRefund: refundEvidence(incomingRefund),
});
}
const paidCurrency = tryNormalizeCurrency(merchantOrder.refundablePaidCurrency);
const refundCurrency = tryNormalizeCurrency(obj.refundCurrency);
if (
attempt.clinkOrderId !== merchantOrder.refundablePaidClinkOrderId ||
!paidCurrency ||
refundCurrency !== paidCurrency
) {
return recordRefundReview(tx, reviewContext, 'refund_order_or_currency_conflict', {
storedBasis: {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
},
storedRefund: refundEvidence(storedRefund),
incomingRefund: refundEvidence(incomingRefund),
});
}
const persisted = await persistRefundOnce(tx, incomingRefund, reviewContext);
if (persisted.outcome === 'manual_review') return persisted.outcome;
const refunds = await tx.refunds.listSuccessByMerchantOrder(merchantOrder.id);
const invalid = refunds.find((x) => !tryPositiveDecimal(x.amount));
const wrongOrder = refunds.find(
(x) => x.clinkOrderId !== merchantOrder.refundablePaidClinkOrderId,
);
const wrongCurrency = refunds.find(
(x) => tryNormalizeCurrency(x.currency) !== paidCurrency,
);
const conflicting = invalid ?? wrongOrder ?? wrongCurrency;
if (conflicting) {
return recordRefundReview(
tx,
{ ...reviewContext, refundId: conflicting.refundId },
'stored_successful_refund_conflict',
{ storedBasis: basis, conflictingRefund: refundEvidence(conflicting) },
);
}
const refundedAmount = refunds.reduce(
(sum, x) => sum.plus(tryPositiveDecimal(x.amount)),
new Decimal(0),
);
const refundablePaidAmount = tryPositiveDecimal(merchantOrder.refundablePaidAmount);
if (!refundablePaidAmount || refundedAmount.greaterThan(refundablePaidAmount)) {
return recordRefundReview(tx, reviewContext, 'refund_total_exceeds_payment_basis', {
refundablePaidAmount: merchantOrder.refundablePaidAmount,
refundedAmount: refundedAmount.toString(),
refundIds: refunds.map((x) => x.refundId),
});
}
await tx.merchantOrders.update(merchantOrder.id, { refundedAmount: refundedAmount.toString() });
// Do not write refunded here; the reconcile task settles the final status
// from the Order query
await tx.outbox.insert({
eventId: event.id,
task: 'refund_reconcile',
orderId: merchantOrder.id,
clinkOrderId: attempt.clinkOrderId,
payload: { refundId: obj.refundId },
dedupeKey: `refund_reconcile:${obj.refundId}`,
});
return 'done';
}
```
**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:
| Layer | Enforced by | Governs |
| -------------------------- | -------------------------------------------------- | ------------------------------------------- |
| Event idempotency | `webhook_events.id` unique index + one transaction | One `event.id` produces side effects once |
| Single-attempt convergence | `last_event_created` comparison + terminal states | A given `clink_order_id` only moves forward |
| Merchant order aggregation | Scanning every attempt on the order | Which of several Orders decides the outcome |
**`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 `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.
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:
```sql theme={null}
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
task TEXT NOT NULL
CHECK (task IN (
'fulfill', 'refund_reconcile', 'reprocess_webhook',
'reconcile_attempt', 'reconcile_session',
'apply_refund_policy', 'manual_reconciliation',
'page_outbox_failure'
)),
event_id TEXT NOT NULL,
order_id TEXT, -- merchant order number, read by the worker
clink_order_id TEXT, -- Clink Order ID, read by refund reconciliation
payload JSONB, -- task arguments
dedupe_key TEXT NOT NULL, -- stops the same work being queued twice
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'succeeded', 'failed')),
attempt INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claim_token TEXT, -- a fresh random value on every claim
worker_id TEXT,
lease_until TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- The claim query runs on this index
CREATE INDEX idx_outbox_claim ON outbox (status, next_retry_at);
-- One row per piece of work
CREATE UNIQUE INDEX uq_outbox_dedupe ON outbox (dedupe_key);
```
Queuing a task writes only business fields; `status`, `attempt`, and `next_retry_at` come from the defaults:
```sql theme={null}
INSERT INTO outbox
(task, event_id, order_id, clink_order_id, payload, dedupe_key)
VALUES
($1, $2, $3, $4, $5::jsonb, $6)
ON CONFLICT (dedupe_key) DO NOTHING;
```
`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.
```javascript theme={null}
import crypto from 'node:crypto';
import Decimal from 'decimal.js';
import {
db,
fulfill,
applyRefundPolicy,
reconciliationDesk,
alerting,
logger,
} from './merchant-adapters.js';
const LEASE_MS = 5 * 60 * 1000; // Lease length; leave room for the slowest external call
const HEARTBEAT_MS = Math.floor(LEASE_MS / 3);
const MAX_ATTEMPT = 12;
const MAX_BACKOFF_MS = 60 * 60 * 1000;
function backoff(attempt, { now = Date.now(), random = Math.random } = {}) {
const numericAttempt = Number(attempt);
const normalizedAttempt = Number.isFinite(numericAttempt)
? Math.max(1, Math.floor(numericAttempt))
: 1;
const exponential = Math.min(
MAX_BACKOFF_MS,
1000 * (2 ** Math.min(normalizedAttempt - 1, 30)),
);
const randomValue = Math.min(1, Math.max(0, Number(random()) || 0));
const jittered = Math.min(
MAX_BACKOFF_MS,
Math.round(exponential * (0.75 + randomValue * 0.5)),
);
return new Date(Number(now) + jittered);
}
// Step 1: claim exactly one task atomically. A worker never lets a batch wait
// behind the first slow task while every lease counts down from the same instant.
async function claimTask(workerId) {
// A new token on every claim — never reuse the old one
const claimToken = crypto.randomUUID();
const { rows } = await db.query(
`UPDATE outbox SET
status = 'processing',
claim_token = $1,
worker_id = $2,
lease_until = NOW() + ($3 || ' milliseconds')::interval
WHERE id IN (
SELECT id FROM outbox
WHERE (status = 'pending' AND next_retry_at <= NOW())
OR (status = 'processing' AND lease_until < NOW()) -- expired lease, reclaim
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING
id,
task,
event_id AS "eventId",
order_id AS "orderId",
clink_order_id AS "clinkOrderId",
payload,
dedupe_key AS "dedupeKey",
status,
attempt,
next_retry_at AS "nextRetryAt",
claim_token AS "claimToken",
worker_id AS "workerId",
lease_until AS "leaseUntil",
last_error AS "lastError",
created_at AS "createdAt",
updated_at AS "updatedAt"`,
[claimToken, workerId, LEASE_MS]
);
return rows[0] ?? null;
}
function startLeaseHeartbeat(t) {
let leaseLost = false;
let stopped = false;
let renewal = Promise.resolve(true);
const controller = new AbortController();
function markLeaseLost(reason) {
if (leaseLost) return;
leaseLost = true;
controller.abort(reason);
logger.warn({ taskId: t.id }, 'lease lost, stop claimed task');
}
function ensureOwned() {
renewal = renewal.then(async () => {
if (stopped || leaseLost) return false;
try {
const owned = await renewLease(t);
if (!owned) markLeaseLost(new Error('outbox lease lost'));
return owned;
} catch (err) {
// A renewal error means ownership is unknown. Stop rather than risk a
// side effect under an expired token.
markLeaseLost(err);
return false;
}
});
return renewal;
}
const timer = setInterval(() => { void ensureOwned(); }, HEARTBEAT_MS);
timer.unref?.();
return {
signal: controller.signal,
isLost: () => leaseLost,
ensureOwned,
async stop() {
stopped = true;
clearInterval(timer);
await renewal;
},
};
}
function throwIfAborted(signal) {
if (signal?.aborted) throw signal.reason ?? new Error('operation aborted');
}
async function runOutboxWorker(workerId) {
// The claim statement commits here; the row lock is released with it.
const t = await claimTask(workerId);
if (!t) return;
// A task may have waited between claim and execution. Confirm ownership
// before starting any API call or external side effect.
if (!await renewLease(t)) {
logger.warn({ taskId: t.id }, 'lease lost before task start');
return;
}
const lease = startLeaseHeartbeat(t);
try {
await executeClaimedTask(t, lease);
if (lease.isLost() || !await lease.ensureOwned()) return;
await finishTask(t, 'succeeded');
} catch (err) {
if (lease.isLost() || !await lease.ensureOwned()) return;
await failTask(t, err);
} finally {
await lease.stop();
}
}
// Step 2: external calls happen outside row-lock transactions. Every adapter
// receives its stable business idempotency key and AbortSignal where supported.
async function executeClaimedTask(t, lease, adapters = {
fulfill,
reconcileRefund,
applyRefundPolicy,
reprocessWebhook,
reconcileAttempt,
reconcileSession,
reconciliationDesk,
alerting,
findReconciliationCase: (caseKey) =>
db.reconciliationCases.findByDedupeKey(caseKey),
}) {
if (t.task === 'fulfill') {
await adapters.fulfill(t.orderId, {
eventId: t.eventId,
idempotencyKey: t.orderId,
signal: lease.signal,
});
} else if (t.task === 'refund_reconcile') {
await adapters.reconcileRefund(t.orderId, t.clinkOrderId, t.eventId, t.payload, {
signal: lease.signal,
});
} else if (t.task === 'apply_refund_policy') {
// Deduplicate on refundId, and only advance the policy's stored cumulative
// amount so an older task cannot roll entitlement backward.
await adapters.applyRefundPolicy({
merchantOrderId: t.orderId,
refundId: t.payload.refundId,
orderStatus: t.payload.orderStatus,
refundedAmount: t.payload.refundedAmount,
paymentCurrency: t.payload.paymentCurrency,
idempotencyKey: t.payload.refundId,
signal: lease.signal,
});
} else if (t.task === 'manual_reconciliation') {
const review = await adapters.findReconciliationCase(t.payload.caseKey);
if (!review) throw new Error(`manual-review case ${t.payload.caseKey} missing`);
const reviewContext = {
caseKey: review.dedupeKey,
eventId: review.eventId,
merchantOrderId: review.merchantOrderId,
clinkOrderId: review.clinkOrderId ?? null,
refundId: review.refundId ?? null,
reason: review.reason,
evidence: review.evidence,
};
await adapters.reconciliationDesk.openOrUpdate({
...reviewContext,
externalId: t.payload.caseKey,
signal: lease.signal,
});
// Desk and paging are separate external steps. Re-check the token so a
// lost worker never starts the second step.
if (!await lease.ensureOwned()) return;
await adapters.alerting.openOrUpdate({
dedupeKey: t.payload.caseKey,
title: 'payment data requires manual reconciliation',
context: reviewContext,
signal: lease.signal,
});
} else if (t.task === 'reprocess_webhook') {
await adapters.reprocessWebhook(t.eventId, { signal: lease.signal });
} else if (t.task === 'reconcile_attempt') {
await adapters.reconcileAttempt(t.clinkOrderId, t.eventId, { signal: lease.signal });
} else if (t.task === 'reconcile_session') {
await adapters.reconcileSession(t.orderId, t.eventId, { signal: lease.signal });
} else if (t.task === 'page_outbox_failure') {
await adapters.alerting.openOrUpdate({
dedupeKey: t.dedupeKey,
title: 'outbox task exhausted',
context: {
failedTaskId: t.payload.failedTaskId,
failedTask: t.payload.failedTask,
errorName: t.payload.errorName,
errorCode: t.payload.errorCode,
},
signal: lease.signal,
});
} else {
// Never mark an unimplemented task succeeded.
throw new Error(`unknown outbox task: ${t.task}`);
}
}
// Raw SQL is snake_case, but claimTask aliases every returned task property to
// camelCase. Worker code must use that one shape exclusively.
// Step 3: every status update must carry claim_token and status='processing'
async function finishTask(t, status) {
const { rowCount } = await db.query(
`UPDATE outbox SET
status = $1, claim_token = NULL, worker_id = NULL, lease_until = NULL
WHERE id = $2 AND claim_token = $3 AND status = 'processing'`,
[status, t.id, t.claimToken]
);
if (rowCount === 0) {
// The lease expired and another worker owns this task now. Let go
logger.warn({ taskId: t.id }, 'lease lost, result discarded');
}
return rowCount;
}
async function failTask(t, err) {
const attempt = t.attempt + 1;
// Paging failures retry forever (with a capped delay) and never recursively
// create another page_outbox_failure task.
const exhausted = t.task !== 'page_outbox_failure' && attempt >= MAX_ATTEMPT;
const errorName = typeof err?.name === 'string' ? err.name.slice(0, 100) : 'Error';
const errorCode = ['string', 'number'].includes(typeof err?.code)
? String(err.code).slice(0, 100)
: null;
const safeLastError = errorCode ? `${errorName}:${errorCode}` : errorName;
if (!exhausted) {
const { rowCount } = await db.query(
`UPDATE outbox SET
status = 'pending', claim_token = NULL, worker_id = NULL, lease_until = NULL,
attempt = $1, next_retry_at = $2, last_error = $3
WHERE id = $4 AND claim_token = $5 AND status = 'processing'`,
[attempt, backoff(attempt), safeLastError, t.id, t.claimToken]
);
if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded');
return rowCount;
}
// Marking the original task failed and creating its paging task is one
// transaction. A crash after commit still leaves durable work to claim.
const rowCount = await db.transaction(async (tx) => {
const result = await tx.query(
`UPDATE outbox SET
status = 'failed', claim_token = NULL, worker_id = NULL, lease_until = NULL,
attempt = $1, next_retry_at = $2, last_error = $3
WHERE id = $4 AND claim_token = $5 AND status = 'processing'`,
[attempt, backoff(attempt), safeLastError, t.id, t.claimToken]
);
if (result.rowCount === 0) return 0;
await tx.outbox.insert({
eventId: t.eventId,
task: 'page_outbox_failure',
payload: {
failedTaskId: t.id,
failedTask: t.task,
errorName,
errorCode,
},
dedupeKey: `outbox_failure_alert:${t.id}`,
});
return result.rowCount;
});
if (rowCount === 0) logger.warn({ taskId: t.id }, 'lease lost, result discarded');
return rowCount;
}
// Renewal carries the token too, or it would extend someone else's lease
async function renewLease(t) {
const { rowCount } = await db.query(
`UPDATE outbox SET lease_until = NOW() + ($1 || ' milliseconds')::interval
WHERE id = $2 AND claim_token = $3 AND status = 'processing'`,
[LEASE_MS, t.id, t.claimToken]
);
return rowCount === 1; // false means the lease is gone — stop work immediately
}
// Retry an event that was deferred until its dependency existed
async function reprocessWebhook(eventId, { signal } = {}) {
throwIfAborted(signal);
const outcome = await db.transaction(async (tx) => {
const row = await tx.webhookEvents.findById(eventId);
if (!row || row.status === 'processed') return 'done';
const outcome = await handleEvent(JSON.parse(row.payload), tx);
if (outcome === 'done' || outcome === 'manual_review') {
await tx.webhookEvents.update(eventId, { status: 'processed' });
}
throwIfAborted(signal); // abort rolls the transaction back before commit
return outcome;
});
// Throw only after the transaction commits. Any reconciliation task queued
// by handleEvent stays durable while this task goes through backoff.
if (outcome === 'deferred') throw new Error('dependency still missing');
}
// Two status events on one Order shared a timestamp — converge from the API
async function reconcileAttempt(clinkOrderId, eventId, { signal } = {}) {
throwIfAborted(signal);
// Capture the local version before the external query.
const snapshot = await db.paymentAttempts.findByClinkOrderId(clinkOrderId);
if (!snapshot) throw new Error(`attempt ${clinkOrderId} missing`);
// Query first, then open the transaction. External calls never run under a lock.
const order = await clinkGet(`/order/${clinkOrderId}`, { signal });
throwIfAborted(signal);
const outcome = await db.transaction(async (tx) => {
// Same lock, same order as the webhook handler. Re-read the attempt only
// after taking this lock, because every local attempt write takes it first.
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(snapshot.merchantOrderId);
if (!merchantOrder) throw new Error(`merchant order ${snapshot.merchantOrderId} missing`);
const attempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId);
if (
!attempt ||
merchantOrder.clinkSessionId !== snapshot.clinkSessionId ||
attempt.merchantOrderId !== snapshot.merchantOrderId ||
attempt.clinkSessionId !== snapshot.clinkSessionId ||
attempt.status !== snapshot.status ||
attempt.lastEventCreated !== snapshot.lastEventCreated ||
attempt.attemptCreatedAt !== snapshot.attemptCreatedAt
) {
// Local state advanced while Clink was being queried. Roll back and let
// the worker retry from a fresh snapshot; never apply this stale result.
throw new Error(`attempt ${clinkOrderId} changed during reconciliation`);
}
if (
order.orderId !== clinkOrderId ||
order.sessionId !== attempt.clinkSessionId ||
order.merchantReferenceId !== merchantOrder.id
) {
const manualOutcome = await recordManualReview(tx, {
dedupeKey: `order_query_ownership_conflict:${merchantOrder.id}:${clinkOrderId}`,
eventId,
merchantOrderId: merchantOrder.id,
clinkOrderId,
reason: 'order_query_ownership_conflict',
evidence: {
requestedOrderId: clinkOrderId,
returnedOrder: orderEvidence(order),
localAttempt: attemptEvidence(attempt),
storedBasis: {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
},
},
});
await tx.webhookEvents.update(eventId, { status: 'processed' });
return manualOutcome;
}
// An already-refunded order takes the refund path, never the
// "paid -> queue fulfillment" one
let aggregateOutcome = 'done';
if (REFUND_STATUS.has(order.status)) {
await tx.paymentAttempts.update(clinkOrderId, {
status: 'succeeded',
failureCode: null,
failureMessage: null,
});
} else {
const mapped = ORDER_STATUS_TO_ATTEMPT[order.status];
if (!mapped) {
// Still pending, or an unknown value — not settled yet
throw new Error(`order ${clinkOrderId} not settled yet: ${order.status}`);
}
await tx.paymentAttempts.update(clinkOrderId, {
status: mapped,
failureCode: mapped === 'failed' ? order.failureCode : null,
failureMessage: mapped === 'failed' ? order.failureMessage : null,
});
}
if (order.status === 'success' || REFUND_STATUS.has(order.status)) {
// GET /order exposes amountTotal and paymentCurrency for Hosted Checkout,
// so this path can establish the same basis as order.succeeded. The
// attempt update above remains durable if the basis is quarantined.
const basisOutcome = await persistRefundablePaymentBasis(
tx,
merchantOrder,
{
clinkOrderId,
amountTotal: order.amountTotal,
paymentCurrency: order.paymentCurrency,
},
{ eventId },
);
if (basisOutcome === 'manual_review') {
await tx.webhookEvents.update(eventId, { status: 'processed' });
return basisOutcome;
}
}
if (REFUND_STATUS.has(order.status)) {
await advanceRefundStatus(tx, merchantOrder, order.status);
} else {
aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId);
}
// Close the event only after both the attempt and merchant-order aggregate
// have converged. A Session tie remains pending.
if (aggregateOutcome === 'done' || aggregateOutcome === 'manual_review') {
await tx.webhookEvents.update(eventId, { status: 'processed' });
}
return aggregateOutcome;
});
if (outcome === 'deferred') throw new Error('merchant order still needs Session reconciliation');
}
const ORDER_STATUS_TO_ATTEMPT = {
success: 'succeeded',
failed: 'failed',
requires_action: 'action_required',
};
// Refund states are handled separately and never feed the payment aggregation
const REFUND_STATUS = new Set(['partial_refunded', 'refunded']);
// Refund state is monotonic: refunded is never overwritten by partial_refunded
const REFUND_RANK = { partial_refunded: 1, refunded: 2 };
async function advanceRefundStatus(tx, merchantOrder, orderStatus) {
const curRank = REFUND_RANK[merchantOrder.paymentStatus] ?? 0;
if (REFUND_RANK[orderStatus] <= curRank) return; // forward only
await tx.merchantOrders.updateIfStatus(
merchantOrder.id,
merchantOrder.paymentStatus,
{ paymentStatus: orderStatus },
);
}
async function queueRefundPolicy(tx, merchantOrder, refund) {
// Queue once per successful refund, even when the Order remains
// partial_refunded. State rank and policy evaluation are separate concerns.
await tx.outbox.insert({
eventId: refund.eventId,
task: 'apply_refund_policy',
orderId: merchantOrder.id,
payload: {
refundId: refund.refundId,
orderStatus: refund.orderStatus,
refundedAmount: refund.refundedAmount,
paymentCurrency: refund.paymentCurrency,
},
dedupeKey: `apply_refund_policy:${refund.refundId}`,
});
}
// Stable comparison only. Lexical Order ID sorting here does not choose the
// current attempt; it merely makes identical local snapshots hash the same way.
function fingerprintAttempts(attempts) {
return attempts
.map((x) => JSON.stringify([
x.clinkOrderId,
x.merchantOrderId,
x.clinkSessionId,
x.status,
x.attemptCreatedAt ?? null,
x.lastEventCreated,
]))
.sort()
.join('|');
}
// Two attempts share an attemptCreatedAt — ask the Session which one is current
async function reconcileSession(merchantOrderId, eventId, { signal } = {}) {
throwIfAborted(signal);
const local = await db.merchantOrders.findById(merchantOrderId);
if (!local) throw new Error(`merchant order ${merchantOrderId} missing`);
const localAttempts = await db.paymentAttempts.listByMerchantOrder(merchantOrderId);
const snapshot = {
clinkSessionId: local.clinkSessionId,
currentClinkOrderId: local.currentClinkOrderId,
currentAttemptConfirmed: local.currentAttemptConfirmed,
attemptsFingerprint: fingerprintAttempts(localAttempts),
};
const session = await clinkGet(`/checkout/session/${snapshot.clinkSessionId}`, { signal });
throwIfAborted(signal);
await db.transaction(async (tx) => {
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId);
if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`);
const lockedAttempts = await tx.paymentAttempts.listByMerchantOrder(merchantOrderId);
if (
merchantOrder.clinkSessionId !== snapshot.clinkSessionId ||
merchantOrder.currentClinkOrderId !== snapshot.currentClinkOrderId ||
merchantOrder.currentAttemptConfirmed !== snapshot.currentAttemptConfirmed ||
fingerprintAttempts(lockedAttempts) !== snapshot.attemptsFingerprint
) {
// Another Order event or reconciliation changed local truth after the
// query began. Discard the stale Session answer and retry.
throw new Error(`attempt set for ${merchantOrderId} changed during Session reconciliation`);
}
if (
session.sessionId !== snapshot.clinkSessionId ||
session.merchantReferenceId !== merchantOrder.id
) {
const manualOutcome = await recordManualReview(tx, {
dedupeKey: `session_response_conflict:${merchantOrderId}:${snapshot.clinkSessionId}`,
eventId,
merchantOrderId,
clinkOrderId: session.orderId ?? null,
reason: 'session_response_ownership_conflict',
evidence: {
querySnapshot: snapshot,
returnedSession: sessionEvidence(session),
},
});
await tx.webhookEvents.update(eventId, { status: 'processed' });
return manualOutcome;
}
if (!session.orderId) {
// No Order yet, or the state is unsettled — back off and retry.
// Never treat this as success.
throw new Error(`session ${snapshot.clinkSessionId} has no current orderId yet`);
}
// Read by global Order ID inside the lock. Missing is retryable; an Order
// already owned by another merchant/session is a durable manual review.
const attempt = await tx.paymentAttempts.findByClinkOrderId(session.orderId);
if (!attempt) {
throw new Error(`session order ${session.orderId} not persisted for ${merchantOrderId}`);
}
if (
attempt.merchantOrderId !== merchantOrderId ||
attempt.clinkSessionId !== snapshot.clinkSessionId
) {
const manualOutcome = await recordManualReview(tx, {
dedupeKey: `session_order_conflict:${merchantOrderId}:${session.orderId}`,
eventId,
merchantOrderId,
clinkOrderId: session.orderId,
reason: 'session_order_ownership_conflict',
evidence: {
querySnapshot: snapshot,
returnedSession: sessionEvidence(session),
storedAttempt: attemptEvidence(attempt),
storedBasis: {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
},
},
});
await tx.webhookEvents.update(eventId, { status: 'processed' });
return manualOutcome;
}
// Persist both the Session answer and its provenance. Only this confirmed
// pointer may break an attemptCreatedAt tie.
await tx.merchantOrders.update(merchantOrderId, {
currentClinkOrderId: session.orderId,
currentAttemptConfirmed: true,
});
merchantOrder.currentClinkOrderId = session.orderId;
merchantOrder.currentAttemptConfirmed = true;
const aggregateOutcome = await recomputeMerchantOrder(tx, merchantOrder, eventId);
if (aggregateOutcome === 'deferred') {
throw new Error(`session ${snapshot.clinkSessionId} did not resolve the current attempt`);
}
await tx.webhookEvents.update(eventId, { status: 'processed' });
return aggregateOutcome;
});
}
async function reconcileRefund(
merchantOrderId,
clinkOrderId,
eventId,
refund,
{ signal } = {},
) {
throwIfAborted(signal);
if (!refund?.refundId) {
throw new Error('refund reconciliation payload is incomplete');
}
// The query runs outside the transaction
const order = await clinkGet(`/order/${clinkOrderId}`, { signal });
throwIfAborted(signal);
// Only once a result is in hand: open the transaction and take the same
// lock, in the same order, as every other path
await db.transaction(async (tx) => {
const merchantOrder = await tx.merchantOrders.findByIdForUpdate(merchantOrderId);
if (!merchantOrder) throw new Error(`merchant order ${merchantOrderId} missing`);
const reviewContext = { eventId, merchantOrderId, clinkOrderId, refundId: refund.refundId };
const storedAttempt = await tx.paymentAttempts.findByClinkOrderId(clinkOrderId);
if (
!storedAttempt ||
storedAttempt.merchantOrderId !== merchantOrder.id ||
storedAttempt.clinkSessionId !== merchantOrder.clinkSessionId ||
order.orderId !== clinkOrderId ||
order.sessionId !== merchantOrder.clinkSessionId ||
order.merchantReferenceId !== merchantOrder.id
) {
const storedRefund = await tx.refunds.findByRefundId(refund.refundId);
return recordRefundReview(tx, reviewContext, 'refund_order_query_ownership_conflict', {
requestedOrderId: clinkOrderId,
returnedOrder: orderEvidence(order),
storedAttempt: attemptEvidence(storedAttempt),
storedRefund: refundEvidence(storedRefund),
storedBasis: {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
},
});
}
// Ownership is valid but the refund state has not settled yet. Back off
// instead of reading a pre-refund status as a completed reconciliation.
if (!REFUND_STATUS.has(order.status)) {
throw new Error('order status not settled yet');
}
const basisOutcome = await persistRefundablePaymentBasis(
tx,
merchantOrder,
{
clinkOrderId,
amountTotal: order.amountTotal,
paymentCurrency: order.paymentCurrency,
},
{
eventId,
refundId: refund.refundId,
dedupeKey: `refund_conflict:${refund.refundId}`,
},
);
if (basisOutcome === 'manual_review') return basisOutcome;
if (
merchantOrder.refundablePaidClinkOrderId == null ||
merchantOrder.refundablePaidAmount == null ||
merchantOrder.refundablePaidCurrency == null
) {
return recordRefundReview(tx, reviewContext, 'refund_payment_basis_missing', {
storedBasis: {
clinkOrderId: merchantOrder.refundablePaidClinkOrderId,
amount: merchantOrder.refundablePaidAmount,
currency: merchantOrder.refundablePaidCurrency,
},
});
}
if (merchantOrder.refundablePaidClinkOrderId !== clinkOrderId) {
return recordRefundReview(tx, reviewContext, 'refund_reconciliation_order_conflict', {
storedOrderId: merchantOrder.refundablePaidClinkOrderId,
incomingOrderId: clinkOrderId,
});
}
// Compute the minimum state the Order API must have reached from the
// latest locked totals. Decimal avoids rounding a full refund down into a
// partial one. A lagging API result must retry; there is no later
// order.refunded event that can repair a premature success.
const refundedAmount = tryPositiveDecimal(merchantOrder.refundedAmount);
const refundablePaidAmount = tryPositiveDecimal(merchantOrder.refundablePaidAmount);
const paymentCurrency = tryNormalizeCurrency(merchantOrder.refundablePaidCurrency);
if (!refundedAmount || !refundablePaidAmount || !paymentCurrency) {
return recordRefundReview(tx, reviewContext, 'invalid_locked_refund_totals', {
refundedAmount: merchantOrder.refundedAmount,
refundablePaidAmount: merchantOrder.refundablePaidAmount,
paymentCurrency: merchantOrder.refundablePaidCurrency,
});
}
if (refundedAmount.greaterThan(refundablePaidAmount)) {
return recordRefundReview(tx, reviewContext, 'refund_total_exceeds_payment_basis', {
refundedAmount: refundedAmount.toString(),
refundablePaidAmount: refundablePaidAmount.toString(),
});
}
const expectedStatus = refundedAmount.greaterThanOrEqualTo(refundablePaidAmount)
? 'refunded'
: 'partial_refunded';
if (REFUND_RANK[order.status] < REFUND_RANK[expectedStatus]) {
throw new Error('order refund status has not caught up yet');
}
// Status advances only when its rank increases, but every refundId gets a
// policy task so repeated partial refunds are never collapsed together.
await advanceRefundStatus(tx, merchantOrder, order.status);
await queueRefundPolicy(tx, merchantOrder, {
eventId,
refundId: refund.refundId,
orderStatus: order.status,
// Use the latest value read under the lock, never the task's old snapshot.
refundedAmount: refundedAmount.toString(),
paymentCurrency,
});
});
}
```
A task moves through these states:
```
pending ──claim (new token)──> processing ──success──> succeeded
↑ │
├──failure (under the cap)──────┤
│ └──cap reached──> failed + pending page task
└──lease expiry, reclaimed by another worker
```
**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 `SELECT`s 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
HMAC SHA-256 over the raw body, compared against `X-Clink-Signature`. Return 401 on mismatch.
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.
Check both `merchantReferenceId` and `sessionId`. If only one matches, treat it as an anomaly and do not update anything.
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.
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**
| Scenario | Expected result |
| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A non-success Attempt has not received `order.created`, with no confirmed current | Store its status, reset a nonterminal old aggregate to `pending`, queue one `reconcile_session` plus `reprocess_webhook`, and keep the event `pending` |
| `order.succeeded` arrives before `order.created` | Success is globally decisive: write the refund basis, move to `paid`, process the event, and queue exactly one fulfillment; the later created event only fills time |
| `order.created` arrives late for an already `succeeded` attempt | Only `attemptCreatedAt` is filled in; the status does not roll back |
| A failed known Order A is followed by unordered Order B `next_action` | B is stored, the old `payment_failed` aggregate becomes `pending`, and Session reconciliation actively discovers the current Order |
| Session reconciliation returns unordered B | Persist B as confirmed current; converge to `action_required` or `payment_failed` from B and mark the original event processed |
| A new unordered Order appears after another current was Session-confirmed | Invalidate `currentAttemptConfirmed`, return to `pending`, and issue a fresh Session query |
| Order A's `order.created` arrives before B's, and both share an `attemptCreatedAt` | The inferred pointer is marked unconfirmed, `reconcile_session` is queued, and the Session decides; arrival order is never used |
| Two status events on one Order share an `event.created` | `reconcile_attempt` is queued and the Order query decides |
| `reconcile_attempt` resolves to `failed`, then a later query resolves to a non-failed state | Failure code/message are written for `failed` and cleared for the later state; stale failure details never remain |
| Order A establishes the refund basis, then Order B reports success with a different Order ID, amount, or currency | The old basis is not overwritten; B's attempt and the conflict evidence commit, the Webhook is acknowledged, and semantic replays produce exactly one manual-reconciliation case/task |
| An Order event matches `merchantReferenceId` but carries another Session ID | No attempt is attached to the order; one durable manual-review case/task preserves both Session IDs and the Webhook is acknowledged |
**Payment Attempt ownership and first-insert arbitration**
| Scenario | Expected result |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| A new Clink Order is first observed for merchant order A / Session A | One attempt is inserted with owner A / Session A and normal aggregation continues |
| The same Order is replayed for the same merchant order and Session | The immutable owner is unchanged and the replay is idempotent |
| Each of `order.created`, `order.next_action`, `order.succeeded`, and `order.failed` targets an existing attempt | Both merchant-order and Session ownership are checked before any field can change |
| `order_X` belongs to A / Session A, then B / Session B sends `order.created` for `order_X` | A's status and creation time stay unchanged; B gets no current pointer; exactly one case/task is created |
| The same cross-owner conflict uses `order.next_action`, `order.failed`, or `order.succeeded` | A's status, failure details, and creation time stay unchanged; B gets no refund basis, fulfillment, or aggregate update |
| The merchant order matches but the incoming Session differs | The event becomes a durable `manual_review`; the attempt is not modified |
| The Session matches but the incoming merchant order differs | The event becomes a durable `manual_review`; the attempt is not modified |
| The same ownership conflict is redelivered with the same or a different `event.id` | Stable key `order_ownership_conflict:{incomingMerchantOrderId}:{clinkOrderId}` leaves one business case and one `manual_reconciliation` Outbox row |
| A and B concurrently first-insert the same `clinkOrderId` | The primary key chooses one owner; the loser rereads it, records `manual_review`, and acknowledges instead of returning a generic 500 |
| Two events for the same owner concurrently first-insert one `clinkOrderId` | No false manual case is created; the result still follows `event.created` ordering and terminal-state rules |
| The winning owner sends later valid events after a concurrent ownership conflict | Processing continues normally without locking or mutating the losing merchant order |
| A conflicting `order.succeeded` targets an attempt owned elsewhere | `refundablePaidClinkOrderId`, amount, and currency are not written or overwritten |
| Any ownership conflict is quarantined | No `fulfill`, `refund_reconcile`, or `reconcile_session` task is created from that conflicting event |
| A manual-review transaction fails while writing the case, Outbox row, or processed marker | The case, task, attempt evidence, and `webhook_events.processed` change all roll back together |
| A leased `manual_reconciliation` task is replayed | Both the desk and alert adapters use `caseKey` as the external idempotency key, so no duplicate ticket or alert is opened |
**Concurrent writes to one order**
| Scenario | Expected result |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| New Order B's `next_action` arrives after old Order A failed, but B's created event is delayed | The aggregate becomes `pending`; after Session returns B it converges to `action_required` |
| `order.succeeded` concurrent with another Order's `order.failed` | Ends as `paid`, with exactly one fulfillment outbox row |
| A late `next_action` / `failed` after `paid` | No rollback |
| Session query returns Order B, then Order C commits before the worker gets the lock | The attempt fingerprint mismatch discards B's stale answer; the worker retries and C is not overwritten |
| Session query returns an Order already owned by another merchant or Session | The ownership evidence is quarantined once, the event is closed, and the deterministic conflict does not consume retries |
| Order query returns a status, then that attempt changes before the worker gets the lock | The version mismatch discards the stale status and retries |
**Out-of-order and multiple Orders**
| Scenario | Expected result |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| One Order: `order.failed` first, then an older `order.next_action` | The attempt stays `failed`; the merchant order stays `payment_failed` and does not roll back |
| A succeeded attempt then receives a late `order.failed` | The attempt stays `succeeded`; the merchant order stays `paid` |
| Same Session: Order A fails, Order B succeeds | The merchant order ends up `paid` |
| Order B is current, then a late event for Order A arrives | Neither Order B nor the aggregate changes |
| The same `event.id` delivered repeatedly | Business state and side effects happen exactly once |
**Session lifecycle**
| Scenario | Expected result |
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Matching `session.complete` | Set only `clinkSessionStatus = completed`, persist its `event.created` version, and mark the Webhook processed |
| Matching `session.expired` | Set only `clinkSessionStatus = expired`, persist its `event.created` version, and mark the Webhook processed |
| Either terminal event after `paid`, `partial_refunded`, or `refunded` | Preserve payment, refund, fulfillment, and all Attempt states |
| Missing `merchantReferenceId` or merchant order | Keep the Webhook pending and queue one `reprocess_webhook` task |
| Missing/conflicting Session ID or a payload status inconsistent with the event type | Keep lifecycle state unchanged and atomically persist one `manual_review` case/task |
| An older Session terminal event arrives after a newer one | Ignore the older lifecycle update by comparing `event.created`; never use arrival time |
| Different terminal states share one `event.created` millisecond | Do not guess; keep the stored lifecycle and persist durable `manual_review` |
| The same terminal event is replayed | The lifecycle version, state, and tasks remain idempotent |
**Out-of-order refunds**
| Scenario | Expected result |
| ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `refund.succeeded` arrives before `order.succeeded` | The first pass leaves the event `pending` and queues `reprocess_webhook`; once the order exists the worker reprocesses it, the refund is recorded, and the event becomes `processed` |
| Normal `order.succeeded` followed by `refund.succeeded` | The successful Order writes its Order ID, `amountTotal`, and `paymentCurrency`; refund currency validation and reconciliation complete |
| `reconcile_attempt` reaches `success`, then a refund arrives | The Order query backfills the same payment basis, so refund currency validation and reconciliation complete without relying on the success webhook path |
| A refund or any stored successful refund uses a currency different from the saved payment currency | Stop before summing, preserve the stored basis, and escalate to manual reconciliation |
| The same `refund.succeeded` delivered three times | The `webhook_events` unique index rejects the second and third; one refund row, `refundedAmount` not doubled |
| The same `refundId` is replayed with the same immutable business fields | The first refund row is reused exactly; no field is updated and no second amount is counted |
| The same `refundId` reappears with a different amount, currency, Clink Order, or merchant order | The first refund row remains unchanged; one `refund_conflict:{refundId}` case/task preserves both the stored and incoming values |
| An existing `refundId` reappears with an unknown Order ID whose attempt does not exist | The old refund row identifies the merchant case to lock; the mismatch goes straight to `manual_review` instead of being deferred |
| A refund amount is zero, negative, non-finite, or malformed | No refund row or automatic reconciliation task is written; one durable manual-review case records the invalid input |
| Cumulative successful refunds exactly equal the refundable payment basis | The locked total is written and refund reconciliation continues toward `refunded` |
| Cumulative successful refunds exceed the refundable payment basis | `refundedAmount` is not written past the basis and no `refund_reconcile` task is queued; the conflict is durably routed to manual review |
| Two partial refunds succeed while the Order remains `partial_refunded` | Two policy tasks exist, one per `refundId`, each carrying the latest locked cumulative `refundedAmount` at reconciliation time; out-of-order execution cannot roll the applied amount backward |
| A partial refund is followed by the final refund; the first Order query still says `partial_refunded` | Because locked `refundedAmount` has reached `refundablePaidAmount`, the task retries until the Order query returns `refunded`; local status cannot remain partial forever |
| The dependent order never appears | The reprocess task backs off to the cap; one transaction marks it `failed` and creates a pending `page_outbox_failure`. The event is **never recorded as `processed`** |
| Normal order (order first, refund second) | Behaviour is unchanged; handled in a single pass |
**Outbox lease race**
| Step | Expected result |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Worker A claims the task and gets `tokenA` | `status = processing`, `claim_token = tokenA` |
| The worker is about to start, but a preflight renewal returns 0 rows | Start no external call or side effect; log lease loss only |
| A claim query is asked for a batch | This implementation still returns at most one row, so no later row waits behind the first task's lease |
| A long task remains owned | Heartbeat renewals every `LEASE_MS / 3` keep moving `lease_until`; another worker cannot reclaim it |
| A stalls, the lease expires, Worker B reclaims with `tokenB` | `claim_token` becomes `tokenB`, `lease_until` extends |
| A heartbeat returns 0 rows | Mark the local lease lost, abort supported HTTP calls, start no later step, and skip finish/fail updates |
| Manual desk creation finishes, then ownership is lost | Recheck before paging; do not start the alert step |
| A comes back and reports success or failure | The conditional update affects **0 rows**; log only, no status change |
| B completes normally | The task becomes `succeeded`, **not overwritten by A** |
| A calls `renewLease()` mid-flight | Returns `false`; A must stop immediately |
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**
| Scenario | Expected result |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| A task fails below `MAX_ATTEMPT` | Return it to `pending`; create no paging task |
| A task reaches `MAX_ATTEMPT` | Atomically mark it `failed` and insert one `outbox_failure_alert:{taskId}` paging task |
| The exhaustion transaction rolls back or the claim token is stale | Commit neither the `failed` state nor a paging task |
| The worker process exits immediately after the exhaustion transaction | The independent paging task remains durable and `pending` |
| Paging succeeds | Await the adapter, then mark `page_outbox_failure` `succeeded` |
| Paging fails | Return the same paging task to `pending` with capped backoff; never recurse |
### Which events to subscribe to
For one-time payments, at least these:
| Event | What to do |
| ------------------- | ------------------------------------------------------------------------------------------------------- |
| `order.created` | **Required.** Its `event.created` is the only basis for ordering payment attempts |
| `order.succeeded` | Confirm payment, trigger fulfillment |
| `order.failed` | Record the reason, allow a retry |
| `order.next_action` | The customer needs extra verification; park the order |
| `session.complete` | Set the independent Session lifecycle to `completed`; do not infer payment success |
| `session.expired` | Set the separate `clinkSessionStatus` to `expired`; never translate Session expiry into payment failure |
| `refund.succeeded` | Refund processed. Record it by `refundId`; settle the Order status separately |
Subscription products need their own set of subscription and invoice events — see [Subscriptions](/subscriptions). The full list is in the [Webhook reference](/api-reference/webhook/order), 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:
```bash theme={null}
cloudflared tunnel --url http://127.0.0.1:3000 --no-autoupdate
```
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.
```javascript theme={null}
// send-test-event.mjs
import crypto from 'node:crypto';
const WEBHOOK_URL = 'http://127.0.0.1:3000/api/webhooks/clink';
const signingKey = process.env.CLINK_WEBHOOK_SIGNING_KEY;
if (!signingKey) throw new Error('CLINK_WEBHOOK_SIGNING_KEY is required');
async function sendSignedEvent(event, { signatureOverride } = {}) {
// Sign each exact serialized body with a fresh delivery timestamp.
const rawBody = JSON.stringify(event);
const timestamp = String(Date.now());
const signature = signatureOverride ?? crypto
.createHmac('sha256', signingKey)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const res = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Clink-Timestamp': timestamp,
'X-Clink-Signature': signature,
'X-Clink-SignType': 'SHA256',
},
body: rawBody,
});
console.log(event.id, res.status, await res.text());
return res.status;
}
const merchantReferenceId = 'order_10001'; // must already exist locally
const sessionId = 'sess_test_001'; // must match local clinkSessionId
const orderId = 'order_test_001';
const createdEvent = {
id: 'event_order_created_test',
object: 'event',
type: 'order.created',
created: 1750000000000,
data: {
object: {
orderId,
merchantReferenceId,
sessionId,
status: 'created',
},
},
};
const succeededEvent = {
id: 'event_order_succeeded_test',
object: 'event',
type: 'order.succeeded',
created: 1750000001000,
data: {
object: {
orderId,
merchantReferenceId,
sessionId,
status: 'success',
amountTotal: 19.99,
paymentCurrency: 'USD',
},
},
};
const olderFailedEvent = {
id: 'event_order_failed_old_test',
object: 'event',
type: 'order.failed',
created: 1750000000500, // older than the succeeded status version
data: {
object: {
orderId,
merchantReferenceId,
sessionId,
status: 'failed',
failureCode: 'fixture_declined',
failureMessage: 'Local fixture only',
},
},
};
// Executable happy path, exact duplicate, then an out-of-order old failure.
await sendSignedEvent(createdEvent);
await sendSignedEvent(succeededEvent);
await sendSignedEvent(succeededEvent); // same Event ID and identical business object
await sendSignedEvent(olderFailedEvent);
// Negative fixtures: run individually when checking these branches.
const mismatchedSessionEvent = {
...succeededEvent,
id: 'event_order_succeeded_wrong_session_test',
data: {
object: { ...succeededEvent.data.object, sessionId: 'sess_other' },
},
};
// await sendSignedEvent(mismatchedSessionEvent); // durable manual_review, no payment update
// await sendSignedEvent(createdEvent, { signatureOverride: '0'.repeat(64) }); // expect 401
```
Expected local results:
| To test | Change |
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
| Created, then succeeded | `paymentStatus = paid`; refund basis is `order_test_001 / 19.99 / USD`; one fulfillment task |
| Duplicate delivery | The exact same `succeededEvent` is sent twice; the second returns 200 without another fulfillment |
| Out-of-order | `olderFailedEvent` is delivered after success but has an older status timestamp; the order stays paid |
| Bad signature | Run the commented `signatureOverride` call — expect 401 |
| Mismatched Session | Run the commented mismatch fixture — one durable `manual_review` case/task, no payment update |
## Who owns what
| Component | Owns | Must not |
| --------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Backend | Creating orders, calling Clink, storing IDs, exposing order status | Return the Secret Key to the browser; charge again while a result is unknown |
| Frontend | Calling the merchant backend, redirecting or mounting checkout, showing status | Call Clink directly; fulfill based on a redirect or an SDK event |
| Webhook handler | Verifying, deduplicating, matching, updating status, triggering fulfillment | Depend on event order; let stale state overwrite newer state |
## 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
Key rotation, IP restrictions, delivery and retry rules.
The scenarios to verify before switching to production.
# July 2026 Changelog
Source: https://docs.clinkbill.com/changelog/july-2026
Agentic Payment, Pay by Link, and cross-currency payment updates
In July, Clink expanded Agentic Payment, no-code collections, global payment coverage, and cross-currency transactions while improving merchant APIs, checkout, and financial reconciliation.
Discover and install reviewed payment-enabled Skills.
Create and share payment links without calling an API.
Present local currencies automatically with predictable fallback behavior.
## Agentic Payment
### Skill Marketplace
Merchants can upload, review, publish, and maintain Skills from the Dashboard. Published Skills appear in the public Marketplace, where users can:
* Search by name, category, or publisher
* Review capabilities, versions, and publisher information
* Install Skills through the CLI or a prompt
* Review version history and release notes
* Tip creators through supported Skills
Before publication, Clink validates package structure, versions, security, and Agentic Payment behavior to help users discover reviewed payment-enabled Skills.
[Publish and manage Skills](/guides/agent/skill_marketplace)
Building payments, automatic top-ups, or authorization controls for Agents? [Contact the Clink team](https://clinkbill.com/contact) to discuss integration options and Early Access.
## Payments and global coverage
### Pay by Link: collect payments without development
Merchants can now create payment links directly from the Developers page in the Dashboard without calling an API.
Enter the amount, currency, description, and payer email to generate a shareable checkout link for sales follow-up, business payments, support-led collections, and other non-standard checkout flows.
[Explore Clink Payments](https://clinkbill.com/products/payment) · [Read the Checkout Session docs](/guides/payments/checkout_session)
### More local payment methods
Clink expanded local payment coverage with:
* Allinpay
Merchants still integrate Clink once and can present the payment methods most relevant to each customer market.
[View supported payment methods](/finance/pricing#more-ways-for-customers-to-pay)
### More flexible cross-currency payments
One-time payments can now specify the currency charged to the customer and prioritize local-currency display.
For international customers, Clink selects an appropriate currency based on location, configured prices, and payment-method support:
* Use a merchant-configured local price when available
* Convert automatically when no fixed local price exists
* Fall back to the original pricing currency when the local currency is unavailable
* Allow merchants to specify `paymentCurrency` through the API
This simplifies global pricing and reduces confusion when multiple currencies could otherwise appear at checkout.
[Learn about local currencies and adaptive pricing](/guides/payments/currencies) · [Configure multi-currency product prices](/guides/resources/product#multi-currency-pricing)
## Checkout
### A clearer, more reliable checkout experience
Hosted Checkout now includes:
* A PCI security indicator
* Merchant name on the payment-success page
* Automatic payment-result polling while a payment is pending
* Improved promotion-code display
* More complete receipts for one-time payments
These changes help customers identify the merchant, understand payment status, and receive a more complete payment record.
[Learn about Checkout Session](/guides/payments/checkout_session)
## Billing and finance
### Subscription upgrade and downgrade APIs
Developers can now build a more complete plan-change experience:
Retrieve the target plan and pricing changes.
Present the upgrade or downgrade for approval.
Apply the new plan configuration after confirmation.
Cancel a pending change before it takes effect.
Merchants can now use APIs to upgrade or downgrade users' subscription products.
[Preview a subscription change](/api-reference/endpoint/preview-subscription-update) · [Confirm a subscription change](/api-reference/endpoint/confirm-subscription-update) · [Cancel a subscription change](/api-reference/endpoint/cancel-subscription-update)
### Better settlement and reconciliation data
Settlement data now includes:
* Merchant reference IDs and settlement dates on settlement bills
* Downloadable fully managed settlement reports
These additions make it easier to match Clink transactions, refunds, and settlements with merchant order systems.
[Learn about order and reconciliation fields](/guides/resources/order)
## Developer capabilities
### More complete order and refund responses
Order APIs and webhooks now return clearer payment-failure reasons. Refund responses also include:
* `failureCode`
* `failureMessage`
* `refundMerchantOrderId`
Order lists can also be searched by merchant reference ID, making reconciliation with merchant order numbers more direct.
[List orders](/api-reference/endpoint/list-orders) · [Create a refund](/api-reference/endpoint/create-refund)
# June 2026 Changelog
Source: https://docs.clinkbill.com/changelog/june-2026
Subscription orchestration, smart routing, and product portfolio finance updates
In June, Clink expanded subscription monetization, payment routing, merchant onboarding, and financial management for multi-product portfolios.
Apply different prices automatically across billing periods.
Configure rules, traffic splits, and Smart Retry more clearly.
Manage multiple products, prices, and local currencies in one place.
## Products and subscriptions
### Subscription schedules with phased pricing
Merchants can now configure consecutive pricing phases when creating a subscription, allowing one subscription to use different prices across billing periods.
For example:
Charge \$9 for the first month.
Charge \$39 per month for the next two months.
Continue renewing at \$59 per month.
Each phase can define its price and duration. The final phase can continue indefinitely. This supports introductory offers, scheduled price increases, and limited-time plans without combining multiple coupons. If a customer upgrades or downgrades, the active schedule ends and the subscription continues on the new standard plan.
[Learn about products and prices](/guides/resources/product) · [Learn about subscription integration](/subscriptions)
### Control upgrades and downgrades separately
Customer Portal settings now let merchants independently allow customers to:
* Upgrade to a higher plan
* Downgrade to a lower plan
This allows businesses to enable only the changes supported by their entitlement and provisioning workflows.
[Learn about Customer Portal](/guides/billing/customer_portal)
### More subscription API and billing-cycle data
This release also adds:
* Immediate subscription cancellation
* The number of completed renewals in subscription responses
* Improved subscription schedule display in Checkout and Customer Portal
[Cancel a subscription](/api-reference/endpoint/cancel-subscription) · [Retrieve a subscription](/api-reference/endpoint/get-subscription)
## Smart routing and payment orchestration
### Upgraded visual payment routing
The payment routing experience now provides a clearer visual rule builder. Merchants can:
* Prioritize routing rules
* Match transactions with conditional nodes
* Branch based on payment results
* Configure channel splits and Smart Retry
* See whether each rule is enabled
Routing rules can be enabled or disabled independently. Disabling a rule preserves its configuration while removing it from payment matching, so temporary strategy changes no longer require deleting the rule.
These controls help merchants tailor payment paths to markets, payment methods, and transaction conditions while reducing duplicate configuration.
[Explore Clink Smart Routing](https://clinkbill.com/products/routing)
## Product portfolio management
### Subscription and pricing management across a portfolio
Clink Product Studio provides one place to manage:
* One-time and subscription products
* Weekly, monthly, and annual billing intervals
* Multiple prices for one product
* Localized prices for different markets
* Free trials and tax categories
* Product archiving with compatibility for existing subscriptions
Merchants can use one Price ID for multiple local-currency prices and connect products to Checkout, subscriptions, and invoices.
[Learn about Product & Price](/guides/resources/product)
### Tenant-level bills and multi-merchant finance
Tenant administrators can now generate bills for all merchants or a selected set of merchants in one operation.
The result is downloaded as a ZIP file with a separate bill for each merchant, supporting central finance operations while keeping merchant records distinct.
[Learn about balances](/finance/balance) · [Learn about payouts](/finance/payout)
## Merchant operations
### Insights and detailed exports
The dashboard now includes additional insights and more export fields for transaction analysis, customer payments, and business trends.
Transaction lists also support amount ranges, routing-rule filters, and multi-select filters for faster investigation.
[Learn about order and transaction data](/guides/resources/order)
### Self-service onboarding and production activation
Merchant onboarding now includes self-service production KYC, invitation-code registration, and test-environment information synchronization.
Merchants can complete registration and integration validation in the test environment before moving more smoothly into production activation.
[Learn about going live](/go-live)
## Developers and checkout
### Webhook management and event replay
Developers can create Webhook Endpoints and replay events from the Dashboard when delivery fails, a merchant service is temporarily unavailable, or business processing needs to run again.
Order responses now include more payment details, including card brand, last four digits, issuer country, issuer, and account information for methods such as PayPal.
[Configure webhooks](/integration#webhooks) · [Create a Webhook Endpoint](/api-reference/endpoint/create-webhook-endpoint)
### Checkout and Customer Portal improvements
Hosted Checkout and Customer Portal now display subscription schedules more clearly. This release also improves:
* Payment without saving a card
* Elements embedded payments
* Mobile back navigation
* Returning to the merchant while a payment is pending
* Merchant and product information display
[Learn about Checkout Session](/guides/payments/checkout_session) · [Learn about Elements](/elements)
# Choose an Integration
Source: https://docs.clinkbill.com/choose-integration
Three ways to host checkout, four server-side paths, and when each one fits.
Two decisions come before any code: where the customer pays, and which endpoint the backend calls. They are independent — pick each one separately.
## Where the customer pays
| Option | What the customer sees | Frontend work | Fits |
| --------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| Hosted Checkout | Redirected to Clink's checkout page | Almost none — just redirect | New projects, fastest path to live |
| JS SDK | The same redirect, or the full checkout page as an iframe inside the merchant page | Install a package and call one method; embedding also means managing a container and its lifecycle | Frontends already using the SDK, or keeping customers on site |
| Elements | Payment inputs, wallet buttons, and 3DS inside a merchant-designed checkout page | The order summary, pay button, and promo code input are built by the merchant | Branded or multi-step checkout |
Start with Hosted Checkout unless there is a specific reason not to. It gets the whole chain — order, payment, webhook, fulfillment — working fastest. Once that chain is proven, the frontend can be swapped for Elements without rewriting the backend.
### Hosted Checkout
Send `uiMode: "hostedPage"` when creating the Session, hand the returned `url` to the frontend, and redirect.
```javascript theme={null}
// This is all the frontend needs
const { checkoutUrl, merchantOrderId } = await fetch('/api/checkout/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: 'prd_xxx', quantity: 1 }),
}).then((r) => r.json());
sessionStorage.setItem('merchantOrderId', merchantOrderId);
window.location.assign(checkoutUrl);
```
After paying, the customer returns to the configured `successUrl`. On that page, look up the local order by `merchantOrderId` and show the result.
Showing "payment successful" on the return page is for the customer. It is not what triggers fulfillment — the webhook chain does that. Keep the two separate.
Full server-side code is in [Hosted Checkout](/build-integration).
### JS SDK redirect and embedded
Install [`@clink-ai/clink-js`](/api-reference/javascript_sdk) and initialize it with a Publishable Key.
* `redirectToCheckout()` sends the customer to the full checkout page
* `initEmbeddedCheckout()` mounts that same checkout page as an iframe in a supplied container
In embedded mode the backend still creates the Session; the SDK fetches it through the supplied `fetchSession` callback. Create it with `uiMode: "hostedPage"` — the SDK mounts the full hosted checkout page, so `elements` mode is not involved. The browser only ever sees the Publishable Key and Session-related fields.
Methods, parameters, and events are in the [JavaScript SDK](/api-reference/javascript_sdk) reference.
### Elements
Elements splits the page in two. The merchant owns the order summary, pay button, promo code input, and status messages. The SDK owns card entry, wallet buttons, 3DS, and QR codes.
Two backend changes:
* Create the Session with `uiMode: "elements"`
* `returnUrl` becomes required. A typical value is `https://YOUR_DOMAIN/complete.html?session_id={ELEMENTS_SESSION_ID}`, where Clink substitutes the real session ID for `{ELEMENTS_SESSION_ID}`
The backend returns **only `sessionId`** to the frontend — not a `url`, and not Clink's raw response passed straight through.
`publishKey` and `environment` are not part of the Create Session response. They are frontend deployment config, held in a `VITE_` or `NEXT_PUBLIC_` style environment variable.
Full usage is in [Elements](/elements).
The embedded mode of `@clink-ai/clink-js` and the `@clink-ai/clink-elements` package are different things. The first mounts a complete checkout iframe; the second mounts composable payment components. Pick one per checkout page.
## Which endpoint the backend calls
| Goal | Call | When |
| ------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Send the customer to checkout to pick a payment method | `POST /checkout/session` | The default for website payments |
| Create a payment server-side | `POST /payment` | No full checkout UI is needed |
| Create a subscription | `POST /subscription` | A recurring product and a payment instrument already exist, and the subscription starts directly. Full flow in [Subscriptions](/subscriptions) |
| Let customers change cards, cancel, switch plans, or view invoices | `POST /billing/session` | An existing Clink customer needs to manage subscriptions, billing, or payment methods — send them to the portal instead of through checkout again |
Most website payments use the first.
`POST /payment` **creates a payment server-side without providing a full Checkout UI**. It covers more than background charging — customer-present payments, wallets, and QR codes all go through it. What it does not provide is the payment interface, which the merchant builds.
Because there is no hosted UI, when 3DS or another verification is required it returns `status: 5` and an `action` the customer has to be guided through.
Cards and similar methods need a stored payment instrument (`paymentInstrumentId`) first. Wallets — CashApp, GCash, TNG, WeChat, Kakao, Alipay, QRIS, PromptPay — are created by the backend from the payment method, so none has to be set up in advance.
## How to define products
Two modes, chosen by whether the item is a permanent part of the catalog.
**Registered products** — created ahead of time in the dashboard or through `POST /product` and `POST /price`, then referenced by `productId` and `priceId` when creating a Session. Use this for plans, membership tiers, and anything sold long-term. Subscriptions require it, and the price must be a recurring price.
**Inline products** — not created ahead of time. Describe the name, unit price, and quantity directly in `priceDataList` when creating the Session. Use this for top-ups, custom amounts, and one-off items.
Both can coexist in one system. Choose per item type.
Amounts in both modes use the major currency unit. USD 19.99 is `19.99`, not `1999`.
**Subscriptions require registered products**, and the price must be recurring (`priceType: "recurring"`). Inline items cannot create one — see [Subscriptions](/subscriptions).
## Whether to offer discounts
If so, decide where the promo code appears:
| Option | Configuration | Fits |
| ------------------------- | ----------------------------------------------------------------------- | -------------------------------- |
| Customer enters it | `allowPromotionCodes: true` on the Session | Public campaigns |
| Pre-applied, input hidden | Add `showPromotionCode: false` and `promotionCode` | Targeted offers, channel pricing |
| Custom input in Elements | Enable it on the Session, then call `promoCodeChange` from the frontend | Checkout pages designed in-house |
Creation rules, validation, and how long a subscription discount lasts are in [Discounts and promotion codes](/promotions).
## Write the choices down
Once decided, record these somewhere the team can find them. They determine most of the code below.
| Decision | Choice | Determines |
| ------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Product mode | Registered / inline | Whether to send `productId` + `priceId` or `priceDataList` |
| Server path | Checkout / direct payment / subscription / portal | Which endpoint gets called |
| Frontend | Hosted / SDK redirect / SDK embedded / Elements | The `uiMode` value, which fields the backend returns, which package the frontend installs |
## Next
What to write on the backend, the frontend, and the webhook.
Every Checkout Session parameter, in detail.
If an agent is already writing the code, let it do the integration.
# Elements
Source: https://docs.clinkbill.com/elements
Embed Clink payment inputs in a merchant-owned checkout page, with the server still creating the Session.
Elements embeds the payment inputs, wallet buttons, 3DS, QR codes, and third-party payment interactions that Clink manages into a merchant-owned page. The order summary, page structure, pay button, and status messages stay with the merchant.
Use [Hosted Checkout](/build-integration) to get money moving fastest. Reach for Elements when the order summary, page structure, and interaction design have to be custom.
`@clink-ai/clink-elements` is currently published at `0.0.1` and the API may still change. Pin the version in `package.json`.
## What the full chain looks like
Elements only replaces the frontend segment. **Creating the Session still has to happen on the merchant server**, because that call uses the Secret Key.
```mermaid theme={null}
sequenceDiagram
participant B as Browser
participant M as Merchant backend
participant C as Clink API
participant E as Clink Elements
participant W as Merchant webhook
B->>M: 1. Submit product ID or cart
M->>M: 2. Validate items and amount, create pending local order
M->>C: 3. Call POST /checkout/session with the Secret Key
C-->>M: 4. Return sessionId, url, expireTime and more
M->>M: Store local order - merchantReferenceId - sessionId mapping
M-->>B: 5. Return only sessionId
B->>E: 6. Initialize with sessionId and Publishable Key
E->>E: 7. Render methods, handle submit, wallets, 3DS, QR codes
C->>W: 8. Deliver payment events
W->>M: Verify, deduplicate, update the local order
B->>M: 9. Query merchant order status
M-->>B: Return the final result
```
Step 1 submits a **product identifier**, not a final amount computed in the browser. The amount is recalculated server-side in step 2.
### Who owns what
| Party | Owns | Must not |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Merchant frontend | Calling the merchant wrapper endpoint, initializing and mounting Elements, updating UI from SDK events, querying merchant order status | Hold the Secret Key, call Clink's Create Session directly, or decide on its own that a payment succeeded |
| Merchant backend | Validating items and amounts, creating the local order, calling Clink, storing the ID mapping, returning a frontend-safe payload, handling webhooks | Pass the Secret Key or Clink's full raw response to the browser |
| Clink API / Elements | Creating the Session, rendering secure payment UI, handling submission, third-party buttons, 3DS, and QR codes | Own the local order or the fulfillment |
| Clink webhook | Delivering server-side payment state to the merchant backend | Be replaced by frontend events |
## 1. Server: write a wrapper Session endpoint
This layer is not an optional optimization. It is three boundaries at once: **the Secret Key security boundary, the amount validation boundary, and the local order association boundary.**
The `clinkRequest` helper below is the authenticated helper from [Hosted Checkout](/build-integration); it returns the `data` field of the response.
```javascript theme={null}
// This is the merchant backend endpoint, not a Clink browser API
app.post('/api/payments/elements-session', requireUser, async (req, res) => {
const { productId, quantity } = req.body;
// Recalculate server-side; never trust a price from the browser
const quote = await catalog.quote(productId, quantity);
const order = await orders.createPending({
customerId: req.user.id,
amount: quote.amount,
currency: quote.currency,
});
const session = await clinkRequest('/checkout/session', {
customerEmail: req.user.email,
merchantReferenceId: order.id,
originalAmount: quote.amount,
originalCurrency: quote.currency,
priceDataList: quote.items,
uiMode: 'elements',
returnUrl: `${APP_ORIGIN}/payment/return?session_id={ELEMENTS_SESSION_ID}`,
});
await orders.bindSession(order.id, session.sessionId);
// This is a custom frontend response — keep it minimal
res.json({ sessionId: session.sessionId });
});
```
Three things to get right:
* `uiMode` is `elements` and `returnUrl` is required. Put `{ELEMENTS_SESSION_ID}` in the URL and Clink substitutes the real Session ID
* The field is `returnUrl`. Older material calls it `redirectUrl`, which is wrong — `redirectUrl` is a response field used in `requires_action` flows
* `merchantReferenceId` is for reconciliation only. It is **not an idempotency key**; the same value twice produces two Sessions
### What Clink returns
The `data` that `clinkRequest` hands back looks like this:
```json theme={null}
{
"sessionId": "sess_xxxxxxxx",
"uiMode": "elements",
"returnUrl": "https://merchant.example.com/payment/return?session_id=sess_xxxxxxxx",
"url": "https://uat-checkout.clinkbill.com/...",
"merchantReferenceId": "merchant_order_xxx",
"expireTime": "2026-07-30 12:00:00"
}
```
Clink's raw response wraps this in a `{ "code": 200, "msg": "success", "data": { ... } }` envelope; `clinkRequest` has already unwrapped it.
`expireTime` currently comes back as `"2026-07-30 12:00:00"` — **no timezone designator**, and not RFC 3339.
Do not hand it straight to `new Date()` or parse it as local time; different runtimes will disagree.
**Do not infer a "merchant account timezone" or the browser timezone either.** The backend currently serializes using the service process default timezone, with no per-account conversion, so the string does not state which zone it belongs to.
Precise cross-timezone handling has to wait until the backend upgrades this contract to RFC 3339 with an offset, or to a Unix timestamp. Until then, read `status` to decide whether a Session can still be paid rather than computing from this string.
That is an excerpt; the full field list is in [Create checkout session](/api-reference/endpoint/create-checkout-session). Two things to note:
* **The response contains no Publishable Key and no `environment`.** Both come from the merchant application config — see the next section
* The response **does** contain `url`, the hosted checkout address. An Elements integration does not use it
Do not forward Clink's raw response to the browser. The wrapper endpoint should return only what the frontend actually needs — ideally just `sessionId`.
## 2. Frontend: call the wrapper endpoint, then initialize
The browser does two things: ask the merchant backend for a `sessionId`, then initialize the SDK with it.
```javascript theme={null}
import { loadClinkElements } from '@clink-ai/clink-elements';
// This calls the merchant backend, not Clink
const response = await fetch('/api/payments/elements-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity }),
});
if (!response.ok) throw new Error('Unable to create checkout session');
const { sessionId } = await response.json();
const clink = await loadClinkElements({
sessionId,
publishKey: PUBLIC_CLINK_PUBLISHABLE_KEY,
environment: 'sandbox',
presetOptions: {
locale: 'en-US',
theme: 'light',
primaryColor: '#1677FF',
},
});
```
`PUBLIC_CLINK_PUBLISHABLE_KEY` and `environment` are **the application deployment config**, taken from the Publishable Key under **Developers > API Keys** (it starts with `pk_uat_`). They are safe to expose in the browser, but they do not come from the Create Session response.
```bash theme={null}
# .env
VITE_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
VITE_CLINK_ENVIRONMENT=sandbox
```
```bash theme={null}
# .env.local
NEXT_PUBLIC_CLINK_PUBLISHABLE_KEY=pk_uat_xxxxxxxx
NEXT_PUBLIC_CLINK_ENVIRONMENT=sandbox
```
The SDK parameter is named `publishKey`. When this page says Publishable Key, that is the field it means — write `publishKey` in code.
## 3. Mounting, submitting, and third-party buttons
Two kinds of button can appear on a checkout page, and they behave differently:
**The merchant pay button** — for cards and anything else where the host triggers submission. Call `clink.submit()` on click.
**SDK built-in third-party buttons** — Apple Pay, Google Pay, PayPal, and similar. The `paymentMethod` element **renders these internally** and handles the click itself. When one takes over, the SDK emits `submit-visible: false` and the host page hides its own button.
```javascript theme={null}
const paymentMethod = clink.createElement('paymentMethod');
paymentMethod.mount('#payment-method');
// Only if customers pick a currency, and always after paymentMethod
const currencySelect = clink.createElement('currencySelect');
currencySelect.mount('#currency-select');
let canSubmit = false;
let submitting = false;
clink.on('submit-enabled', (enabled) => {
canSubmit = enabled;
payButton.disabled = !enabled || submitting;
});
// Hide the host button when a third-party button takes over
clink.on('submit-visible', (visible) => {
payButton.hidden = !visible;
});
payButton.addEventListener('click', () => {
if (!canSubmit || submitting) return;
submitting = true;
payButton.disabled = true;
clink.submit();
});
```
Three things not to do:
* **Do not hardcode button visibility by payment method name.** Follow `submit-visible`
* **Do not draw a second set of Apple Pay, Google Pay, or PayPal buttons.** The SDK renders those; a hand-built copy does nothing when clicked
* **Do not call `clink.submit()` for third-party buttons.** The SDK handles their clicks
Which third-party methods appear depends on merchant configuration, the Session, currency, browser, and device. Do not promise a fixed set on the page.
`submit-enabled` reports whether submission is allowed. Write `disabled = !enabled` rather than passing the event value straight into `disabled`, which inverts the logic.
## 4. Events
Two groups, by what they are used for.
**Update host UI**
| Event | What to do |
| ---------------------- | ------------------------------------------------------------- |
| `session-init-success` | Clear the host skeleton state |
| `submit-enabled` | Enable or disable the pay button and promo code controls |
| `submit-visible` | Hide the host pay button when a third-party button takes over |
| `amount-change` | Update total, currency, discount, tax, and button label |
| `promo-code-error` | End the promo code loading state and show a field-level error |
| `error` | Branch by error type — see the table below |
**Flow signals**
| Event | What to do |
| ----------------- | ------------------------------------ |
| `session-success` | Navigate to the merchant result page |
| `session-pending` | Show a "confirming payment" state |
`session-success` is **not proof of payment**, and neither is `returnUrl`. Browser events can be forged.
Do this instead: on `session-success`, navigate to the merchant result page, and have that page query **the merchant backend** for the order status. Shipping, top-ups, and entitlements are decided by the backend from signature-verified webhooks and server-side queries.
Verification, deduplication, and order matching are covered in [Hosted Checkout](/build-integration).
## 5. Promo codes
Optional. Creating Coupons and Promotion Codes server-side is covered in [Discounts and promotion codes](/promotions). This section is frontend only.
```javascript theme={null}
clink.on('amount-change', ({ amount }) => {
promoSection.hidden = !amount.enablePromotionCode;
renderApplied(amount.promotionCodeInfo);
updateTotal(amount.dueTodayAmount, amount.currency);
});
applyButton.addEventListener('click', () => {
setPromoLoading(true);
clink.promoCodeChange({ type: 'apply', code: promoInput.value });
});
removeButton.addEventListener('click', () => {
setPromoLoading(true);
clink.promoCodeChange({ type: 'clear' });
});
clink.on('promo-code-error', ({ message }) => {
setPromoLoading(false);
showPromoError(message);
});
```
The payload is `{ amount }`, with the fields nested under `amount`. Writing `(info) => info.enablePromotionCode` yields `undefined`, and the promo code entry never appears.
Discounts and the final amount due come from `amount`. Do not recompute them.
## 6. Error handling
`0.0.1` throws three different kinds of thing, and they are handled differently.
### Initialization: `loadClinkElements()`
It does **not** throw only `ClinkApiError`. There are at least three paths:
| What is thrown | When |
| ------------------ | ---------------------------------------------------------------------------------- |
| `ClinkApiError` | A Clink endpoint returned non-2xx, or the response body had `code !== 200` |
| Native `TypeError` | The request never completed — offline, DNS, CORS, or blocked |
| Native `Error` | `environment` was an unrecognized value; the message is `Unknown environment: xxx` |
A response body that is not valid JSON also surfaces as a native `SyntaxError` from `await res.json()`.
So treat the `catch` parameter as `unknown` and **always keep a fallback branch**:
```typescript theme={null}
import { loadClinkElements, ClinkApiError } from '@clink-ai/clink-elements';
try {
const clink = await loadClinkElements(options);
} catch (error: unknown) {
if (error instanceof ClinkApiError) {
// A business error from a Clink endpoint; message comes from the API
showInitError(error.message);
} else if (error instanceof TypeError) {
// Network or transport failure — offer a retry
showNetworkError();
} else {
// Bad arguments, response parsing failures, anything else
showUnknownError(error);
}
}
```
`0.0.1` also exports `SessionExpiredError`, `SessionCompleteError`, `SessionLoadError`, `SessionNotSupportedError`, and `PromoCodeError`. In the shipped build those are **class definitions with no throw sites at all**.
**Do not write `instanceof` branches against them** — the conditions never hold. Revisit once a later version actually uses them.
When an initialization error does arrive, check these. Each has its own source of truth — do not attribute all three to one endpoint:
| What to check | Where the answer comes from |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| Do `publishKey`, `environment`, and `sessionId` belong to the same environment | The configuration each one came from; a sandbox key against a production Session fails |
| Did the backend send `uiMode: elements` | The Create Session request, or the `uiMode` returned by the Session API |
| Has the Session expired or already been paid | The `status` returned by the Session API — do not infer it in the browser |
### After initialization: some errors are thrown synchronously
**Not every runtime problem arrives as an event.** These are **synchronously thrown native `Error`s** and need a `try/catch`:
| Message | Trigger |
| -------------------------------------------------------- | ------------------------------------------------------- |
| `Element "xxx" already created` | The same element type was created twice on one instance |
| `currencySelect cannot be created without paymentMethod` | `currencySelect` was created first |
| `Element "xxx" is already mounted` | `mount()` called twice on the same element |
| `Mount target not found: xxx` | The `mount()` selector matches nothing on the page |
| `ClinkElements instance has been destroyed` | The instance was used after `destroy()` |
```javascript theme={null}
try {
const paymentMethod = clink.createElement('paymentMethod');
paymentMethod.mount('#payment-method');
} catch (err) {
// Creation order, duplicate creation, and missing mount targets all land here
reportIntegrationBug(err);
}
```
### Errors during the payment flow: events
Errors that belong to the payment flow itself are forwarded from the checkout iframe as events, and are not wrapped in any error class.
| Event | When | What to do |
| ------------------ | ------------------------------- | -------------------------------------------------------------- |
| `error` | Something failed during payment | Show the message from the event data and keep the form usable |
| `promo-code-error` | The promo code is invalid | Show it in the promo area only, leaving the payment form alone |
## 7. Implementation constraints
Get these wrong and the integration breaks. Everything else about layout is a free design decision.
| Constraint | What goes wrong | What to do |
| ------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| One instance per Session | Reusing an old Session loads a completed or expired state | Destroy the old instance and reinitialize when `sessionId` changes |
| Fixed creation order | Creating `currencySelect` first throws | `paymentMethod` first, then the optional `currencySelect`; each element type once per instance |
| Teardown must clean up | Orphaned iframes and window message listeners | Call `destroy()` on route change or component unmount. Repeat calls are safe to ignore, but a destroyed instance cannot be reused |
| Stable container with flexible height | Wallet, QR code, or 3DS interactions get clipped | Do not give the payment iframe's parent a fixed height, and do not clip dynamic content with `overflow: hidden` |
| Browser-only execution | SSR fails when it touches the DOM | In Next.js and similar, put it in a client component or a client-side init step |
## Before shipping
* [ ] No Secret Key and no webhook signing key in browser Network traffic or the build output
* [ ] The browser only calls the merchant Session wrapper endpoint, never Clink's Create Session directly
* [ ] The backend does not trust a final amount from the browser; it recalculates from the product or cart
* [ ] The local order, `merchantReferenceId`, and `sessionId` mapping is stored
* [ ] The Publishable Key and `environment` come from application config, and are not described as Create Session response fields
* [ ] `submit-visible` hides the host button when Apple Pay, Google Pay, PayPal, or similar appear
* [ ] `session-success` does not trigger fulfillment; the final state comes from verified webhooks and backend queries
* [ ] Old instances are destroyed when the Session changes or the page unmounts
## Next
The backend and webhook halves are identical to Hosted Checkout.
The checklist before switching to production.
# Balance
Source: https://docs.clinkbill.com/finance/balance
Understanding fund management and financial flows
## Overview
Your merchant account can maintain multiple balance accounts, each holding different types of funds. Understanding how these funds are managed will help you effectively handle payouts and maintain positive balances.
Note: If you're using Clink solely as a payment gateway for other payment service providers, these balances won't reflect the actual amounts in those external accounts.
## Balance Types
Balance details can be accessed through the **Balances** tab in the left menu panel.
### Incoming
The incoming balance shows funds that customers have paid but are still pending settlement. These funds are not yet available for withdrawal.
### Available
The available balance increases when funds are settled. You can use these funds for payouts, refunds, or other debit transactions.
## Balance Activity
To view detailed balance updates, navigate to the **All activities** tab.
### Activity Entry
Each activity entry represents a transaction that affects your account balance, containing the following information:
* Amount: Total transaction value (charge or refund)
* Fees: Clink's service charges
* Net: Total credit or debit amount applied to your account
* Type: Transaction category (Charge, Refund, Chargeback, etc.)
* Available on: Estimated date when funds will be accessible in your balance account
# Payout
Source: https://docs.clinkbill.com/finance/payout
Understanding payout processes and timing expectation
## Overview
Clink transfers funds to your bank account through a secure payout process.
## Managing Your Payouts
Access your payout details through **Balances** > **Payouts** in the left menu panel.
### Bank Account Setup
Clink supports bank accounts from the United States and Hong Kong. You must have a verified bank account to receive payouts.
Required Bank Details:
• Routing Number: 111000000 (9 digits)
• Account Number: (Format varies by bank)
• Clearing Code: 123 (3 digits)
• Branch Code: 456 (3 digits)
• Account Number: 123456-789 (6-9 digits)
#### Adding a Bank Account
1. Go to **Balances** > **Payouts**
2. Click **Add Bank Account** (top right)
3. Select your region
4. Complete the form with your bank details
#### Editing Bank Details
1. Navigate to **Settings** > **Merchant** > **Bank Accounts and Currencies**
2. Find the bank account to edit
3. Update the details and click **Save**
All bank accounts undergo a review process (2-3 business days). Additional documentation may be required. Any changes to bank details will trigger a new review.
### Making a Payout
#### Prerequisites
* Positive available balance
* Verified bank account
#### Steps to Request Payout
1. Go to **Balances** > **Payouts**
2. Click **Pay out** (top right)
3. Complete the payout form and submit
Initial payout requests require 2-3 business days for review. Subsequent payouts are processed faster. Status updates are provided based on bank confirmations.
# Simple, transparent pricing
Source: https://docs.clinkbill.com/finance/pricing
Simple, transparent pricing for global payments, billing, tax, compliance, and agentic commerce.
Clink gives AI-native teams one global payments stack for human and agent transactions.
## 3.9% + \$0.30 per transaction
One rate covers payment processing, Merchant of Record services, global tax calculation and remittance, fraud and chargeback protection, compliance, and payouts.
There is no separate gateway, tax, or platform fee on top.
**Registration starts in UAT.** Register in the sandbox to begin integration and complete a test transaction. When testing is complete, enter the production environment and submit the required account-verification details there.
Create your test account and begin integrating with hosted checkout or the developer API.
Discuss custom requirements, billing-only plans, or agentic payment flows.
There is no setup fee or monthly minimum. Chargebacks are \$20 per dispute, passed through directly from the card network. Clink adds no markup or additional charge. UPI transactions use a separate India-specific payment route and are billed at 8% instead of the standard rate.
## What's included
Hosted checkout, a PCI-compliant vault, fraud prevention, dynamic routing, and automatic retries. Clink acts as your Merchant of Record.
Subscriptions, recurring invoices, dunning, proration, a self-service customer portal, and automated VAT, GST, and sales-tax handling across 50+ jurisdictions.
Enable autonomous transactions with programmable authorization, spending controls, automatic top-ups, and global payment methods.
### Built for AI-native teams
* **AI-powered risk protection:** Machine-learning fraud detection and dispute prevention adapt to high-velocity, agent-driven transaction patterns.
* **Subscription-ready:** Manage recurring billing, retries, plan changes, and customer self-service without building the underlying infrastructure.
* **White-glove support:** Work with engineers who understand agentic and high-growth payment flows.
## More ways for customers to pay
Clink is more than a card-first MoR. Customers can pay using familiar cards, wallets, and local payment rails in each market—all through one integration.
Cards\
Apple Pay\
Google Pay
**United States:** Cash App Pay
**Brazil:** Pix
**Mainland China:** Alipay, WeChat Pay\
**Philippines:** GCash\
**South Korea:** Kakao Pay\
**Thailand:** PromptPay\
**Indonesia:** QRIS\
**Malaysia:** Touch 'n Go\
**India:** UPI
Supported currencies and recurring billing availability vary by payment method. We're continuously expanding our payment-method coverage. If you need one that isn't listed, [contact us](https://clinkbill.com/contact).
## How Clink compares
| Feature | Clink | Typical full-service MoR |
| ------------------------- | ---------------------------: | -----------------------: |
| Per-transaction fee | **3.9% + \$0.30** | \~5% + \$0.50 |
| Smart routing and retries | Included | Often not included |
| Agentic Payment support | Included | Often not included |
| Payout fee | **\$5 flat for US entities** | \~1% |
*For Hong Kong entities, the payout fee is \$15 flat.*
Raw payment gateways may advertise a lower processing rate, but they do not act as the Merchant of Record. You remain responsible for tax, compliance, chargebacks, and payment routing.
## Other pricing options
If the standard Merchant of Record plan is not the right fit, [contact us](https://clinkbill.com/contact) about:
* **Billing only:** Keep your existing payment processor and use Clink for subscriptions, invoicing, tax, and the customer portal.
* **Agentic Payment:** Add programmable authorization, automatic top-ups, and agent-native payment flows.
* **Affiliate referrals:** Earn rewards for referring other builders to Clink.
Tell us about your payment flow, and we'll help you find the right setup.
## Frequently asked questions
It covers payment processing, global tax calculation and remittance, fraud and chargeback protection, compliance, and payouts. There is no separate gateway, tax, or platform fee on top.
A Merchant of Record is the legal seller of your product. Clink takes responsibility for tax collection, compliance, and chargebacks worldwide, so you can sell globally without registering for VAT or GST in every country.
There is no setup fee, monthly minimum, or hidden international-card surcharge. Chargebacks are \$20 per dispute, passed through directly from the card network with no markup or additional charge from Clink. UPI transactions use a separate India-specific payment route and are billed at 8% instead of the standard rate.
UPI, or Unified Payments Interface, is India's instant bank-to-bank payment system, developed by the National Payments Corporation of India (NPCI). Customers can pay directly from participating bank accounts through a UPI app using a UPI ID, QR code, or payment intent—without entering card details. Because UPI is processed through a separate India-specific route with different local channel costs, Clink bills UPI transactions at 8% rather than the standard 3.9% + \$0.30 rate.
Yes. Agentic Payment lets autonomous agents pay with programmable authorization, spending controls, and automatic top-ups on the same payment infrastructure as human customers.
You can begin testing in minutes with hosted checkout or the developer API. Register in the sandbox, complete a test transaction, then enter the production environment and submit the required account-verification details there.
## Ready to get started?
Register in the sandbox to begin integration and complete a test transaction. When testing is complete, enter the production environment and submit the required account-verification details there.
# Go Live
Source: https://docs.clinkbill.com/go-live
What to verify, what to change, and how to debug when switching to production.
Working in the sandbox is not the same as being ready to launch. This page lists what to clear first.
## Complete account verification in production
Taking real payments and receiving payouts requires account verification.
Submit the account details under **Settings**, where Clink reviews the payment entity and its settlement eligibility. Status is one of not started, under review, rejected, or approved.
For merchants going through onboarding, the rest of Settings unlocks once business verification (KYB) or personal verification (KYC) is approved. Creating a product or completing a first payment is not required.
Some production accounts see only the first step, **Complete account verification**, with nothing after it. For those accounts that step unlocks payouts.
Account verification takes place only in production. Merchants can complete payment integration and test transactions in the sandbox before entering production and submitting real verification details. Sandbox development remains available while production review is pending. The sandbox does not collect or migrate production account-verification details.
## Verify in four layers
Work through these in order. Do not move up a layer until the one below passes.
No real payments. Verify amount calculation, product snapshots, idempotency keys, state transitions, and fulfillment deduplication. Unit tests cover this layer.
Send signed requests to the webhook endpoint and verify: the raw body is read correctly, the HMAC matches, repeated events do not fulfill twice, out-of-order events do not overwrite newer state, and bad signatures are rejected.
Have the backend create a real sandbox Checkout Session and confirm the frontend redirects or mounts correctly.
Pay with a test card end to end. Confirm the local order becomes paid **and** that shipping, top-up, or entitlement actually ran.
Layer four passes when the business outcome is complete, not when the webhook returned 200. Check the fulfillment status in the local database rather than reading logs.
## Scenario checklist
| Scenario | Confirm |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Product mode | Registered products use the right `productId`/`priceId`; inline `priceDataList` amounts and currency add up to the total |
| Frontend | Session creation, redirect, or mounting all work; the browser only receives the Publishable Key and Session fields |
| Payment methods | Test every enabled method separately, including QR codes and flows with extra verification |
| Multi-currency | Fixed multi-currency prices or automatic conversion produce the right result; the settled amount and currency reconcile against the Order |
| Success | The order becomes paid, fulfillment runs, and runs exactly once |
| Failure | `failureCode` is recorded and the customer can retry |
| `pending` | Shows "confirming" and never triggers an automatic second charge |
| `requires_action` | The customer can complete 3DS or other verification |
| Duplicate webhooks | The same event delivered three times fulfills once |
| Out-of-order webhooks | `session.expired` arriving before `order.succeeded` still ends in a successful state |
| Success after expiry | An expired entry point whose payment later succeeds is not treated as a failure |
| Refunds | The same `refundMerchantOrderId` twice does not refund twice; refund status syncs |
| Subscriptions | First payment, trial, renewal, `past_due`, customer portal, and cancellation each move entitlements correctly |
| Disputes | `dispute.created`, `dispute.updated`, `dispute.won`, `dispute.lost`, `dispute.closed` route into manual handling |
### Additional checks for subscriptions
| Scenario | Confirm |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| First subscription | A recurring price completes a first subscription through Hosted Checkout, and the Subscription, Invoice, and Order IDs are all persisted |
| Free trial | Access opens only after `subscription.trialing`, and the first charge after the trial is not started twice |
| `past_due` | It is not treated as paid, and does not trigger a second concurrent charge |
| Cancellation | End-of-period and immediate cancellation end access at different times, and immediate cancellation is not described as an automatic refund |
| Renewal idempotency | Renewals are idempotent on `invoiceId`; duplicate or out-of-order `invoice.paid` does not extend access twice |
### Additional checks for discounts
| Scenario | Confirm |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every code path | Visible input, hidden pre-applied, invalid code, expired code, minimum spend not met, product mismatch, and fixed-amount currency mismatch are each tested |
| Discount duration | `once`, `repeating`, and `forever` produce the expected number of periods both with and without a free trial |
| Amount display | The frontend shows only the original price, discount, and amount due returned by the backend — nothing recalculated locally |
The only sandbox test data published today is the success card `4242 4242 4242 4242`. There is no public test data for `failed`, 3DS, or `pending`.
Those three rows therefore cannot currently be reproduced in the sandbox, so cover them with code review and unit tests instead. Read the `pending` path carefully: confirm the code does not treat it as a failure and start a second charge, which is the most common cause of double billing.
## What to change for production
| Item | From | To |
| ---------------------------------------- | ------------------------------------- | ------------------------------------------ |
| Dashboard | `https://uat-dashboard.clinkbill.com` | `https://dashboard.clinkbill.com` |
| API | `https://uat-api.clinkbill.com` | `https://api.clinkbill.com` |
| Secret Key | `sk_uat_…` | `sk_prod_…` |
| Publishable Key | `pk_uat_…` | `pk_prod_…` |
| Webhook URL | Temporary tunnel address | The production domain |
| Webhook signing key | The sandbox one | Newly generated on the production endpoint |
| `successUrl` / `cancelUrl` / `returnUrl` | Local or sandbox domains | Production domain |
Everything above is environment-specific configuration. **The account itself does not change** — sandbox and production share one login, and switching happens through **Enter production** in the top right of the sandbox dashboard. There is no second registration.
Production keys must be initialized in the **production dashboard**, and the webhook endpoint registered again in production with a different signing key. Miss this and every production event fails signature verification — quietly. The endpoint keeps returning 401, customers pay, and nothing ever ships.
After launch, run one small real transaction end to end and confirm payment, webhook, fulfillment, and reconciliation all work before opening the traffic.
## Debugging
### The customer says they paid, but the order did not change
Work down this list:
1. Look for the transaction on the **Transactions** page — in the dashboard for the matching environment, since sandbox and production are separate
2. If it is not there, call `GET /checkout/session/{id}` with the `sessionId` and read `status` and `orderId`
3. If there is an `orderId`, call `GET /order/{id}` and check whether Order `status` is `success`
4. If the Order is `success` but the local order did not update, the problem is in the webhook path — keep reading
### Webhooks never arrive
| Check | Notes |
| ----------------------------------- | ---------------------------------------------------------- |
| Is the URL public HTTPS | localhost, private IPs, and loopback are rejected |
| Is the endpoint enabled | Check **Developers > Webhooks** |
| Are the events subscribed | Subscribing only to `session.*` means no `order.succeeded` |
| What the receiving endpoint returns | Anything other than 2xx counts as a failure and is retried |
| Is a firewall or WAF blocking it | Check whether the requests reach the access logs at all |
### Signature verification always fails
The three usual causes:
* The parsed body was used instead of the raw one (`express.json()` in Express)
* The signing key does not belong to the current endpoint — it was rotated, or copied from the sandbox
* The `.` between timestamp and body is missing, or a self-generated timestamp was used instead of the header value
### A customer was charged twice
Check these:
* Whether the create-order endpoint guards against double submission, and whether one cart can produce two orders
* Whether `pending` was treated as failure and triggered an automatic retry
* Whether webhooks are deduplicated by `event.id`
`merchantReferenceId` is not an idempotency key. Creating two Sessions with the same value produces two distinct Sessions, and both can be paid.
### Production account verification was rejected
**Settings** shows the specific reason. Correct the details and resubmit. Integration work is unaffected — keep building in the sandbox.
## After launch
When money becomes withdrawable and how fees are calculated.
The refund flow and status synchronization.
# Merchant
Source: https://docs.clinkbill.com/guides/account/merchant
The operating unit for your business.
## Overview
When you create an account with Clink, we automatically create a company account and an associated merchant account for you. A company account can have multiple merchants, which is useful if you operate separate product lines or maintain multiple brands.
A merchant account is the primary operating unit within Clink. Your API key and all related data are tied to your merchant account. Importantly, customer data is not shared between different merchants, even if they belong to the same company account. Each merchant maintains its own independent settings to ensure a customized customer experience.
On the dashboard, all displayed data corresponds to the currently selected merchant, which can be changed using the dropdown menu in the top-left corner.
## Update Merchant
You can update your merchant profile by navigating to **Settings** -> **Merchant**. From the merchant list, select the merchant you wish to update.
The merchant name and logo you set will be visible to your customers on the checkout page and customer portal. Each merchant has a unique merchant ID that cannot be modified.
The timezone setting determines how your data is displayed and when statements are generated.
## Create New Merchant
To create a new merchant:
1. Navigate to **Settings** -> **Merchant**
2. Click the **Add** button
3. Complete the required form
Note that Clink must review and approve your request before the new merchant can accept payments.
## Disable Merchant
To disable a merchant (which prevents it from accepting payments):
1. Go to **Settings** -> **Merchant**
2. Locate the merchant you want to disable
3. Click the overflow menu button (⋮)
4. Select **Disable** from the dropdown menu
# User
Source: https://docs.clinkbill.com/guides/account/user
Manage dashboard users and permissions
## Overview
A user represents a dashboard account owner. When you create an account with Clink, we automatically create an administrative account using your email address, along with the associated company and merchant accounts.
## Profile
To access your profile settings, click the avatar icon in the top-right corner and select **Profile** from the dropdown menu.
### Basic Information
In the Profile page, you can update your:
* Avatar
* Nickname
* Phone number
Note: User ID and email address cannot be modified.
### Security Settings
This section allows you to:
* Update your password
* Configure or reset Multi-Factor Authentication (MFA)
### Active Sessions
View your current active sessions, including login IP addresses and browser information. You can remotely terminate specific sessions if needed.
## User Management
User management is handled at the company account level. Administrators can access user management by navigating to **Settings** -> **Users**. This page displays all users under the company account, including their roles and basic information.
### Creating New Users
To add a new user:
1. Navigate to **Settings** -> **Users**
2. Click the **Add** button
3. Complete the required form
1. Assign at least one role
2. Grant access to at least one merchant
After creation, the new user will receive a verification email containing a secure link. By following this link, they can set up their password and activate their account. Once completed, they will be able to access the dashboard.
### Security Management
Administrators can help users with security-related issues by:
* Resetting passwords
* Resetting MFA settings
To perform these actions:
1. Locate the user
2. Click the overflow menu button (···)
3. Select either **Reset Password** or **Reset MFA**
### Editing User Permissions
To modify a user's role or merchant access:
1. Click the overflow menu button (···)
2. Select **Edit**
3. Update the desired settings
### Disabling Users
To disable a user account:
1. Locate the user in the list
2. Toggle the **Active** switch to OFF
This action will immediately terminate all active sessions for that user.
## Role Types
Clink offers four distinct roles with varying permission levels. Users can be assigned multiple roles.
| Menu | Admin | Developer | Operations | Finance |
| :------------ | :---------- | :---------- | :----------- | :-------- |
| Transactions | Full Access | Read Only | Full Access | Full |
| Balances | Full Access | No Access | No Access | Full |
| Customers | Full Access | Read Only | Limited | Read Only |
| Subscriptions | Full Access | Read Only | Full Access | Read Only |
| Products | Full Access | Read Only | Full Access | Read Only |
| Developers | Full Access | Full Access | No Access | No Access |
| Settings | Full Access | Limited | Limited | Limited |
## Merchant Access
Merchants are the operational units within Clink. Users can be granted access to multiple merchants, and they will maintain their role-based permissions across all assigned merchants.
# Skill Marketplace
Source: https://docs.clinkbill.com/guides/agent/skill_marketplace
Prepare, submit, publish, and manage Skills in the Clink Skill Marketplace.
Clink Skill Marketplace lets merchants publish reviewed Skills for users to discover, inspect, and install. Use **Developers > Skill Marketplace** in the Clink Dashboard to upload packages, follow review progress, publish approved versions, and maintain public listing content.
Only Skills in the **Published** state appear in the public Marketplace. A Skill in **Ready** has passed review but still requires a merchant to publish it.
## Publishing lifecycle
Create a ZIP package that contains the current `SKILL.md`, then prepare the Skill name, version, category, and marketplace summary.
Upload the ZIP package from the Dashboard. Clink checks the package and submitted marketplace information.
A successful review moves the Skill to **Ready**. If the review fails, use the failure details to fix the package and upload it again.
Review the public content, then select **Publish** to make the Skill visible in the public Marketplace.
## Status reference
| Status | Meaning | Next action | Publicly visible |
| :------------ | :----------------------------------------------------------- | :------------------------------------------------------------ | :--------------: |
| **Uploaded** | The ZIP package was submitted and is waiting for processing. | View the submission and wait for review. | No |
| **Polishing** | Clink is processing or reviewing the Skill. | Wait for the review result. | No |
| **Ready** | The Skill passed review but has not been published. | Review the listing and select **Publish**. | No |
| **Published** | The Skill is available in the public Marketplace. | Manage the listing, submit an update, or unpublish it. | Yes |
| **Failed** | One or more review checks did not pass. | Review the failure details, fix the package, and reupload it. | No |
## Before you begin
| Item | What to prepare |
| :------------------ | :---------------------------------------------------------------------------------------------------------------- |
| Merchant account | An account that can access the Clink Dashboard. The published Skill belongs to the current merchant or publisher. |
| Skill package | A ZIP package containing the files required by the Skill. |
| `SKILL.md` | The current Skill instructions. The declared version must match the version submitted in the form. |
| Marketplace content | A clear name, version, category, and one-sentence summary. |
| Tips configuration | Optional suggested amounts and a public note. Tips are displayed only after the required Clink check passes. |
Merchants do not enter or edit the public CLI command or installation Prompt. Clink generates both from the reviewed Skill package and displays them as read-only installation methods.
## Publish a Skill
### Prepare the package
Before uploading, confirm that:
* The ZIP package opens successfully and contains `SKILL.md`.
* The version declared in `SKILL.md` exactly matches the version you plan to submit.
* The summary explains what the Skill does and who should use it.
* The package contains enough accurate instructions for Clink to generate its installation methods.
* Payment-trigger and user-confirmation behavior is clear if the Skill uses Agent Payment.
* The required tip payment handler is present if you plan to enable Tips.
### Submit the Skill
Sign in to the Clink Dashboard and go to **Developers > Skill Marketplace**. Select **Upload skill**.
Choose the package you want to submit. Clink checks the ZIP structure and `SKILL.md` during review.
Enter the public marketplace information.
| Field | Purpose |
| :---------------------- | :------------------------------------------------------------------ |
| **Skill Name** | Displayed on public cards and the Skill detail page. |
| **Version** | Used for the listing and version history. It must match `SKILL.md`. |
| **Category** | Used for public filtering and the category label. |
| **Marketplace summary** | Displayed on the public list and at the top of the detail page. |
Enable **Tips** only when the Skill supports the required tip payment flow. Add suggested amounts and a public note, then submit the package.
Follow the status in **My skills**. The review can include version comparison, package schema, security, and Agent Payment checks. Clink also generates the public CLI command and Prompt from the reviewed package.
Enabling Tips in the form does not make the Tips panel public by itself. The related Clink check must also pass.
### Review and publish
When the Skill reaches **Ready**:
1. Select **View** and check the submitted listing, generated installation methods, overview cards, Tips configuration, and version information.
2. Use **Preview public listing** to review how the publisher's Skills appear in the public Marketplace.
3. Return to **My skills** and select **Publish**.
The generated CLI command and Prompt are read-only. If either method is inaccurate, correct the relevant package content and submit a new version for review instead of editing the generated text directly.
## Manage a published Skill
### Public Marketplace presentation
The public list contains only **Published** Skills. A list card can include the Skill name, category, current version, summary, capabilities, publisher, Users metric, verification badge, generated installation Prompt, and an eligible CLI tips label.
The public detail page can include the listing metadata, generated CLI and Prompt installation methods, overview cards, `SKILL.md`, package files, version history, and eligible Tips content.
### Review and maintain content
Select **View** for a Skill to inspect its listing.
| Content | How it is managed |
| :--------------------- | :--------------------------------------------------------------------------------------------------------------------- |
| Submitted listing | The merchant provides the category, version, and marketplace summary. |
| Install methods | Clink generates the CLI command and Prompt from the reviewed package. They are read-only for merchants. |
| Public detail overview | Clink-generated overview cards can be edited, added, or removed by the merchant. |
| Tips | The merchant can manage suggested amounts and the public note; public display also depends on the related Clink check. |
| Version timeline | Shows published versions, dates, status labels, and merchant-provided version notes. |
| Marketplace metrics | Shows the Users metric reported by the Marketplace. |
### Submit a version update
You can submit an update for a **Ready** or **Published** Skill.
Update the Skill files and `SKILL.md`, then create a new ZIP package.
Open the Skill details and select **Submit update**.
Enter a version that is newer than the current version and matches `SKILL.md`. Add a concise version note for the version timeline.
Review the listing and Tips configuration, then submit the package. Clink reviews the new package and regenerates its CLI command and Prompt.
Submitting an update does not immediately replace the public package. The new version must pass review before it can become the public version.
### Unpublish a Skill
Select **Unpublish** for a **Published** Skill to remove it from the public Marketplace. The Skill remains available in the merchant Dashboard, where the current status and available actions indicate whether it can be published again.
Unpublishing removes the Skill from the public list and public detail page. Confirm that this is the intended user impact before continuing.
## Handle Skill payment webhooks
Some Marketplace Skills require users to purchase additional credits before they can continue. After Clink completes the recharge order, the merchant webhook must apply the result to the merchant account and return the corresponding account response.
Before a merchant enters this flow, Clink Marketplace has already registered the order-event webhook and prepared the required webhook configuration. Merchants do not create or update the endpoint, manually change event subscriptions, or manage signing-secret rotation. This section covers only the webhook handler logic that the merchant server must implement.
### Successful payment event
When the event type is `order.succeeded`, reconcile the order, find the merchant account by `data.object.customerEmail`, apply the successful recharge, and return HTTP `200` with `account.reloaded` or `account.created`.
### Secure processing sequence
Verify `X-Clink-Timestamp`, `X-Clink-Signature`, and `X-Clink-SignType` against the raw request body before reading or acting on the event. Reject requests with an invalid signature.
Prefer the event `id` as the idempotency key and store the original response for retries. If the merchant also stores `merchantReferenceId` and `sessionId`, match both when both are present and quarantine mismatches instead of applying credits.
Continue to account resolution only when the event type is `order.succeeded`. Use `data.object.customerEmail` to identify the merchant account.
If the email belongs to a user already registered with the merchant, apply the recharge once and prepare `account.reloaded`. If no account exists, the merchant first creates the user account, applies the purchased credits as its initial recharge, and prepares `account.created`.
Commit the local order state, account creation or recharge, and idempotency record before returning HTTP `200`. A retry must return the same stored response without creating another account or applying credits again.
Do not treat HTTP `200` as the recharge itself. Return success only after the merchant account change is committed. If local fulfillment fails, return a non-2xx response so the event can be retried safely.
### Account response fields
The API reference names the examples `accountReloaded` and `accountCreated`. The JSON `type` values are `account.reloaded` and `account.created`.
| Response field | Source or requirement |
| :------------------- | :-------------------------------------------------------------------------------------------------- |
| `object` | Always `event`. |
| `type` | `account.reloaded` for an existing merchant account, or `account.created` after creating a new one. |
| `data.customerEmail` | Must match incoming `data.object.customerEmail`. |
| `data.webSite` | The merchant's absolute website URL, including the `http` or `https` scheme. |
| `data.userId` | The user ID in the merchant system, not the Clink `customerId`. |
| `data.amount` | Copy incoming `data.object.amountTotal`. |
| `data.currency` | Copy incoming `data.object.paymentCurrency`. |
```json Existing merchant account theme={null}
{
"object": "event",
"type": "account.reloaded",
"data": {
"customerEmail": "customer@example.com",
"webSite": "https://merchant.example.com",
"userId": "usr_xxxxx",
"amount": 19.99,
"currency": "USD"
}
}
```
```json New merchant account theme={null}
{
"object": "event",
"type": "account.created",
"data": {
"customerEmail": "customer@example.com",
"webSite": "https://merchant.example.com",
"userId": "usr_xxxxx",
"amount": 19.99,
"currency": "USD"
}
}
```
### Retry and ordering requirements
* Process the same event more than once without duplicating account creation or credits.
* Store the response associated with the event and return it again for duplicate deliveries.
* Tolerate out-of-order deliveries and apply the recharge only after processing a valid `order.succeeded` event.
* Use a unique merchant-account constraint for normalized customer email so concurrent deliveries cannot create duplicate users.
* Record the order ID, event ID, resolved merchant user ID, applied amount and currency, and final account response for audit and recovery.
## Resolve review failures
A Skill enters **Failed** when one or more review checks do not pass. Failed Skills remain private and are not available in the public Marketplace.
### Read the review feedback
| Detail | Meaning |
| :----------------------- | :------------------------------------------------------------ |
| Failure reason | A summary and explanation of the issue. |
| Failed check | The review check that did not pass. |
| Detected in | The file or configuration location associated with the issue. |
| Review run | The identifier for the review attempt. |
| Fix suggestion and steps | Recommended changes for the package. |
| Impact | How the issue prevents publication or affects the Skill. |
| Next step | Instructions for packaging and resubmitting the Skill. |
### Fix and reupload
Use **Failed check** and **Detected in** to find the affected file or configuration.
Update the package using the review feedback. If payment behavior is involved, make the trigger and user-confirmation logic explicit and verifiable.
Confirm that the ZIP opens, includes the current `SKILL.md`, and uses the same version in the package and submission form.
Select **Reupload**, choose the corrected ZIP package, and submit it for another review.
## Publication checklist
### Package and technical content
* [ ] The ZIP package opens successfully and contains `SKILL.md`.
* [ ] The submitted version matches the version declared in `SKILL.md`.
* [ ] The package contains accurate content for Clink to generate the CLI command and Prompt.
* [ ] Agent Payment behavior is clear and reviewable when used.
* [ ] A reviewable tip payment handler is present when Tips are enabled.
### Marketplace content
* [ ] The Skill name is concise and recognizable.
* [ ] The category supports accurate discovery.
* [ ] The summary explains the Skill's main outcome.
* [ ] Generated installation methods were reviewed as part of the listing preview.
* [ ] Overview cards describe real capabilities and prerequisites.
* [ ] A version note is included for an update.
### Before publishing
* [ ] The Skill status is **Ready**.
* [ ] The listing, generated installation methods, overview content, Tips, and version information were reviewed.
* [ ] The public listing preview was checked.
* [ ] **Publish** is selected only after the public content is ready.
## Frequently asked questions
Only **Published** Skills appear publicly. **Uploaded**, **Polishing**, **Ready**, and **Failed** Skills remain private. A **Ready** Skill still needs to be published by the merchant.
No. Clink generates both installation methods from the reviewed package. Correct the relevant package content and submit a new version when the generated result needs to change.
Tips must be enabled by the merchant and pass the related Clink check before the public CLI tips content is displayed.
No. The submitted version must match the version declared in the package's `SKILL.md`. An update must also use a version newer than the current one.
Overview cards and Tips configuration can be maintained from the Skill details. Package or generated installation-method changes should be submitted as a new version for review.
Review timelines, marketplace fees, settlement, revenue sharing, and refund policies are not defined by this guide. Use the latest Clink commercial terms or contact Clink support for current policy information.
# Customer Portal
Source: https://docs.clinkbill.com/guides/billing/customer_portal
Empower customers to manage their own subscriptions and billing
## Overview
The Customer Portal enables self-service management of subscriptions, payment methods, and billing information. Customers access the portal through secure, time-limited magic links generated either via API or email verification.
## Features
### Subscription Management
Customers can view subscription details (product, pricing, renewal dates, payment method) and perform updates or cancellations as needed.
### Payment Methods
Customers can:
* Set default payment methods
* Update billing addresses
* Remove non-default payment methods, or default payment method when no active subscriptions exist
### Billing Information
Customers can update invoice-related billing details separate from payment method information.
### Invoice History
Customers can download PDF copies of subscription-generated [invoices](/guides/resources/subscription#invoice) from the Billing History section.
## Access Options
### Email Verification
1. Obtain your unique portal link from **Settings** → **Merchant** → **Customer Portal**
2. Configure portal settings and save
3. Share the link ending with *cpc\_xxxxx* with customers
4. Customers enter their registered email to receive a magic link
### API Integration
Generate magic links programmatically:
To limit the products customers can choose when upgrading or downgrading subscriptions in this session, pass `subscriptionUpdateProductIds`. If omitted or empty, the session uses your Customer Portal subscription management configuration without an additional product allowlist.
Always test your code thoroughly before deploying to production!
```json Generate access link via API theme={null}
curl --location --request POST 'https://uat-api.clinkbill.com/api/billing/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerId": "cus_euuqxrz3sqlo",
"subscriptionUpdateProductIds": [
"prd_001",
"prd_002"
]
}'
```
# Checkout Session
Source: https://docs.clinkbill.com/guides/payments/checkout_session
A hosted checkout page experience
## Overview
A Checkout Session is dynamically generated through the POST Session API. The API provides a time-limited link to a Clink-hosted checkout page with pre-filled customer information. We recommend creating a new Session for each customer payment intention.
Each checkout session can accommodate multiple payment attempts until a successful transaction is completed. Successful transactions can represent either one-time purchases or subscriptions.
## Session Data
### Status
* Open: The checkout session is created but no successful payment has been received
* Complete: The checkout session has concluded with a successful payment
* Expired: The checkout session has expired and no further payment attempts are allowed
### Product & Price
If you have configured products and prices in the dashboard, you can simply reference them using their IDs. For subscription-based recurring payments, pre-created products are mandatory.
For one-time purchase products, you can define product details (name, unit price, quantity, etc.) in the priceDataList. The checkout session will display these product details accordingly.
### Amount Units
`originalAmount` and `priceDataList[].unitAmount` are expressed in the **major currency unit**, not the minor unit. Send `19.99` for USD 19.99. Sending `1999` is interpreted as USD 1999.
Currencies with no decimal places — JPY, KRW, IDR — accept integers only.
### Subscription Scheduled Phases
For subscription checkout sessions, send `scheduledPhases` as a top-level request field when you want the subscription created after checkout to change plans at future renewal boundaries.
The initial subscription is still created from `productId` and `priceId`. Each scheduled phase references a subscription `priceSnapshotId`, sets the target `quantity`, and uses `effectiveCycle` to say which renewal cycle activates the phase. `sequence` and `effectiveCycle` both start at `1` and must be strictly increasing. You can include up to 10 phases.
Use the `priceSnapshotId` returned by the Product or Price APIs. Phase snapshots must be subscription prices with valid recurring details and must support the checkout payment currency. `metadata` is optional and is limited to 20 keys, 40 characters per key, and 500 characters per value.
### Customer
Checkout sessions include pre-filled customer information. Provide at least one of `customerId`, `customerEmail`, `referenceCustomerId`, or a complete `historicalPaymentInstrumentImport` object. If none is provided, the request fails with `CUSTOMER_NOT_FOUND`.
`referenceCustomerId` is the merchant-side customer ID. It can be used to locate, validate, create, or bind a Clink customer.
When `customerId` is provided, it is the primary identifier. Clink first looks up the customer by `customerId`. If `customerEmail` is also provided, it must match that customer. If `referenceCustomerId` is also provided, it must match that customer; when the customer does not already have a `referenceCustomerId` and the reference ID is not used by another customer, Clink automatically binds it to the customer.
When `customerId` is not provided, Clink resolves the customer by `customerEmail` and/or `referenceCustomerId`:
* Only `customerEmail`: Clink finds an existing customer by email, or creates a new customer if none exists.
* Only `referenceCustomerId`: Clink finds an existing customer by the merchant-side customer ID, or creates a new customer if none exists.
* Both `customerEmail` and `referenceCustomerId`: both identifiers must resolve to the same customer. If the email resolves to customer A and the reference ID resolves to customer B, and A is not B, the request fails with `CUSTOMER_IDENTIFIER_NOT_MATCHED`. If only one identifier matches an existing customer, the request also fails with `CUSTOMER_IDENTIFIER_NOT_MATCHED`. If neither identifier matches an existing customer, Clink creates a new customer with both identifiers.
When `customerId`, `customerEmail`, and `referenceCustomerId` are all omitted, Clink can use a complete `historicalPaymentInstrumentImport` object to resolve or create the customer.
The checkout session stores the resolved `customerId`. `customerEmail`, `referenceCustomerId`, and `historicalPaymentInstrumentImport` are used to locate, validate, create, or bind the customer.
### Historical Payment Instrument Import
To resolve or create a customer and import payment instruments previously saved at a merchant-owned hosted Stripe channel, provide `historicalPaymentInstrumentImport` when creating the checkout session. Other channel types are not currently supported.
Both `channelAlias` and `channelCustomerReference` are required when `historicalPaymentInstrumentImport` is provided:
* `channelAlias`: the alias of the merchant-owned hosted Stripe channel.
* `channelCustomerReference`: the Stripe customer ID assigned by that channel.
* `paymentMethodTypes`: optional payment method types to import. Supported values are `CARD` and `CASHAPP`.
If `paymentMethodTypes` is omitted or empty, Clink uses the server-side payment method set allowed for the merchant and channel.
`CASHAPP` takes effect only when it is enabled for both the merchant and the channel. Otherwise, Clink filters it out without failing the request.
```json theme={null}
{
"historicalPaymentInstrumentImport": {
"channelAlias": "mcht_xxx-stripe-m1",
"channelCustomerReference": "cus_xxx",
"paymentMethodTypes": [
"CARD"
]
}
}
```
### URLs
Use `uiMode` to control how the checkout session is rendered:
* `hostedPage`: hosted checkout page
* `elements`: embedded checkout
For hosted checkout, we strongly recommend providing `successUrl` and `cancelUrl` for post-checkout navigation.
When `uiMode` is `elements`, `returnUrl` is required.
### Direct QR Launch
`directPaymentQrCodePaymentMethodType` controls whether the QR code payment flow is launched directly when opening checkout.
Currently supports `CASHAPP` and `QRIS`.
This parameter takes effect only when:
* `paymentMethodType` is set to the same value as `directPaymentQrCodePaymentMethodType`
* The selected payment method is available in the checkout session
Behavior:
* When effective, checkout directly initiates the QR code payment flow
* In no-password scenarios, payment will not be automatically completed
Ignored when:
* The selected payment method is not available in checkout
* `paymentMethodType` does not match `directPaymentQrCodePaymentMethodType`
Allowed values:
* `CASHAPP`
* `QRIS`
### Promotion Code Display
Set `allowPromotionCodes` to `true` to enable promotion code support in checkout. By default, checkout shows the promotion code input.
To apply a promotion code without showing the input box, set `showPromotionCode` to `false` and provide `promotionCode`. In this mode, `promotionCode` is required and Clink validates it when the session is created.
### Local Price Only
`localPriceOnly` applies only to one-time payments. Set it to `true` when you want checkout to prioritize price options that match the customer's local currencies resolved from the checkout access IP. By default, this setting is `false`.
When `localPriceOnly` is enabled for a one-time payment, checkout shows local price options first. If no local price option can be shown, Clink falls back to the original pricing currency when it is available. This setting does not apply to subscription checkout sessions.
### Merchant Reference
The merchant reference serves as your internal identifier for tracking purposes. This reference will be recorded on orders created through the checkout session.
**Idempotency**: Clink does not maintain idempotency based on merchant reference IDs.
These IDs are solely for reconciling Sessions with your internal systems. Multiple checkout sessions created with the same merchant reference ID will be treated as distinct sessions.
## Return and Redirect URLs
For hosted checkout sessions created with `uiMode: hostedPage`, Clink redirects customers to `successUrl` after a successful payment and to `cancelUrl` when they leave the flow.
During the hosted success redirect, Clink appends the session ID as a URL parameter, allowing you to retrieve session data via the Session#Get API.
Hosted success URL examples:
* https\://your\_success\_url.com?sessionId=sess\_randoms
* https\://your\_success\_url.com?custom=xxxxxxxx\&sessionId=sess\_randoms
For embedded checkout sessions created with `uiMode: elements`, `returnUrl` is required. You can include `{ELEMENTS_SESSION_ID}` in the URL, and Clink will replace it with the created session ID before returning control to your site.
Example `returnUrl`:
https\://YOUR\_DOMAIN/complete.html?session\_id=
## Customer Experience
Clink provides a streamlined solution with a standardized checkout experience. While customization options are limited, the interface maintains a clean, professional design.
Merchant information and the cancel URL are accessible from the top left. Product and price information is displayed based on either dashboard configurations or API inputs.
Available payment methods and currencies adapt automatically based on:
* Purchase type (one-time or subscription)
* Customer's geographical location
For one-time payments, create the checkout session with `localPriceOnly: true` to prioritize the customer's local currencies. If no local price option can be shown, Clink falls back to the original pricing currency when it is available.
## Quick Start Examples
Reference these code snippets to get started:
Always test your code thoroughly before deploying to production!
```json One-time Purchase Without Product theme={null}
curl --location --request POST 'https://api.clinkbill.com/api/checkout/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerEmail": "customer@example.com",
"originalAmount": ${amount},
"originalCurrency": "USD",
"uiMode": "hostedPage",
"priceDataList":[
{
"name":"A one-time purchase",
"quantity": 1,
"unitAmount":${unitAmount},
"currency":"USD"
}
]
}'
```
```json Purchase With Pre-created Product theme={null}
curl --location --request POST 'https://api.clinkbill.com/api/checkout/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerEmail": "customer@example.com",
"originalAmount": ${amount},
"originalCurrency": "USD",
"uiMode": "hostedPage",
"priceId": "${price_id}",
"productId": "${prd_id}"
}'
```
```json Hidden Promotion Code theme={null}
curl --location --request POST 'https://api.clinkbill.com/api/checkout/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerId": "cus_xxxxx",
"originalAmount": 19.99,
"originalCurrency": "USD",
"uiMode": "hostedPage",
"priceId": "price_xxxxx",
"productId": "prd_xxxxx",
"allowPromotionCodes": true,
"showPromotionCode": false,
"promotionCode": "SAVE10"
}'
```
```json Subscription With Scheduled Phases theme={null}
curl --location --request POST 'https://api.clinkbill.com/api/checkout/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerId": "cus_xxxxx",
"originalAmount": ${amount},
"originalCurrency": "USD",
"uiMode": "hostedPage",
"priceId": "${initial_price_id}",
"productId": "${prd_id}",
"successUrl": "https://YOUR_DOMAIN/success",
"cancelUrl": "https://YOUR_DOMAIN/cancel",
"scheduledPhases": [
{
"sequence": 1,
"effectiveCycle": 2,
"priceSnapshotId": "${growth_price_snapshot_id}",
"quantity": 1,
"metadata": {
"source": "checkout_schedule"
}
},
{
"sequence": 2,
"effectiveCycle": 4,
"priceSnapshotId": "${scale_price_snapshot_id}",
"quantity": 2
}
]
}'
```
```json Embedded Checkout (Elements) theme={null}
curl --location --request POST 'https://api.clinkbill.com/api/checkout/session' \
--header 'X-Timestamp: ${currentMillisecondsTimestamp}' \
--header 'X-API-Key: ${sk_key}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerEmail": "customer@example.com",
"originalAmount": ${amount},
"originalCurrency": "USD",
"uiMode": "elements",
"returnUrl": "https://YOUR_DOMAIN/complete.html?session_id={ELEMENTS_SESSION_ID}",
"priceDataList":[
{
"name":"Embedded checkout purchase",
"quantity": 1,
"unitAmount":${unitAmount},
"currency":"USD"
}
]
}'
```
# Currencies
Source: https://docs.clinkbill.com/guides/payments/currencies
Enable customers to pay in their local currency
## Overview
[Checkout Session](/guides/payments/checkout_session) provides a unified hosted checkout experience. Where the merchant business model, settlement configuration, payment channel, and target currency all support it, Checkout can display or process prices in that currency. Subscriptions generally require a fixed price configured per currency in advance. The actual supported range follows the merchant contract and channel configuration.
Offering local currency support provides several advantages:
* Higher conversion rates: Customers prefer to pay in familiar currencies
* Access to local payment methods: Many local payment methods only support their native currency
* Market-specific pricing: Implement competitive pricing strategies for different markets
* Cost reduction: Avoid foreign exchange fees that some payment providers charge customers
## Customer Experience
When customers access your checkout page from a location with a currency different from your defined price currencies, they'll see both their local currency and your default currency. Currency options are determined based on the customer's public IP address location.
If the customer's local currency matches one of your defined price currencies, only that currency will be shown.
For one-time payments, set `localPriceOnly` to `true` when creating the checkout session to prioritize local currency price options. Clink resolves the customer's local currencies from the checkout access IP. If no local price option can be shown, Clink falls back to the original pricing currency when it is available. This setting does not apply to subscription checkout sessions.
## Pre-defined Local Currency Prices
Merchants can configure products with multiple local currencies to meet various business needs.
Clink allows you to set multiple currencies for a single **price**. To configure this, visit the [Multi-currency Pricing](/guides/resources/product#multi-currency-pricing) section on the product page.
After configuration, simply use a **single Price ID** to create checkout sessions. Customers will automatically see your pre-defined price in their local currency, with no additional conversion needed.
**Subscription**: To support local currencies in recurring payments, you must configure them in advance through price settings. Adaptive pricing is not available.
**Settlement Currency**: For payments made in a pre-defined local currency different from your settlement currency, Clink automatically handles conversion during settlement.
If a customer's local currency isn't included in your configuration, adaptive pricing will be used instead.
## Adaptive Pricing
As mentioned in the [Customer Experience](#customer-experience) section, customers will see their local currency alongside your default price currency. For multi-currency price, only the primary currency will be shown.
**Settlement Currency**: With adaptive pricing, orders settle in your primary price currency. Clink manages all customer-side currency conversions during payment processing.
### Restrictions
Adaptive Pricing is not available for:
* Subscription payments: Due to exchange rate fluctuations and the need for customer agreement on renewal amounts
* Sessions where the customer's currency is already covered by pre-defined price currencies
## API One-time Payments
When creating a [one-time payment](/api-reference/endpoint/create-payment) by API, use `paymentCurrency` for the currency you want to charge the customer in. If `paymentCurrency` is omitted, the payment uses the original pricing currency. For direct amount payments, `currency` is still required as the original pricing currency. For price-based payments, Clink uses the currency from the price.
Clink first uses a configured fixed multi-currency price when available. If no fixed price exists, Clink automatically converts the amount from the original pricing currency to the requested payment currency. The selected payment method must support the requested `paymentCurrency`.
For `CASHAPP`, `GCASH`, `TNG`, `WECHAT`, `KAKAO`, `ALIPAY`, `QRIS`, and `PROMPTPAY`, you can omit `paymentInstrumentId`; Clink automatically creates the payment instrument and returns its ID in the response. Other payment methods still require an existing `paymentInstrumentId`.
```json Specify payment currency theme={null}
{
"customerId": "cus_xxxxx",
"productId": "prd_xxxxx",
"priceId": "price_xxxxx",
"paymentInstrumentId": "pi_xxxxx",
"paymentMethodType": "CARD",
"paymentCurrency": "HKD",
"returnUrl": "https://merchant.example.com/payment/return"
}
```
## Supported Currencies
Clink supports these major currencies:
| | | |
| :----------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------- |
| United States Dollar (USD) | Euro (EUR) | Chinese Yuan (CNY) |
| Canadian Dollar (CAD) | Japanese Yen (JPY) | Australian Dollar (AUD) |
| Singapore Dollar (SGD) | Hong Kong Dollar (HKD) | South Korean Won (KRW) |
| British Pound (GBP) | UAE Dirham (AED) | Thai Baht (THB) |
| Indonesian Rupiah (IDR) | Philippine Peso (PHP) | Malaysian Ringgit (MYR) |
| Brazilian Real (BRL) | Indian Rupee (INR) | |
# Link External Account
Source: https://docs.clinkbill.com/guides/payments/link_psp
Connect with your existing payment processors
## Overview
Clink is fully **PCI DSS 4.0.1 compliant**, enabling secure connections with your existing payment service providers to process customer payments, renewals, and subscriptions.
We support multiple external providers and multiple accounts per provider, with configurable routing rules to manage transaction flows according to your needs.
## Connection Management
### Add a Connection
Prerequisites:
* An active account with the target payment provider
* Card payment method enabled with raw PAN payment capability
* If required, contact us for an Attestation of Compliance (AoC) report
Steps to add a connection:
1. Go to **Settings** -> **Merchant** (select the relevant merchant account if you have multiple)
2. Click **Linked Payment Services Providers**, then **New Connection**
3. Complete the connection form:
1. Select your provider from the dropdown list
2. Enter your API Key (we apply PCI requirements on API Key persistence)
3. Provide your Channel Merchant ID
* For AirWallex: Use the Client ID associated with your API Key
* For Stripe: Optional - use account name (without spaces) or account ID for reference
4. Enter the API endpoint URL
* Some providers (e.g., Adyen) offer account-specific endpoints
* Others (e.g., Stripe, AirWallex) use a common endpoint - refer to their developer guide
5. Set up webhook integration:
* Copy the **Clink Generated Webhook URL**
* Create a webhook endpoint in **your provider's dashboard**
* Ensure payment-related events are selected
6. Copy the webhook signature from your provider and paste it into **Webhook Signature Key**
7. Click **Confirm** to save
### Edit Connection
To modify an existing connection:
1. Navigate to **Settings** -> **Merchant** -> **Linked Payment Services Providers**
2. Find the connection you want to update
3. Click the **Edit** button on the right
4. Make your changes and click **Confirm**
All changes take effect immediately.
### Delete Connection
To remove an existing connection:
1. Navigate to **Settings** -> **Merchant** -> **Linked Payment Services Providers**
2. Locate the connection to delete
3. Click the **Delete** button on the right
4. Confirm by clicking **OK**
Without an active connection, all customer payments and subscription renewals will fail.
# Coupon
Source: https://docs.clinkbill.com/guides/resources/coupon
Create and manage discounts for bills, subscriptions, and customer accounts.
Coupons in Clink allow you to offer discounts to your customers. These can be applied to individual bills, specific subscriptions, or an entire customer account.
**Key Concept:** It is helpful to understand the two-layer structure:
* **The Coupon (The Rule):** Defines *what* the discount is (e.g., "\$2.50 off") and its validity.
* **The Promotion Code (The Key):** The specific string (e.g., `SUMMER2025`) a customer enters. A single Coupon can have multiple Promotion Codes.
## Creating a Coupon
To set up a new discount rule, follow these steps:
Go to **Products > Coupon** in the sidebar and click the **Add** button.
Fill in the primary details for the discount rule.
| Field | Description |
| :------------------------ | :---------------------------------------------------------------------------------- |
| **Name** | Internal name (e.g., "Black Friday"). This appears on customer receipts. |
| **Type** | Choose **Percentage off** (%) or **Fixed amount off** (\$). |
| **Applied to** | Select scope: **None** (Total invoice), **Product** (Specific items), or **Price**. |
| **Subscription Duration** | **Once** (First invoice only) or **Repeating** (All future invoices). |
At the bottom of the form, configure high-level restrictions:
* **Exchange deadline:** The date range during which the coupon is valid.
* **Limit total redemptions:** The total number of times this coupon can be used globally.
* **Minimum amount requirement:** Specifies the minimum transaction amount required to redeem this coupon.
Click **Add** to create the Coupon.
***
## Managing Promotion Codes
Once a Coupon is created, you must generate codes so customers can redeem it.
1. Click on the **Name** of the coupon you just created in the list.
2. Scroll down to the **Promotion code** section.
3. Click the small button on the right side.
### Code Configuration
When adding a promotional code, you can customize specific constraints:
Enter a custom code (e.g., `WELCOME10`) or leave blank to auto-generate a random string.
Check **Limited to specific customers** to restrict redemption to specific Customer IDs.
Set how many times *this specific code* can be redeemed (distinct from the global coupon limit).
Set an **Expires on** date specific to this code.
***
## Monitoring & Management
### The Coupon List
Navigate to **Products > Coupon** for an overview. You can filter by discount type or duration.
* **Terms:** Shows the rule (e.g., "\$2.5 off once").
* **Exchange:** Shows the current redemption count.
### Coupon Details
Clicking into a specific Coupon provides a detailed dashboard.
**Disabling Codes:** If a code is compromised, you can click the **Disable** button next to the specific code in the "Promotion code" table. This invalidates the code without deleting the parent Coupon.
### Editing & Deleting
* **Rename:** You can rename a coupon at any time via the **Rename Coupon** button.
* **Delete:** Clicking **Delete coupon** will remove the discount rule.
Deleting a coupon will permanently disable all associated promotion codes immediately.
# Customer
Source: https://docs.clinkbill.com/guides/resources/customer
Managing your customer base
## Overview
Customers are a core resource within Clink, representing your real-world clients. Each customer profile contains essential information including personal details, billing information (including payment methods), and tax data.
## Customer Management
A customer record is required for all purchases and subscriptions. When a customer doesn't exist in the system, Clink can automatically create one using the information you provide.
### Creating a Customer
To create a customer through the dashboard:
1. Navigate to the **Customers** tab
2. Click the **Add** button
3. Complete the required profile information:
1. Customer name
2. Email address
4. Click **Save** to create the customer record
### Editing Customer Information
To update customer details:
1. Go to the **Customers** tab
2. Find the customer using their customer ID or email address
3. Click to open their profile page
4. Click the **Edit** button in the top-right corner
5. Make your desired changes
6. Click **Confirm** to save the updates
### Deleting a Customer
To delete a customer:
1. Open the customer's profile page
2. Click the **Delete** button in the top-right corner
Note: Deleting a customer will:
* Cancel all active subscriptions
* Preserve historical payment records and invoices
* Not affect past transaction data
## Customer Data
### Profile Information
The customer profile includes:
* Billing address (if not provided, the address used during checkout becomes the default)
* Language preference (affects email communications and invoice language)
### Payment History
View a comprehensive list of the customer's payment attempts. Click any record to view detailed [transaction](/guides/resources/order) information.
### Payment Methods
Displays a list of the customer's active and valid payment methods. For PCI compliance, only limited payment method details are visible.
### Subscriptions
Shows all customer subscriptions and associated invoice data. Click any subscription to view detailed [subscription](/guides/resources/subscription) information.
# Order
Source: https://docs.clinkbill.com/guides/resources/order
Understanding payment transactions
## Overview
An Order represents a payment transaction in the system. Orders are created for both customer-initiated payments and automated subscription renewals, regardless of whether the payment succeeds or fails. Every payment attempt generates an order record.
## Order Management
### Listing and Querying
Orders can be viewed under the **Transactions** tab. By default, orders are listed chronologically based on their receipt time. You can filter orders using the following criteria:
* Amount: The monetary value of the order
* Currency: The payment currency used
* Customer Email: The email address associated with the order
* Card Last Four: The last four digits of the payment card (when applicable)
* Created Date: The time period when orders were created
* Status: The current order status (multiple status selections allowed)
### Order Details
Clicking on any order from the list will display its detailed information, including:
* Timeline: A chronological overview of the order's lifecycle
* Payment Method: Comprehensive card information, including:
* Last four digits
* Card type, issuer, and issuing country
* Billing address (when provided)
* Additional card details
* Processing Details: Critical transaction information, including:
* Authorization code
* CVV verification result
* Address Verification Service (AVS) result
* 3D Secure authentication result
* Failure Reason: For failed orders, Clink exposes a standardized `failureCode` and `failureMessage` when available, so you can handle failures consistently across payment channels.
* Payment Breakdown: Detailed breakdown of the order amount
* Product Information: List of products included in the order
* Events & Logs: Chronological timeline of all events throughout the order lifecycle
* Customer: Essential customer information related to the payment
# Product & Price
Source: https://docs.clinkbill.com/guides/resources/product
Managing products and pricing structures
## Overview
Products and Prices are core resources within Clink that define what you sell and how much you charge for it. These entities integrate seamlessly with subscriptions, invoices, and checkout sessions.
## Product Management
Access your product list through the **Products** tab.
### Creating a Product
To create a new product:
1. Navigate to the **Products** tab and click **Add**
2. Enter the product name and upload a product image
3. Click **Add Price** to set pricing options:
1. Choose between *Recurring* or *One-off* price types
2. For recurring prices, select a *Billing Period* (Daily, Weekly, Monthly, Quarterly, Half-yearly, Yearly, or Custom)
3. Optionally enable *Free Trial* and specify the trial duration
4. Set as *Default Price* if desired
5. Add additional pricing tiers as needed
4. Select the appropriate *Tax Category* from the dropdown menu
### Create Products by API
You can upload a product image with the [Upload Product Image API](/api-reference/endpoint/upload-product-image) before creating a product. The API returns an `ossId` that you pass as `image` when calling [Create Product API](/api-reference/endpoint/create-product).
You can also create products through the [Create Product API](/api-reference/endpoint/create-product). The API supports creating a product together with its prices and accepts localized product names through `localizedNames`.
Use localized names when you want checkout and billing surfaces to display a customer-facing product name in different languages. The default `name` remains the fallback value.
### Editing Products
To modify an existing product:
1. Go to the **Products** tab
2. Find the product you want to edit
3. Click the **Edit** button
4. Make your changes
5. Click **Confirm** to save
### Archiving Products
When you archive a product, it becomes unavailable for new subscriptions while existing subscriptions remain active until canceled.
To archive a product:
1. Navigate to the **Products** tab
2. Locate the target product
3. Click **Archive** and confirm in the popup window
To restore an archived product, follow the same steps but click **Unarchive** instead.
## Price Management
Products can have single or multiple pricing options, combining both recurring and one-off prices. For example:
A starter plan could offer:
* \$5.99 weekly
* \$39.99 quarterly
* \$69.99 half-yearly
* \$15.99 monthly
* \$119.99 yearly
* \$29.99 one-time purchase
Recurring prices support these interval values: `day`, `week`, `month`, `quarter`, `half_year`, `year`, and `custom`. For custom intervals, use `interval_count` to define the number of days in the billing cycle.
### Multi-currency Pricing
Multi-currency pricing allows you to set localized prices for different markets using a single price configuration (only one price ID). For example:
* Default price: \$5.00 USD per week
* European price: €3.00 EUR per week
* Japanese price: ¥680 JPY per week
The payment currency and your settlement currency can differ. Clink calculates the settlement amount from the settlement configuration currently in effect for your merchant account and the applicable exchange rate. Which payment currencies and settlement currencies are available, and how conversion is applied, depend on your contract, business model, settlement account, and channel configuration — your contract and the settings in your dashboard are authoritative.
### Automatic Currency Conversion
For **One-off** prices without specific multi-currency settings, Clink automatically offers local currency options to international customers during checkout.
### Edit Price
Price changes in Clink are managed through a snapshot system. While merchants cannot view or edit these snapshots directly, each price update creates a new snapshot. Here's how price changes affect your subscriptions or one-time sales:
* New subscriptions and purchases will use the updated price
* Existing subscriptions will continue with their original price until canceled
* Price history is maintained automatically through snapshots
Price API responses include `priceSnapshotId`, which is the currently active snapshot for that price. Product API responses also include `priceSnapshotId` on each item in `priceList`. Use this snapshot ID when another API needs an immutable price reference, such as scheduled subscription phases.
To edit a price:
1. Navigate to the **Products** tab and locate your target product
2. Expand the price list using the arrow on the left, or click the **Edit** button
3. Find the price you want to update and click its overflow menu button (⋮)
4. Select **Edit** from the menu
5. Make your desired changes on the edit page
6. Click **Confirm** to save your changes
# Refund
Source: https://docs.clinkbill.com/guides/resources/refund
Refund and Chargeback Policies
## Overview
Merchants have complete control over their refund policies. However, Clink reserves the right to process refunds on merchants' behalf to mitigate chargeback risks.
## Refund
As merchants best understand their business needs, they have the flexibility to implement their own refund policies. Refunds can be processed at any time through the Clink dashboard, and the refunded amount will be automatically deducted from the merchant's balance account.
While Clink respects merchant-defined policies, we maintain the right to issue refunds on merchants' behalf when there are chargeback concerns. It's important to note that regardless of merchant or Clink actions, customers always retain the right to initiate chargebacks through their issuers.
### Issuing a Refund
To issue a refund through the Clink dashboard:
1. Navigate to the **Transactions** tab in the menu panel
2. Locate the target order for refund
3. Click the **Refund** button in the top right corner
4. Complete the refund form:
1. The maximum refundable amount is automatically calculated based on the order's current status and previous refunds
2. Select one of the four refund reasons from the dropdown menu
3. Add a description if needed
5. Click the **Confirm** button
The **Refund** button may be unavailable for the following reasons:
* Payment was unsuccessful
* Order has been fully refunded
* Current refundable amount is zero
* Payment method doesn't support refunds
Refund requests will be rejected if you have insufficient funds in your balance.
## Chargeback
A chargeback (also known as a dispute) occurs when customers request their card issuers to reverse a transaction. Common reasons include unauthorized charges and service dissatisfaction.
When an issuer approves a chargeback request, the transaction amount is automatically withdrawn from the merchant's account, typically accompanied by a processing fee ranging from US\$15 to US\$20. Merchants can choose to either accept or challenge the chargeback.
Clink's involvement in chargeback cases is limited by regulations. While we will assist merchants in collecting evidence for disputes when necessary, the final decision lies with the card issuer and card scheme.
To maintain platform reputation and protect customer interests, Clink reserves the right to implement preventive measures, including account suspension, for merchants experiencing unusually high chargeback rates.
For more detailed information about chargebacks, we recommend Stripe's comprehensive guide: [Chargebacks 101](https://stripe.com/resources/more/chargebacks-101)
# Subscription
Source: https://docs.clinkbill.com/guides/resources/subscription
Understanding the recurring payment contract
## Overview
A subscription is an agreement where a customer pays a recurring fee (for example daily, weekly, monthly, quarterly, half-yearly, yearly, or custom) to access a product, service, or content.
Key characteristics of subscriptions include:
* Recurring Payments: Customers are billed regularly.
* Continuous Access: Subscribers receive ongoing access to the product or service as long as payments are made.
* Flexibility: Subscriptions may offer options to upgrade, downgrade, or cancel at any time, depending on the provider's terms.
## Subscription Data
### Status
* Incomplete: The subscription was created, but the checkout session was not paid successfully.
* Incomplete Expired: The subscription is incomplete and has been closed because the checkout session expired.
* Active: The subscription is active with no outstanding bills.
* Trialing: The subscription is in a free trial period. No payments have been attempted yet, but the customer has provided payment information.
* Past Due: Payment attempts for renewal have failed, but the subscription remains active and will retry.
* Canceled: The subscription has been terminated.
### [Product & Price](/guides/resources/product)
The product represents the service that customer and merchant have agreed upon. The price contains information about the billing cycle and the payment amount to maintain the agreement.
Supported recurring intervals are `day`, `week`, `month`, `quarter`, `half_year`, `year`, and `custom`. Custom intervals use `interval_count` to define the cycle length in days.
### [Customer](/guides/resources/customer)
A subscription contains a customer ID, representing the real-world client who agrees to pay periodically for the merchant service. The customer owns the payment instrument used for recurring payments.
If a customer deletes the payment instrument associated with the subscription but has other payment instruments available, Clink will use the default payment instrument for renewal.
## Subscription Management
### Create a Subscription by API
Use the [Create Subscription API](/api-reference/endpoint/create-subscription) to create a subscription directly. The request requires a product, a recurring price, a payment method type, a payment currency, and at least one customer identifier.
For `CASHAPP`, `GCASH`, you can omit `paymentInstrumentId`; Clink automatically creates the payment instrument and returns its ID in the response. Other payment methods still require an existing `paymentInstrumentId`.
You can also include `scheduledPhases` when creating a subscription to schedule future plan changes at billing period boundaries. Each phase uses a target `priceSnapshotId`, `quantity`, and an `effectiveCycle` that counts renewals from subscription creation. The subscription response returns remaining `scheduledPhases` and `elapsedCycles`, so you can tell how many billing cycles have already passed before the next scheduled change takes effect.
Scheduled phases must be ordered by strictly increasing `sequence` and `effectiveCycle`, with a maximum of 10 phases. The target price snapshot must support the subscription's `paymentCurrency`.
### Listing and Querying
Subscriptions can be viewed under the **Subscriptions** tab. By default, subscriptions are listed chronologically based on their creation time. You can filter subscriptions using the following criteria:
* Amount: The monetary value of the product
* Currency: The currency used in the agreement
* Customer ID: The customer ID associated with the subscription
* Created Date: The time period when subscriptions were created
* Status: The current subscription status
### Subscription Detail
Clicking on any subscription from the list will display its detailed information, including:
* Product & Price
* Customer and the payment instrument
* Invoices and the billing cycle coverage
### Cancel a Subscription
To cancel a subscription:
1. Go to the **Subscriptions** tab
2. Locate the subscription using the search function and open the details page
3. Click on the **Action** button on the top right
4. Select **Cancel subscription** from the dropdown list
5. Select the reason for cancellation in the popup window
6. Click the **OK** button
### Downgrade or Upgrade a Subscription
Use the [Preview Subscription Update API](/api-reference/endpoint/preview-subscription-update) to calculate the target plan, proration, tax, discount, and whether the change takes effect immediately. The target price must be a recurring price owned by your merchant account and support the subscription's payment currency.
After reviewing the preview, call the [Confirm Subscription Update API](/api-reference/endpoint/confirm-subscription-update) with the returned `priceSnapshotId` and optional `promotionCode`. Immediate updates may require payment and can return a next action; period-end updates are scheduled for the next billing boundary. If you need to revoke a pending period-end change before it takes effect, call the [Cancel Subscription Update API](/api-reference/endpoint/cancel-subscription-update).
Customers can still use the customer portal for self-service plan changes when your portal configuration allows it.
**Refund**: Canceling a subscription will not issue a prorated refund automatically.
For example, if your service is usage-based metering, you will need to calculate the refund amount. If a refund is required upon cancellation, please submit a [refund](/guides/resources/refund) request.
## Invoice
An invoice is generated on every billing cycle for the subscription. Customers can download invoices for expense purposes, proof of payments, etc.
### Status
* Open: The initial status of an invoice, indicating it is waiting for payment.
* Paid: The bill amount has been received.
* Void: The invoice is closed but unpaid.
### Invoice Management
Invoices are generated automatically when the service is initially subscribed to or when renewal is triggered. We do not currently support manual invoice creation.
Invoices are strongly tied to subscriptions. To view the list of invoices, navigate to the subscription details page and scroll down.
# How Payments Work
Source: https://docs.clinkbill.com/how-payments-work
Checkout Session, Order, and the merchant order — what each one actually means.
## What actually happens during a payment
The frontend sends the product and quantity to the merchant backend. It does not call Clink.
Record what was bought, for how much, and by whom. This record is the source of truth in the merchant system, and every status below hangs off it.
Clink returns a `sessionId` and a `url`. That `url` is the checkout page.
Choosing a payment method, entering card details, and passing 3DS all happen on Clink's page. Card numbers never reach the merchant.
`order.succeeded` on success, `order.failed` on failure.
Shipping, top-ups, and access are granted only after the event is received, verified, and matched to the merchant order.
Step 2 is the one people skip. Using Clink's Session as the order record breaks reconciliation later: Clink knows someone paid 19.99 USD, but not which user bought which item.
## Three things called "order"
Three things in those six steps can all be called an order: the **Checkout Session** Clink creates, the **Order** the customer's payment produces, and the merchant backend's own record. Similar names, completely different jobs.
| Object | What it is | Created by | Used for |
| ---------------- | ------------------------------------------------------- | ----------------------------- | -------------------------------------------------- |
| Checkout Session | One checkout visit — a time-limited payment entry point | Clink, on an API call | Getting the customer to a checkout page |
| Order | The result of one payment attempt | Clink, when the customer pays | Deciding whether the money actually arrived |
| Merchant order | The business record in the merchant database | The merchant backend | Deciding whether to fulfill, and reconciling later |
One Session can produce several Orders. A customer whose first card is declined and who then pays with a second card produces two Orders under the same Session. So read the Order to determine the payment result, not the Session.
## Reading the statuses
### Checkout Session
`status` reports whether the payment entry point is still usable:
| Session `status` | Meaning | What to do |
| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `open` | Still valid, payment is still possible | Wait for the customer, or send them back to checkout |
| `completed` | The session flow finished; no further attempts accepted | Whether money was collected comes from the Order (or the Invoice, for subscriptions) |
| `expired` | The entry point expired; no new attempts | Create a new Session to keep collecting |
`paymentStatus` separately reports `unpaid`, `processing`, or `paid`, which is convenient for showing progress in the UI. For reconciliation, still read the Order.
`expired` does not mean the payment failed. A customer may have started a payment moments before expiry, and that Order can still succeed afterwards. Do not mark the merchant order as failed just because the Session expired.
`completed` does not mean money arrived either. A successful one-time payment does move the Session to `completed`, but a subscription with a free trial also completes its session without a single charge. The status only says the entry point is done.
### Order
`status` is the payment result, and the field that maps onto the merchant business status:
| Order `status` | Meaning | What to do |
| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `success` | Payment succeeded | Fulfill, idempotently |
| `pending` | In progress, no result yet | Wait for the webhook, or poll `GET /order/{id}`. **Do not charge again** |
| `failed` | Payment failed | Record `failureCode` and `failureMessage`; let the customer retry once nothing is in flight |
| `requires_action` | The customer must do one more step, such as 3DS | Use the returned `action` to guide them through it |
| `partial_refunded` | Partially refunded | Update the refunded total |
| `refunded` | Fully refunded | Revoke access or run the returns flow |
`pending` causes the most damage. It means "not known yet", not "failed". Starting a second charge here is how customers get billed twice.
## What counts as grounds for fulfillment
Exactly one thing: the backend received a signature-verified `order.succeeded` event, or polled `GET /order/{id}` and got `success`, and that Order matches the merchant order record.
These four look like the money arrived. On their own, none of them count:
* The customer landed on the `successUrl` — anyone can type that address
* The Session `status` became `completed` — that only says the entry point closed
* The webhook returned HTTP 200 — that only tells Clink something was received
* A frontend SDK fired `complete` or `session-success` — browser events can be forged
## Two environments
Clink has a sandbox and a production environment. The sandbox is what people usually call the test environment; no real money moves.
| | Sandbox | Production |
| ------------ | ------------------------------------- | --------------------------------- |
| Dashboard | `https://uat-dashboard.clinkbill.com` | `https://dashboard.clinkbill.com` |
| API | `https://uat-api.clinkbill.com` | `https://api.clinkbill.com` |
| Key prefixes | `sk_uat_` / `pk_uat_` | `sk_prod_` / `pk_prod_` |
| Money | No real funds move | Real charges |
The two environments are separate dashboards. Sandbox payment-domain resources and environment-specific configuration are not copied to production, including products, customers, orders, API keys, webhook endpoints, signing keys, and verification applications and details. On first access to production, Clink synchronizes the tenant, merchant, user sign-in credentials, and role assignments required for production access.
The sandbox is for payment integration and test transactions and does not collect production account-verification details. Do not use it for load testing, and do not put real customer names, emails, or card numbers into it. Test data cannot be used as production data.
During development, log in to the sandbox dashboard at `uat-dashboard.clinkbill.com`. Keys initialized in the production dashboard start with `sk_prod_`, and using one against the sandbox API fails authentication with an error that does not mention environments at all.
Test card `4242 4242 4242 4242`, any 3-digit CVC, any future expiry. It works in the sandbox only.
## Next
Follow along and see a successful order in about fifteen minutes.
Redirect to checkout, or embed it in a merchant-owned page.
# Introduction
Source: https://docs.clinkbill.com/index
Clink aims to provide a simplified but solid subscription and payment solution.
To get started, please [contact us](https://www.clinkbill.com/contact).
## Integration path
These five pages, in order, cover a payment from checkout through to fulfillment. For anyone new to payment integrations, the first one is not optional.
What a Session, an Order, and a merchant order each mean.
Run a first payment in the sandbox.
Redirect to checkout, or embed it in a merchant-owned page.
What to write on the backend, the frontend, and the webhook.
The checklist before switching to production.
## As needed
Pages to reach for when they apply, rather than read in order.
For embedding payment inputs in a custom-designed checkout page.
When an agent is already writing the code, let it do the integration.
For recurring billing.
For discounts and promo codes.
Key rotation, IP restrictions, signatures, and delivery rules.
## Guides and Resources
How to manage a dashboard account.
API and webhook details.
Explanation of basic concepts.
Understand the balance and get paid.
# API Keys & Webhooks
Source: https://docs.clinkbill.com/integration
Generating, rotating, and restricting API keys; registering, verifying, and receiving webhooks.
This page is the configuration reference. To run one payment end to end, start with the [Quickstart](/quickstart); for implementation code, see [Hosted Checkout](/build-integration).
## Environments
Clink has a sandbox and a production environment. The sandbox is what people usually call the test environment.
| | Sandbox | Production |
| --------------- | ------------------------------------- | --------------------------------- |
| Dashboard | `https://uat-dashboard.clinkbill.com` | `https://dashboard.clinkbill.com` |
| API | `https://uat-api.clinkbill.com` | `https://api.clinkbill.com` |
| Secret Key | `sk_uat_…` | `sk_prod_…` |
| Publishable Key | `pk_uat_…` | `pk_prod_…` |
These are two separate dashboards. Sandbox payment-domain resources and environment-specific configuration are not copied to production, including products, customers, orders, API keys, webhook endpoints, signing keys, and verification applications and details. On first access to production, Clink synchronizes the tenant, merchant, user sign-in credentials, and role assignments required for production access. Debug thoroughly in the sandbox before deploying to production, and do not use real customer data in the sandbox.
## API keys
Clink authenticates requests with two headers:
```json theme={null}
{
"X-API-Key": "sk_uat_*********************",
"X-Timestamp": "${currentMillisecondsTimestamp}"
}
```
`X-Timestamp` is a millisecond timestamp. **In production, it must be within 2 minutes of platform time**, ahead or behind. Compute it per request rather than hardcoding or caching one.
**The sandbox is far more lenient** — currently ±24 hours. That is a testing convenience, not an interface guarantee, and it can be tightened at any time.
Passing in the sandbox therefore does not mean passing in production. A cached timestamp, a hardcoded one, or an unsynced server clock all sail through the sandbox and then fail authentication across the board once traffic moves to production. Generate the value at call time, and keep the server clock on NTP.
Both values are hot-reloadable configuration and may change.
### Initialize a key
Go to **Developers** and click **Initialize Key**. The Secret Key is displayed once — copy and store it.
### The two key types
**Secret Key** (`sk_`) authenticates server-side API calls. By default it is unrestricted and can perform any API request, including taking payments and issuing refunds.
The Secret Key belongs on the server only. Keep it out of frontend code, out of version control, and out of app bundles.
**Publishable Key** (`pk_`) is used in the browser. It can reach exactly two client-side SDK endpoints:
* `/api/sdk/bootstrap`
* `/api/checkout/session/verify-merchant`
Every other `/api/**` endpoint requires the Secret Key. A Publishable Key therefore cannot create Checkout Sessions, read orders, or issue refunds; those operations are server-side only.
### Rotate a key
Rotating revokes the current key and generates a replacement. The old key can be expired immediately or given a window to allow a gradual cutover.
Go to **Developers**.
Click the overflow button (⋮) on the key's row and select **Roll Key**.
Pick how long the old key stays valid. In production, leave an overlap so the new key can be deployed before the old one dies.
Copy the new value from the dialog. It cannot be retrieved later.
### Delete a key
Deleting stops API calls with that key immediately. A key that is the last valid one on the account **cannot** be deleted.
Rotate to a new key first, update the code, confirm production is using it, then delete the old one.
### Restrict by IP
Only Secret Keys support IP restrictions. Requests can be limited to specific IPs, or to a range using [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing).
Click the overflow button (⋮) on the key's row, select **Manage IP Restrictions**, enable *Restrict usage to a set of IP addresses*, then add IPs or CIDR ranges one at a time.
Update this whenever the server's egress IP changes, or every API request starts failing.
## Webhooks
Webhooks are how Clink pushes results out. Payment results, refunds, and subscription renewals all reach the merchant server this way.
### Register an endpoint
**In the dashboard**: go to **Developers > Webhooks**, click **Add**, enter an HTTPS URL, and select the events to subscribe to.
**Through the API**: call [`PUT /webhook/endpoints/ensure`](/api-reference/endpoint/ensure-webhook-endpoint) with `url` and `events`. This endpoint is idempotent on the URL — calling it repeatedly updates rather than duplicating, which makes it suitable for deployment scripts.
**Through the CLI**: `clink-integ-cli` combines registration and secret sync into one step:
```bash theme={null}
export CLINK_INTEG_CLI=/path/to/clink-integ-skills/vendor/clink-integ-cli/clink-integ-cli
node "$CLINK_INTEG_CLI" webhook endpoint ensure \
--url https://your-site.com/api/webhooks/clink \
--events session.complete,session.expired,order.created,order.next_action,order.succeeded,order.failed,refund.created,refund.succeeded,refund.failed \
--save-secret \
--sync-env-file .env.local \
--json
```
**Do not use `--events core`.** It subscribes to six events — `session.complete`, `order.succeeded`, `order.failed`, `refund.succeeded`, `subscription.created`, `invoice.paid` — and **omits `order.created` and `order.next_action`**.
Without `order.created` there is no basis for ordering payment attempts; without `order.next_action`, orders that need extra customer verification stay stuck in a pending state. The multi-attempt model in [Hosted Checkout](/build-integration) cannot run on `core`.
That is why the nine event names are listed explicitly above. Once `clink-integ-cli` ships a `checkout` or `commerce` preset, `--events checkout` can replace the list.
`--save-secret` stores the returned signing secret in the CLI profile and `--sync-env-file` writes it into the given env file. **A restart or redeploy is still required** before the service loads it. Rerun this whenever the webhook URL changes, then sync and restart again.
The tool ships with [clink-integ-skills](https://github.com/clinkbillcom/clink-integ-skills). It is not the npm package [`@clink-ai/clink-cli`](/api-reference/clink_cli), which is the customer wallet CLI — both start with `clink`, so check which one is installed.
The webhook URL must be publicly reachable over HTTPS. localhost, loopback, private, link-local, and multicast addresses are all rejected.
`GET /webhook/events` returns the currently supported event names. Register with event names, not numeric codes.
When the API returns or rotates a `signingSecret`, store it in the server-side secret manager, then restart or redeploy.
### Verify signatures
Clink signs every event with HMAC SHA-256. The signing key becomes available once an endpoint is registered.
Three headers carry the signature:
| Header | Contains |
| ------------------- | ---------------------------------- |
| `X-Clink-Timestamp` | Unix timestamp in **milliseconds** |
| `X-Clink-Signature` | Clink's signature, hex encoded |
| `X-Clink-SignType` | The algorithm; currently `SHA256` |
Run these in order:
`X-Clink-SignType` must be `SHA256`. Reject anything else rather than computing further.
`X-Clink-Timestamp` is a Unix millisecond timestamp. Require `abs(now - timestamp) <= 5 minutes`.
Reject anything outside the window, anything non-numeric, and anything arriving while the receiving clock is badly skewed. Keep the server on NTP.
The timestamp as a string, then the character `.`, then the raw request body exactly as received.
HMAC-SHA256 with the signing key, hex encoded.
Process the event only if it matches `X-Clink-Signature`. **The comparison must be constant-time** (`crypto.timingSafeEqual` on Node); a plain `===` leaks how far the byte-by-byte match got.
**Both the freshness check and the HMAC must complete before the JSON is parsed, against the raw body.** Parsing and re-serializing changes key order and whitespace, and the signature will never match.
Note also that the time window stops replays but **not duplicates** — Clink's own retries land inside it. Deduplicate separately and atomically on `event.id`; see [Hosted Checkout](/build-integration).
Copy-pasteable verification code is in the "Verifying the signature" section of [Hosted Checkout](/build-integration).
### Delivery rules
Non-2xx responses and timeouts both count as failed delivery. After the first failure, delivery is retried with exponential backoff.
Under the current policy the waits are approximately **2, 4, 8, 16, 32, 64, 128, 256, and 512 minutes**, for a maximum of **10 HTTP delivery attempts including the first**. If delivery keeps failing, the last actual HTTP attempt lands roughly **17 hours** after the first, and the event then moves to the dead-letter queue.
Those nine waits total 1022 minutes, about 17 hours 2 minutes. Queue scheduling introduces small deviations.
Retry intervals are platform configuration and may change. **Do not hardcode them** into business logic, alert thresholds, or compensation job schedules, and do not depend on minute-level precision.
Where a safety net is needed, schedule the reconciliation pass after the retry window closes — for example, if an event has still not arrived about **18 hours** after it was first due, pull the state through `GET /order/{orderId}` or a similar endpoint.
**Retry exhaustion must be evaluated per event, not from the total outage duration.** An event may have exhausted all automatic attempts when recovery occurs roughly **17 hours** or more after that event's first delivery. Events first delivered shortly before recovery may still be inside their retry window.
For example, in a 20-hour outage the events first delivered at the start are long past their last attempt, while events first delivered an hour before recovery will still be retried. The same outage puts them in completely different positions.
After recovery, reconciling the **entire outage interval** remains the conservative operational policy. Check the Orders, Invoices, Subscriptions, and Refunds inside it through the server-side query or list endpoints rather than assuming a push will fill the gap. Where an operational rule needs a round number, **18 hours** is a safe rounding of the 17 hour 2 minute window — not a separate platform threshold.
Individual events can be resent from the dashboard: open the transaction detail, find the **Webhook delivery** list, and resend to a specific endpoint. That is a one-at-a-time action; there is no bulk replay.
**Events are not guaranteed to arrive in the order they occurred.** Handlers must not depend on ordering, and an earlier event must never overwrite a later state.
Because of retries, the same event arrives more than once. Deduplicate on the event's `id` field.
### Common events
| Resource | Events |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session | `session.complete`, `session.expired` |
| Order | `order.created`, `order.next_action`, `order.succeeded`, `order.failed` |
| Refund | `refund.created`, `refund.succeeded`, `refund.failed` |
| Subscription | `subscription.created`, `subscription.trialing`, `subscription.activated`, `subscription.past_due`, `subscription.cancelled`, `subscription.incomplete_expired`, `subscription.updated.plan_changed`, `subscription.updated.plan_change_canceled`, `subscription.updated.renewed`, `subscription.updated.cancel_at_period_end_set`, `subscription.updated.cancel_at_period_end_revoked` |
| Invoice | `invoice.open`, `invoice.paid`, `invoice.void` |
| Dispute | `dispute.created`, `dispute.updated`, `dispute.won`, `dispute.lost`, `dispute.closed` |
| Customer | `customer.verify` |
For Hosted Checkout, explicitly dispatch both Session terminal events. `session.complete` updates only the Session lifecycle to `completed`; `session.expired` updates it only to `expired`. Store the outer `event.created` as the Session lifecycle version: an older terminal event cannot overwrite a newer one, and two different terminal states at the same millisecond require durable manual review. Neither event is evidence that an Order succeeded or failed, so payment, refund, fulfillment, and Payment Attempt state remain separate.
Payload structures are in the [Webhook reference](/api-reference/webhook/order). Endpoint management is in [Webhook Endpoint Management](/api-reference/endpoint/list-webhook-endpoints).
### Rotate the signing secret
Call [`POST /webhook/endpoints/{id}/rotate-secret`](/api-reference/endpoint/rotate-webhook-signing-secret).
After rotating, update the signing key on the server and restart. Otherwise every newly delivered event fails verification.
## The dashboard
Day-to-day operations happen in the dashboard:
The business operating unit.
A dashboard account owner.
The selling unit and pricing configuration.
Account balances and fees.
# Discounts and Promotion Codes
Source: https://docs.clinkbill.com/promotions
Creating coupons and codes, and applying them across every integration path.
Discounts in Clink have two layers. Once those are clear, the rest of the configuration follows.
## The two layers
**A Coupon is the discount rule** — how much comes off, which items it applies to, how many periods it lasts, and the total redemption cap.
**A Promotion Code is the string the customer types** — or one the merchant attaches in advance.
Creating a Coupon does not give customers anything to enter. **At least one Promotion Code must exist under that Coupon** before anyone can redeem it.
Everywhere `promotionCode` appears below, it is the **customer-facing code string** such as `WELCOME20` — never a `couponId` or `promotionCodeId`.
## Creating them
One call can create a Coupon together with its codes:
```json theme={null}
{
"couponName": "Welcome offer",
"discountType": "percentage",
"percentage": 20,
"applyType": "product",
"applicableProducts": ["prd_xxxxx"],
"durationType": "repeating",
"durationMonths": 3,
"promotionCodes": [
{
"code": "WELCOME20",
"firstOrderOnly": true,
"maxRedemptionLimit": 100
}
]
}
```
Field rules:
* `discountType` is `percentage` or `fixed_amount`. A percentage must be above 0 and no more than 100
* Fixed amounts use `fixedAmounts` keyed by currency, still in the **major currency unit** (19.99 is `19.99`)
* `applyType` is `none`, `product`, or `price`. The last two require the matching IDs
* `durationType` is `once`, `repeating`, or `forever`
* A promotion code is 1 to 32 letters or digits. Omit `code` and the platform can generate one
* Validity uses 13-digit Unix millisecond timestamps. A code's end time never exceeds its Coupon's end time
**The name `durationMonths` is misleading.** It counts **billing periods**, not calendar months. On a monthly subscription `3` means three months; on a weekly one it means three weeks.
To add codes to an existing Coupon, use [`POST /promotion-code/{couponId}`](/api-reference/endpoint/create-promotion-code). Full fields are in [Create coupon](/api-reference/endpoint/create-coupon).
## Applying them per integration path
| Situation | Configuration or call | What the customer sees |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Hosted Checkout, customer enters the code | Send `allowPromotionCodes: true` when creating the Session | The input is shown by default; the customer can apply or remove a code |
| Pre-applied, not editable | `allowPromotionCodes: true` + `showPromotionCode: false` + `promotionCode: "WELCOME20"` | Validated at Session creation; checkout shows the discount but no editable input |
| Elements | Enable `allowPromotionCodes` on the Session; the frontend calls `promoCodeChange({ type: "apply", code })` or `({ type: "clear" })` | The input is built by the merchant, taking totals from `amount-change` and errors from `promo-code-error` |
| Direct payment | Send `promotionCode` on `POST /payment` | The backend applies the discount before charging |
| Direct subscription | Send `promotionCode` on `POST /subscription` | The discount lands on the first invoice; later periods follow the Coupon duration |
| Plan changes | Send **the same** `promotionCode` on both preview and confirm | Preview returns the discount and proration; confirm applies it |
The full Elements event code lives in [Elements](/elements) — it is not duplicated here.
## Validation rules
These catch people out most often:
**Inline items only work with unrestricted Coupons.** Items built from `priceDataList` have no `productId` or `priceId`, so they can only use a Coupon with `applyType: none`. A Coupon restricted to a product or price fails validation.
**A fixed-amount Coupon must cover the order currency.** If the order's original currency has no amount in `fixedAmounts`, the Coupon does not apply. A discount larger than the order reduces it to 0 rather than going negative.
**Percentage discounts round down to the currency's maximum decimal places.** Do not recompute on the frontend — display the amount returned by the Session, the preview endpoint, or the Elements `amount-change` event.
**`firstOrderOnly` is judged by whether that Customer has a successful Order.** Not by browser, not by email text, and not by visits to the merchant's pages.
**`minimumSpend` is checked against the order's original amount and currency.** If that currency is not configured, the condition is not met.
**Errors surface at different moments.** The hidden-code mode fails **when the Session is created**. The visible-input mode shows the error **when the customer clicks apply**.
**Redemptions are not counted manually.** The system reserves one when the order is created, confirms it on successful payment, and releases it on failure.
## How long a subscription discount lasts
| `durationType` | Actual behaviour |
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
| `once` | Covers **one actual paid period**. With a free trial, the trial does not consume it — the first paid period does |
| `repeating` | Covers `durationMonths` consecutive **billing periods**. The unit is periods, not calendar months |
| `forever` | Keeps applying to later renewals that match the product, price, and currency |
## Discounts produce no webhooks of their own
Discounts do **not** emit a separate "payment succeeded" event. Confirming money still works the same way:
* One-time payments: read the Order and Session result
* Subscriptions: read the Subscription and Invoice result
`couponId`, `promotionCode`, the original price, the discount, and the amount paid can all be stored locally for display and reconciliation. But **do not recalculate the discount after a webhook arrives** — the amounts Clink returns are authoritative.
## Limits on two fields
**Do not send an empty `restrictedCustomerIds` array.** Omit the field entirely when no customer restriction applies. An empty array triggers a containment check and leaves the promotion code unusable for everyone.
**`perCustomerRedemptionLimit` is not enforced.** The field can be written and read back, but it does not cap how many times an individual customer redeems. Where a per-customer limit is needed, count redemptions locally and decide there whether to allow the next one.
## Next
Recurring prices, subscription status, and renewals.
Resource definitions and dashboard management.
# Quickstart
Source: https://docs.clinkbill.com/quickstart
Run one complete payment in the sandbox, from creating a Session to confirming the money arrived.
The goal of this page is to produce an order with `status: success` in the sandbox. The sandbox is what people usually call the test environment — no real money moves.
[How Payments Work](/how-payments-work) makes this easier to follow, especially the difference between a Session and an Order.
## How this maps to the dashboard
The sandbox dashboard home page shows a three-step onboarding guide. This page follows those steps and adds the API calls and checks needed to complete a test transaction.
| This page | Step in the dashboard panel |
| --------- | --------------------------------------- |
| Step 1 | `Confirm your test purchase details` |
| Step 2 | `Integrate and complete a test payment` |
| Step 3 | `Ready for Go-Live` |
The steps unlock in order: complete the current step before opening the next one. The sandbox does not include account verification or collect real verification details. Account verification is a separate production-only process described in [Go Live](/go-live).
A successful Order can satisfy the dashboard's test-transaction check. **That does not mean the webhook is working** — confirm it separately in step 2 below.
## Before starting
Register a sandbox account first. Open [`uat-dashboard.clinkbill.com/auth/register`](https://uat-dashboard.clinkbill.com/auth/register) and fill in four fields: email, password, password again, and an **invite code**.
Without a code, email [contact@clinkbill.com](mailto:contact@clinkbill.com) to request one.
Log in straight after registering.
**Everything on this page happens in the sandbox dashboard**: `https://uat-dashboard.clinkbill.com`
Production is a separate address (`dashboard.clinkbill.com`). Keys initialized there start with `sk_prod_` and fail against the sandbox API used below.
## Step 1: Confirm your test purchase details
Decide how items are defined first. Everything below follows from this choice.
Describe the name, unit price, and quantity directly in `priceDataList` when creating the Session. Good for single purchases, top-ups, and one-off services — no product needs to exist first.
Create the product and price on the **Products** page first, then reference them by `productId` and `priceId`. Good for recurring goods, plans, and subscriptions.
**This page uses the first one**, because nothing has to be created up front. Both modes can coexist in one system — choose per item type. Full comparison in [Choose an Integration](/choose-integration).
## Step 2: Integrate and complete a test payment
Five parts. **Configure the webhook before paying**, so one payment verifies the whole chain.
### 2.1 Get a sandbox key
In the sandbox dashboard, go to **Developers** and click **Initialize Key**.
The Secret Key is shown in full exactly once. Copy it before closing the dialog.
Sandbox keys start with `sk_uat_`. A key starting with `sk_prod_` means the wrong dashboard.
The Secret Key belongs on the server only. Keep it out of frontend code, out of version control, and out of app bundles — anyone holding it can take payments and issue refunds through that account.
### 2.2 Configure the webhook
Clink pushes the payment result out. Only two sources establish that the money actually arrived: a **signature-verified webhook**, or the **server reading `success` from `GET /order/{orderId}`**. Browser redirects and frontend SDK events do not count — a customer can fake either one.
Go to **Developers > Webhooks**, click **Add**, enter a publicly reachable HTTPS URL, and select the events to subscribe to. For payments, subscribe to at least `order.succeeded` and `order.failed`.
Local development has no public address. Use a tunnel such as cloudflared to expose one temporarily. localhost, loopback, and private IPs are rejected.
```bash theme={null}
cloudflared tunnel --url http://127.0.0.1:3000 --no-autoupdate
```
Receiving the event is not the same as being ready to fulfill — signature verification, deduplication, and order matching are still required. See the "Receiving webhooks" section of [Hosted Checkout](/build-integration).
No service to receive requests yet? Skip this part for now and get checkout working first. The cost is one extra payment later, when webhooks get verified.
### 2.3 Create a Checkout Session
This runs as-is once the key is swapped in.
`X-Timestamp` is a millisecond timestamp. Compute it per request — never hardcode or cache it. The sandbox is lenient about this, but **production only accepts values within 2 minutes of platform time**, so a shortcut here fails everywhere on the first production call.
```bash curl theme={null}
curl --location --request POST 'https://uat-api.clinkbill.com/api/checkout/session' \
--header "X-Timestamp: $(date +%s)000" \
--header 'X-API-Key: sk_uat_xxxxxxxxxxxx' \
--header 'Content-Type: application/json' \
--data-raw '{
"customerEmail": "customer@example.com",
"originalAmount": 19.99,
"originalCurrency": "USD",
"merchantReferenceId": "order_10001",
"uiMode": "hostedPage",
"priceDataList": [
{
"name": "Test item",
"quantity": 1,
"unitAmount": 19.99,
"currency": "USD"
}
],
"successUrl": "https://example.com/pay/success",
"cancelUrl": "https://example.com/pay/cancel"
}'
```
```javascript Node.js theme={null}
const res = await fetch('https://uat-api.clinkbill.com/api/checkout/session', {
method: 'POST',
headers: {
'X-API-Key': process.env.CLINK_SECRET_KEY,
'X-Timestamp': String(Date.now()),
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerEmail: 'customer@example.com',
originalAmount: 19.99,
originalCurrency: 'USD',
merchantReferenceId: 'order_10001',
uiMode: 'hostedPage',
priceDataList: [
{ name: 'Test item', quantity: 1, unitAmount: 19.99, currency: 'USD' },
],
successUrl: 'https://example.com/pay/success',
cancelUrl: 'https://example.com/pay/cancel',
}),
});
const { sessionId, url } = (await res.json()).data;
```
```python Python theme={null}
import os, time, requests
res = requests.post(
"https://uat-api.clinkbill.com/api/checkout/session",
headers={
"X-API-Key": os.environ["CLINK_SECRET_KEY"],
"X-Timestamp": str(int(time.time() * 1000)),
"Content-Type": "application/json",
},
json={
"customerEmail": "customer@example.com",
"originalAmount": 19.99,
"originalCurrency": "USD",
"merchantReferenceId": "order_10001",
"uiMode": "hostedPage",
"priceDataList": [
{"name": "Test item", "quantity": 1, "unitAmount": 19.99, "currency": "USD"}
],
"successUrl": "https://example.com/pay/success",
"cancelUrl": "https://example.com/pay/cancel",
},
)
data = res.json()["data"]
```
That is the minimum set of fields that works. What each one does:
* `customerEmail` identifies the customer. `customerId` and `referenceCustomerId` work too, but **at least one is required** — omitting all three returns `CUSTOMER_NOT_FOUND`
* `priceDataList` describes what is being bought. It **cannot be empty**, unless the registered-product mode from step 1 is used instead, sending `productId` and `priceId`
* `originalAmount` and `originalCurrency` are the amount and currency, both required
* **Amounts are in the major currency unit**, not minor units. USD 19.99 is `19.99`. Sending `1999` means USD 1999
* `merchantReferenceId` is the merchant order number, for reconciliation. It is not an idempotency key — calling twice with the same value produces two different Sessions
* `successUrl` and `cancelUrl` only control where the customer lands afterwards. They have no effect on whether the payment succeeds
Currencies with no decimal places — JPY, KRW, IDR — accept integers only. 1999 yen is `1999`.
Every endpoint wraps its response like this, with the payload under `data`:
```json theme={null}
{
"code": 200,
"msg": "success",
"data": {
"sessionId": "sess_xxxxxxxx",
"url": "https://...",
"expireTime": "2026-07-30 12:00:00"
}
}
```
Store `sessionId` on the merchant order. `url` is the checkout page, used in the next part.
`expireTime` currently comes back as `"2026-07-30 12:00:00"` — **no timezone designator**, and not RFC 3339.
Do not hand it straight to `new Date()` or parse it as local time; different runtimes will disagree.
**Do not infer a "merchant account timezone" or the browser timezone either.** The backend currently serializes using the service process default timezone, with no per-account conversion, so the string does not state which zone it belongs to.
Precise cross-timezone handling has to wait until the backend upgrades this contract to RFC 3339 with an offset, or to a Unix timestamp. Until then, read `status` to decide whether a Session can still be paid rather than computing from this string.
The sandbox dashboard also has a **Generate test checkoutUrl** button. That one is for **seeing what checkout looks like** — the dashboard frontend calls the Checkout API directly, so it never touches the merchant server. To verify a real integration, create the Session through the API as above.
### 2.4 Pay with a test card
Opening `url` in a browser lands on Clink's checkout page.
| Card number | `4242 4242 4242 4242` |
| ----------- | --------------------- |
| Expiry | Any future date |
| CVC | Any 3 digits |
After paying, the browser goes to the configured `successUrl`.
Landing on `successUrl` does not mean the money arrived. A customer can type that address into a browser. The real confirmation is next.
### 2.5 Check all three places
One payment has to line up in three places before the integration counts as working:
Look up the session with the `sessionId` from 2.3. `status` should be `completed` and `orderId` should have a value:
```bash theme={null}
curl --location 'https://uat-api.clinkbill.com/api/checkout/session/sess_xxxxxxxx' \
--header "X-Timestamp: $(date +%s)000" \
--header 'X-API-Key: sk_uat_xxxxxxxxxxxx'
```
Then read the Order. Only `status: success` means the payment went through:
```bash theme={null}
curl --location 'https://uat-api.clinkbill.com/api/order/order_xxxxxxxx' \
--header "X-Timestamp: $(date +%s)000" \
--header 'X-API-Key: sk_uat_xxxxxxxxxxxx'
```
Find it on the **Transactions** page of the sandbox dashboard. Sandbox and production have separate transaction lists, so do not look in production.
With the webhook configured in 2.2, the service should have received an `order.succeeded` event. If not, work through the debugging section of [Go Live](/go-live).
If 2.2 was skipped, configure it now, then rerun 2.3 and 2.4 to verify.
All three lining up means the key works, checkout opens, the payment result is retrievable, and events arrive.
What this page verifies is a **one-time payment**. Recurring billing and discounts are separate capabilities with their own integration paths — see the cards below.
## Step 3: Ready for Go-Live
After the test payment is complete, select `Go live` in Step 3. This action completes Step 3 and takes you into production. After it succeeds, the onboarding progress shows `3 / 3` and `Complete` becomes available. Select `Complete` to finish and hide the onboarding guide. `Complete` does not take you into production.
Account verification takes place only in production; the sandbox does not collect or migrate verification details.
## Sandbox done — move to production
The Step 3 `Go live` action handles first access to production. For later environment switches, **Enter production** remains available in the top right of the sandbox dashboard. There is no second registration.
In production, initialize fresh `sk_prod_` and `pk_prod_` keys, register the webhook endpoint again with its new signing key, and submit real account-verification details.
Sandbox payment-domain resources and environment-specific configuration are not copied to production, including products, customers, orders, API keys, webhook endpoints, signing keys, and verification applications and details. On first access to production, Clink synchronizes the tenant, merchant, user sign-in credentials, and role assignments required for production access.
After selecting `Complete` in the original sandbox tab, the sandbox onboarding guide is hidden. Follow the existing production onboarding flow for account review and launch preparation.
The full cutover list and debugging steps are in [Go Live](/go-live).
## Once this works
Redirect to checkout, or embed it in a merchant-owned page.
What to write on the backend, the frontend, and the webhook.
For recurring billing.
For discounts and promo codes.
Key rotation, IP restrictions, signature verification.
The checklist before production, including account verification.
# Subscriptions
Source: https://docs.clinkbill.com/subscriptions
Recurring prices, the first subscription, renewals, cancellation, and plan changes.
This page covers recurring billing. Orders, Sessions, and webhooks work exactly as they do for one-time payments, so [Hosted Checkout](/build-integration) is worth reading first.
## Pick a path
Three situations, three different endpoints:
| Situation | Path | Notes |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
| A new customer, or one without a reusable payment instrument | Recurring price + Checkout Session | **Recommended.** The customer picks and authorizes a payment method in checkout |
| A Clink Customer and a usable Payment Instrument already exist | `POST /subscription` | The server creates the subscription and starts the first payment directly |
| An active subscription where the customer wants to change cards, cancel, switch plans, or see invoices | `POST /billing/session` | Send them to the Customer Portal first; use the query and update APIs for server-side automation |
`POST /checkout/session` and `POST /subscription` are not two spellings of the same call. The first gives the customer an interactive checkout; the second is for a server that already has both a customer and a payment instrument.
## 1. Prepare a recurring product and price
Subscriptions **require** a pre-registered Product and a recurring price. The inline `priceDataList` approach cannot create one.
```json theme={null}
{
"productId": "prd_xxxxx",
"currency": "USD",
"unitAmount": 29.99,
"priceType": "recurring",
"recurringDetails": {
"interval": "month",
"intervalCount": 1,
"trialPeriodDays": 7,
"pricingModel": "flat_rate"
},
"isDefaultPrice": true
}
```
`interval` accepts `day`, `week`, `month`, `year`, `quarter`, `half_year`, and `custom`.
`quarter` and `half_year` are resolved by the platform — do not convert them into 3 or 6 months. `intervalCount` only means a number of days when `interval` is `custom`.
Examples on this page use a monthly `flat_rate` price. For other pricing models, see the field reference in [Create price](/api-reference/endpoint/create-price).
## 2. Recommended: create the first subscription through Checkout
The shape is identical to a one-time payment — the backend creates the Session, the frontend opens checkout, webhooks update local state. Only the item changes, from an inline product to a recurring price.
```json theme={null}
{
"customerEmail": "buyer@example.com",
"merchantReferenceId": "membership_10001",
"productId": "prd_xxxxx",
"priceId": "price_xxxxx",
"originalAmount": 29.99,
"originalCurrency": "USD",
"uiMode": "hostedPage",
"successUrl": "https://merchant.example.com/subscription/success",
"cancelUrl": "https://merchant.example.com/subscription/cancel"
}
```
Amount and currency are still required, and the backend checks them against the Product and Price.
At the moment the Session is created, **no subscription exists and no money has been collected**.
Once the customer submits payment, checkout follows the recurring price and produces a Subscription, an Invoice, and an Order. Returning to `successUrl` only means the interaction ended — read the final state from the API and from signature-verified webhooks.
## 3. Server-side: create the subscription directly
Use this when the customer already has a Clink Customer record and a usable payment instrument.
```json theme={null}
{
"customerId": "cus_xxxxx",
"merchantReferenceId": "membership_10002",
"productId": "prd_xxxxx",
"priceId": "price_xxxxx",
"paymentInstrumentId": "pi_xxxxx",
"paymentMethodType": "CARD",
"paymentCurrency": "USD",
"returnUrl": "https://merchant.example.com/subscription/return"
}
```
Supply at least one of `customerId`, `customerEmail`, or `referenceCustomerId`. The recurring price must belong to the current merchant and support the `paymentCurrency` sent.
Whether a given payment method works for subscriptions also depends on channel configuration and currency. Verify each method the account has actually enabled rather than assuming a shared list.
The numeric `status` in the response describes **this payment attempt**:
| `status` | Meaning | What to do |
| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `1` | This payment succeeded | Wait for the matching webhook and handle it idempotently. **This response alone is not grounds for granting access** |
| `2` | In progress | Show a pending state and keep polling. Do not treat it as failure and charge again |
| `3` | Failed | Record the result and let the customer switch methods or retry |
| `5` | The customer needs one more step | Follow the returned `action` for a redirect, QR code, or other verification |
The response also returns `subscriptionId`, `sessionId`, `paymentInstrumentId`, `orderId`, and `invoiceId`. **Store all five on the local subscription record** — one field alone will not be enough for later queries and reconciliation.
## 4. Mapping subscription status to access
This is where subscription integrations most often go wrong. Payment status and entitlement status are two different things.
| Subscription status | Meaning | What to do with access |
| -------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `incomplete` | Subscription created, first payment not finished | **Do not grant paid access** |
| `free_trial` | Inside the free trial | Grant access under the applicable trial rules and record `trialEnd` |
| `active` | Valid with no outstanding invoice | Keep access open |
| `past_due` | Renewal failed, retries may still happen | Apply the applicable grace period policy. **This is not paid, and it is not a reason to start a second charge** |
| `incomplete_expired` | The first subscription never completed and timed out | Close out the unfinished flow |
| `cancelled` | Subscription ended | Revoke access at the actual end time |
**Do not use `subscription.created` as the signal to grant paid access.** It only means the subscription record exists.
Free trials are driven by `subscription.trialing`. Starting paid access and renewing it are two different signals — do not conflate them:
| Situation | Events to read |
| ----------------------------------------------- | ------------------------------------------------------------------- |
| First paid activation | `subscription.activated` + `invoice.paid` / `order.succeeded` |
| Trial ends and the first real charge succeeds | `subscription.activated` + `invoice.paid` / `order.succeeded` |
| A `past_due` retry succeeds and service resumes | `subscription.activated` + `invoice.paid` / `order.succeeded` |
| An ordinary renewal | `subscription.updated.renewed` + `invoice.paid` / `order.succeeded` |
`subscription.activated` means **the subscription entered or returned to `active`**, not "first activation only". Moving from `incomplete`, `free_trial`, or `past_due` into `active` all fire it. An ordinary `active` to `active` renewal fires only `subscription.updated.renewed` and does not repeat `activated`.
Getting this wrong in either direction hurts: watch only `activated` and renewals will never fire it, so access looks expired at the end of the second period; watch only `renewed` and both dunning recovery and the trial-to-paid transition are missed.
Entitlement changes belong on the local subscription and invoice records, not on a single order row — a subscription is an ongoing relationship and one order number cannot hold it. Make both activation and renewal idempotent on `invoiceId`, so a given period is fulfilled exactly once.
## 5. Events to subscribe to
The `order.*` events from one-time payments still apply. Subscriptions add these.
**Lifecycle**
`subscription.created`, `subscription.trialing`, `subscription.activated`, `subscription.past_due`, `subscription.incomplete_expired`, `subscription.cancelled`
**Plan changes**
`subscription.updated.plan_changed`, `subscription.updated.plan_change_canceled`, `subscription.updated.renewed`, `subscription.updated.cancel_at_period_end_set`, `subscription.updated.cancel_at_period_end_revoked`
**Invoices**
`invoice.open`, `invoice.paid`, `invoice.void`
Subscribe with full event names. Wildcards like `subscription.*` and `invoice.*` **are not values the API accepts**.
Events are retried and arrive out of order. The handler must at least:
* Deduplicate atomically on `event.id`
* Resolve out-of-order arrivals by subscription status precedence, so an older event cannot overwrite a newer state
* Run fulfillment for a given `invoiceId` exactly once
Signature verification, deduplication, and order matching are covered in [Hosted Checkout](/build-integration).
## 6. Cancellation
```json theme={null}
{
"reason": "Customer requested cancellation",
"cancelReasonCode": "no_longer_needed",
"cancelImmediately": false
}
```
`reason` is required, 1 to 255 characters. `cancelReasonCode` is optional and accepts `too_expensive`, `need_more_features`, `found_alternative`, `no_longer_needed`, `poor_customer_service`, `poor_usability`, `poor_quality`, and `other_reasons`.
Omitting `cancelImmediately` or sending `false` cancels **at the end of the current period**. Sending `true` cancels immediately.
**Cancelling is not refunding.** An immediate cancellation does not return money already collected for the current period. Refunds go through the [refund API](/api-reference/endpoint/create-refund) separately.
## 7. Plan changes
Preview first, then confirm.
**Step one — preview**
```json theme={null}
{
"priceId": "price_yyyyy",
"quantity": 1,
"promotionCode": "WELCOME20"
}
```
**Step two — confirm with the `priceSnapshotId` from the preview**
```json theme={null}
{
"priceSnapshotId": "snap_xxxxx",
"quantity": 1,
"promotionCode": "WELCOME20"
}
```
When a promotion code is used, send **the same code** in both calls or the amounts will not line up.
`immediate: true` may produce a prorated charge and return an `action`. `false` schedules the change for the next billing boundary. A pending change can be withdrawn with `POST /subscription/{id}/update/cancel`.
`quantity` is the number of units of the target price — **optional**, an integer, minimum 1. Omit it and the server uses `1`; an explicit `null` or a value below 1 returns a parameter error. The examples still spell it out so the quantity is visible at a glance.
## 8. Scheduled phases
`scheduledPhases` lets a subscription switch plans automatically at future renewal boundaries, up to 10 phases, with `sequence` and `effectiveCycle` both starting at `1` and strictly increasing.
This is an advanced capability, not needed for a first integration. Field details are in [Checkout Session](/guides/payments/checkout_session).
## Next
Adding discounts, and how many periods they last.
Subscription checks before production.
# Advance Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/advance-test-clock
POST /subscription/test-clocks/{clockId}/advance
Advance a test clock to the target frozen timestamp
# Cancel Subscription
Source: https://docs.clinkbill.com/api-reference/endpoint/cancel-subscription
POST /subscription/{id}/cancel
Cancel a subscription either immediately or at the end of the current billing period.
# Complete Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/complete-test-clock
POST /subscription/test-clocks/{clockId}/complete
Complete a test clock and stop further simulated execution
# Create Agent Payment Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-agent-payment-session
POST /order/payment-session
Create agent payment session
# Create Checkout Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-checkout-session
POST /checkout/session
Create a new checkout session for payment processing
# Customer Portal Session
Source: https://docs.clinkbill.com/api-reference/endpoint/create-customer-portal
POST /billing/session
Create a new customer portal session for billing management
# Create Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/create-test-clock
POST /subscription/test-clocks
Create a new test clock for a subscription
# Create Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/create-webhook-endpoint
POST /webhook/endpoints
Create a webhook endpoint for the current merchant. The endpoint URL must use HTTPS and resolve to a public host.
# Delete Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/delete-webhook-endpoint
DELETE /webhook/endpoints/{id}
Delete a webhook endpoint. Deleted endpoints stop receiving webhook events.
# Disable Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/disable-webhook-endpoint
POST /webhook/endpoints/{id}/disable
Disable a webhook endpoint. Disabled endpoints are saved but do not receive webhook events.
# Enable Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/enable-webhook-endpoint
POST /webhook/endpoints/{id}/enable
Enable a webhook endpoint.
# Ensure Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/ensure-webhook-endpoint
PUT /webhook/endpoints/ensure
Create or update a webhook endpoint by URL. This endpoint is designed for idempotent setup flows where applications need to safely create or reconcile a webhook endpoint. For existing endpoints, Clink does not return the stored plaintext signing secret unless the secret is rotated.
# Get Agent Payment Session
Source: https://docs.clinkbill.com/api-reference/endpoint/get-agent-payment-session
GET /order/payment-session/{sessionId}
Get agent payment session
# Get Checkout Session
Source: https://docs.clinkbill.com/api-reference/endpoint/get-checkout-session
GET /checkout/session/{id}
Retrieve details of an existing checkout session
# Get Invoice
Source: https://docs.clinkbill.com/api-reference/endpoint/get-invoice
GET /subscription/invoice/{id}
Get detailed information about a specific invoice
# Get Order
Source: https://docs.clinkbill.com/api-reference/endpoint/get-order
GET /order/{id}
Get detailed information about a specific order
# Get Price
Source: https://docs.clinkbill.com/api-reference/endpoint/get-price
GET /price/{id}
Get price information under your current merchant account based on the price ID
# Get Price List
Source: https://docs.clinkbill.com/api-reference/endpoint/get-price-list
GET /price
Get all price information under your current merchant account
# Get Product
Source: https://docs.clinkbill.com/api-reference/endpoint/get-product
GET /product/{id}
Get product information under your current merchant account based on the product ID
# Get Product List
Source: https://docs.clinkbill.com/api-reference/endpoint/get-product-list
GET /product
Get all product information under your current merchant account
# Get Refund
Source: https://docs.clinkbill.com/api-reference/endpoint/get-refund
GET /refund/{id}
Get detailed information about a specific refund
# Get Subscription
Source: https://docs.clinkbill.com/api-reference/endpoint/get-subscription
GET /subscription/{id}
Get detailed information about a specific subscription
# Get Test Clock
Source: https://docs.clinkbill.com/api-reference/endpoint/get-test-clock
GET /subscription/test-clocks/{clockId}
Retrieve details of a specific test clock
# Get Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/get-webhook-endpoint
GET /webhook/endpoints/{id}
Get a webhook endpoint by ID.
# List Test Clocks
Source: https://docs.clinkbill.com/api-reference/endpoint/list-test-clocks
GET /subscription/test-clocks/list
List all active test clocks under your current merchant account
# List Webhook Endpoints
Source: https://docs.clinkbill.com/api-reference/endpoint/list-webhook-endpoints
GET /webhook/endpoints
Get webhook endpoints under the current merchant account.
# List Webhook Events
Source: https://docs.clinkbill.com/api-reference/endpoint/list-webhook-events
GET /webhook/events
Return supported webhook events and event aliases. Use event names in webhook endpoint management requests; numeric event codes are returned for reference only.
# Rotate Webhook Signing Secret
Source: https://docs.clinkbill.com/api-reference/endpoint/rotate-webhook-signing-secret
POST /webhook/endpoints/{id}/rotate-secret
Rotate the signing secret for a webhook endpoint. The previous secret stops working immediately.
# Update Webhook Endpoint
Source: https://docs.clinkbill.com/api-reference/endpoint/update-webhook-endpoint
PATCH /webhook/endpoints/{id}
Update URL, events, description, or enabled status for a webhook endpoint. Omitted fields remain unchanged.
# customer.verify
Source: https://docs.clinkbill.com/api-reference/webhook/customer.verify
WEBHOOK customer.verify
Webhook notification triggered when customer verification is required
# dispute
Source: https://docs.clinkbill.com/api-reference/webhook/dispute
WEBHOOK dispute
Webhook notification triggered when dispute status changes. When a dispute reaches won or lost, an additional dispute.closed event is sent.
# invoice
Source: https://docs.clinkbill.com/api-reference/webhook/invoice
WEBHOOK invoice
Webhook notification triggered when an invoice is created or updated
# order
Source: https://docs.clinkbill.com/api-reference/webhook/order
WEBHOOK order
Handles order lifecycle events.
For `order.succeeded`:
1. Find the merchant account using `data.object.customerEmail`.
2. Create a missing account and return `account.created`, or return `account.reloaded` after confirming the successful-payment notification for an existing account.
3. Map `data.object.amountTotal` to `data.amount` and `data.object.paymentCurrency` to `data.currency`.
Handle retries idempotently using the event `id` or `orderId`, and return the original result for duplicate events. Other order events may return an empty HTTP 200 response.
# refund
Source: https://docs.clinkbill.com/api-reference/webhook/refund
WEBHOOK refund
Webhook notification triggered when refund is created or updated
# session
Source: https://docs.clinkbill.com/api-reference/webhook/session
WEBHOOK session
Webhook notification triggered when session is completed or expired
# subscription
Source: https://docs.clinkbill.com/api-reference/webhook/subscription
WEBHOOK subscription
Webhook notification triggered when a subscription is created or updated