Skip to Content
Intercept vendor calls

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

  1. rystic run <product> --intercept boots the simulator for the gateway. The URLs it hands back keep the vendor’s hosts, such as PayPal’s links, Lob’s next_url and GitHub’s html_url, instead of naming localhost. A client that follows one comes back through the gateway.
  2. The gateway runs one per project, on 127.0.0.1 only. For each host that an --intercept simulator in the project claims, it terminates TLS with a certificate from the Rystic CA and forwards the request to that simulator, Host unchanged. It re-reads the simulators as requests arrive, so it picks up a simulator started or stopped later without a restart.
  3. 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.
  4. eval "$(rystic env)" puts the same exports in your shell, so every later command in it is routed.
  5. rystic gateway runs the gateway in the foreground and logs to the terminal. --port fixes its port, and exec and env use it while it runs. Ctrl-C ends 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

  1. Starts on the first rystic exec or rystic env in a project with no gateway running.
  2. Listens on a free port on 127.0.0.1. rystic env prints it.
  3. Is recorded in ~/.rystic/run/gateway/<project>.json.
  4. Logs to ~/.rystic/run/gateway/<project>.log, one line per connection and per request.
  5. Stops only on rystic gateway --stop. rystic stop and rystic down leave 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
  1. bundle.pem holds your system’s roots plus the Rystic gateway CA, so hosts the gateway passes through still verify. ca.pem is the CA alone.
  2. NO_PROXY keeps calls to a simulator’s own localhost port off the gateway.
  3. GODEBUG is appended to any value you already set.
  4. rystic env --json prints the same under data.env, with the gateway’s URL, pid, CA, bundle and log paths.
  5. The first exec or env creates 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

clienttrusts the CA through
curlCURL_CA_BUNDLE, else SSL_CERT_FILE
gitGIT_SSL_CAINFO
Python urllib and ssl, and libraries built on them such as slack_sdkSSL_CERT_FILE
Python requestsREQUESTS_CA_BUNDLE, else CURL_CA_BUNDLE
boto3, AWS CLIAWS_CA_BUNDLE
Node fetch, the global https agent and SDKs that use them, such as @slack/web-apiNODE_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::HTTPSSL_CERT_FILE
Goon 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

clientunchanged, it…make it route
stripe for Pythonpins its own CA bundle: APIConnectionError: Could not verify Stripe's SSL certificatestripe.ca_bundle_path = os.environ["SSL_CERT_FILE"]
stripe for Nodebrings its own agent and reaches Stripe directly: StripeAuthenticationError: Invalid API Key providednew Stripe(key, { httpAgent: https.globalAgent })
AWS SDK for JavaScript v3brings its own agent and reaches AWS directly: InvalidAccessKeyIdnew S3Client({ …, requestHandler: { httpsAgent: https.globalAgent } })
Java HttpClientignores 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_FAILEDa Python built on OpenSSL, such as Homebrew’s
Go before 1.27 on macOS, Homebrew gh among themx509: certificate signed by unknown authorityrystic 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
  1. The CA’s name constraints confine it to the simulated hosts, so trusting it vouches for no other site.
  2. When a newer rystic simulates more hosts, it replaces the CA and says so. Run rystic trust again.
  3. On Linux, SSL_CERT_FILE carries the CA and rystic trust changes nothing. On Windows, Go tools built before Go 1.27 need the CA in the Windows certificate store.

Which hosts each simulator claims

simulatorhosts
Clerkapi.clerk.com, img.clerk.com
Dropboxapi.dropboxapi.com, content.dropboxapi.com
GitHubapi.github.com, github.com, avatars.githubusercontent.com
Google Drivewww.googleapis.com
Lobapi.lob.com
Notionapi.notion.com
Oktasubdomain.okta.com
PayPalapi-m.paypal.com, api-m.sandbox.paypal.com, api.sandbox.paypal.com, developer.paypal.com, www.paypal.com, www.sandbox.paypal.com
S3s3.amazonaws.com, s3.us-east-1.amazonaws.com
Slackslack.com, slack-files.com
Stripeapi.stripe.com, billing.stripe.com, checkout.stripe.com
Kalshi, Linear, Resendnone: point the client at the simulator’s URL
  1. A host covers its subdomains, such as wdcontractors.slack.com, files.slack.com, codeload.github.com and <bucket>.s3.amazonaws.com.
  2. Everything else is tunnelled to the vendor, or refused under --strict. That includes S3 in any region other than us-east-1, an Okta org such as dev-123456.okta.com, api.paypal.com, Stripe’s pay.stripe.com and files.stripe.com, and raw.githubusercontent.com. Use region us-east-1 for S3. For Okta, point the SDK’s orgUrl at the simulator, as its page shows.
  3. rystic run --intercept prints a simulator’s hosts on its next: line, and rystic describe <product> on its hosts: line. Kalshi, Linear and Resend print declares no service hosts, so the gateway has nothing to route to it.

Limits

What isn’t intercepted

  1. 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, --strict included.
  2. Hosts that no simulator claims, and the hosts of a simulator started without --intercept.
  3. Clients that pin certificates, as stripe for Python does.
  4. HTTP/2. Intercepted connections negotiate HTTP/1.1.

How a call fails

you seeit meansdo this
curl: (56) CONNECT tunnel failed, response 403, or Python’s Tunnel connection failed: 403 Forbiddena 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 defaultrun 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 --projecttwo --intercept simulators in one project claim the host. Other hosts still routestop 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 abovefix what the log names
502: … did not answer: … connection refusedthe simulator stopped in the last two seconds. After that, the call is the 403 abovestart 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 ownWhat each runtime reads
the vendor’s own error, such as Invalid API Key provided or InvalidAccessKeyIdthe call reached the real vendor. The log shows no CONNECT for it, or a tunnel <host> lineClients 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 gatewayrystic 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
  1. One gateway serves a project with one policy. To switch between --strict and the default, run rystic gateway --stop first.
  2. --strict applies to requests that reach the gateway. A client that connects directly isn’t affected.

Next

  1. CLI reference: every flag of exec, env, gateway and trust.
  2. Control plane: RYSTIC_PRESERVE_SERVICE_URLS, which --intercept sets.
  3. Integration tests in CI: the simulator as a service container, by base URL.
Last updated on