Webhooks or polling
Why webhooks win, when to read the API instead, and what it costs.
A mobile money payment is not over when your request returns. The payer still has to open the USSD prompt and type a PIN, and that takes anywhere from four seconds to four minutes. So there are only three ways to learn how it ended.
| Way | Mechanism | Latency | What it costs |
|---|---|---|---|
| Webhook | Wajub posts to your URL | Under a second | A public HTTPS endpoint |
| Read one payment | GET /payments/{id} | Your polling interval | Rate limit budget |
| Read the event log | GET /events | Whenever you run it | One request per page |
Webhooks are the answer for fulfilment. The other two exist for the cases webhooks genuinely do not cover, and this page is about telling those apart.
Use each one for what it is good at
| Situation | Use |
|---|---|
| Deliver the order, credit the wallet, send the receipt | Webhook, always |
| The payer just landed back on your return page | One read of GET /payments/{id} |
| A script, a cron, a back office tool with no public URL | Polling, with a deadline |
| Your endpoint was down for an hour and you need the gap | GET /events, then replay |
| A dashboard showing live status to a human | Webhook into your own push channel |
Polling is not a lighter alternative to webhooks. It is slower, it burns request budget, and it cannot see anything that happened while your process was not running. It is a supplement.
What polling actually costs you
GET /payments/{id} always returns the current status. It is also counted twice: once against your account's overall per-minute
budget, and once against the payments endpoint bucket, which is shared with payment creation.
| Plan | Account budget, per minute | payments bucket, per minute |
|---|---|---|
| Pay as you go | 120 | 30 |
| Growth | 360 | 90 |
| Scale | 720 | 180 |
| Enterprise | Unlimited | Unlimited |
The endpoint bucket is the one that bites
Every call to a payments endpoint counts against the payments bucket, creation included. A
loop polling one payment every five seconds spends 12 of those per minute, and ten concurrent
payments spend 120, which is already over the ceiling on Pay as you go and Growth. Poll a queue of
payments on one timer, not one timer per payment.
Over the ceiling you get a 429 with a Retry-After header in seconds, and the same figure in
the body.
type tells you which ceiling you hit: team for the account budget, endpoint for the bucket
above, api_key for the per-key limit, which is the same figure as the account budget, and ip
for a call made without a key.
Polling correctly
Four rules make the difference between a poll that helps and one that gets you rate limited.
| Rule | Why |
|---|---|
| Stop at a terminal status | succeeded, failed, cancelled and expired are final. pending, processing and partial are not |
| Give the loop a deadline | Two minutes covers mobile money. Past that, let the webhook finish the job and tell the customer you will confirm by email |
| Back off | Start at two seconds and grow the interval. A payment that has not moved in thirty seconds will not move in the next two |
| Never poll what you were already told | A payment.succeeded delivery already carries the final payment. Reading it back doubles your traffic for nothing |
# One read. The status is in .transaction.status
curl https://api.wajub.com/payments/trx_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…"The four SDKs unwrap the response envelope, so retrieve hands you the payment itself rather than
the { code, status, message, transaction } wrapper you see with cURL.
Catching up after an outage
If your endpoint was unreachable, the events still exist. GET /events lists everything recorded
for your account in the current environment, newest first, and POST /events/{id}/resend puts one
back through the normal delivery path, signed as usual.
curl "https://api.wajub.com/events?type=payment.succeeded&per_page=100" \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…"
curl -X POST https://api.wajub.com/events/evt_aio5DpN577tNU2vOxdmuZGhT/resend \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…"Three details decide whether this works.
| Detail | What it means |
|---|---|
Filters are per_page, page and type | There is no date range. Page back until the timestamps are old enough |
| Pagination is page based | meta carries current_page, last_page, per_page and total |
| Sandbox and live are separate | The environment comes from the key you use, not from a parameter |
Resending goes to every active endpoint subscribed to that type that has not accepted the event
yet, each getting its next attempt. An endpoint that already accepted it is not sent it again, so
on an account with two endpoints the healthy one receives no duplicate. When every endpoint has
accepted it, the call still answers 202 and sends nothing.
The log holds only what an endpoint receives
The checkout timeline entries recorded next to the events (transaction.*,
payment.attempt_refused, payment.checkout_page_opened, payment.redirected_to_callback) are
never delivered, so GET /events does not list them and answers 404 for one asked by its id,
to retrieve or to resend. They stay visible in Events in
Konsole.
The return page is not a confirmation
When a payer comes back to your callback URL, all you know is that a browser loaded a URL. The
payment may have succeeded, failed, or still be waiting for a PIN. The URL can also be opened by
anyone who guesses it.
Never fulfil on the redirect
Read the payment server side before showing a success page, and fulfil on the webhook. A return page that ships the order is a return page that ships free orders to whoever loads it twice.
The shape that works in production is three layers, and each one covers the previous one's blind spot.
- 1
The webhook fulfils
It arrives whether or not the customer ever came back, and it is the only one of the three that is guaranteed to happen.
- 2
One server side read paints the return page
A single
GET /payments/{id}tells you which of the three pages to render: succeeded, still pending, or failed. No loop. - 3
A daily job reconciles
List the payments your database still has as open, read each one, and close the gap. This step is required. An event can reach you up to an hour late while Wajub builds it again, and none is sent while Wajub restricts your webhooks (Retries): without this job, such a payment stays open on your side for good.
Related pages