Repository navigation
Echo Replay
Many of a site's messages are triggered by a webhook coming in: Stripe says a payment succeeded and the shop sends the order email, an SMS and a CRM update. rhythm echo replay sends a signed test webhook to your staging site and shows what the site sends in response. This page covers the event library, your own payloads, overriding fields, the signing schemes and their secrets, the safety rules and why PayPal can't be replayed.
export ECHO_STRIPE_WEBHOOK_SECRET=whsec_… # the staging endpoint's test signing secret
rhythm echo replay payment_intent.succeeded --to /webhooks/stripe --set data.object.receipt_email=jamie@shop.testrhythm echo 1.0.0 - https://staging.shop.test/webhooks/stripe
Event: stripe/payment_intent.succeeded (stripe)
Response: 200 in 15ms
08:23:49 email to jamie@shop.test: Your order is confirmed [faked]
08:23:49 email to office@shop.test: New order paid [faked]
The site answered 200 and sent 2 emails in response.
The header shows the full URL the replay went to, with any secret in it masked. The output is from a local test shop whose Stripe endpoint is /webhooks/stripe; for WooCommerce's Stripe gateway it's /?wc-api=wc_stripe. Replay needs Echo running and sealed, so whatever the site sends back is caught; --to is absolute, or relative to site: in .echo/config.yaml.
rhythm echo replay --listclerk/user.created
github/ping
paypal/PAYMENT.CAPTURE.COMPLETED
resend/email.bounced
resend/email.delivered
stripe/charge.refunded
stripe/checkout.session.completed
stripe/customer.subscription.created
stripe/customer.subscription.deleted
stripe/customer.subscription.updated
stripe/invoice.paid
stripe/invoice.payment_failed
stripe/payment_intent.payment_failed
stripe/payment_intent.succeeded
woocommerce/order.created
Name an event in full (stripe/invoice.paid) or by its type alone when that's unique (invoice.paid). Stripe events get a fresh evt_test_… ID and created time on every replay, so a receiver that de-duplicates on the event ID processes each one. An unknown name lists the library: unknown event "foo" (try one of: clerk/user.created, …).
Any JSON file works too. Say how to sign it with --scheme. Give its event type with --type when the payload has no top-level "type":
ECHO_WEBHOOK_SECRET=… rhythm echo replay lead.json --scheme hmac --to /hooks/lead -H "X-Source: crm"The scheme defaults to hmac. -H "Name: value" (repeatable) adds headers. It can't replace the signature headers.
--set path=value changes one field before signing. It can be repeated; overrides apply in order:
rhythm echo replay payment_intent.succeeded --to /?wc-api=wc_stripe \
--set data.object.amount=2400 --set data.object.receipt_email=jamie@shop.test \
--set data.object.metadata.order_id='"1042"'- Paths are dot-separated keys, with numbers indexing arrays:
line_items.0.quantity. Missing objects are created; an index one past the end appends. - The value is JSON when it parses as JSON (
2400,true,null,{"a":1}). Otherwise it's a string, sojamie@shop.testneeds no quotes and'"1042"'keeps a number-like value a string.
| Scheme | Used for | Headers sent | Secret, from | Secret must be |
|---|---|---|---|---|
stripe |
Stripe | Stripe-Signature: t=<time>,v1=<HMAC-SHA256 of "<time>.<body>"> |
ECHO_STRIPE_WEBHOOK_SECRET |
a whsec_… signing secret |
standard |
Standard Webhooks and Svix senders: Resend, Clerk |
webhook-id, webhook-timestamp, webhook-signature: v1,<base64> and the same as svix-*
|
ECHO_WEBHOOK_SECRET |
a whsec_<base64> key |
github |
GitHub |
X-Hub-Signature-256: sha256=…, X-GitHub-Event, X-GitHub-Delivery
|
ECHO_GITHUB_WEBHOOK_SECRET |
any |
woocommerce |
WooCommerce webhooks, for receivers of them |
X-WC-Webhook-Signature (base64 HMAC-SHA256 of the body), X-WC-Webhook-Topic, -Resource, -Event, -ID, -Delivery-ID, -Source
|
ECHO_WC_WEBHOOK_SECRET |
any |
hmac |
anything else | X-Signature: <hex HMAC-SHA256 of the body> |
ECHO_WEBHOOK_SECRET |
any |
paypal |
refused; see PayPal |
Every replay also sends Content-Type: application/json, a user agent like the provider's and X-Echo-Run, which ties what the site sends to this replay. Library events are signed with their directory's scheme; --scheme is for your own files.
The hmac scheme always sends X-Signature with a hex HMAC-SHA256 of the body; its header name, algorithm and encoding can't be changed from the command line or the config. For a receiver that verifies another way, write a small test of your own, or use the provider's own test tooling.
The signing secret comes from the environment, never from a flag, so it isn't in your shell history or the config. --secret-env NAME (or secret_env: in a replay action) reads another variable. Use the test endpoint's secret: for Stripe, the signing secret of the staging endpoint in test mode, or the one stripe listen prints.
rhythm echo: no signing secret: set ECHO_STRIPE_WEBHOOK_SECRET to the endpoint's test signing secret
That's a usage error (exit 2), printed after the replay's header and Event: line.
Results show the secret only as ECHO_STRIPE_WEBHOOK_SECRET=•••••. Signatures show only their first characters.
Replay sends real requests, so it checks before it sends. It refuses with exit code 2:
| Rule | Refusal |
|---|---|
| Echo must be running and sealed, so the site's reactions can't reach anyone | Echo is in observe mode; replays only run sealed, so the site's reactions can't reach anyone (rhythm echo up --seal) |
The target must be listed under staging: in the config, or resolve only to loopback or private addresses (RFC 1918, fc00::/7; not link-local, where cloud metadata lives). The address is checked again when connecting, so DNS can't change the answer in between. |
replay refused: 203.0.113.10 resolves to 203.0.113.10, which is neither loopback nor private; add the staging URL under staging: in the Echo config to allow it |
No live Stripe secret or restricted key (sk_live_…, rk_live_…) anywhere in reach: the environment, .env, wp-config.php, ../wp-config.php and .echo/config.yaml
|
replay refused: a live Stripe key is in reach (STRIPE_SECRET_KEY=sk_live_•••••); run replays where only test keys are configured |
The payload must not say "livemode": true
|
replay refused: the payload has "livemode": true; replays are for test-mode events only |
| The secret must not be a live key or an API key | replay refused: ECHO_STRIPE_WEBHOOK_SECRET holds a Stripe API key, not a webhook signing secret (whsec_…) |
Redirects are never followed (a redirect could point anywhere, including production). No proxy is used. A staging: entry matches on scheme, host and port, with the path at a segment boundary:
staging: ["https://staging.shop.test"]rhythm echo: PayPal webhooks can't be replayed: receivers verify them by asking PayPal's API (POST /v1/notifications/verify-webhook-signature) or with PayPal's signing certificate, so no local secret can sign one. Use the PayPal sandbox instead: Developer Dashboard → Webhooks simulator, pointed at the staging URL, with sandbox credentials
The library still has paypal/PAYMENT.CAPTURE.COMPLETED so you can see the payload's shape. To test a PayPal flow, use the sandbox's simulator with Echo running: the site's calls to PayPal's sandbox API are recorded. They're faked unless you allow *.sandbox.paypal.com.
After the response, replay waits for what the site sends, until nothing new arrives for 1.5 s and at most --wait (default 5s). Then it lists it. Async senders need more: raise --wait, or use a replay action with flush: commands.
| Code | Meaning |
|---|---|
0 |
The site answered 2xx
|
1 |
The site answered something else (a 400 for a bad signature, a 500) |
2 |
Refused by a safety rule, or a usage error: an unknown event, a missing signing secret |
3 |
Echo isn't running, or the request failed |
Response: 400 in 1ms
The site answered 400 and sent no messages in response.
A 400 usually means the site rejected the signature: check the secret matches the one the site's endpoint uses. --json prints the replay (event, scheme, URL, run, status, the site's answer, masked headers and secret) and the messages together.
To compare what a webhook triggers before and after a change, make it an action:
actions:
- name: Stripe payment succeeded
replay:
event: payment_intent.succeeded
to: /?wc-api=wc_stripe
set: {data.object.amount: "2400"}
secret_env: ECHO_STRIPE_WEBHOOK_SECRETThe same rules apply. A non-2xx answer fails the action. See Actions and compare.
← With Soundcheck · Home · Deliverability →
rhythm · Useful ideas. Working systems. · Check only sites you own or have permission to test. Keep staging sealed (responsible use) · This wiki is published from wiki/ in the rhythm repository; suggest changes there with a pull request.
Get started
rhythm in depth
Tuner
- Overview
- Your first scan
- Reading results
- Choosing checks
- Requests and politeness
- Output and reports
- Batch scans
- Local scans
Tuner in depth
- Known vulnerabilities
- PHP advisories
- CI and code scanning
- Writing checks
- Check packs
- Finding reference
- CLI reference
- FAQ and troubleshooting
Soundcheck
- Overview
- Your first check
- Reading results
- Discovery
- Page health
- Visual changes
- Interactions
- Forms and flows
- Cache
- Speed
Soundcheck in depth
Echo
- Overview
- Your first run
- Connectors
- Sealing staging
- Messages and checks
- Actions and compare
- With Soundcheck
- Replay
- Deliverability
Echo in depth
Project