# Intercept vendor calls

Send an unmodified app's calls to api.stripe.com, slack.com or s3.amazonaws.com to local simulators, with no code change and no base-URL override.

`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](#which-hosts-each-simulator-claims) 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](https://www.rystic.ai/docs/simulators/stripe.md). That route needs no gateway.

## Quick start

```bash
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:

```bash
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
```

```python
# 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](#go-tools-on-macos-rystic-trust).

## 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](#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
```

```bash
rystic gateway --stop
# -> stopped the gateway for project default (pid 85040)
```

### Stack files

A [stack file](https://www.rystic.ai/docs/cli.md#up-down-export--the-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

```bash
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](#which-hosts-each-simulator-claims), 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`](#go-tools-on-macos-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)
```

```bash
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

| simulator | hosts |
|---|---|
| [Clerk](https://www.rystic.ai/docs/simulators/clerk.md) | `api.clerk.com`, `img.clerk.com` |
| Dropbox | `api.dropboxapi.com`, `content.dropboxapi.com` |
| [GitHub](https://www.rystic.ai/docs/simulators/github-pulls.md) | `api.github.com`, `github.com`, `avatars.githubusercontent.com` |
| Google Drive | `www.googleapis.com` |
| [Lob](https://www.rystic.ai/docs/simulators/lob.md) | `api.lob.com` |
| [Notion](https://www.rystic.ai/docs/simulators/notion.md) | `api.notion.com` |
| [Okta](https://www.rystic.ai/docs/simulators/okta.md) | `subdomain.okta.com` |
| [PayPal](https://www.rystic.ai/docs/simulators/paypal.md) | `api-m.paypal.com`, `api-m.sandbox.paypal.com`, `api.sandbox.paypal.com`, `developer.paypal.com`, `www.paypal.com`, `www.sandbox.paypal.com` |
| [S3](https://www.rystic.ai/docs/simulators/s3.md) | `s3.amazonaws.com`, `s3.us-east-1.amazonaws.com` |
| [Slack](https://www.rystic.ai/docs/simulators/slack.md) | `slack.com`, `slack-files.com` |
| [Stripe](https://www.rystic.ai/docs/simulators/stripe.md) | `api.stripe.com`, `billing.stripe.com`, `checkout.stripe.com` |
| [Kalshi](https://www.rystic.ai/docs/simulators/kalshi.md), [Linear](https://www.rystic.ai/docs/simulators/linear.md), [Resend](https://www.rystic.ai/docs/simulators/resend.md) | none: 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`](#hosts-no-simulator-claims). 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](#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 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](#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](#clients-that-need-one-more-step), or check the [host table](#which-hosts-each-simulator-claims) |
| `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:

```bash
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](https://www.rystic.ai/docs/cli-reference.md#exec): every flag of [`exec`](https://www.rystic.ai/docs/cli-reference.md#exec), [`env`](https://www.rystic.ai/docs/cli-reference.md#env), [`gateway`](https://www.rystic.ai/docs/cli-reference.md#gateway) and [`trust`](https://www.rystic.ai/docs/cli-reference.md#trust).
2. [Control plane](https://www.rystic.ai/docs/control-plane.md#environment-variables): `RYSTIC_PRESERVE_SERVICE_URLS`, which `--intercept` sets.
3. [Integration tests in CI](https://www.rystic.ai/docs/integration-testing.md): the simulator as a service container, by base URL.

---

Source: https://www.rystic.ai/docs/intercept · Markdown: https://www.rystic.ai/docs/intercept.md
