coal
coal

Checkout Sessions

A Checkout Session is a server-side payment intent you create programmatically. It represents a single, time-bounded payment request tied to a specific amount and merchant. Sessions power both the hosted Coal checkout page and the embeddable widget.

Payment Links vs Checkout Sessions: Payment Links are persistent, reusable URLs you share with anyone. Checkout Sessions are one-time, ephemeral objects created when a specific buyer is ready to pay. Use sessions when your server controls the checkout flow or when you need a one-off checkout URL.


Session Lifecycle

Session Created
Coalstatus: "pending" — waiting for buyer to submit txHash
Payment Submitted
Your AppCoal
POST /api/pay/confirm

{ sessionId, txHash }

Coalstatus: "verifying" — on-chain verification in progress
Verification Outcome
CoalChecks pass → status: "confirmed" ✓
CoalChecks fail → status: "failed" ✗
CoalNo action after 24h → status: "expired"
StatusMeaning
pendingSession created, waiting for the buyer to submit a transaction hash.
verifyingBuyer has submitted a txHash. Coal is confirming the transaction on-chain.
confirmedPayment verified. Funds are on their way to your payout address.
failedOn-chain verification rejected the transaction (wrong amount, wrong recipient).
expiredSession was not completed within 24 hours and is no longer usable.

1. Create a Session

POST /api/checkout/init

This endpoint is public. It resolves a payment link slug, validates the amount, and creates a new session for hosted checkout.

Request body

FieldTypeRequiredDescription
slugstringYesThe slug of an active payment link.
amountnumberNoRequired only for flexible (no-product) links. Ignored otherwise.

cURL

bash
1# Product-linked link (amount is fixed, do not send amount)
2curl -X POST https://api.usecoal.xyz/api/checkout/init \
3 -H "Content-Type: application/json" \
4 -d '{ "slug": "pro-plan" }'
5
6# Flexible link (buyer-specified amount)
7curl -X POST https://api.usecoal.xyz/api/checkout/init \
8 -H "Content-Type: application/json" \
9 -d '{ "slug": "donate", "amount": 10 }'

Response

json
1{
2 "sessionId": "clz9session123",
3 "amount": 49.00,
4 "currency": "USDC",
5 "description": "Pro Plan",
6 "merchant": {
7 "name": "Acme Corp",
8 "payoutAddress": "1AcmePayoutAddress..."
9 },
10 "expiresAt": "2026-03-23T10:00:00.000Z"
11}

2. Confirm Payment

POST /api/pay/confirm

After the buyer's wallet has broadcast the settlement-token transfer, your client sends the transaction hash to Coal. This moves the session from pending to verifying and triggers on-chain verification.

Request body

FieldTypeRequiredDescription
sessionIdstringYesThe session ID returned from /api/checkout/init.
txHashstringYesThe transaction hash from the wallet.

cURL

bash
1curl -X POST https://api.usecoal.xyz/api/pay/confirm \
2 -H "Content-Type: application/json" \
3 -d '{
4 "sessionId": "clz9session123",
5 "txHash": "a1b2c3d4e5f6..."
6 }'

Response

json
1{
2 "status": "verifying",
3 "sessionId": "clz9session123"
4}

This call is idempotent — submitting the same sessionId + txHash pair again returns the current status safely.


3. Poll Session Status

GET /api/pay/status/:sessionId

Poll this endpoint until the status is confirmed, failed, or expired. Coal recommends polling every 2 seconds with a maximum of 60 attempts (2 minutes timeout on the client).

cURL

bash
1curl https://api.usecoal.xyz/api/pay/status/clz9session123

Response (verifying)

json
1{
2 "status": "verifying",
3 "txHash": "a1b2c3d4e5f6..."
4}

Response (confirmed)

json
1{
2 "status": "confirmed",
3 "txHash": "a1b2c3d4e5f6...",
4 "redirectUrl": "https://yoursite.com/success"
5}

When status is confirmed and a redirectUrl is present, redirect the user to that URL.


Session Expiry

Sessions expire 24 hours after creation. If a buyer attempts to pay an expired session, /api/pay/confirm returns a 410 Gone response. The session status is also automatically set to expired the next time /api/pay/status/:sessionId is polled.

