Webhooks

Webhooks let your backend react to ICPay events such as payments being created or completed. Endpoints receive signed JSON payloads with Stripe-like structure and X-ICPay-Signature.

Overview

  • Configure endpoints in icpay.org under Settings → Webhook Endpoints.
  • Subscribe per-endpoint to one or more event types.
  • ICPay retries failed deliveries up to your endpoint’s configured retry count.

Create endpoints

In icpay.org → Settings → Webhook Endpoints, click “New Endpoint”, set:

  • URL: your HTTPS receiver.
  • Subscribed events.
  • Retry count and timeout.
  • Optional custom headers.

Event types

Supported event types map to WebhookEventType in the API:

  • payment.created
  • payment.updated
  • payment.completed
  • payment.failed
  • payment.cancelled
  • payment.refunded
  • payment_intent.created
  • account.updated
  • account.verified
  • wallet.created
  • wallet.updated
  • wallet.balance_changed
  • ledger.created
  • ledger.updated

Payload format

Deliveries use this canonical shape:

{
  "id": "evt_...",
  "object": "event",
  "api_version": "2025-08-11",
  "created": 1733966400,
  "data": {
    "object": { /* domain object, e.g. payment */ },
    "previous_attributes": { /* optional diff */ }
  },
  "livemode": true,
  "pending_webhooks": 1,
  "request": { "id": "req_...", "idempotency_key": null },
  "type": "payment.completed"
}

Example payment.completed (includes mismatch context when applicable). When a normal transaction has an amount mismatch, the payment status is mismatched and both amounts are present:

{
  "type": "payment.completed",
  "data": {
    "object": {
      "id": "pay_123",
      "accountId": "acc_abc",
      "paymentIntentId": "pi_123",
      "transactionId": "tx_789",
      "transactionSplitId": "spl_456",
      "basePaymentAccountId": "acc_base",
      "canisterTxId": 123456,
      "amount": "150000000",
      "ledgerCanisterId": "ryjl3-tyaaa-aaaaa-aaaba-cai",
      "ledgerTxId": "ltx_001",
      "accountCanisterId": 42,
      "status": "mismatched",
      "requestedAmount": "150000000",
      "paidAmount": "140000000", // If the amounts match, `status` will be `completed`.
      "invoiceId": "inv_001",
      "metadata": {"orderId": "ORD-123"},
      "createdAt": "2025-01-01T12:00:00Z",
      "updatedAt": "2025-01-01T12:00:10Z"
    }
  }
}

Payment object fields

  • transactionSplitId: Identifier of the TransactionSplit that this payment represents.
  • basePaymentAccountId: The base account for the intent/transaction (always present with ≥ 0.01% in split rules).
  • ledgerTxId: Linked LedgerTransaction id once sweep/linking completes.
  • accountCanisterId: Canister id of the target account for this payment.

Verify signatures

Every request includes:

  • X-ICPay-Signature: t=<unix>,v1=<hex> HMAC-SHA256 over "<t>." + payload using your account secret key
  • X-ICPay-Timestamp: Unix seconds

Node/Express verification

import crypto from 'crypto'

function verifyIcpaySignature(payload: string, header: string, secret: string, toleranceSec = 300) {
  const parts = header.split(',')
  const t = Number(parts.find(p => p.startsWith('t='))?.split('=')[1])
  const sig = parts.find(p => p.startsWith('v1='))?.split('=')[1]
  if (!t || !sig) return false
  if (Math.abs(Math.floor(Date.now()/1000) - t) > toleranceSec) return false
  const signed = `${t}.${payload}`
  const computed = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex')
  return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(computed, 'hex'))
}

Example handlers

Express

import express from 'express'
const app = express()
app.post('/webhooks/icpay', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-ICPay-Signature') || ''
  const ok = verifyIcpaySignature(req.body.toString('utf8'), signature, process.env.ICPAY_SECRET!)
  if (!ok) return res.status(401).send('Invalid signature')
  const event = JSON.parse(req.body.toString('utf8'))
  switch (event.type) {
    case 'payment.completed': /* fulfill order */ break
    case 'payment.failed': /* notify user */ break
    default: /* log */
  }
  res.sendStatus(200)
})

Next.js Route Handler

export const config = { api: { bodyParser: false } }
export async function POST(req: Request) {
  const payload = await req.text()
  const sig = req.headers.get('x-icpay-signature') || ''
  if (!verifyIcpaySignature(payload, sig, process.env.ICPAY_SECRET!)) {
    return new Response('Invalid signature', { status: 401 })
  }
  const event = JSON.parse(payload)
  // handle event.type
  return new Response('ok')
}

Best practices

  • Verify signatures and enforce timestamp tolerance.
  • Respond within 5s; perform heavy work async.
  • Implement idempotency using event id evt_*.
  • Log deliveries and store last processed evt_*.
  • Use retries prudently; keep endpoints highly available.

Was this page helpful?