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.
The two records, and the one link between them
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.
You cannot fetch a payment by your own reference
GET /payments/{id} resolves the Wajub id and nothing else. Sending your order number there
answers 404. This is the most common mistake in a first integration, so store the id before
you redirect, not after.
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.
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.
https://api.wajub.com/paymentscurl 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.
| Channel | Reaches you when | Trustworthy on its own |
|---|---|---|
The browser returning to your callback | The customer comes back to your site | No |
The payment.succeeded webhook | Always, browser or no browser | Yes, 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.
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.
Check the amount, not only the status
A succeeded payment for 500 XAF against an order of 5 000 XAF is still a successful payment. If
your checkout ever lets the client suggest a price, the amount comparison above is the only thing
standing between you and shipping goods for a tenth of their value.
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.
| Header | What it carries |
|---|---|
X-Wajub-Signature | v1= followed by an HMAC-SHA256 of the timestamp and the body |
X-Wajub-Timestamp | The Unix second the event was signed, rejected beyond 300 seconds of drift |
X-Wajub-Event | The event name, also present in the body |
X-Wajub-Delivery-Id | Stable 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/jsonAnswer 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.
The raw body, not the parsed one
Signature verification runs against the exact bytes Wajub signed. A JSON body parser reorders
nothing but does re-serialise, which is enough to break the hash. Mount express.raw() on this
route only, or read the raw request in your framework's equivalent. See
Signature verification.
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.
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.
| Event | What your worker does with it |
|---|---|
payment.succeeded | fulfillPayment(data.id) |
payment.failed | Mark the order failed, store data.failure_reason, offer a retry |
payment.expired | Same, the session ran out before the customer confirmed |
payment.cancelled | Same, the customer or you stopped it |
payment.created, payment.processing | Log 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.
| Suffix | Outcome | What to check in your code |
|---|---|---|
000000 | Succeeds | The order reaches fulfilled, exactly once |
000001 | Insufficient funds | The order reaches payment_failed, the customer sees why |
000002 | Declined by the operator | Same path, different reason |
000003 | Operator timeout | The pending page holds, then the sweep resolves it |
000004 | The customer declines the prompt | The 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.
Related pages
- Payments quickstartThe four API calls this guide builds on.
- Payment lifecycleEvery status a payment can hold, and what moves it.
- Mobile Money checkoutWhat happens on the customer handset, and how it fails.
- Webhook eventsThe full catalog, retries and signature verification.
- IdempotencyKeys that actually deduplicate, and the ones that do not.
- Handle refundsThe reverse journey, once an order has been paid.