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 keyX-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.