Always display the expiresAt timestamp to the buyer so they know how long they have.


Error Codes

CodeHTTPDescription
SESSION_NOT_FOUND404No session exists with the given ID.
SESSION_EXPIRED410Session has passed its expiresAt timestamp.
SESSION_ALREADY_CONFIRMED409Session is not in pending status; payment already processed.
TXHASH_ALREADY_USED409The transaction hash was already used in a confirmed payment.
TXHASH_IN_ANOTHER_SESSION409The hash is already pending in a different session.
VALIDATION_ERROR400Missing or invalid request body fields.

Node.js Server-Side Example (Next.js App Router)

Create the session server-side so your business logic stays off the client.

typescript
1// app/actions.ts
2'use server';
3
4export async function createCheckoutSession(slug: string, amount?: number) {
5 const res = await fetch(`${process.env.COAL_API_URL}/api/checkout/init`, {
6 method: 'POST',
7 headers: { 'Content-Type': 'application/json' },
8 body: JSON.stringify({ slug, amount }),
9 });
10
11 if (!res.ok) {
12 const err = await res.json();
13 throw new Error(err?.error?.code ?? 'Failed to create session');
14 }
15
16 const { data } = await res.json();
17 return data as {
18 sessionId: string;
19 amount: number;
20 currency: string;
21 description: string;
22 merchant: { name: string; payoutAddress: string };
23 expiresAt: string;
24 };
25}

Call this server action from a React Server Component or a form action, then pass sessionId down to the client component that renders the checkout UI.


React Client-Side Flow Example

tsx
1'use client';
2
3import { useState } from 'react';
4
5const POLL_INTERVAL_MS = 2000;
6const MAX_POLLS = 60;
7
8interface CheckoutProps {
9 sessionId: string;
10 payoutAddress: string;
11 amount: number;
12}
13
14export function CheckoutFlow({ sessionId, payoutAddress, amount }: CheckoutProps) {
15 const [status, setStatus] = useState<string>('idle');
16 const [error, setError] = useState<string | null>(null);
17
18 async function handlePay() {
19 setStatus('sending');
20 try {
21 // 1. Send the configured settlement token via the user's wallet
22 // Replace with your actual wallet SDK call
23 const txHash = await sendSettlementTransfer({
24 to: payoutAddress,
25 amount,
26 });
27
28 // 2. Confirm the payment with Coal
29 setStatus('confirming');
30 const confirmRes = await fetch('/api/pay/confirm', {
31 method: 'POST',
32 headers: { 'Content-Type': 'application/json' },
33 body: JSON.stringify({ sessionId, txHash }),
34 });
35
36 if (!confirmRes.ok) {
37 const err = await confirmRes.json();
38 throw new Error(err?.error?.code ?? 'Confirmation failed');
39 }
40
41 // 3. Poll for final status
42 setStatus('verifying');
43 let polls = 0;
44 const interval = setInterval(async () => {
45 polls++;
46 if (polls > MAX_POLLS) {
47 clearInterval(interval);
48 setStatus('timeout');
49 return;
50 }
51
52 const statusRes = await fetch(`/api/pay/status/${sessionId}`);
53 const data = await statusRes.json();
54
55 if (data.status === 'confirmed') {
56 clearInterval(interval);
57 setStatus('confirmed');
58 if (data.redirectUrl) window.location.href = data.redirectUrl;
59 } else if (data.status === 'failed' || data.status === 'expired') {
60 clearInterval(interval);
61 setStatus(data.status);
62 setError(`Payment ${data.status}.`);
63 }
64 }, POLL_INTERVAL_MS);
65
66 } catch (err: unknown) {
67 setStatus('error');
68 setError(err instanceof Error ? err.message : 'Unknown error');
69 }
70 }
71
72 return (
73 <div>
74 <p>Status: {status}</p>
75 {error && <p style={{ color: 'red' }}>{error}</p>}
76 <button onClick={handlePay} disabled={status !== 'idle'}>
77 Pay {amount} tokens
78 </button>
79 </div>
80 );
81}