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} is read fresh from the database on every call, so a poll always gives you
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 | 60 |
| Growth | 360 | 180 |
| Scale | 720 | 30 |
| Enterprise | Unlimited | 30 |
The endpoint bucket is the one that bites
The payments bucket counts every request whose path contains payments, 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 every plan. Poll a queue of payments on
one timer, not one timer per payment. The unexpected ordering of the last two rows is explained
in Limits.
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, and ip.
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.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"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.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"
curl -X POST https://api.wajub.com/events/evt_aio5DpN577tNU2vOxdmuZGhT/resend \
-H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"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 is not free of consequences: the event goes to every active endpoint subscribed to that type, not only the one that missed it. On an account with two endpoints, the healthy one receives a duplicate, which is another reason the handler has to be idempotent.
`transaction.*` events cannot be resent
They are internal checkout telemetry that is never delivered to merchants. Asking for a resend
returns 422 Internal events cannot be resent.
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 catches the rare event that was never delivered and never replayed.
Related pages