Repository navigation
Echo Install Connectors
A connector is the part of Echo that lives inside your site. It hooks the site's mailer and HTTP client, reports every message to Echo before it leaves and obeys Echo's answer. This page covers each connector (WordPress, Laravel, Next.js and Node) plus the generic route for anything else (SMTP, sendmail and the HTTPS proxy). For each one it says what it hooks and what it can't see.
rhythm echo install wordpress /var/www/staging # writes the mu-plugin
rhythm echo install laravel # prints the package and .env lines
rhythm echo install next # writes instrumentation.ts, prints the rest
rhythm echo install node
rhythm echo install generic # SMTP, sendmail_path, HTTPS_PROXYWordPress is installed for real. For the other stacks, install prints the package to add and the lines to put in your own files, since those are your project's files to change (install next also writes instrumentation.ts when there's none).
| Stack | Connector | Catches | Can't see |
|---|---|---|---|
| WordPress | one mu-plugin, rhythm echo install wordpress
|
every wp_mail() (it wins over FluentSMTP and Post SMTP), every wp_remote_*() call (SMS plugins, WooCommerce webhooks, Slack, GA4, Meta CAPI, API mailers) |
SDKs that call cURL themselves (the Twilio and Stripe PHP SDKs): add the proxy |
| Laravel |
rhythmsh/echo-laravel (Composer) |
every mailer and transport, the Http client, container-built Guzzle clients, Vonage and Slack notifications, queued mail and jobs |
new GuzzleHttp\Client(), raw cURL, SDKs with their own HTTP stack |
| Next.js · Node |
@rhythmsh/echo (npm) |
fetch (Node and Edge), http/https (axios, most SDKs), Nodemailer |
a separate undici package, http2, raw sockets |
| Anything | SMTP sink, rhythm-echo sendmail, HTTPS proxy |
mail and outbound HTTP(S) from any language | traffic that ignores the proxy: use a hard seal |
Every connector switches itself off in production and fails closed: if it can't reach Echo while sealed, it refuses to send rather than let the message out.
rhythm echo install wordpress /var/www/stagingInstalled the Echo connector (wp-content/mu-plugins/rhythm-echo.php), reporting to http://127.0.0.1:8025.
It runs only where WP_ENVIRONMENT_TYPE is local, development or staging. WordPress
treats an unset WP_ENVIRONMENT_TYPE as production, and there the connector does
nothing. On staging, add this to wp-config.php, above "That's all, stop editing!":
define( 'WP_ENVIRONMENT_TYPE', 'staging' );
(or set it in the server's environment). On a box that isn't production but says
so, reinstall with --force.
Next: rhythm echo up, then rhythm echo doctor (with site: in .echo/config.yaml)
Flush async mail and webhooks after actions: add to .echo/config.yaml
flush: ["wp cron event run --due-now", "wp action-scheduler run"]
When the site's wp-config.php already defines WP_ENVIRONMENT_TYPE as local, development or staging, the middle paragraph is one line instead: wp-config.php sets WP_ENVIRONMENT_TYPE to staging, so the connector runs. With --force it's Forced on: it runs even where WordPress says production. Through --wp-cli or --container Echo can't read wp-config.php, so it always prints the paragraph.
The connector is one file, wp-content/mu-plugins/rhythm-echo.php, embedded in the rhythm-echo binary. Its settings go to wp-content/mu-plugins/rhythm-echo/settings.php, a PHP file that returns an array, so the web server never serves the token. mu-plugins load before every plugin and can't be turned off from the admin, which is what lets the connector beat SMTP plugins to wp_mail(). It needs PHP 7.4 and WordPress 5.7 or later.
| Form | Does |
|---|---|
install wordpress [path] |
Writes the files into the WordPress at path (default .): the site root, with wp-config.php or wp-content/, or the wp-content folder itself. Any other folder is a usage error. An existing rhythm-echo.php that Echo didn't write is never overwritten. |
install wordpress [path] --wp-cli |
Pipes an installer to wp eval-file - --path=<path>, for a site you reach through WP-CLI (over SSH, or as the site's user). It writes into WPMU_PLUGIN_DIR, so custom layouts work. |
install wordpress --container <name> |
Runs the same installer with docker exec -i <name> wp eval-file - --allow-root. The container needs WP-CLI; see WordPress in Docker. |
install wordpress [path] --uninstall |
Removes the plugin and its settings folder and prints Removed the Echo connector. Combine with --wp-cli or --container to remove it the same way. Prints The connector isn't installed there. when there's nothing to remove. |
| Flag | Default | Effect |
|---|---|---|
--url <url> |
RHYTHM_ECHO_URL, else listen from .echo/config.yaml, else http://127.0.0.1:8025
|
Echo's address as the site reaches it: http://host.docker.internal:8025 from a container. |
--token <t> |
RHYTHM_ECHO_TOKEN |
Sent as X-Echo-Token with every capture. Also locks the connector's REST routes to that token. |
--proxy <url> |
none | Echo's HTTPS proxy as the site reaches it, e.g. http://127.0.0.1:8026. Raw cURL in each request then goes through it. |
--ca <path> |
none | Path, on the site's machine, to Echo's CA certificate (rhythm echo ca). Added to the WordPress HTTP API's CA bundle, not replacing it. |
--mode seal|observe |
seal |
Only decides what happens when Echo can't be reached: seal fails closed, observe lets mail and requests out. When Echo answers, Echo's mode decides. Any other value is a usage error. |
--force |
off | Run even when WordPress says this is production. Only for a production-like box that isn't production. |
--wp-cli, --container <name>, --uninstall
|
See above. |
The installer reads .echo/config.yaml (or --config <file>) for the addresses Echo runs on: listen for the default --url; smtp and proxy for the lines the other stacks print. Pass --url when the site reaches Echo at another address than the config's, such as host.docker.internal from a container.
The connector reads each setting from the first place that has it:
- a
define()constant inwp-config.php:RHYTHM_ECHO_URL,RHYTHM_ECHO_TOKEN,RHYTHM_ECHO_MODE,RHYTHM_ECHO_FORCE,RHYTHM_ECHO_PROXY,RHYTHM_ECHO_CA; - the same names in the environment;
-
mu-plugins/rhythm-echo/settings.php, written byinstall; - the defaults: Echo at
http://127.0.0.1:8025, sealed, not forced, no proxy.
So a host's environment or wp-config.php can override what the installer wrote, without editing the plugin.
On a production site the connector installs no hooks at all: mail and requests go out exactly as without it. "Production" is wp_get_environment_type(), which is production when WP_ENVIRONMENT_TYPE isn't set. Set it on every staging copy:
define( 'WP_ENVIRONMENT_TYPE', 'staging' ); // or development, or localWhile it's off, administrators see a notice: Rhythm Echo is installed but not running: WP_ENVIRONMENT_TYPE is not set, and WordPress treats that as production. Echo only runs on test sites. Set WP_ENVIRONMENT_TYPE to staging, development or local on a test copy, or remove wp-content/mu-plugins/rhythm-echo.php (run rhythm echo install wordpress --uninstall). rhythm echo doctor reports it as the connector is installed but off: production. RHYTHM_ECHO_FORCE (or --force) overrides the guard.
To remove the connector, run rhythm echo install wordpress <path> --uninstall, or delete wp-content/mu-plugins/rhythm-echo.php and the rhythm-echo/ folder next to it.
| Hook | Catches |
|---|---|
its own wp_mail()
|
Every email WordPress, a plugin or a theme sends. Sealed, the message is reported and not sent; the caller gets true. Observed, it's sent the way core sends it, with core's filters and actions. The outcome is reported. |
pre_wp_mail, phpmailer_init
|
A fallback for when something defined wp_mail() first. Echo still catches mail sent through those hooks. doctor shows the warning: wp_mail() was already defined (…) before Rhythm Echo loaded…
|
pre_http_request |
Every wp_remote_*() call, before it's sent: API mailers (SendGrid, Mailgun, Postmark and others), SMS plugins, WooCommerce webhooks, Slack, GA4, Meta CAPI, CRMs. Echo answers allow, fake (a provider-shaped response) or block (a WP_Error). |
http_api_debug |
The response of each allowed request, so observe mode records whether it worked. |
| cron and Action Scheduler | The run ID of the request that scheduled an event or action is stored with it and restored when it runs, so queued mail and webhooks are tied to the action that caused them. |
The site's own host (cron and Action Scheduler loopbacks) and Echo itself are never reported. Platform update checks to api.wordpress.org and friends pass through without being recorded; see Sealing staging.
The Twilio and Stripe PHP SDKs and some mailers call cURL directly, so pre_http_request never sees them. With --proxy, the connector sets https_proxy and http_proxy (and no_proxy for the site, Echo and loopback) at the start of each request, which libcurl reads for every handle without its own proxy. With --ca, Echo's CA is appended to the WordPress HTTP API's bundle so requests that reach the decrypting proxy verify:
rhythm echo up --proxy 127.0.0.1:8026 --mitm
rhythm echo install wordpress /var/www/staging --proxy http://127.0.0.1:8026 --ca "$(rhythm echo ca)"doctor then lists proxy among the layers. SDKs that ship their own CA bundle need more; see FAQ.
When sealed and Echo can't be reached, wp_mail() returns false and fires wp_mail_failed; wp_remote_*() returns a WP_Error. The PHP error log says why:
Rhythm Echo: email http://host.docker.internal:8025/v1/capture failed (Failed to connect to host.docker.internal port 8025 after 0 ms: Could not connect to server); sealed, so it was not sent
The request's error message is Rhythm Echo could not be reached and this site is sealed, so the request was not sent.
The connector adds two REST routes for rhythm echo doctor:
| Route | Method | Does |
|---|---|---|
/wp-json/rhythm-echo/v1/ping (or ?rest_route=/rhythm-echo/v1/ping) |
GET | Whether the connector is active and why not, the environment, the email layer, the proxy, warnings and whether the site can reach Echo. |
/wp-json/rhythm-echo/v1/canary |
POST | Sends one wp_mail() to canary@echo.invalid and one wp_remote_post() to https://canary.echo.invalid/, so doctor can check both are caught. .invalid addresses can't be delivered even when unsealed. |
With a token configured, both routes need X-Echo-Token. Without one, an administrator, or a request from the same machine or a private network (Echo on the Docker host), may call them; anyone else gets 401 rest_forbidden.
The official wordpress:*-apache images don't include WP-CLI, so --container on the web container fails with running wp-cli in shop-wp: exit status 127 (is wp-cli installed in the container? or copy the files with docker cp). Two other failures get their own hint: a container that doesn't exist (no container named shop-cli; see: docker ps) and a permission error (wp-cli's user in the container can't write wp-content/mu-plugins; give that folder to it, or copy the files with docker cp). Use a wordpress:cli container on the same volume, as the web server's user, with the same database and WORDPRESS_CONFIG_EXTRA environment as the web container (otherwise WP-CLI sees a different environment type):
docker run -d --name shop-cli --user 33:33 --volumes-from shop-wp --network shop-net \
-e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_USER=wp -e WORDPRESS_DB_PASSWORD -e WORDPRESS_DB_NAME=wp \
-e "WORDPRESS_CONFIG_EXTRA=define('WP_ENVIRONMENT_TYPE','staging');" \
--add-host host.docker.internal:host-gateway --entrypoint sleep wordpress:cli infinity
rhythm echo install wordpress --container shop-cli --url http://host.docker.internal:8025 --token "$RHYTHM_ECHO_TOKEN"Installed /var/www/html/wp-content/mu-plugins/rhythm-echo.php
Installed the Echo connector (wp-content/mu-plugins/rhythm-echo.php), reporting to http://host.docker.internal:8025.
The WP_ENVIRONMENT_TYPE paragraph and the Next: lines follow. If --url points at 127.0.0.1 or localhost, a last line warns: In Docker, 127.0.0.1 and localhost are the container itself: reinstall with --url http://host.docker.internal:8025.
Echo must listen where the container can reach it, which means beyond loopback and therefore with a token; see FAQ. Without --user 33:33, the wordpress:cli image can't create wp-content/mu-plugins in the Apache image's volume.
rhythm echo install laravelLaravel: add the package and point it at Echo.
composer require rhythmsh/echo-laravel
It's a regular dependency, not --dev: staging deploys usually run composer install
--no-dev, which would leave it out. It does nothing where APP_ENV is production or
testing (RHYTHM_ECHO_FORCE=true overrides that, only for a box that isn't production).
Then in .env on staging or your machine:
RHYTHM_ECHO_URL=http://127.0.0.1:8025
QUEUE_CONNECTION=sync # or flush with: php artisan queue:work --stop-when-empty
Check it: php artisan echo:status, then: rhythm echo doctor
Packages that skip Laravel's mailer and Http client (raw Guzzle, SDKs) are caught
through Echo's proxy: rhythm echo up --proxy 127.0.0.1:8026 --mitm, then set
HTTPS_PROXY=http://127.0.0.1:8026 and trust: /home/you/.cache/rhythm/echo/ca/ca.pem (rhythm echo ca creates it)
The package supports Laravel 11, 12 and 13 on PHP 8.2 or later. Its service provider is auto-discovered. With a token, a RHYTHM_ECHO_TOKEN=<the token you started Echo with> line is added under RHYTHM_ECHO_URL. The (rhythm echo ca creates it) note goes away once the CA exists.
Everything is set from the environment. To have the file, publish it with php artisan vendor:publish --tag=rhythm-echo (config/rhythm-echo.php).
| Key | Env | Default | Effect |
|---|---|---|---|
enabled |
RHYTHM_ECHO_ENABLED |
unset | Unset means on in every environment except production and testing. Set true to capture in your test suite. |
url |
RHYTHM_ECHO_URL |
http://127.0.0.1:8025 |
Where Echo listens. |
token |
RHYTHM_ECHO_TOKEN |
none | Sent as X-Echo-Token, needed when Echo listens beyond loopback. |
force |
RHYTHM_ECHO_FORCE |
false |
Run even when APP_ENV=production. |
mode |
RHYTHM_ECHO_MODE |
seal |
What to assume when Echo can't be reached: seal fails closed, observe lets traffic through. |
timeout |
RHYTHM_ECHO_TIMEOUT |
2 |
Seconds to wait for Echo. |
guzzle |
RHYTHM_ECHO_GUZZLE |
true |
Also cover Guzzle clients the container builds and the Vonage client. |
The testing environment is off by default, so your own test suite keeps its Mail::fake() and Http::fake().
| Path | Covered |
|---|---|
Mailables, Mail::raw, mail notifications, queued mail, every mailer and transport, failover and round-robin |
yes: each mailer's transport is wrapped, so the capture is the finished message |
Http:: facade, Http::pool, packages built on it (the Slack Web API channel) |
yes: Http::globalMiddleware
|
Guzzle clients built by the container (a type-hinted GuzzleHttp\Client, the Slack webhook channel) |
yes, with guzzle on |
| Vonage SMS notifications | yes, with guzzle on |
new GuzzleHttp\Client(), raw cURL, SDKs with their own HTTP stack (Twilio and Stripe PHP SDKs, AWS SDK) |
no: use the proxy |
Sealed, mail is kept and the app sees a normal send; a sealed HTTP call gets Echo's response (fake) or a ConnectionException (block). Observed, the real transport sends. The outcome is reported against the same message. If Echo can't be reached while sealed, mail fails with a TransportException and HTTP with a ConnectionException; the array and log mailers keep working.
For clients the package can't see, run the worker or server through the proxy and trust Echo's CA in openssl.cafile or curl.cainfo:
HTTPS_PROXY=http://127.0.0.1:8026 HTTP_PROXY=http://127.0.0.1:8026 php artisan queue:workWithout the package at all, point the mailer at the SMTP sink: MAIL_MAILER=smtp MAIL_HOST=127.0.0.1 MAIL_PORT=1025, then php artisan config:clear.
The run ID comes from the request's X-Echo-Run header or cookie, then from the Context a queued job carried over from that request, then from RHYTHM_ECHO_RUN (which Echo sets on the commands it runs). A global middleware stores it as a hidden Context value, so queued mail, notifications and jobs keep it with no change to your code.
Queued work is only sent when a worker runs it. For staging checks, either set QUEUE_CONNECTION=sync, or keep the queue and flush it after each action:
# .echo/config.yaml
flush: ["php artisan queue:work --stop-when-empty"]-
php artisan echo:status [--json]: whether the connector is active and why not, Echo's URL and mode and a ping of Echo. Exits non-zero when it's off or Echo can't be reached. -
php artisan echo:canary [--json]: sends one mail tocanary@echo.invalidand onePOST https://canary.echo.invalid/through the default mailer and theHttpfacade.rhythm echo doctor, run from the Laravel project (whereartisanis), calls it and checks both arrive.
npm install --save-dev @rhythmsh/echo
rhythm echo install nextNext.js: add the package.
npm install --save-dev @rhythmsh/echo
Wrote instrumentation.ts, which starts Echo's connector (it stays off in production).
Wrap your next.config so mail libraries run outside the bundle and get captured:
import { withEcho } from '@rhythmsh/echo/next';
export default withEcho(nextConfig);
Environment (.env.local, or the Preview environment on Vercel):
RHYTHM_ECHO_URL=http://127.0.0.1:8025
Then: rhythm echo up, and rhythm echo watch while you use the app.
install next [dir] needs the project folder (default ., with a package.json; otherwise rhythm echo: <dir> has no package.json; run this in the Next.js project, exit 2). It writes instrumentation.ts, or src/instrumentation.ts when the app lives in src/app, unless one exists (.ts or .js, at the root or in src/). The file it writes starts the connector:
// instrumentation.ts
export async function register() {
const { register } = await import('@rhythmsh/echo/next');
await register();
}Run again, it sees the file already loads @rhythmsh/echo and says instrumentation.ts already starts Echo's connector; nothing to add there. A file of your own that doesn't load it gets the lines to add instead:
You already have instrumentation.ts; add this inside its register(), which must be async:
const { register: registerEcho } = await import('@rhythmsh/echo/next');
await registerEcho();
register() runs in both runtimes: on Node.js it hooks fetch, http/https and Nodemailer; on Edge it hooks fetch. Next.js bundles server dependencies. The connector can't reach a bundled Nodemailer, so withEcho adds nodemailer to serverExternalPackages and changes nothing else. On Next.js 14, add nodemailer to experimental.serverComponentsExternalPackages yourself.
Route handlers, server actions and dynamic pages get the run ID automatically. In middleware and after() callbacks, wrap the work in withEchoRun(request, fn).
Load the connector before the app:
node --import @rhythmsh/echo/register server.js # ESM or CJS, Node 18.19 or later
node -r @rhythmsh/echo/register server.js # CJSor make import '@rhythmsh/echo/register' the app's first import. To tie captures to the request that caused them, add the middleware, or wrap a handler with withEchoRun(req, () => handle(req)) in other frameworks:
import express from 'express';
import { echoMiddleware } from '@rhythmsh/echo';
const app = express();
app.use(echoMiddleware());Scripts and cron jobs run with node --import @rhythmsh/echo/register script.js and pick up RHYTHM_ECHO_RUN; a queue worker passes the run along itself with withEchoRun(job.data.echoRun, () => process(job)). In serverless functions and short scripts, await flush() before exiting.
| Variable | Default | Effect |
|---|---|---|
RHYTHM_ECHO_URL |
http://127.0.0.1:8025 |
Echo's capture API. On a Vercel preview, a URL that reaches your Echo, such as a tunnel. |
RHYTHM_ECHO_TOKEN |
none | Sent as X-Echo-Token. |
RHYTHM_ECHO_FORCE |
off |
1 turns the connector on where the production guard says no. |
RHYTHM_ECHO_SITE |
none | Extra hosts that count as the site itself, comma separated; never captured. |
RHYTHM_ECHO_RUN |
none |
<run>/<action> for work outside a request. Echo sets it on the commands it runs. |
register(options) takes the same settings in code (url, token, force, site) plus maxBody (default 256 KiB), timeout (milliseconds, default 5000), logger and captureLoopback.
rhythm echo install node prints the install line (npm install --save-dev @rhythmsh/echo, or a regular dependency if staging installs without dev dependencies), the --import line and RHYTHM_ECHO_URL. Then it says: The connector is off on VERCEL_ENV=production, and on NODE_ENV=production unless RHYTHM_ECHO_URL is set: setting it there turns the connector on.
The connector stays inert, with no hooks installed, when VERCEL_ENV=production, when NODE_ENV=production outside Vercel unless RHYTHM_ECHO_URL is set, or during next build. Preview and development deployments activate it, including next start on a preview. When active, it logs one line per runtime, such as [rhythm-echo] active (VERCEL_ENV=preview, nodejs): capturing fetch, http, https, nodemailer via …. Sealed and unable to reach Echo, requests fail as network errors and Nodemailer rejects.
A separately installed undici package's request() or fetch(), http2, raw net and tls sockets, a hand-built ClientRequest with createConnection and a bundled Nodemailer without withEcho. Cover them with the proxy:
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://127.0.0.1:8026 NODE_EXTRA_CA_CERTS="$(rhythm echo ca)" next startNODE_USE_ENV_PROXY covers fetch from Node 22.21 and 24.0. It covers http/https from Node 22.21 and 24.5.
rhythm echo install genericAny stack: point mail and outbound HTTPS at Echo.
Mail (SMTP): host 127.0.0.1, port 1025, any username and password
Mail (PHP): sendmail_path = "/usr/local/bin/rhythm-echo sendmail -t -i" (php.ini or php-fpm pool)
HTTP(S): rhythm echo up --proxy 127.0.0.1:8026 --mitm
HTTPS_PROXY=http://127.0.0.1:8026 https_proxy=http://127.0.0.1:8026
and trust Echo's CA: /home/you/.cache/rhythm/echo/ca/ca.pem (rhythm echo ca creates it)
(curl, PHP cURL, Python requests and Go honor these; Node needs
NODE_USE_ENV_PROXY=1 and NODE_EXTRA_CA_CERTS on 22.21+/24.5+)
Tie to actions: send X-Echo-Run: <run>/<action> on the request that triggers the work,
or set RHYTHM_ECHO_RUN for scripts and workers.
For a seal that can't be bypassed, see docs/seal in the Echo repository: the app on an
internal Docker network or behind a firewall, with Echo as its only way out.
sendmail_path shows the full path of the rhythm-echo binary that printed it, since php-fpm runs without a PATH. The addresses come from .echo/config.yaml; when the SMTP sink listens beyond loopback, the first line says any username, Echo's token as the password. install doesn't create the CA: rhythm echo ca, or up with --mitm, does.
Point any mailer at 127.0.0.1:1025. On loopback the sink accepts any login, or none (so a site set up for a real SMTP provider works unchanged). Beyond loopback, which up only allows with a token, it asks for AUTH PLAIN with any username and the token as the password. It refuses mail until then (530 Authentication required: log in with any username and Echo's token as the password). It takes up to 500 recipients and 25 MB per message and never delivers anything. It records every message as faked, in observe mode too. Envelope recipients that aren't in To or Cc are recorded as Bcc. An X-Echo-Run header in the message ties it to an action; otherwise it belongs to whichever action is running. Try it with curl:
curl smtp://127.0.0.1:1025 --mail-from shop@shop.test --mail-rcpt jamie@shop.test --upload-file mail.txt08:16:19 email to jamie@shop.test: Your order #1042 [faked]
PHP's mail(), cron and most Unix tools call sendmail. rhythm-echo sendmail reads a message on stdin like sendmail does and hands it to Echo:
; php.ini or the php-fpm pool; use the full path, php-fpm has no PATH
sendmail_path = "/usr/local/bin/rhythm-echo sendmail -t -i"| Flag | Meaning |
|---|---|
-t |
Recipients from To, Cc and Bcc; Bcc is removed from the message and recorded as Bcc. |
-i, -oi
|
A line with a single dot doesn't end the message. |
-f <addr>, -r <addr>
|
The envelope sender. |
-F <name> |
The sender's full name, when the message has no From. |
-o…, -B, -N, -R, -V, -X, -L, -v, -U, -bm, -em, -ep, -eq, -G
|
Accepted and ignored, so callers that pass them keep working. |
other -b… modes |
Refused: only sending is supported. |
It adds Date and Message-ID when they're missing. It adds X-Echo-Run from RHYTHM_ECHO_RUN when the message has none (a header already in the message wins), so mail from a command action is tied to it. It sends to the SMTP sink at RHYTHM_ECHO_SMTP (default 127.0.0.1:1025), logging in with RHYTHM_ECHO_TOKEN when it's set, or, when only RHYTHM_ECHO_URL is set, to the capture API (with RHYTHM_ECHO_TOKEN). Like the sink's own mail, it's recorded as faked in every mode. It refuses to run when WP_ENVIRONMENT_TYPE, APP_ENV, VERCEL_ENV or NODE_ENV says production, unless RHYTHM_ECHO_FORCE=1.
Its exit codes are sendmail's, which callers understand:
| Code | Meaning | Example |
|---|---|---|
0 |
Handed to Echo | |
64 |
Usage |
no recipients (give them as arguments, or use -t), mode -bs is not supported (only sending, -bm)
|
65 |
Bad message | headers that can't be read, over 25 MB |
69 |
Echo unreachable | can't reach Echo at 127.0.0.1:1025 (is `rhythm echo up` running? set RHYTHM_ECHO_SMTP): … connection refused |
75 |
Temporary failure | the sink or API refused the message |
78 |
Refused on production | refusing to run: APP_ENV says production (set RHYTHM_ECHO_FORCE=1 to force) |
For anything that sends HTTP itself, run Echo with its proxy and point the app at it:
rhythm echo up --proxy 127.0.0.1:8026 --mitm
export HTTPS_PROXY=http://127.0.0.1:8026 https_proxy=http://127.0.0.1:8026With --mitm, the proxy decrypts HTTPS with a certificate from Echo's own CA, so it can record and fake each request; the app must trust that CA. rhythm echo ca prints its path and rhythm echo ca --pem the certificate. Containers can fetch it from http://<proxy>/ca.pem. Without --mitm, sealed HTTPS to unknown hosts is refused outright, since it can't be answered. Payment APIs (Stripe, live PayPal) are never tunnelled blind, in any mode and even when allowlisted: without --mitm a CONNECT to them is refused and recorded as blocked with payment APIs need --mitm so Echo can tell test from live keys; with --mitm they're decrypted so Echo can check the key. How each runtime picks up the proxy and the CA:
| Client | Proxy | Trust Echo's CA |
|---|---|---|
| curl |
HTTPS_PROXY, http_proxy (lowercase for plain HTTP) |
--cacert bundle.pem, or CURL_CA_BUNDLE
|
| PHP cURL (Twilio SDK, Guzzle) |
https_proxy or HTTPS_PROXY; for php-fpm, env[] in the pool |
curl.cainfo and openssl.cafile, or CURLOPT_CAINFO
|
WordPress wp_remote_*
|
WP_PROXY_HOST and WP_PROXY_PORT constants, or the connector's --proxy
|
the connector's --ca
|
Node 22.21+, 24.5+ (fetch, https) |
NODE_USE_ENV_PROXY=1 with HTTPS_PROXY
|
NODE_EXTRA_CA_CERTS=ca.pem (adds to the defaults) |
Python requests
|
HTTPS_PROXY |
REQUESTS_CA_BUNDLE=bundle.pem (replaces the defaults) |
Go net/http
|
HTTPS_PROXY |
SSL_CERT_FILE=bundle.pem, or the system store |
| Debian or Alpine system store | copy to /usr/local/share/ca-certificates/rhythm-echo.crt, then update-ca-certificates
|
A client that replaces its trust store needs a bundle of the system CAs plus Echo's, because hosts on Echo's allowlist (other than payment APIs) are tunnelled to the real server with its real certificate:
cat /etc/ssl/certs/ca-certificates.crt "$(rhythm echo ca)" > /etc/rhythm-echo/bundle.pemA sealed request through the proxy, faked with Twilio's own response shape:
curl -x http://127.0.0.1:8026 --cacert "$(rhythm echo ca)" -u AC0000:secret \
-d 'To=%2B15551230000' -d 'From=%2B15005550006' -d 'Body=Your table for 2 is booked' \
https://api.twilio.com/2010-04-01/Accounts/AC0000/Messages.json{"sid":"SM2db0469431cb00000000000000000000","status":"queued","to":"+15551230000","from":"+15005550006","body":"Your table for 2 is booked","date_created":"Sat, 24 Oct 2026 02:22:51 +0000","num_segments":"1","error_code":null,"error_message":null}
Nothing reached Twilio: the proxy terminated TLS itself and answered. Beyond loopback the proxy requires the Echo token as its password (http://echo:TOKEN@host:8026, or Proxy-Authorization: Bearer TOKEN), so it's never an open relay. The PHP-specific settings (php-fpm's clear_env, env[] lines, httpoxy) are in Sealing staging.
Echo ties a message to the action that caused it through X-Echo-Run: <run>/<action>. Echo's own actions and Soundcheck send it on every request to the site, as a header and as a cookie; a connector copies it into each capture. Scripts and workers read RHYTHM_ECHO_RUN from the environment, which Echo sets on the commands it runs. A message without either still lands in the action that's running when it arrives. See Actions and compare.
Writing a connector for another stack is a small JSON protocol; see API.
← Your first run · Home · Sealing staging →
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