Skip to content

Accept a payment

The order lifecycle around a payment: store it, verify it, fulfil it once.

Calling the API is the easy half. Four requests and you have a payment, which the payments quickstart walks through line by line. This guide is the other half: the order in your own database, which has to end up correct whatever the customer, the browser and the network decide to do.

Everything below serves one sentence. Your order is fulfilled exactly once, and only when Wajub has confirmed the money.

You are keeping two records. Yours is the order: what was bought, by whom, and whether it has been delivered. Wajub keeps the payment: how much was collected, from which operator, and whether it succeeded.

The two records are joined by exactly one value, the payment id. Store it on the order the moment you create the payment, and every later question has an answer.

The reverse link exists too. reference is a field you set on the payment, normally your order number, and Wajub returns it on every read. It is convenient for a human reading the Dashboard, but it carries no uniqueness constraint and no lookup endpoint, so never build logic on it.

1. Give the order a status before you call anything

The order exists before the payment does. Create it first, in a state that says clearly that nothing has been paid and nothing has been sent.

The split between paid and fulfilled in the diagram above is what makes the rest of this guide possible. Confirming the money and delivering the goods are two different events that can fail independently, and collapsing them into one column means a crash halfway through delivery leaves you with no way to tell what has already been done.

Here is the shape of the table. Two columns do the real work: payment_id, which links to Wajub, and fulfilled_at, which is the guard against delivering twice.

The orders table
CREATE TABLE orders (
  id              BIGSERIAL PRIMARY KEY,
  reference       VARCHAR(64) NOT NULL UNIQUE,
  amount          NUMERIC(12, 2) NOT NULL,
  currency        CHAR(3) NOT NULL,
  status          VARCHAR(32) NOT NULL DEFAULT 'awaiting_payment',
  payment_id      VARCHAR(64) UNIQUE,
  attempts        INTEGER NOT NULL DEFAULT 0,
  fulfilled_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX orders_payment_id_idx ON orders (payment_id);

2. Create the payment and store its id

Now the API call. Send the amount, the currency, the customer and the address you want the browser sent back to.

POSThttps://api.wajub.com/payments
curl https://api.wajub.com/payments \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Idempotency-Key: order-4172" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "XAF",
    "customer": { "email": "amina@example.com" },
    "reference": "order-4172",
    "callback": "https://shop.example.com/orders/4172/return"
  }'

The Idempotency-Key header is what keeps a customer who double-clicks from opening two payments. Derive it from something stable, like the order reference, rather than letting the SDK generate a fresh random one. Within twenty-four hours the same key returns the original payment; the same key with a different payload is refused.

Store payment.id and only then redirect the browser to payment.authorization_url. If your process dies between the two, an order with no payment_id is a recoverable problem. An order that was paid and cannot be matched to anything is not.

3. Two things race to tell you the news

After the redirect, the customer pays. Two independent channels will try to tell you, and they can arrive in either order, or only one of them, or neither.

ChannelReaches you whenTrustworthy on its own
The browser returning to your callbackThe customer comes back to your siteNo
The payment.succeeded webhookAlways, browser or no browserYes, after signature checks

The browser return is not evidence. Anyone can open that URL by hand, and a customer who pays then closes the tab never opens it at all. The webhook is the one that always fires, including for the operator confirmation that lands ten minutes later.

So neither one owns the fulfilment. Both of them call the same function, and that function decides.

4. Write the fulfilment once, and make it repeatable

This is the piece everything else leans on. One function, taking a payment id, safe to call any number of times.

Three things make it safe. It fetches the payment from Wajub rather than trusting its caller, it checks the amount as well as the status, and it claims the order with a conditional update before doing any work.

# The check the function makes, by hand. Fulfil on this and nothing else.
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

The claim is the important line. It is a single conditional UPDATE that only succeeds for the first caller, which is what makes two simultaneous deliveries impossible without a lock.

Claiming an order, in SQL
UPDATE orders
   SET status = 'paid'
 WHERE id = $1
   AND fulfilled_at IS NULL
   AND status <> 'paid';

One row updated means you won the race and may deliver. Zero rows means somebody else already has it, and the correct response is to do nothing at all.

5. The webhook handler

