Webhook
Bitpayway sends a webhook when a payment order changes to one of the following statuses:
partial paidpaidexpired partial paidexpiredcanceled
No webhook is sent for the initial pending status when an order is created, nor while a payment that completes the order waits for block confirmations (paid waiting confirmations): you are notified once the order is paid. The only exception is multi-destination orders: a multi-destination order whose settlementStatus changed to mismatch is notified even while its status is still pending or paid waiting confirmations.
To avoid sending a separate HTTP request for every processed order, notifications are grouped by business before delivery, with up to 30 notifications in each request. Therefore, the request body is always a JSON array, even when there is only one notification.
Order statuses
The table below lists every status an order can have, including the ones that do not trigger a webhook. They can appear when you query an order through the API and in the status field of a notification.
| Status | Meaning |
|---|---|
pending | Waiting for payment. |
partial paid | Part of the amount was paid and confirmed. |
paid waiting confirmations | The amount was paid and is waiting for block confirmations. |
paid | The amount was paid and confirmed. |
expired | The 30 minutes ended without a payment. |
expired partial paid | The 30 minutes ended with only part of the amount paid. |
canceled | The order was canceled. |
An order accepts payments for 30 minutes after it is created. An order that is not fully paid in that time becomes expired, or expired partial paid if it received part of the amount.
HTTP request
Method: POST
Headers:
Content-Type: application/jsonSPP-Api-Timestamp: Unix timestamp in milliseconds indicating when the request was sent.SPP-Api-Signature: HMAC-SHA256 signature in the formatsha256=<hex digest>.
Body:
The body is an array of notifications. Each notification has notification: 'order' and a data object with the order. The shape of data depends on the order's payment model:
- Standard order:
datacontains only the common fields. ThepaymentModelfield is not present. - Multi-destination order:
datacontains the common fields plus the multi-destination fields, andpaymentModelis'split'.
Use the presence of paymentModel === 'split' to tell the two cases apart. An integration that does not use multi-destination payments can ignore the extra fields.
type WebhookBody = OrderNotification[];
type OrderNotification = {
notification: 'order';
data: StandardOrderData | MultiDestinationOrderData;
};
// Fields present on every notification.
type StandardOrderData = {
id: string;
requestId: string;
description: string;
amount: string;
status: OrderStatus;
network: string;
currency: string;
paidAmount: string;
netAmount: string;
fee: string;
};
// Multi-destination order: every common field + the fields below.
type MultiDestinationOrderData = StandardOrderData & {
paymentModel: 'split';
settlementStatus: 'pending' | 'matched' | 'mismatch';
settlementVersion: number;
split: {
partnerId: string;
partnerCodeId: string;
partnerAmount: string;
};
partnerNetAmount: string;
partnerGrossAmount: string;
clientCommission: string;
partnerCommission: string;
commissionReceived: string;
};
type OrderStatus =
| 'pending' // only on multi-destination orders in `mismatch`
| 'paid waiting confirmations' // only on multi-destination orders in `mismatch`
| 'partial paid'
| 'paid'
| 'expired partial paid'
| 'expired'
| 'canceled';
Common fields
Present on every notification, for standard and multi-destination orders.
| Field | Description |
|---|---|
id | UUID generated by Bitpayway for the payment order. |
requestId | UUID provided by your application when the order was created. |
description | Description provided when the order was created. |
amount | Requested payment amount, represented as a decimal string. |
status | Current order status detected by Bitpayway. |
network | Blockchain network name. |
currency | Token symbol. |
paidAmount | Total amount paid on the blockchain before fees, represented as a decimal string. |
netAmount | Amount sent to your wallet after payment fees, represented as a decimal string. |
fee | Platform spread, represented as a decimal string. On multi-destination orders it is the sum of clientCommission and partnerCommission. |
All monetary values are decimal strings already converted to the token's unit (for example, "1000.52" USDC), not raw wei values.
Example: standard order
[
{
"notification": "order",
"data": {
"id": "45ccdc4c-8457-4e9c-a16d-4c9d35f128ed",
"requestId": "2f3c9d2c-1b75-4e9d-9b2f-67d0a9c3c7d1",
"description": "Order #1234",
"amount": "1000.52",
"status": "partial paid",
"network": "ethereum",
"currency": "USDC",
"paidAmount": "1000",
"netAmount": "950",
"fee": "50"
}
}
]
Multi-destination orders
A multi-destination order sends the same notification, extended with the fields below. They are absent on standard orders, so an existing integration keeps working unchanged.
| Field | Description |
|---|---|
paymentModel | Always split. Absent on standard orders. |
settlementStatus | pending, matched or mismatch. A mismatch order needs reconciliation with your partner. It is independent of the order status. |
settlementVersion | Version of the confirmed amounts; it increases whenever they change. |
split.partnerId | UUID of the partner link. |
split.partnerCodeId | On-chain identifier of the partner link. |
split.partnerAmount | Gross share agreed when the order was created. |
netAmount | Amount effectively received by you. |
partnerNetAmount | Amount effectively received by the partner. |
partnerGrossAmount | Gross amount accumulated for the partner. |
clientCommission | Nominal fee charged on your share. |
partnerCommission | Nominal fee charged on the partner's share. |
commissionReceived | Amount effectively received by the platform. |
Example: multi-destination order
[
{
"notification": "order",
"data": {
"id": "45ccdc4c-8457-4e9c-a16d-4c9d35f128ed",
"requestId": "2f3c9d2c-1b75-4e9d-9b2f-67d0a9c3c7d1",
"description": "Order #1234",
"amount": "1000",
"status": "paid",
"network": "ethereum",
"currency": "USDC",
"paidAmount": "1000",
"netAmount": "665",
"fee": "50",
"paymentModel": "split",
"settlementStatus": "matched",
"settlementVersion": 1,
"split": {
"partnerId": "9b1d2c3e-4f50-4a6b-8c7d-0e1f2a3b4c5d",
"partnerCodeId": "12",
"partnerAmount": "300"
},
"partnerNetAmount": "285",
"partnerGrossAmount": "300",
"clientCommission": "35",
"partnerCommission": "15",
"commissionReceived": "50"
}
}
]
When a multi-destination order is notified
Besides status changes, a multi-destination order is also notified whenever its settlementStatus or confirmed amounts change — which increments settlementVersion. This has two consequences:
- A notification can arrive after a terminal status, so a multi-destination order can be notified more than once and the
statuscan repeat. - An order in
settlementStatus: "mismatch"is notified even withstatus: "pending", so you can start reconciliation before any payment confirmation.
Treat settlementVersion as part of your idempotency key for financial updates, and do not repeat a commercial fulfilment on every update. A response to an older notification does not confirm delivery of a newer version.
See Multi-destination payments for how the amounts are calculated and when an order is considered approved.
Delivery and retries
Your endpoint should process every item in the array and return a successful 2xx response after accepting the notifications.
The result of an HTTP request applies to the complete batch:
- A successful
2xxresponse marks every order whose status has not changed since the batch was created as notified. - A timeout, connection error, or non-
2xxresponse leaves the complete batch eligible for retry.
Bitpayway makes up to five delivery attempts. Because a request can be processed by your endpoint even when Bitpayway does not receive a successful response, a retry can deliver duplicate notifications. Process notifications idempotently, using the order id and status together as the idempotency key.
Verifying the signature
The signature is calculated from the exact raw request body:
<timestamp>\n<uppercase HTTP method>\n<raw request body>
For a webhook, the HTTP method is POST. The URL and path are not part of the signed content.
Use the webhook secret key generated in the business configuration as the HMAC secret. Capture the raw body before parsing it; serializing the parsed JSON again can produce different bytes and invalidate the signature.
The example below verifies the signature using Node.js:
import crypto from 'node:crypto';
const verifyHmacSignature = (params: {
apiSecret: string;
timestampHeader: string | undefined;
signatureHeader: string | undefined;
method: string;
rawBody: string;
maxSkewMs?: number;
}) => {
const {
apiSecret,
timestampHeader,
signatureHeader,
method,
rawBody,
maxSkewMs = 1000 * 60 * 5, // 5 minutes
} = params;
if (!timestampHeader || !signatureHeader) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
// Protect against replay attacks.
if (Math.abs(Date.now() - timestamp) > maxSkewMs) {
return false;
}
const expectedPrefix = 'sha256=';
if (!signatureHeader.startsWith(expectedPrefix)) {
return false;
}
const receivedHmacHex = signatureHeader.slice(expectedPrefix.length);
const stringToSign = [timestamp.toString(), method.toUpperCase(), rawBody].join('\n');
const calculatedHmacHex = crypto
.createHmac('sha256', apiSecret)
.update(stringToSign, 'utf8')
.digest('hex');
const receivedBuffer = Buffer.from(receivedHmacHex, 'hex');
const calculatedBuffer = Buffer.from(calculatedHmacHex, 'hex');
if (receivedBuffer.length !== calculatedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(receivedBuffer, calculatedBuffer);
};
The five-minute tolerance in this example is a client-side policy and can be adjusted. Validating the timestamp is strongly recommended to reduce the risk of replay attacks.