Guides
Intercept vendor calls
rystic run --intercept puts a simulator behind a local HTTPS gateway. rystic exec runs your command with that gateway as its proxy. Calls to api.stripe.com, slack.com, s3.amazonaws.com and the other simulated hosts land on your simulators, and your code stays as it is.
To point a client at http://localhost:<port> instead, see the base-URL change on each simulator’s page, for example Stripe. That route needs no gateway.
Quick start
rystic run stripe-twin --intercept -d --name stripe --port 0
# -> stripe-twin 0.0.11 (model …) → http://localhost:52114 [native stripe] · intercept
# -> next: rystic exec --project default -- <command> # its calls to api.stripe.com, checkout.stripe.com, billing.stripe.com land here
rystic exec -- curl -s https://api.stripe.com/v1/charges -u sk_test_anything: \
-d amount=2000 -d currency=usd -d source=tok_visa
# -> {…,"id":"ch_000000000000000000000001",…,"status":"succeeded",…}
Two more simulators, and a Python app that names neither:
rystic run slack-twin --intercept -d --name slack --port 0 --scenario design_studio
rystic run s3-twin --intercept -d --name s3 --port 0
rystic exec -- python3 app.py
# -> True 1
# app.py: boto3 and slack_sdk with their default hosts
import boto3
from slack_sdk import WebClient
s3 = boto3.client("s3", region_name="us-east-1",
aws_access_key_id="AKIAEXAMPLE", aws_secret_access_key="secret")
s3.create_bucket(Bucket="statements")
s3.put_object(Bucket="statements", Key="2026/10/acme.pdf", Body=b"%PDF-1.7")
slack = WebClient(token="xoxb-anything")
r = slack.chat_postMessage(channel="CWDC0000004", text="statement for acme filed")
print(r["ok"], s3.list_objects_v2(Bucket="statements")["KeyCount"])
On a Mac, exec prints one more line on stderr until the CA is in your keychain. It matters only for Go tools; see Go tools on macOS.
How the pieces fit
rystic run <product> --interceptboots the simulator for the gateway. The URLs it hands back keep the vendor’s hosts, such as PayPal’slinks, Lob’snext_urland GitHub’shtml_url, instead of naminglocalhost. A client that follows one comes back through the gateway.- The gateway runs one per project, on
127.0.0.1only. For each host that an--interceptsimulator in the project claims, it terminates TLS with a certificate from the Rystic CA and forwards the request to that simulator,Hostunchanged. It re-reads the simulators as requests arrive, so it picks up a simulator started or stopped later without a restart. rystic exec -- <cmd>starts the project’s gateway if none runs, sets the environment, then becomes<cmd>. The exit code and signals are the command’s own.eval "$(rystic env)"puts the same exports in your shell, so every later command in it is routed.rystic gatewayruns the gateway in the foreground and logs to the terminal.--portfixes its port, andexecandenvuse it while it runs.Ctrl-Cends it.
--project names the project on run, exec, env and gateway alike, and defaults to default. Each project gets its own gateway, so two copies of one simulator can run side by side under different projects.
The detached gateway
- Starts on the first
rystic execorrystic envin a project with no gateway running. - Listens on a free port on
127.0.0.1.rystic envprints it. - Is recorded in
~/.rystic/run/gateway/<project>.json. - Logs to
~/.rystic/run/gateway/<project>.log, one line per connection and per request. - Stops only on
rystic gateway --stop.rystic stopandrystic downleave it running.
… rystic gateway for project default on http://127.0.0.1:61203 (without --strict) — …
… CONNECT api.stripe.com:443 → stripe-twin/stripe (http://localhost:52114)
… POST api.stripe.com/v1/charges → 200 (stripe-twin/stripe)
… tunnel example.com:443
rystic gateway --stop
# -> stopped the gateway for project default (pid 85040)
Stack files
A stack file has no intercept key, and rystic up refuses one with unknown key "intercept" (exit 2). Start each simulator you want intercepted with rystic run --intercept --project <the stack's project>.
The environment
rystic env
export HTTPS_PROXY=http://127.0.0.1:61203
export https_proxy=http://127.0.0.1:61203
export HTTP_PROXY=http://127.0.0.1:61203
export http_proxy=http://127.0.0.1:61203
export NO_PROXY=localhost,127.0.0.1,::1
export no_proxy=localhost,127.0.0.1,::1
export NODE_USE_ENV_PROXY=1
export SSL_CERT_FILE=/Users/you/.rystic/ca/bundle.pem
export REQUESTS_CA_BUNDLE=/Users/you/.rystic/ca/bundle.pem
export CURL_CA_BUNDLE=/Users/you/.rystic/ca/bundle.pem
export GIT_SSL_CAINFO=/Users/you/.rystic/ca/bundle.pem
export AWS_CA_BUNDLE=/Users/you/.rystic/ca/bundle.pem
export NODE_EXTRA_CA_CERTS=/Users/you/.rystic/ca/ca.pem
export GODEBUG=x509sslcertoverrideplatform=1
bundle.pemholds your system’s roots plus the Rystic gateway CA, so hosts the gateway passes through still verify.ca.pemis the CA alone.NO_PROXYkeeps calls to a simulator’s ownlocalhostport off the gateway.GODEBUGis appended to any value you already set.rystic env --jsonprints the same underdata.env, with the gateway’s URL, pid, CA, bundle and log paths.- The first
execorenvcreates the CA under~/.rystic/ca/, valid for five years. Its critical name constraints let it sign only for the simulated hosts, never for an IP address.
What each runtime reads
| client | trusts the CA through |
|---|---|
| curl | CURL_CA_BUNDLE, else SSL_CERT_FILE |
| git | GIT_SSL_CAINFO |
Python urllib and ssl, and libraries built on them such as slack_sdk | SSL_CERT_FILE |
Python requests | REQUESTS_CA_BUNDLE, else CURL_CA_BUNDLE |
| boto3, AWS CLI | AWS_CA_BUNDLE |
Node fetch, the global https agent and SDKs that use them, such as @slack/web-api | NODE_EXTRA_CA_CERTS. The proxy is used only when Node reads NODE_USE_ENV_PROXY (Node 24, or 22.21 and later); an older Node connects directly |
Ruby Net::HTTP | SSL_CERT_FILE |
| Go | on macOS before Go 1.27, the login keychain only: rystic trust. Elsewhere SSL_CERT_FILE, which GODEBUG makes Go 1.27 read on macOS |
Clients that need one more step
| client | unchanged, it… | make it route |
|---|---|---|
stripe for Python | pins its own CA bundle: APIConnectionError: Could not verify Stripe's SSL certificate | stripe.ca_bundle_path = os.environ["SSL_CERT_FILE"] |
stripe for Node | brings its own agent and reaches Stripe directly: StripeAuthenticationError: Invalid API Key provided | new Stripe(key, { httpAgent: https.globalAgent }) |
| AWS SDK for JavaScript v3 | brings its own agent and reaches AWS directly: InvalidAccessKeyId | new S3Client({ …, requestHandler: { httpsAgent: https.globalAgent } }) |
Java HttpClient | ignores the variables and connects directly | -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=<gateway port> -Djavax.net.ssl.trustStore=<store>, with ca.pem imported into <store> by keytool -importcert |
macOS /usr/bin/python3 (3.9, LibreSSL 2.8) | rejects the CA: CERTIFICATE_VERIFY_FAILED | a Python built on OpenSSL, such as Homebrew’s |
Go before 1.27 on macOS, Homebrew gh among them | x509: certificate signed by unknown authority | rystic trust |
Go tools on macOS: rystic trust
On macOS, Go before 1.27 checks certificates against the keychain only, so SSL_CERT_FILE doesn’t reach it. Until the CA is trusted, exec, env and doctor say so:
rystic: the gateway CA is not trusted in your login keychain, so Go tools built before Go 1.27 (Homebrew gh among them) refuse simulated hosts — `rystic trust` adds it (one password prompt)
rystic trust # add ~/.rystic/ca/ca.pem to the login keychain as a trusted root; one password prompt
rystic trust --remove # take this install's current and superseded gateway CAs out again
- The CA’s name constraints confine it to the simulated hosts, so trusting it vouches for no other site.
- When a newer
rysticsimulates more hosts, it replaces the CA and says so. Runrystic trustagain. - On Linux,
SSL_CERT_FILEcarries the CA andrystic trustchanges nothing. On Windows, Go tools built before Go 1.27 need the CA in the Windows certificate store.
Which hosts each simulator claims
| simulator | hosts |
|---|---|
| Clerk | api.clerk.com, img.clerk.com |
| Dropbox | api.dropboxapi.com, content.dropboxapi.com |
| GitHub | api.github.com, github.com, avatars.githubusercontent.com |
| Google Drive | www.googleapis.com |
| Lob | api.lob.com |
| Notion | api.notion.com |
| Okta | subdomain.okta.com |
| PayPal | api-m.paypal.com, api-m.sandbox.paypal.com, api.sandbox.paypal.com, developer.paypal.com, www.paypal.com, www.sandbox.paypal.com |
| S3 | s3.amazonaws.com, s3.us-east-1.amazonaws.com |
| Slack | slack.com, slack-files.com |
| Stripe | api.stripe.com, billing.stripe.com, checkout.stripe.com |
| Kalshi, Linear, Resend | none: point the client at the simulator’s URL |
- A host covers its subdomains, such as
wdcontractors.slack.com,files.slack.com,codeload.github.comand<bucket>.s3.amazonaws.com. - Everything else is tunnelled to the vendor, or refused under
--strict. That includes S3 in any region other thanus-east-1, an Okta org such asdev-123456.okta.com,api.paypal.com, Stripe’spay.stripe.comandfiles.stripe.com, andraw.githubusercontent.com. Use regionus-east-1for S3. For Okta, point the SDK’sorgUrlat the simulator, as its page shows. rystic run --interceptprints a simulator’s hosts on itsnext:line, andrystic describe <product>on itshosts:line. Kalshi, Linear and Resend printdeclares no service hosts, so the gateway has nothing to route to it.
Limits
What isn’t intercepted
- A client that ignores the proxy variables, or brings its own HTTP agent, connects straight to the vendor. See Clients that need one more step. Nothing the gateway does stops that,
--strictincluded. - Hosts that no simulator claims, and the hosts of a simulator started without
--intercept. - Clients that pin certificates, as
stripefor Python does. - HTTP/2. Intercepted connections negotiate HTTP/1.1.
How a call fails
| you see | it means | do this |
|---|---|---|
curl: (56) CONNECT tunnel failed, response 403, or Python’s Tunnel connection failed: 403 Forbidden | a simulated host with no --intercept simulator in this project. The gateway log, and the body of a plain http:// call, name it: api.notion.com is simulated by notion-twin, and none runs with --intercept in project default — start it: rystic run notion-twin --intercept -d --project default | run that line, or match --project |
502: api.stripe.com is claimed by two twins, stripe-twin/a (…) and stripe-twin/b (…) — stop one, or run it under another --project | two --intercept simulators in one project claim the host. Other hosts still route | stop one, or give it its own --project |
rystic: the gateway exited at start — its log: … (exit 1) | the gateway refused to start. The log names why, for example the claim conflict above | fix what the log names |
502: … did not answer: … connection refused | the simulator stopped in the last two seconds. After that, the call is the 403 above | start it again |
self-signed certificate in certificate chain, certificate signed by unknown authority. The gateway log shows TLS for <host> failed … (a client that rejects the certificate does not trust the Rystic CA) | the client doesn’t read the CA variables, or pins its own | What each runtime reads |
the vendor’s own error, such as Invalid API Key provided or InvalidAccessKeyId | the call reached the real vendor. The log shows no CONNECT for it, or a tunnel <host> line | Clients that need one more step, or check the host table |
the gateway for project default (pid …) already runs without --strict (exit 9) | exec or env asked for a different policy than the running gateway | rystic gateway --stop, then ask again |
Hosts no simulator claims
By default the gateway tunnels them to the real host without decrypting them, so your app’s other calls keep working. --strict refuses them instead:
rystic exec --strict -- curl -s http://example.com/
# -> example.com is not simulated, and this gateway is --strict — allow it with --allow example.com
rystic exec --strict --allow example.com -- ./run-tests.sh
- One gateway serves a project with one policy. To switch between
--strictand the default, runrystic gateway --stopfirst. --strictapplies to requests that reach the gateway. A client that connects directly isn’t affected.
Next
- CLI reference: every flag of
exec,env,gatewayandtrust. - Control plane:
RYSTIC_PRESERVE_SERVICE_URLS, which--interceptsets. - Integration tests in CI: the simulator as a service container, by base URL.