coal
coal

Payment Flow

This page provides a walkthrough of how a Coal payment works end-to-end - from session creation to on-chain confirmation and webhook delivery. Understanding this flow will help you build reliable integrations and debug issues when they arise.


High-Level Flow Diagram

Session Creation
Your AppCoal
POST /api/checkouts

{ amount, productName, redirectUrl } with x-api-key header

CoalCreates CheckoutSession — status: "pending"
CoalYour App
{ id, checkoutUrl, amount, currency, expiresAt }
User Checkout
User / BrowserCoal
GET /pay/checkout/:sessionId

User visits the hosted checkout URL

CoalUser / Browser
Checkout page renders

Shows product, amount, wallet connect

User / BrowserCoal
Connect wallet

MetaMask, Coinbase Wallet, WalletConnect, or Privy embedded

On-Chain Payment
User / BrowserBase Chain
ERC-20 transfer(to, amount)

Signed directly by the user — Coal never holds funds

Base ChainTransaction mined on Base (~2 sec)
User / BrowserCoal
POST /api/pay/confirm

{ sessionId, txHash }

Coalstatus → "verifying", pendingTxHash saved
CoalUser / Browser
{ status: "verifying" }
Verification & Confirmation
CoalBackground cron runs every ~60 sec
CoalBase Chain
getTransactionReceipt(txHash)
Base ChainCoal
Transfer event logs + receipt
CoalValidates recipient address + amount — status → "confirmed"
Completion
CoalYour App
POST webhook: checkout.session.completed

Signed with HMAC-SHA256 — verify the Coal-Signature header

User / BrowserCoal
Poll GET /api/pay/status/:sessionId

Frontend polls until status === "confirmed"

CoalUser / Browser
{ status: "confirmed", redirectUrl }
User / BrowserRedirected to your redirectUrl?session_id=...

Step-by-Step Breakdown

1. Session Creation

Your server, or the Coal hosted checkout page, calls POST /api/checkout/init with a payment link slug. Coal looks up the payment link, resolves the product and price, and creates a CheckoutSession in the database with status pending.

What happens:

  • Coal validates the slug and checks the link is active
  • For product-linked links: price is read from the Product record
  • For flexible (donation) links: you must supply an amount in the request body
  • A session is created with a 24-hour expiry
  • The session ID, amount, currency, merchant name, payout address, and expiry are returned

Request:

json
1POST /api/checkout/init
2{
3 "slug": "premium-membership"
4}

Response:

json
1{
2 "data": {
3 "sessionId": "cm9x4k2j00003lb08n5qz7v1r",
4 "amount": 25,
5 "currency": "USDC",
6 "description": "Premium Membership",
7 "merchant": {
8 "name": "Acme Corp",
9 "payoutAddress": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
10 },
11 "expiresAt": "2026-03-24T14:00:00.000Z"
12 }
13}

2. Checkout Page Render

When a user visits usecoal.xyz/pay/:slug, the frontend fetches the session and displays the hosted payment page. This page shows:

  • Merchant name and logo
  • Product name and description
  • Exact amount due
  • A wallet connect button

No authentication is required from the user at this point.


3. Wallet Connection

The user connects their EVM wallet (MetaMask, Coinbase Wallet, WalletConnect, or a Privy embedded wallet). Coal's frontend reads the connected address and prepares the ERC-20 transfer parameters:

  • Token contract: Configured settlement token on Base (SETTLEMENT_TOKEN_ADDRESS)
  • Recipient: the merchant's payoutAddress
  • Amount: session.amount converted to raw token units using the configured token decimals

4. On-Chain Transfer

The user reviews and signs the settlement-token ERC-20 transfer in their wallet. This is a standard transfer(address to, uint256 amount) call on the configured token contract. The transaction is broadcast to the Base network.

Coal does not hold funds at any point. The transfer goes directly from the user's wallet to the merchant's configured payout address. Coal's role is to verify the transfer happened correctly.

The transaction is confirmed on Base in approximately 1–2 seconds.


5. Transaction Hash Submission

Once the transfer is sent, the frontend captures the transaction hash and POSTs it to Coal:

Request:

json
1POST /api/pay/confirm
2{
3 "sessionId": "cm9x4k2j00003lb08n5qz7v1r",
4 "txHash": "0x3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b"
5}

Coal performs these checks synchronously:

  1. The session exists and is in pending status
  2. The session has not expired
  3. The txHash has not already been used in a confirmed transaction
  4. The txHash is not already submitted to a different session

If all checks pass, the session transitions to verifying and the pendingTxHash is recorded.

Response:

json
1{
2 "data": {
3 "status": "verifying",
4 "sessionId": "cm9x4k2j00003lb08n5qz7v1r"
5 }
6}

Note on idempotency: If you re-submit the same sessionId + txHash combination (e.g., due to a network retry), Coal returns the current session status without creating a duplicate. This makes the endpoint safe to call more than once.


6. Asynchronous On-Chain Verification

Coal runs a background cron job approximately every minute that processes all sessions in verifying status. The job:

  1. Fetches the transaction receipt from the Base RPC node using getTransactionReceipt(txHash)
  2. If the receipt is not yet available (transaction not mined), the session is left in verifying and retried on the next cron run
  3. If the transaction reverted, the session is marked failed
  4. Decodes the ERC-20 Transfer event from the receipt logs
  5. Validates the recipient address matches the merchant's payoutAddress (case-insensitive)
  6. Validates the transfer amount matches the session amount (using raw BigInt arithmetic - no floating point)
  7. If all validations pass, the session transitions to confirmed and a Transaction record is created

