@clink-ai/clink-js
v1.0.2
Published
Clink hosted checkout JS SDK (V1)
Readme
Clink Embedded Checkout SDK
Clink Checkout SDK:
redirectToCheckout: hosted checkout redirectinitEmbeddedCheckout: embedded checkout
Installation
# Once published
npm install @clink/js
# or pnpm add @clink/js
# Local development inside this repository
npm --prefix packages/clink-js run test
npm --prefix packages/clink-js run buildAPI
loadClink(publicKey, options?)
import { loadClink } from '@clink/js';
const clink = await loadClink('pk_uat_xxx', {
checkoutEnvironment: 'sandbox',
locale: 'zh-CN',
});publicKeymust matchpk_test_*,pk_uat_*, orpk_prod_*- Prefer
checkoutEnvironmentso the SDK can resolve the correct Clink bootstrap endpoint automatically, or passcheckoutBaseUrldirectly checkoutEnvironmentis optional:sandbox/productioncheckoutBaseUrlis optional: explicitly set the checkout base URL, for examplehttps://checkout.clinkbill.com
clink.redirectToCheckout(params) (hosted checkout redirect)
await clink.redirectToCheckout({
sessionParam: 'sess_123#token',
replace: false,
});Behavior:
- At least one of
sessionParamorsessionIdis required - If both are provided,
sessionParamtakes precedence - Redirect URL format:
{checkoutBaseUrl}/pay/{encodeURIComponent(sessionParam)} replace: trueuseslocation.replace; default islocation.assign
clink.initEmbeddedCheckout(options) (embedded checkout)
import { loadClink } from '@clink/js';
const clink = await loadClink('pk_uat_xxx');
// Prefer passing checkoutEnvironment or checkoutBaseUrl explicitly
// const clink = await loadClink('pk_uat_xxx', { checkoutEnvironment: 'sandbox' });
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => {
const resp = await fetch('/api/checkout/session', { method: 'POST' });
const data = await resp.json();
return {
checkoutUrl: data.checkoutUrl as string,
sessionId: data.sessionId as string,
orderId: data.orderId as string,
};
},
pollStatus: async ({ orderId }) => {
if (!orderId) return null;
const resp = await fetch(`/api/topup/status?order_id=${orderId}`);
const data = await resp.json();
if (data.credited) return 'success';
if (data.status === 'failed') return 'error';
if (data.status === 'refunded') return 'cancelled';
return 'pending';
},
onEvent(event) {
console.log('[embedded event]', event.type, event.payload);
},
// default true
autoDestroyOnComplete: true,
});
embedded.mount('#checkout');React integration example:
import { useEffect, useRef, useState } from 'react';
import {
CLINK_ERROR_CODES,
ClinkError,
loadClink,
type EmbeddedCheckout,
} from '@clink/js';
interface CheckoutPageProps {
publicKey: string;
}
export function CheckoutPage({ publicKey }: CheckoutPageProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const embeddedRef = useRef<EmbeddedCheckout | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function setup() {
try {
const clink = await loadClink(publicKey, {
checkoutEnvironment: 'production',
locale: 'en-US',
});
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => {
const resp = await fetch('/api/checkout/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
throw new Error('failed to create checkout session');
}
const data = (await resp.json()) as {
checkoutUrl: string;
sessionId: string;
orderId?: string;
};
return data;
},
pollStatus: async ({ orderId }) => {
if (!orderId) {
return null;
}
const resp = await fetch(`/api/topup/status?order_id=${orderId}`);
if (!resp.ok) {
return null;
}
const data = (await resp.json()) as {
credited?: boolean;
status?: string;
};
if (data.credited || data.status === 'paid') {
return 'success';
}
if (data.status === 'failed') {
return 'error';
}
if (data.status === 'refunded') {
return 'cancelled';
}
return 'pending';
},
onEvent(event) {
if (event.type === 'hosted_return') {
window.location.assign('/payment/result');
return;
}
if (event.type === 'complete' && event.payload?.state === 'success') {
window.location.assign('/payment/success');
return;
}
if (event.type === 'error') {
setError('Payment failed. Please try again.');
}
},
});
if (cancelled || !containerRef.current) {
embedded.destroy();
return;
}
embedded.mount(containerRef.current);
embeddedRef.current = embedded;
setLoading(false);
} catch (err) {
if (cancelled) {
return;
}
if (
err instanceof ClinkError &&
err.code === CLINK_ERROR_CODES.SESSION_ID_FETCH_FAILED
) {
setError('Unable to create checkout session.');
} else {
setError('Unable to load checkout.');
}
setLoading(false);
}
}
void setup();
return () => {
cancelled = true;
embeddedRef.current?.destroy();
embeddedRef.current = null;
};
}, [publicKey]);
return (
<div>
{loading ? <div>Loading checkout...</div> : null}
{error ? <div role="alert">{error}</div> : null}
<div ref={containerRef} id="checkout" />
</div>
);
}React integration recommendations:
- Initialize
loadClink()andinitEmbeddedCheckout()once insideuseEffect - Store the
EmbeddedCheckoutinstance in arefand calldestroy()on unmount - Listen to
completefor the business final state; listen tohosted_returnfor UI cleanup after merchant-defined success or cancel return pages fetchSession()must return{ checkoutUrl, sessionId, orderId? }, andcheckoutUrlshould be theurlreturned by your server-sidecreateCheckoutSession()- By default the SDK destroys the iframe automatically after a successful
complete; passautoDestroyOnComplete: falseif you need to keep it mounted fetchSession()should call only your own backend; never expose secret keys in the frontend
options:
fetchSession: calls your backend and must return{ checkoutUrl, sessionId, orderId? }onEvent(optional): unified event callbackautoResize(optional, defaulttrue): automatically update iframe height fromresizeeventsautoDestroyOnComplete(optional, defaulttrue): destroy the iframe after completion so the host page can take over the success statepollStatus(optional): fallback polling for merchant-confirmed payments; the SDK polls onpollIntervalMsand emitscompleteautomatically on terminal statespollIntervalMs(optional, default2000)
Instance API:
mount(container: string | HTMLElement)unmount()destroy()on(type, handler)(returns an unsubscribe function)getState() -> { mounted, destroyed }
Event types:
readyresizestate_changecompletehosted_returnerror
Recommended mental model:
complete: payment terminal-state event. It comes either from the checkout page itself or from terminal-state confirmation viapollStatus. Treat this as the source of truth for payment status.hosted_return: UI cleanup event after the merchant's customsuccessUrl/cancelUrlreturn page. Use it to close the iframe, return to the host page, or show your own result screen.error: SDK or polling error. This does not necessarily mean the payment itself failed.
Embedded checkout error handling example
import { ClinkError, CLINK_ERROR_CODES } from '@clink/js';
try {
const embedded = await clink.initEmbeddedCheckout({
fetchSession: async () => ({ checkoutUrl: '', sessionId: 'sess_xxx' }),
});
embedded.mount('#checkout');
} catch (error) {
if (error instanceof ClinkError) {
if (error.code === CLINK_ERROR_CODES.INVALID_SESSION_ID) {
console.error('Invalid checkoutUrl or sessionId');
}
}
}Bootstrap environment control
The SDK supports fixing the remote bootstrap environment via environment variables:
CLINK_ENV=sandbox->https://uat-api.clinkbill.com/api/sdk/bootstrapCLINK_ENV=production->https://api.clinkbill.com/api/sdk/bootstrap
checkoutEnvironment should match CLINK_ENV, using sandbox / production.
Priority order (high -> low):
loadClink(..., { checkoutBaseUrl })loadClink(..., { checkoutEnvironment })CLINK_ENV
Remote bootstrap request format:
POST /api/sdk/bootstrap
X-API-Key: pk_uat_xxx / pk_prod_xxx
X-Timestamp: <unix_ms>
Accept-Language: zh-CN, en-US;q=0.9
Content-Type: application/jsonRequest body:
{
"origin": "https://merchant.example.com",
"sdkVersion": "1.0.0",
"locale": "zh_CN"
}Successful response:
{
"code": 200,
"msg": "Success",
"data": {
"checkoutBaseUrl": "https://checkout.clinkbill.com",
"merchantId": "mcht_xxx",
"merchantName": "Demo Merchant",
"environment": "production",
"mode": "production",
"features": {
"embeddedCheckout": true
},
"allowedParentOrigins": ["https://merchant.example.com"]
}
}Error handling
The SDK throws ClinkError with a code field:
import { ClinkError, CLINK_ERROR_CODES, loadClink } from '@clink/js';
try {
const clink = await loadClink('pk_prod_xxx');
await clink.redirectToCheckout({ sessionId: 'sess_001' });
} catch (error) {
if (error instanceof ClinkError) {
if (error.code === CLINK_ERROR_CODES.INVALID_PUBLIC_KEY) {
console.error('Invalid public key format');
}
}
}Common error codes:
INVALID_PUBLIC_KEYINVALID_CHECKOUT_ENVBOOTSTRAP_REQUEST_FAILEDINVALID_BOOTSTRAP_RESPONSEINVALID_REDIRECT_PARAMSINVALID_EMBEDDED_OPTIONSINVALID_SESSION_IDSESSION_ID_FETCH_FAILEDEMBEDDED_CHECKOUT_DISABLEDCONTAINER_NOT_FOUNDNOT_IN_BROWSER
Security boundaries
- The SDK does not handle sensitive payment data such as card numbers or CVV
- The SDK does not contain any secret-key logic
- The SDK uses only
publicKeyfor bootstrap requests - For embedded checkout, the recommended flow is for the merchant backend to create the checkout session and return
checkoutUrl,sessionId, and related metadata to the frontend completeonly means checkout or backend-confirmed terminal state; usehosted_returnfor merchant return-page UI cleanup instead of payment confirmation
Integration guide
- Use
loadClink + redirectToCheckoutfor full-page redirects - Use
loadClink + initEmbeddedCheckout + mountfor iframe embedding - Listen to
completefor the business terminal state - Listen to
hosted_returnfor host-page UI cleanup such as hiding the iframe or returning to the merchant page
Backend contracts
Bootstrap contract: docs/sdk-bootstrap-contract.md
Embedded checkout contract: docs/sdk-embedded-backend-contract.md
