Guide โ Merchant Integration
Integrate Merchant Checkout¶
This guide covers the merchant side of a Stablecoin Stack payment: creating a charge via the checkout API, presenting the payment widget to the customer, receiving the settlement webhook, and reconciling orders. If you have integrated any payment gateway before, the pattern will be familiar.
Time to complete: 1โ2 hours for a working integration.
How it works from the merchant's side¶
Your server Checkout Engine Customer wallet
โ โ โ
โโโ POST /charges โโโบ โ โ
โโโโ { widgetUrl, โ โ
โ ephemeralToken}โ โ
โ โ โ
โโโ present widgetUrl to customer โโโโโโโโโโโโโบโ
โ โโโโ redeem token โโโโโโโโ
โ โโโโ session params โโโโโบโ
โ โโโโ submit payment โโโโโโ
โ โ โ
โ โโโ settlement event โโบ โ
โโโโ webhook โโโโโโโโโโ โ
โ โ โ
Your server creates a charge and gets a widget URL back. The customer scans or follows the link. Your server receives a webhook when the payment settles. You never touch signatures, keys, or the blockchain directly.
Prerequisites¶
- An account and API credentials with a Stablecoin Stack processor
- Your processor's base API URL and webhook secret
- A server capable of receiving HTTPS POST webhooks
- A local stack running for development (see Run Locally)
Step 1 โ Authenticate with the checkout API¶
The checkout API uses mTLS for production merchant connections. For development and testing, your processor will provide a standard bearer token.
// Development / testing
const apiClient = axios.create({
baseURL: 'https://checkout.your-processor.example/api/v1',
headers: {
Authorization: `Bearer ${process.env.CHECKOUT_API_KEY}`,
'Content-Type': 'application/json',
},
});
// Production โ mTLS (mutual TLS)
// Use your processor's CA-issued certificate and private key.
// Example with Node.js https module:
import https from 'https';
import fs from 'fs';
const productionAgent = new https.Agent({
cert: fs.readFileSync('./certs/merchant.crt'),
key: fs.readFileSync('./certs/merchant.key'),
ca: fs.readFileSync('./certs/processor-ca.crt'),
});
Step 2 โ Create a charge¶
When a customer is ready to pay, your server creates a charge. This is a single API call.
interface CreateChargeRequest {
amount: string; // principal in token's smallest unit
token: string; // token contract address
beneficiary: string; // your receiving wallet address
currency: string; // display currency, e.g. 'USDC'
orderId: string; // your internal order reference
expiresIn?: number; // seconds until the charge expires (default: 900)
metadata?: Record<string, string>; // pass-through, returned in webhook
}
const charge = await apiClient.post('/charges', {
amount: '10000000', // $10.00 (USDC has 6 decimals)
token: USDC_CONTRACT_ADDRESS,
beneficiary: YOUR_WALLET_ADDRESS,
currency: 'USDC',
orderId: 'order-abc-123',
metadata: {
customerId: 'cust-456',
productSku: 'sku-789',
},
}).then(r => r.data);
// charge contains:
// {
// chargeId: 'chg_01HXYZ...',
// widgetUrl: 'https://pay.your-processor.example/s/abc123',
// ephemeralToken: 'eyJ...',
// expiresAt: 1780000000,
// status: 'awaiting_payment',
// }
Step 3 โ Present the widget to the customer¶
How you present the widget URL depends on your checkout flow:
The simplest approach โ redirect the customer to the widget URL. After payment, the widget redirects back to your returnUrl.
For in-person or side-by-side displays, encode the widget URL as a QR code.
Step 4 โ Set up webhook handling¶
Your processor sends a webhook to your server when a payment settles. Configure the endpoint URL in your processor's merchant dashboard.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use('/webhooks/stablecoin', express.raw({ type: 'application/json' }));
app.post('/webhooks/stablecoin', async (req, res) => {
// 1 โ Verify the webhook signature
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(`${timestamp}.${req.body}`)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2 โ Parse the event
const event = JSON.parse(req.body.toString());
// 3 โ Handle the event type
if (event.type === 'charge.settled') {
await handleChargeSettled(event.data);
}
// 4 โ Acknowledge immediately
// Always return 200 before doing slow work โ use a queue for processing.
res.status(200).json({ received: true });
});
Step 5 โ Handle the charge.settled event¶
interface ChargeSettledEvent {
chargeId: string;
orderId: string; // your orderId from the charge creation
amount: string; // principal received (after fees)
fee: string; // total fees deducted
token: string; // token contract address
payer: string; // payer wallet address
txHash: string; // on-chain transaction hash
blockNumber: number;
settledAt: number; // Unix timestamp
orderReference: string; // 16-byte reference from the settlement contract event
metadata: Record<string, string>; // passed through from charge creation
}
async function handleChargeSettled(data: ChargeSettledEvent) {
// Idempotency โ the same webhook may be delivered more than once
const existing = await db.orders.findOne({
where: { chargeId: data.chargeId, status: 'paid' }
});
if (existing) return; // Already processed
// Update your order
await db.orders.update({
where: { id: data.orderId },
data: {
status: 'paid',
chargeId: data.chargeId,
txHash: data.txHash,
settledAt: new Date(data.settledAt * 1000),
amountReceived: data.amount,
},
});
// Trigger fulfilment
await fulfillOrder(data.orderId);
// Send customer confirmation email
await sendConfirmationEmail(data.orderId);
}
Step 6 โ Verify settlement on-chain (optional but recommended)¶
For high-value transactions, verify the settlement event directly on-chain using the Event Explorer rather than trusting the webhook alone.
import { ExplorerClient } from '@stablecoin-stack/explorer-sdk';
const explorer = new ExplorerClient('https://explorer.your-processor.example');
async function verifySettlement(txHash: string, orderReference: string) {
const event = await explorer.getEventByTxHash(txHash);
if (!event) throw new Error('Settlement event not found');
// The orderReference is embedded in the first 16 bytes of the ref field
const embeddedRef = event.orderReference.slice(0, 34); // '0x' + 32 hex chars
if (embeddedRef.toLowerCase() !== orderReference.toLowerCase()) {
throw new Error('Order reference mismatch โ do not fulfil');
}
return event;
}
Step 7 โ Handle expired and failed charges¶
app.post('/webhooks/stablecoin', async (req, res) => {
// ... signature verification ...
const event = JSON.parse(req.body.toString());
switch (event.type) {
case 'charge.settled':
await handleChargeSettled(event.data);
break;
case 'charge.expired':
// The customer did not pay within the charge window.
await db.orders.update({
where: { id: event.data.orderId },
data: { status: 'expired' },
});
// Optionally: send customer a "payment link expired" email
// with an option to restart checkout.
break;
case 'charge.failed':
// Payment was attempted but failed on-chain.
// The charge can be retried โ the payer's funds were not moved.
await db.orders.update({
where: { id: event.data.orderId },
data: { status: 'payment_failed', failureReason: event.data.reason },
});
break;
}
res.status(200).json({ received: true });
});
Step 8 โ Query charge status (polling fallback)¶
If your webhook endpoint is unavailable when a payment settles, you can poll the charge status:
async function pollChargeStatus(chargeId: string): Promise<ChargeStatus> {
const charge = await apiClient
.get(`/charges/${chargeId}`)
.then(r => r.data);
return charge.status;
// Possible values: 'awaiting_payment' | 'settled' | 'expired' | 'failed'
}
// Simple polling loop (use exponential backoff in production)
async function waitForSettlement(chargeId: string): Promise<void> {
for (let i = 0; i < 60; i++) {
const status = await pollChargeStatus(chargeId);
if (status === 'settled') return;
if (status === 'expired' || status === 'failed') {
throw new Error(`Charge ${status}`);
}
await new Promise(r => setTimeout(r, 5000));
}
throw new Error('Timed out waiting for settlement');
}
Full integration checklist¶
- Charge creation working โ
widgetUrlandephemeralTokenreturned - Widget presented to customer (redirect, QR, or iframe)
- Webhook endpoint deployed at a publicly accessible HTTPS URL
- Webhook secret configured in merchant dashboard
- Webhook signature verification implemented
-
charge.settledhandler updates order status and triggers fulfilment - Idempotency check implemented (same webhook delivered twice is safe)
-
charge.expiredandcharge.failedhandled gracefully - Polling fallback implemented for webhook outages
- On-chain verification implemented for high-value transactions (optional)
Common mistakes¶
| Mistake | Consequence | Fix |
|---|---|---|
| Not verifying the webhook signature | Accepting forged settlement notifications | Always verify with crypto.timingSafeEqual |
Fulfilling on SUBMISSION_STATUS: SUCCESS instead of the webhook |
Fulfilling before on-chain confirmation | Only fulfil on charge.settled webhook |
| Not implementing idempotency | Double-fulfilling on duplicate webhook delivery | Check for existing chargeId before processing |
| Signing the permit for the principal only (no fees) | On-chain revert | The wallet handles this โ no action needed on your side |
Using the same orderId for retried checkouts |
Mismatched reconciliation | Generate a new charge for each payment attempt |
Next steps¶
- Use the Event Explorer โ โ query historical settlements and build reconciliation dashboards
- Register as an Acquirer โ โ earn commission on payments you refer
- Launch a Processor โ โ deploy your own Stablecoin Stack instance