Skip to content

Receive webhooks locally

Live events forwarded to localhost, signed by Wajub, 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: no public URL, no inbound port. The CLI fetches each event's data from the Wajub API with your key, so that data never travels through the realtime connection, and nothing is sent at all while no wajub listen is running.

CLI 1.6.1 or later

Earlier versions of the CLI stop with a message asking you to update: npm install -g @wajub/cli@latest. Every other command keeps working on them.

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_xxxxxxxxxxxxxxxx… (^C to quit)
It stays the same between runs: set WAJUB_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx… in your app's .env once.
Forwarding to http://localhost:3000/webhooks/wajub
subscribed — waiting for events…

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

Wajub builds and signs each local delivery the way it signs a production one, and the CLI relays it byte for byte: the JSON body as production encodes it, escapes included, with the same headers. Only pending_webhooks can differ: it counts the deliveries still pending when the CLI fetched the event, where a production delivery carries the count from when it was sent.

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-Idwhd_ followed by a UUID, the same for every delivery of an event to this listener
User-AgentWajub-Webhooks/1.0, as in production

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",
},
"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-10-11",
"request": {
"id": null,
"idempotency_key": "idem_4CNn7YQyA1OgyNJy53YT5wTosFAYyWyn"
},
"created": "2026-09-14T04:37:34+00:00"
}

Because the body is the one production sends, a handler that verifies the signature over the raw body passes locally exactly when it will pass deployed. CLIs before 1.6.1 re-encoded the JSON and signed it themselves, so \/ and accented characters could come out differently.

The signing secret is the same every time you run wajub listen for this account and environment, one for sandbox and one for live. Put it in your .env once and your handler verifies without any other change. It is not a registered endpoint's secret: each endpoint keeps its own.

Point your app at the listen secret
export WAJUB_WEBHOOK_SECRET=$(wajub listen --print-secret)
npm run dev

--print-secret prints the secret alone and exits, so it also works in a script. With a live key, add --live (wajub listen --live --print-secret): without it the command fails, and $(…) then captures an empty secret.

If your app answers on an https:// address with a self-signed or local-CA certificate, such as a local site with a self-signed certificate, add --insecure-tls. It skips the certificate check for the forward to your handler only; the CLI's calls to the Wajub API stay verified. Pointing NODE_EXTRA_CA_CERTS at your local CA works too.

Flags

FlagEffect
--forward-toWhere to POST. http:// is assumed when the scheme is missing
--eventsComma separated types, wildcards allowed (payment.*), instead of everything
--liveRequired with a live key: real customers' events reach this machine
--print-secretPrint the signing secret and exit
--unsignedForward without the signature. See the warning above
--insecure-tlsAccept a self-signed or local-CA certificate on an https:// forward URL
--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.*,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. A malformed --events value stops the command before anything starts, and Wajub only sends the types you asked for.

Things worth knowing

The environment comes from your profile. A sandbox key streams sandbox events. A live key streams live ones, and only with --live: real customers' data then reaches your machine. wajub doctor tells you which you are on, and --profile overrides it for one run.

It needs a secret key, or a restricted key with event.read. The key wajub login creates has full access unless you restrict it. A publishable key cannot listen, and a key revoked by wajub logout stops a running stream within half a minute.

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.

Your listeners show up in the Dashboard. Konsole › Webhooks lists each running wajub listen for the current environment, with what your handler answered to the latest events.

It survives a sleeping laptop. A connection that died silently is replaced within two and a half minutes at most, and a session that expired while your laptop slept is reopened on its own.

Twenty at a time. An account can have 20 wajub listen sessions open per environment. The twenty-first answers 429: the CLI waits the 30 seconds its Retry-After asks and tries again, as many times as WAJUB_MAX_RETRIES allows (twice by default), then stops and says so. Stopping a command with ^C frees its place at once; a terminal closed without ^C frees it within 90 seconds.

A 503 means realtime is unavailable. The platform could not open the stream, so the command stops before anything starts. Try again in a moment; if it persists, send support the request id the CLI prints. Every other command keeps working.

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

Events Wajub records for its own use are never delivered: 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?