Skip to main content

Webhooks

Webhooks notify your system in real-time when payment events occur. Instead of polling the API, register a URL and we’ll send HTTP POST requests with event data as payments are confirmed, expire, or fail.

Event Types

EventDescriptionWhen It Fires
payment_intent.confirmedPayment received and confirmed on-chain.~3-15 seconds after customer sends USDC.
payment_intent.expiredPayment intent expired without receiving payment.At the expires_at timestamp.
payment_intent.cancelledPayment intent was cancelled by the integrator.Immediately after cancellation API call.
transaction.createdA new USDC transaction was detected at a merchant’s payment address.When any USDC transfer is confirmed, even if not tied to an active payment intent.
settlement.completedMerchant funds were sent to their bank account.When the ACH transfer is initiated.
settlement.failedBank transfer failed (e.g., invalid account).When the ACH return is received (1-4 business days).

Webhook Payload

Every webhook has the same envelope structure:
{
  "id": "evt_abc123",
  "object": "event",
  "type": "payment_intent.confirmed",
  "api_version": "2026-04-01",
  "created_at": "2026-04-01T20:00:12Z",
  "data": {
    // Event-specific payload (see below)
  }
}

payment_intent.confirmed

{
  "id": "pi_xyz789",
  "object": "payment_intent",
  "status": "confirmed",
  "amount": 4.50,
  "currency": "usd",
  "net_amount": 4.455,
  "fee": 0.045,
  "fee_rate": 0.01,
  "merchant_id": "mer_abc123",
  "payment_address": "0x1a2b3c4d5e6f...abcdef",
  "tx_hash": "0xabc123...def456",
  "chain": "base",
  "token": "USDC",
  "sender_address": "0x9876...5432",
  "confirmed_at": "2026-04-01T20:00:12Z",
  "block_number": 12345678,
  "metadata": {
    "order_id": "order_456",
    "terminal_id": "pos_01"
  }
}

payment_intent.expired

{
  "id": "pi_xyz789",
  "object": "payment_intent",
  "status": "expired",
  "amount": 4.50,
  "currency": "usd",
  "merchant_id": "mer_abc123",
  "expired_at": "2026-04-01T20:05:00Z",
  "metadata": {
    "order_id": "order_456"
  }
}

Handling Webhooks

1. Return 200 quickly

Your endpoint must return a 200 status code within 5 seconds. Do any heavy processing asynchronously after responding.
app.post('/webhooks/stablegenius', async (req, res) => {
  // Return 200 immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  const event = req.body;
  await processEvent(event);
});

2. Handle duplicates

Webhooks may be delivered more than once. Use the evt_* ID to deduplicate:
async function processEvent(event) {
  // Check if we've already processed this event
  const exists = await db.events.findOne({ event_id: event.id });
  if (exists) return; // Already processed

  // Record the event
  await db.events.insert({ event_id: event.id, processed_at: new Date() });

  // Handle the event
  switch (event.type) {
    case 'payment_intent.confirmed':
      await markOrderPaid(event.data);
      break;
    case 'payment_intent.expired':
      await handleExpiredPayment(event.data);
      break;
  }
}

3. Verify signatures

Every webhook includes a signature header for verification. See Webhook Security for details.

Retry Policy

If your endpoint returns a non-2xx status code or doesn’t respond within 5 seconds, we retry with exponential backoff:
AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
612 hours (final)
After 6 failed attempts, the webhook is marked as failed. You can view and manually retry failed webhooks in the dashboard.

Testing Webhooks

In sandbox mode (sk_test_* keys), you can trigger test webhook events from the dashboard to verify your endpoint is working correctly without sending real USDC.