Skip to content

Receive webhooks locally

Live events forwarded to localhost, signed, with no tunnel.

Your localhost is not reachable from the internet, which is why testing a webhook handler usually means deploying it or opening a tunnel. wajub listen does neither. It opens a stream from your machine to Wajub, and reposts what arrives to a local URL you choose.

The whole setup
wajub listen --forward-to localhost:3000/webhooks/wajub

Nothing of yours is exposed. There is no public URL, no third party proxy, and no inbound port.

What you see

wajub listen
$ wajub listen --forward-to localhost:3000/webhooks/wajub

Ready! Streaming sandbox events for your account.
Your webhook signing secret is whsec_test_c7f543a6a4058db0dfb566dd91e922bc (^C to quit)

6:37:35 AM  customer.created    evt_test_NGHXbVLyHqPLOhNY3HKoXMca 200 http://localhost:3000/webhooks/wajub
6:37:35 AM  payment.created     evt_test_wUpoY1JcMvjoUEs8FUdBkbuw 200 http://localhost:3000/webhooks/wajub
6:37:36 AM  payment.processing  evt_test_7wP9Ty2ITqLSzVgWaU1VFH8U 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  payment.succeeded   evt_test_HObNwAnDRT54qb28OxW7fwQJ 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  balance.updated     evt_test_8pymkrpN71IQnazTsIwSxEVB 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  fee.charged         evt_test_u5zOJ37yKP2C0HSQZAKbUtme 200 http://localhost:3000/webhooks/wajub

The number on the right is your own endpoint's answer, so a 500 there is your handler failing, not the CLI. The run above is a single wajub trigger payment.succeeded: one command produced six events, which is what a real payment produces too.

That is the fastest way to discover how many types your handler is quietly ignoring.

The forwarded request is a real webhook

The CLI signs what it forwards, with the same scheme production uses, and sends the same headers.

HeaderValue
X-Wajub-Signaturev1= followed by the HMAC
X-Wajub-TimestampUnix seconds, part of the signed string
X-Wajub-EventThe event type
X-Wajub-Delivery-IdThis attempt, whd_ followed by a UUID

So your verification code runs for real on your laptop, instead of being skipped behind an if (process.env.NODE_ENV === 'development') that hides a bug until production.

What lands on your endpoint
{
"id": "evt_test_wUpoY1JcMvjoUEs8FUdBkbuw",
"event": "payment.created",
"data": {
"id": "trx_test_VPRKcDWn0pi29ScaQL9B",
"amount": 5000,
"status": "pending",
"currency": "XAF",
"sandbox": true,
"amount_paid": 0,
"description": "Payment",
"customer": {
"id": "cus_test_abOtZa54yWAc6xihrXrU2F0C",
"name": "CLI Trigger",
"email": "cli-trigger@example.com"
},
"created_at": "2026-09-14T04:37:34+00:00",
"updated_at": "2026-09-14T04:37:34+00:00"
},
"livemode": false,
"pending_webhooks": 0,
"api_version": "2026-09-01",
"request": {
"id": null,
"idempotency_key": "idem_4CNn7YQyA1OgyNJy53YT5wTosFAYyWyn"
},
"created": "2026-09-14T04:37:34+00:00"
}

Two fields read differently here than in a production delivery. pending_webhooks is 0 because the stream is not a registered endpoint, and the User-Agent is wajub-cli rather than Halo/1.0. If your handler branches on either, it will behave differently locally than it does deployed, which is a trap worth removing rather than working around.

The secret printed at startup is generated for this run. Export it and your handler verifies without any other change.

Point your app at the printed secret
export WAJUB_WEBHOOK_SECRET=whsec_test_c7f543a6a4058db0dfb566dd91e922bc
npm run dev

A new secret on every run gets tedious. Pin one and put it in your .env once.

A stable local secret
wajub listen --forward-to localhost:3000/webhooks/wajub --secret whsec_local_dev

Flags

FlagEffect
--forward-toWhere to POST. http:// is assumed when the scheme is missing
--eventsComma separated types, instead of everything
--secretUse this signing secret rather than generating one
--skip-verifyForward unsigned. See the warning above
--profileWhich account and environment to stream
Narrowing the stream
# Only what your handler currently implements
wajub listen \
  --forward-to localhost:3000/webhooks/wajub \
  --events payment.succeeded,payment.failed,refund.succeeded

# Watch without forwarding anywhere
wajub listen

With no --forward-to, it prints events and posts nothing. That is the fastest way to find out whether an event is being emitted at all, before you blame your handler.

Four things worth knowing

The environment comes from your profile. A sandbox key streams sandbox events, a live key streams live ones. wajub doctor tells you which you are on, and --profile overrides it for one run.

You do not need a registered endpoint. The stream is independent of POST /webhooks. An account with no endpoint at all still streams everything to wajub listen.

It does not replace your registered endpoints. If you also have a live endpoint subscribed to the same events, it keeps receiving them. Listening does not divert anything.

Nothing is buffered. Events produced while the command is not running are not replayed when you start it. To recover those, read wajub events list and resend the ones you want.

Internal events never appear

transaction.* events are checkout telemetry used inside the platform. They are filtered out before the stream, so they never reach wajub listen and cannot be resent.

The loop

Two terminals, and you never touch a phone.

Terminal 1, then terminal 2
# 1: forward everything to your handler
wajub listen --forward-to localhost:3000/webhooks/wajub

# 2: make something happen
wajub trigger payment.succeeded
wajub trigger payment.failed --amount 2500

Write the handler, save, trigger again. The loop is a few seconds long, which is the point.

What did you think of this content?