Docs

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, instead

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

Node.js
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 status field. Four of the seven events all sit inside processing, so a status field would be constant across the middle of the flow.
  • Deduplicate on id. The top-level id is generated once and reused across every delivery attempt. If a delivery times out after you accepted it, you may receive it again. Skip an id you 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"
  }
}
ReasonMeansWhat to do
payment_rejectedThe incoming payment could not be accepted.No funds are held. Start a new order.
payout_failedPulse 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_errorSomething 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.

On this page