The handler has one job beyond calling the function you just wrote: prove the request really came from Wajub. Verification needs the raw request body, the signature, and the timestamp, which arrive in headers.

HeaderWhat it carries
X-Wajub-Signaturev1= followed by an HMAC-SHA256 of the timestamp and the body
X-Wajub-TimestampThe Unix second the event was signed, rejected beyond 300 seconds of drift
X-Wajub-EventThe event name, also present in the body
X-Wajub-Delivery-IdStable across retries of the same delivery, so it is what you deduplicate on

The SDK does the whole check for you, including replay protection. Give it the untouched bytes of the body, answer 200, and only then work.

# What Wajub sends you. Every SDK below verifies these three things.
POST /webhooks/wajub HTTP/1.1
X-Wajub-Signature: v1=8f3c…
X-Wajub-Timestamp: 1789012345
X-Wajub-Event: payment.succeeded
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
Content-Type: application/json

Answer 200 fast. A delivery that does not get a 2xx within ten seconds counts as failed and is retried five times, at thirty seconds, one minute, five minutes, ten minutes and one hour. Slow work belongs in a queue, not in the request.

6. What the event actually contains

Once verified, the event is a plain object with a fixed envelope. event is the name, and data is the object the event is about, which for a payment is the payment itself.

A verified payment.succeeded
{
"id": "evt_9Lq5RtVb2Kd8",
"event": "payment.succeeded",
"livemode": true,
"api_version": "2026-08-01",
"created": "2026-09-13T10:31:02+00:00",
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"amount": 5000,
"amount_paid": 5000,
"currency": "XAF",
"status": "succeeded",
"channel": "cm.mtn",
"customer": {
"id": "cus_sAaim5apjocIgtlhzJY3wQ8s",
"email": "amina@example.com"
}
}
}

So data.id is the payment id, which is exactly what fulfillPayment takes. Two events matter for an order, and the rest are worth logging and nothing more.

EventWhat your worker does with it
payment.succeededfulfillPayment(data.id)
payment.failedMark the order failed, store data.failure_reason, offer a retry
payment.expiredSame, the session ran out before the customer confirmed
payment.cancelledSame, the customer or you stopped it
payment.created, payment.processingLog them. Useful in support, never a trigger

7. The return page

The customer comes back to your callback. This page exists to tell a human what happened, and it should show them something true rather than something optimistic.

Wajub appends its own parameters to that URL, and their names are a trap worth knowing: reference holds the Wajub payment id, trxref holds the reference you sent, and status is whatever the browser was told. Read none of them for anything that matters.

Ask the API instead, using the payment_id you stored on the order.

curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

Note that the success branch calls the same fulfillPayment. Whichever of the two channels arrives first does the work, the second one finds the order already claimed and returns quietly. That is the whole point of writing it once.

A pending payment is not a failure. It is a customer who has not finished confirming on their handset yet. Show them a page that says so and keep the order open.

8. When nothing ever arrives

Some orders never resolve. The customer opened the page and walked away, or the operator went quiet and the session expired unnoticed.

Sweep them on a schedule. Anything still awaiting_payment after an hour gets asked once, directly.

curl "https://api.wajub.com/payments?status=pending&per_page=100" \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

This job is not a substitute for webhooks and should almost never find anything. It is the net under the trapeze, and the day a webhook endpoint is misconfigured it is the reason nobody notices.

9. Test all of it in sandbox

In sandbox the last six digits of the customer's phone number choose the outcome, so you can walk every branch above without an operator. The prefix picks the country and operator; the suffix picks what happens.

SuffixOutcomeWhat to check in your code
000000SucceedsThe order reaches fulfilled, exactly once
000001Insufficient fundsThe order reaches payment_failed, the customer sees why
000002Declined by the operatorSame path, different reason
000003Operator timeoutThe pending page holds, then the sweep resolves it
000004The customer declines the promptThe order stays recoverable, not deleted

Cameroon MTN is +23767, so a successful test number is +237670000000 and an insufficient funds one is +237670000001. Every operator and country prefix is listed in Test scenarios.

Replay a webhook instead of paying again

Once a sandbox payment has succeeded, resend its events from Konsole as many times as you like. It is the fastest way to prove your handler is genuinely idempotent, because it delivers the same event twice on purpose.

What did you think of this content?