Skip to content

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.

WayMechanismLatencyWhat it costs
WebhookWajub posts to your URLUnder a secondA public HTTPS endpoint
Read one paymentGET /payments/{id}Your polling intervalRate limit budget
Read the event logGET /eventsWhenever you run itOne 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

SituationUse
Deliver the order, credit the wallet, send the receiptWebhook, always
The payer just landed back on your return pageOne read of GET /payments/{id}
A script, a cron, a back office tool with no public URLPolling, with a deadline
Your endpoint was down for an hour and you need the gapGET /events, then replay
A dashboard showing live status to a humanWebhook 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.

PlanAccount budget, per minutepayments bucket, per minute
Pay as you go12060
Growth360180
Scale72030
EnterpriseUnlimited30

Over the ceiling you get a 429 with a Retry-After header in seconds, and the same figure in the body.

A 429 from the payments bucket
{
"code": 429,
"status": "Too Many Requests",
"message": "Too many requests",
"type": "endpoint",
"retry_after": 37,
"retry_after_human": "00:00:37"
}

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.

RuleWhy
Stop at a terminal statussucceeded, failed, cancelled and expired are final. pending, processing and partial are not
Give the loop a deadlineTwo minutes covers mobile money. Past that, let the webhook finish the job and tell the customer you will confirm by email
Back offStart 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 toldA 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.

DetailWhat it means
Filters are per_page, page and typeThere is no date range. Page back until the timestamps are old enough
Pagination is page basedmeta carries current_page, last_page, per_page and total
Sandbox and live are separateThe 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.

The shape that works in production is three layers, and each one covers the previous one's blind spot.

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

What did you think of this content?