Timing: Under normal conditions, confirmation happens within 60–90 seconds of the on-chain transfer. In practice, most payments confirm in under 30 seconds since the cron runs frequently and Base block times are ~2 seconds.


7. Status Polling

Your frontend (or server) can poll GET /api/pay/status/:sessionId to monitor the session:

bash
1GET /api/pay/status/cm9x4k2j00003lb08n5qz7v1r
json
1{
2 "status": "confirmed",
3 "txHash": "0x3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b",
4 "redirectUrl": "https://yoursite.com/success"
5}

The redirectUrl field is only returned when status === "confirmed". Use this to redirect the user.


8. Webhook Delivery

When a session is confirmed, Coal fires a checkout.session.completed webhook to the callbackUrl set on the session (or the merchant's default webhookUrl if no per-session URL was provided).

Webhook payload:

json
1{
2 "event": "checkout.session.completed",
3 "data": {
4 "id": "cm9x4k2j00003lb08n5qz7v1r",
5 "amount": "25.000000",
6 "currency": "USDC",
7 "status": "confirmed",
8 "txHash": "0x3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b",
9 "explorerUrl": "https://basescan.org/tx/0x3a4b5c6d..."
10 }
11}

Webhooks are signed with HMAC-SHA256. The Coal-Signature header has the format t=TIMESTAMP,v1=SIGNATURE. See Webhooks for full verification instructions.


9. User Redirect

After confirmation is detected (either via polling or webhook), the user is redirected to your redirectUrl with the session ID appended as a query parameter:

text
1https://yoursite.com/success?session_id=cm9x4k2j00003lb08n5qz7v1r

Use the session_id to look up the order on your side if needed.


Session Status State Machine

text
1┌──────────────────────────────┐
2 │ │
3 ┌────────────▼──────────┐ │ expiresAt exceeded
4 │ pending │──────────────────▶│──────────────┐
5 └───────────────────────┘ │ │
6 │ │ ▼
7 POST /api/pay/confirm │ ┌─────────┐
8 │ │ │ expired │
9 ▼ │ └─────────┘
10 ┌──────────────────────┐ │
11 │ verifying │───────────────────┘
12 └──────────────────────┘
13 │ │
14 cron: tx OK cron: tx failed / amount
15 recipient OK mismatch / reverted
16 │ │
17 ▼ ▼
18 ┌──────────┐ ┌────────┐
19 │confirmed │ │ failed │
20 └──────────┘ └────────┘
StatusDescription
pendingSession created, waiting for the user to send the transaction
verifyingTransaction hash submitted; background job is checking the chain
confirmedTransaction verified on-chain; payment complete
failedOn-chain check failed (see failure scenarios below)
expiredSession passed its expiresAt deadline before confirmation

Timing Reference

EventTypical Duration
Base block time~2 seconds
Transaction receipt available2–6 seconds after broadcast
Cron job interval~60 seconds
End-to-end confirmation (typical)30–90 seconds
Session expiry (default)24 hours

Failure Scenarios

Transaction Reverted

The EVM transaction was included in a block but the execution failed (status 0x0 in the receipt). This can happen if the user had insufficient balance or allowance for the configured settlement token at the time of execution.

Result: Session moves to failed.

Recovery: The session cannot be reused. The user must start a new checkout session and attempt the payment again.


Amount Mismatch

The on-chain Transfer event shows a different token amount than what was recorded on the session. Coal compares amounts using raw BigInt arithmetic after normalizing to the configured token decimals - so even a 1-unit discrepancy can cause a failure.

Result: Session moves to failed.

Common cause: The user edited the transfer amount in their wallet, or fees were deducted from the transfer amount.


Recipient Mismatch

The to field in the decoded Transfer event does not match the merchant's configured payoutAddress. The comparison is case-insensitive.

Result: Session moves to failed.

Common cause: Merchant changed their payout address between session creation and payment, or the user sent to a different address.


Transaction Not Mined (Timeout)

The transaction hash was submitted but the transaction has not been mined within the session expiry window. This can happen if the user submitted a transaction with a very low gas price, causing it to be stuck in the mempool.

Result: On each cron run, Coal calls getTransactionReceipt(). If the receipt is unavailable, the session stays in verifying and is retried on the next run. If the session's expiresAt is reached before the transaction mines, the session transitions to expired.


No Transfer Event Found

The transaction was mined and succeeded, but the receipt logs do not contain an ERC-20 Transfer event from the configured settlement token contract address. This indicates the user sent a different token, called a different contract function, or the token address is misconfigured.

Result: Session moves to failed.


Session Expiry

If a session reaches its expiresAt timestamp (24 hours after creation) without being confirmed, it transitions to expired. This is checked both by the status endpoint (on every poll) and by the cron job (on every run).

Result: Session moves to expired. A new session must be created if the user still wants to pay.


Webhook Retry Policy

If Coal cannot reach your webhook endpoint (connection timeout, non-2xx response), it retries using exponential backoff:

AttemptDelay
1stImmediate
2nd1 minute
3rd5 minutes
4th30 minutes
5th2 hours

After 5 failed attempts, the webhook event is marked exhausted and no further retries occur. You can view webhook delivery history in Console → Webhooks.

To avoid missed events, always respond with 200 OK as quickly as possible and process the payload asynchronously (e.g. via a queue).


Next Steps