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
{ sessionId, txHash }
| Status | Meaning |
|---|---|
pending | Session created, waiting for the buyer to submit a transaction hash. |
verifying | Buyer has submitted a txHash. Coal is confirming the transaction on-chain. |
confirmed | Payment verified. Funds are on their way to your payout address. |
failed | On-chain verification rejected the transaction (wrong amount, wrong recipient). |
expired | Session 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
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | The slug of an active payment link. |
amount | number | No | Required only for flexible (no-product) links. Ignored otherwise. |
cURL
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" }'56# 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
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
| Field | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | The session ID returned from /api/checkout/init. |
txHash | string | Yes | The transaction hash from the wallet. |
cURL
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
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
1curl https://api.usecoal.xyz/api/pay/status/clz9session123
Response (verifying)
1{2 "status": "verifying",3 "txHash": "a1b2c3d4e5f6..."4}
Response (confirmed)
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
| Code | HTTP | Description |
|---|---|---|
SESSION_NOT_FOUND | 404 | No session exists with the given ID. |
SESSION_EXPIRED | 410 | Session has passed its expiresAt timestamp. |
SESSION_ALREADY_CONFIRMED | 409 | Session is not in pending status; payment already processed. |
TXHASH_ALREADY_USED | 409 | The transaction hash was already used in a confirmed payment. |
TXHASH_IN_ANOTHER_SESSION | 409 | The hash is already pending in a different session. |
VALIDATION_ERROR | 400 | Missing 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.
1// app/actions.ts2'use server';34export 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 });1011 if (!res.ok) {12 const err = await res.json();13 throw new Error(err?.error?.code ?? 'Failed to create session');14 }1516 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
1'use client';23import { useState } from 'react';45const POLL_INTERVAL_MS = 2000;6const MAX_POLLS = 60;78interface CheckoutProps {9 sessionId: string;10 payoutAddress: string;11 amount: number;12}1314export function CheckoutFlow({ sessionId, payoutAddress, amount }: CheckoutProps) {15 const [status, setStatus] = useState<string>('idle');16 const [error, setError] = useState<string | null>(null);1718 async function handlePay() {19 setStatus('sending');20 try {21 // 1. Send the configured settlement token via the user's wallet22 // Replace with your actual wallet SDK call23 const txHash = await sendSettlementTransfer({24 to: payoutAddress,25 amount,26 });2728 // 2. Confirm the payment with Coal29 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 });3536 if (!confirmRes.ok) {37 const err = await confirmRes.json();38 throw new Error(err?.error?.code ?? 'Confirmation failed');39 }4041 // 3. Poll for final status42 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 }5152 const statusRes = await fetch(`/api/pay/status/${sessionId}`);53 const data = await statusRes.json();5455 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);6566 } catch (err: unknown) {67 setStatus('error');68 setError(err instanceof Error ? err.message : 'Unknown error');69 }70 }7172 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} tokens78 </button>79 </div>80 );81}
