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

PlanAccount budget, per minutepayments bucket, per minute
Pay as you go12030
Growth36090
Scale720180
EnterpriseUnlimitedUnlimited

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

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

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

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

What did you think of this content?