Skip to content

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.

// In your checkout controller:
res.redirect(charge.widgetUrl + '?returnUrl=' + encodeURIComponent(
  `https://your-store.example/orders/${orderId}/confirmation`
));

For in-person or side-by-side displays, encode the widget URL as a QR code.

import QRCode from 'qrcode';

const qrDataUrl = await QRCode.toDataURL(charge.widgetUrl);
// Render qrDataUrl as an <img> src in your checkout page

Embed the widget directly on your checkout page.

<iframe
  src="{{ charge.widgetUrl }}"
  width="420"
  height="560"
  frameborder="0"
  allow="payment"
  title="Complete your payment"
></iframe>

Listen for the completion postMessage:

window.addEventListener('message', (event) => {
  if (event.origin !== 'https://pay.your-processor.example') return;
  if (event.data.type === 'PAYMENT_COMPLETE') {
    window.location.href = `/orders/${orderId}/confirmation`;
  }
});

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);
}

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 โ€” widgetUrl and ephemeralToken returned
  • 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.settled handler updates order status and triggers fulfilment
  • Idempotency check implemented (same webhook delivered twice is safe)
  • charge.expired and charge.failed handled 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