Webhooks

Webhooks let your backend react to ICPay events such as payment intents being created or payments being completed. Endpoints receive signed JSON payloads with a 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 according to your endpoint’s retry count.

Create endpoints

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

  • URL: Your HTTPS receiver.
  • Subscribed events: One or more of the event types listed below.
  • Retry count and timeout.
  • Optional custom headers.

Event types

Only the following event types are currently emitted. Subscribe to these in your endpoint:

Event typeWhen it is triggered
payment_intent.createdA new payment intent is created (e.g. when a customer starts checkout).
payment.createdA payment record is created or first linked to a transaction.
payment.updatedA payment’s attributes change (e.g. status) without transitioning to completed/failed/cancelled/refunded.
payment.completedA payment has been successfully completed.
payment.failedA payment has failed.
payment.cancelledA payment was cancelled.
payment.refundedA payment was refunded.

Payload format

Every delivery uses this envelope. The data.object and optional data.previous_attributes depend on the event type.

{
  "id": "evt_...",
  "object": "event",
  "api_version": "2025-08-11",
  "created": 1733966400,
  "data": {
    "object": { },
    "previous_attributes": { }
  },
  "livemode": true,
  "pending_webhooks": 1,
  "request": { "id": "req_...", "idempotency_key": null },
  "type": "payment.completed"
}
  • id: Unique event id (e.g. evt_...). Use for idempotency.
  • type: One of the event types above.
  • data.object: The resource (payment intent or payment); see each event section below.
  • data.previous_attributes: Present for payment.updated (and similar) when relevant; contains the previous values of changed fields.

Sections below describe the data.object (and where applicable data.previous_attributes) for each event type.

payment_intent.created

Triggered when: A new payment intent is created via the API (e.g. when a customer initiates checkout).

Emitted by: ICPay API when createPaymentIntent succeeds.

data.object (payment intent):

{
  "id": "pi_...",
  "accountId": "acc_...",
  "amount": "100000000",
  "ledgerCanisterId": "ryjl3-tyaaa-aaaaa-aaaba-cai",
  "description": "Order #123",
  "expectedSenderPrincipal": null,
  "status": "requires_payment",
  "intentCode": 123456,
  "metadata": { "orderId": "ORD-123" },
  "createdAt": "2025-01-01T12:00:00.000Z"
}
FieldTypeDescription
idstringPayment intent id.
accountIdstringAccount id.
amountstringAmount in smallest unit (e.g. 8 decimals).
ledgerCanisterIdstringLedger canister id (token).
descriptionstring | nullOptional description.
expectedSenderPrincipalstring | nullOptional expected sender address.
statusstringe.g. requires_payment, processing, completed, failed, canceled, mismatched.
intentCodenumberIntent code (always a number in webhook payloads).
metadataobjectKey-value metadata from the intent.
createdAtstringISO 8601 timestamp.

payment.created

Triggered when: A payment record is created, or an existing payment is first linked to a transaction (e.g. when the chain sync attaches a transaction to the payment).

Emitted: When a payment is saved or when a previously pending payment gets a transactionId for the first time.

data.object (payment):

{
  "id": "pay_...",
  "accountId": "acc_...",
  "paymentIntentId": "pi_...",
  "transactionId": "tx_...",
  "transactionSplitId": "spl_...",
  "canisterTxId": "123456",
  "status": "pending",
  "amount": "150000000",
  "ledgerCanisterId": "ryjl3-tyaaa-aaaaa-aaaba-cai",
  "ledgerTxId": "ltx_...",
  "accountCanisterId": 42,
  "basePaymentAccountId": "acc_base",
  "invoiceId": null,
  "metadata": { "orderId": "ORD-123" },
  "requestedAmount": "150000000",
  "paidAmount": "140000000",
  "createdAt": "2025-01-01T12:00:00.000Z",
  "network": "ic",
  "token": "ICP"
}
FieldTypeDescription
idstringPayment id.
accountIdstringAccount id.
paymentIntentIdstringRelated payment intent id.
transactionIdstring | nullRelated transaction id once linked.
transactionSplitIdstring | nullTransaction split id for this payment.
canisterTxIdstring | nullChain/canister transaction id.
statusstringpending, completed, failed, canceled, refunded, mismatched.
amountstringAmount in smallest unit.
ledgerCanisterIdstringLedger (token) canister id.
ledgerTxIdstring | nullLedger transaction id.
accountCanisterIdnumber | nullTarget account canister id.
basePaymentAccountIdstring | nullBase account id for split.
invoiceIdstring | nullInvoice id if created.
metadataobjectFrom the payment intent.
requestedAmountstring | nullIntent amount (for mismatch comparison).
paidAmountstring | nullActual paid amount from transaction.
createdAtstringISO 8601 (mandatory).
networkstring | nullChain type from the chains table (e.g. ic, evm, sol).
tokenstring | nullToken symbol from the ledger (e.g. ICP, USDC).
intentobject | undefinedWhen present, includes intentCode (number), amount, metadata, etc.

If the payment is created with status failed, a payment.failed event is also emitted.

payment.updated

Triggered when: A payment is updated and the new status is not one of completed, failed, cancelled, or refunded (e.g. status or other attributes change while still processing).

data.object: Same shape as payment.created (see above). data.previous_attributes may contain the previous values of changed fields (e.g. { "status": "pending" }).

payment.completed

Triggered when: A payment’s status becomes completed (funds received and payment recorded).

data.object: Same payment shape as payment.created, with createdAt (mandatory), completedAt (when the payment was completed), network (chain type: ic | evm | sol) and token (symbol). When the payment has an associated intent, intent is included with intentCode as a number. Typically includes transactionId, ledgerTxId, amount, and optional requestedAmount / paidAmount for mismatch handling. Use this event to fulfill orders or unlock content.

payment.failed

Triggered when: A payment’s status becomes failed, or a payment is created with status failed.

data.object: Same payment shape as payment.created. Use this event to notify the user or retry logic.

payment.cancelled

Triggered when: A payment’s status becomes canceled.

data.object: Same payment shape as payment.created with status: "canceled".

payment.refunded

Triggered when: A payment’s status becomes refunded.

data.object: Same payment shape as payment.created with status: "refunded". Use this event to update your records or inventory.


Payment object fields (summary)

  • createdAt: ISO 8601 timestamp; mandatory on all payment events (including payment.completed).
  • network: Chain type from the chains table (e.g. ic, evm, sol). Always present so you don't need to derive it from ledgerCanisterId.
  • token: Token symbol from the ledger (e.g. ICP, USDC). Always present.
  • intent: When present, includes intentCode (number), amount, metadata, and other intent fields.
  • transactionSplitId: Id of the transaction split that this payment represents.
  • basePaymentAccountId: Base account for the intent/transaction (e.g. split rules).
  • ledgerTxId: Linked ledger transaction id after sweep/linking.
  • accountCanisterId: Canister id of the target account for this payment.
  • When status is mismatched, requestedAmount and paidAmount indicate the requested vs actual paid amount.

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 (use for replay tolerance).

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_intent.created': /* e.g. log or prepare order */ break
    case 'payment.completed': /* fulfill order */ break
    case 'payment.failed': /* notify user */ break
    case 'payment.refunded': /* update records */ 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 a timestamp tolerance (e.g. 300 seconds) to prevent replay.
  • Respond within ~5s; do heavy work asynchronously.
  • Implement idempotency using the event id (evt_*); store last processed ids to avoid duplicate handling.
  • Log deliveries and failures for debugging.
  • Use retries wisely; keep your endpoint highly available.

Was this page helpful?