Skip to content

Receive webhooks locally

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

wajub listen forwards your account's events to a local URL you choose, signed like production deliveries, without deploying your handler or opening a tunnel.

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

Nothing of yours is exposed: no public URL, 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_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_Example00000000000000001  → 200 http://localhost:3000/webhooks/wajub
6:37:35 AM  payment.created     evt_test_Example00000000000000002  → 200 http://localhost:3000/webhooks/wajub
6:37:36 AM  payment.processing  evt_test_Example00000000000000003  → 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  payment.succeeded   evt_test_Example00000000000000004  → 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  balance.updated     evt_test_Example00000000000000005  → 200 http://localhost:3000/webhooks/wajub
6:37:39 AM  fee.charged         evt_test_Example00000000000000006  → 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

Each local delivery is signed the way a production one is, with the same body and 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-Idwhd_ followed by a UUID, the same for every delivery of an event to this listener

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_Example00000000000000002",
"event": "payment.created",
"data": {
"id": "trx_test_Example0000000000001",
"amount": 5000,
"status": "pending",
"currency": "XAF",
"sandbox": true,
"amount_paid": 0,
"description": "Payment",
"customer": {
"id": "cus_test_Example00000000000000001",
"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-10-11",
"request": {
"id": null,
"idempotency_key": "idem_Example0000000000000000000000001"
},
"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.

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.

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. It reconnects on its own, nothing to do.

Twenty at a time. An account can have 20 wajub listen sessions open per environment; the twenty-first is refused with 429. Stopping a command with ^C frees its place at once.

A 503 means the stream could not be opened. 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.

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?