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.
wajub listen --forward-to localhost:3000/webhooks/wajubNothing 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 --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/wajubThe 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.
| Header | Value |
|---|---|
X-Wajub-Signature | v1= followed by the HMAC |
X-Wajub-Timestamp | Unix seconds, part of the signed string |
X-Wajub-Event | The event type |
X-Wajub-Delivery-Id | whd_ followed by a UUID, the same for every delivery of an event to this listener |
User-Agent | Wajub-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.
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.
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.
`--unsigned` removes the signature entirely
The flag exists for wedging a payload into a handler that cannot verify yet. It forwards events
unsigned, so your endpoint has no way to tell Wajub apart from anyone else who can reach it.
Never leave it in a script, and never use it against an endpoint that is also deployed.
--skip-verify is a deprecated alias of it and says so.
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
| Flag | Effect |
|---|---|
--forward-to | Where to POST. http:// is assumed when the scheme is missing |
--events | Comma separated types, wildcards allowed (payment.*), instead of everything |
--live | Required with a live key: real customers' events reach this machine |
--print-secret | Print the signing secret and exit |
--unsigned | Forward without the signature. See the warning above |
--insecure-tls | Accept a self-signed or local-CA certificate on an https:// forward URL |
--profile | Which account and environment to stream |
# Only what your handler currently implements
wajub listen \
--forward-to localhost:3000/webhooks/wajub \
--events "payment.*,refund.succeeded"
# Watch without forwarding anywhere
wajub listenWith 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.
# 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 2500Write the handler, save, trigger again. The loop is a few seconds long, which is the point.