Webhooks
Pulse calls your endpoint when an order reaches a milestone.
Pulse sends a POST to your endpoint each time an order reaches a milestone. There
are seven events. Each one fires at most once per order, and each carries only what
that moment produced.
An event carries one fact, not the whole order. To read the full order, call
GET /v1/orders/{id}.
What an event looks like
Every event has the same shape. The data object starts with order_id and
reference, then adds the fields for that event.
{
"id": "0198e2f1-3b7c-7a41-9e05-1c8d2f60b933",
"type": "order.payment_received",
"environment": "test",
"created_at": "2026-07-28T10:04:52Z",
"data": {
"order_id": "0198e2d0-…",
"reference": "piggy-txn-88213"
}
}reference is the one you set when you created the order, so you can match an event
to your own record without storing our order_id.
Pilot events carry environment: "test". Production events carry
environment: "live". Use the field as a guard against mixing environments.
The sequence
An order emits its events in this order.
order.awaiting_payment
↓
order.payment_received
↓
order.conversion_initiated
↓
order.payout_initiated
↓
order.completed ← terminal
order.expired ← terminal, instead
order.failed ← terminal, insteadExactly one terminal event fires: completed, expired, or failed. Treat any of
the three as the end.
To make an order emit each of these on pilot, see Testing on pilot.
Manage the endpoint
Open Webhooks in Pulse Console
and select the test or live environment. You can add an HTTPS endpoint, disable
or enable delivery, inspect delivery attempts, replay a delivery, rotate the signing
secret, or delete the endpoint.
The signing secret appears after endpoint creation or rotation. Store it in your secret manager before you close the dialog. Rotation invalidates the old secret immediately, so update your receiver at the same time.
Verify deliveries
Every delivery includes X-Pulse-Signature. Its value is the lowercase hexadecimal
HMAC-SHA256 of the raw request body, signed with the endpoint secret. Verify the
signature before you parse or process the JSON.
import { createHmac, timingSafeEqual } from 'node:crypto';
function validPulseSignature(
rawBody: Buffer,
signature: string,
secret: string,
): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}Use the exact bytes Pulse sent. Parsing the JSON and serializing it again changes the bytes and invalidates the signature. Compare digests in constant time, reject a missing or invalid signature, and keep the signing secret on your server.
A delivery can be retried after your endpoint completes the work but before Pulse
receives the response. After signature verification, process each event id once.
Record the id with the resulting business update, and return 2xx without repeating
the update when that id arrives again.
Handling deliveries
Acknowledge first, then do your work
Respond 2xx quickly. Anything else is retried with backoff, so a slow handler
looks like a failure and earns a duplicate. Acknowledge the event, then process it.
After a failed delivery, Pulse retries after 10 seconds, 30 seconds, 1 minute, 3 minutes, 5 minutes, 10 minutes, and 15 minutes.
- The event type is the state. There is no
statusfield. Four of the seven events all sit insideprocessing, so a status field would be constant across the middle of the flow. - Deduplicate on
id. The top-levelidis generated once and reused across every delivery attempt. If a delivery times out after you accepted it, you may receive it again. Skip anidyou have already processed. - Order of arrival is not guaranteed. Each event is a dated fact that fires once, so record which events have arrived rather than driving a state machine off their order.
- No provider or bank name appears in a payload. The one external identifier is
transaction_hash, which is public on-chain data.
Events
order.awaiting_payment
The order is live and waiting to be funded. You already have this from the create response. It repeats here so a create call that timed out still reaches you, and because an offramp deposit address is not always ready when that response returns.
{
"id": "0198e2d1-0a44-7f19-8c33-b7e2409d5a18",
"type": "order.awaiting_payment",
"environment": "test",
"created_at": "2026-07-28T10:02:11Z",
"data": {
"order_id": "0198e2d0-…",
"reference": "piggy-txn-88213",
"rate": "1538.46",
"amount": "150000",
"currency": "NGN",
"funding_account": {
"account_name": "Pulse / Ada Obi",
"account_number": "9901234567",
"bank_name": "Rubies MFB"
},
"expires_at": "2026-07-28T10:32:11Z"
}
}rate appears on this event and no other. It was fixed when the quote was accepted
and cannot change. On an offramp, funding_account is { "deposit_address": "0x…", "network": "POLYGON" }, and currency is the asset the customer sends.
order.payment_received
Onramp: the customer's naira landed in the virtual account. Offramp: their crypto landed at the deposit address. The money is now Pulse's to work with. Most integrations act on this event.
{
"id": "0198e3f8-4c11-7e63-9a07-2d5b81f4c920",
"type": "order.payment_received",
"environment": "test",
"created_at": "2026-07-29T14:33:52Z",
"data": {
"order_id": "0198e3f7-…",
"reference": "piggy-txn-88301",
"amount": "200",
"currency": "USDT",
"network": "POLYGON"
}
}amount is what actually arrived
amount is the amount that landed, which is not always what was quoted. A customer
who sends 190 USDT against a 200 USDT order produces this event with 190. Pulse
uses the received amount and the accepted quote rate to calculate the NGN payout.
network is present only when the leg is on-chain, so an onramp payment_received
carries amount and currency alone.
order.conversion_initiated
Pulse has started converting. Nothing is required of you. It gives a progress view something to show between payment and payout.
{
"id": "0198e2f2-6e05-7a88-b410-93cd7e2f6045",
"type": "order.conversion_initiated",
"environment": "test",
"created_at": "2026-07-28T10:05:30Z",
"data": {
"order_id": "0198e2d0-…",
"reference": "piggy-txn-88213",
"from": { "currency": "NGN", "amount": "150000" },
"to": { "currency": "USDT", "network": "POLYGON" }
}
}to has no amount. The conversion has started, not finished; the delivered figure
arrives on order.completed.
order.payout_initiated
Pulse has sent the other side. Onramp: crypto is on its way to the customer's address. Offramp: naira is on its way to the beneficiary's bank. Sent, not yet confirmed.
{
"id": "0198e2f3-11ba-7c47-9e82-40d6b5197ce3",
"type": "order.payout_initiated",
"environment": "test",
"created_at": "2026-07-28T10:06:58Z",
"data": {
"order_id": "0198e2d0-…",
"reference": "piggy-txn-88213",
"amount": "97.50",
"currency": "USDT",
"network": "POLYGON",
"transaction_hash": "0x7d4f1e83a6c2905b41fd7e3820ac95661b0d8f47e2c31a95604fb8d72e0a1c36"
}
}This is the first point either payout identifier exists, so store it against your
record. An onramp carries transaction_hash, the on-chain hash of the
transfer, safe to show and to link to a block explorer. An offramp carries
payout_reference instead, our reference for the bank transfer; quote it to us in
any query about a payout that has not landed. The two never appear together.
order.completed
Settled. The customer has their money, and the order will not change again.
{
"id": "0198e3fb-9d27-7b04-a165-6c80f213ae59",
"type": "order.completed",
"environment": "test",
"created_at": "2026-07-29T14:41:07Z",
"data": {
"order_id": "0198e3f7-…",
"reference": "piggy-txn-88301",
"amount": "304600",
"currency": "NGN",
"payout_reference": "PLS-8f3a2c91"
}
}Reconcile against this amount. It is what was actually delivered. For an offramp,
Pulse calculates it from the crypto received and the accepted quote rate. An onramp
order.completed carries network and transaction_hash in place of
payout_reference.
order.expired
The funding window closed without payment. No money moved, and there is nothing to reverse. Start a new quote and order if the customer still wants to proceed; an expired order cannot be revived, and its rate is stale.
{
"id": "0198e30c-15b8-7d93-8e41-77a0c2f6b501",
"type": "order.expired",
"environment": "test",
"created_at": "2026-07-28T10:32:11Z",
"data": {
"order_id": "0198e2ff-…",
"reference": "piggy-txn-88240",
"amount": "75000",
"currency": "NGN"
}
}amount is what was never paid, so you can tell the customer what they missed
without a lookup. order.expired never follows order.payment_received: an order
can only expire while awaiting payment. Money sent to a funding account after expiry
is not attributed to this order.
order.failed
The order could not be completed. Whether money is at stake depends on how far it
got, which is what reason tells you.
{
"id": "0198e41a-2f66-7b15-9d80-8e37c05a1b44",
"type": "order.failed",
"environment": "test",
"created_at": "2026-07-29T15:02:38Z",
"data": {
"order_id": "0198e414-…",
"reference": "piggy-txn-88318",
"reason": "payout_failed"
}
}| Reason | Means | What to do |
|---|---|---|
payment_rejected | The incoming payment could not be accepted. | No funds are held. Start a new order. |
payout_failed | Pulse took the payment but could not deliver the other side. | Funds are held and reconciled by Pulse. Contact us with the reference. Do not retry. |
internal_error | Something went wrong on our side. | Contact us with the reference. |
reason answers the two questions that change what you do: which side failed, and
whether the customer's money is sitting with us.