diff --git a/docs.json b/docs.json
index 3f96b11..182309a 100644
--- a/docs.json
+++ b/docs.json
@@ -78,7 +78,7 @@
},
{
"group": "Guides",
- "pages": ["rest-api/guides/quickstart"]
+ "pages": ["rest-api/guides/quickstart", "rest-api/guides/proxy"]
},
{
"group": "Endpoints",
diff --git a/rest-api/guides/proxy.mdx b/rest-api/guides/proxy.mdx
new file mode 100644
index 0000000..25a8faf
--- /dev/null
+++ b/rest-api/guides/proxy.mdx
@@ -0,0 +1,144 @@
+---
+title: "Proxy passthrough"
+description: "Have the psxdata REST API fetch PSX data through your own HTTP or SOCKS proxy with the X-PSX-Proxy header."
+---
+
+Send an `X-PSX-Proxy` header with any PSX-backed request, and the API fetches that request's data from PSX **through your proxy** instead of its own connection. That includes the `X-Req-Id` request token PSX requires, which is fetched through the same proxy. Under the hood this uses the Python SDK's [proxy support](/sdk/guides/proxy).
+
+```bash
+curl -H "X-PSX-Proxy: http://user:pass@proxy.example.com:8080" \
+ https://psxdata-api.fastapicloud.dev/stocks/ENGRO/quote
+```
+
+Requests without the header behave exactly as before. Available from psxdata-api **0.4.0**.
+
+
+ Proxy passthrough is an opt-in server setting. If the server you're calling
+ hasn't enabled it, any request carrying `X-PSX-Proxy` returns
+ `400 bad_request` with the message `X-PSX-Proxy is not enabled on this server`.
+
+
+---
+
+## Cache first, proxy on a miss
+
+Proxied requests use the same cache as everyone else. Your proxy changes the route to PSX, not the data:
+
+1. **The data is already cached.** It's served straight from the cache. Your proxy is never looked up or contacted, and the request doesn't count toward the proxy limits below.
+2. **The data isn't cached.** The API fetches it from PSX through your proxy and caches it, so later requests (yours or anyone else's) are served from the cache.
+
+The [caching rules](/rest-api/introduction#caching) are the same with or without a proxy. On `/stocks/{symbol}/historical`, `X-Cache` reports `HIT`, `MISS` or `STALE` as usual.
+
+
+ Because cached responses never touch your proxy, a `200` doesn't by itself
+ prove your proxy works. Use a request that misses the cache to test it.
+
+
+PSX is HTTPS-only and the API verifies its certificates. A proxy only carries the encrypted connection, so it can't read or change the data it relays.
+
+---
+
+## Supported proxies
+
+| Scheme | Example |
+|---|---|
+| `http://` | `http://proxy.example.com:8080` |
+| `socks5://` | `socks5://proxy.example.com:1080` (the API resolves PSX's hostname) |
+| `socks5h://` | `socks5h://proxy.example.com:1080` (your proxy resolves PSX's hostname) |
+
+- **Credentials** go in the URL: `http://user:pass@host:port`. Percent-encode special characters in the password (for example `@` becomes `%40`).
+- **An explicit port is required.** It must be `80`, `443`, or between `1024` and `65535`.
+- **`https://` proxies are not accepted.** The API pins each connection to the IP address it checked, which isn't compatible with verifying a TLS certificate for the proxy's hostname.
+- **No path, query string or fragment.** At most 2048 characters.
+
+---
+
+## What the API checks
+
+The API connects to an address you choose, so it checks your proxy before using it.
+
+**On every request** (no network involved):
+
+- Proxy passthrough must be enabled on the server.
+- The URL must have a supported scheme and port, as above.
+
+**Only when PSX has to be fetched** (a cache miss):
+
+- **Public addresses only.** The proxy's hostname must resolve only to public internet addresses. Loopback, private ranges, link-local addresses (including cloud metadata such as `169.254.169.254`), carrier-grade NAT and multicast are all rejected, as are IPv6 forms that wrap any of those.
+- **Pinned to the checked address.** The API connects to the exact IP it validated, so the hostname can't be switched to another address afterwards.
+- **Reachability.** The proxy must accept a TCP connection within 5 seconds.
+- **Limits.** At most 10 proxied PSX fetches per minute per IP address, and at most 4 in progress across the whole server. These are on top of the normal [60 requests per minute](/rest-api/introduction#rate-limits).
+
+---
+
+## Errors
+
+All errors use the standard [error envelope](/rest-api/introduction#error-response).
+
+| Status | `error.code` | When |
+|---|---|---|
+| `400` | `bad_request` | Proxy passthrough is disabled on the server, the proxy URL is malformed or uses a disallowed scheme or port, or (on a cache miss) the host can't be resolved or resolves to a non-public address. |
+| `429` | `rate_limited` | More than 10 proxied PSX fetches in a minute from your IP, or 4 proxied fetches already in progress on the server. Retry shortly. |
+| `502` | `proxy_unreachable` | Your proxy didn't accept a connection within 5 seconds. |
+| `503` | `psx_unavailable` | PSX was unreachable or failed through your proxy after retries. Same as without a proxy. |
+
+```json
+{
+ "error": {
+ "status": 502,
+ "code": "proxy_unreachable",
+ "message": "X-PSX-Proxy: proxy did not accept a connection"
+ }
+}
+```
+
+
+ Error messages never include your proxy URL, and the API never logs proxy
+ credentials. Always send the proxy in the `X-PSX-Proxy` header, never in the
+ URL or query string.
+
+
+---
+
+## Examples
+
+
+
+ ```python
+ import requests
+
+ resp = requests.get(
+ "https://psxdata-api.fastapicloud.dev/stocks/ENGRO/historical",
+ params={"start": "2025-01-01", "end": "2025-01-31"},
+ headers={"X-PSX-Proxy": "socks5h://user:pass@proxy.example.com:1080"},
+ )
+ resp.raise_for_status()
+ print(resp.headers.get("X-Cache"), len(resp.json()["data"]))
+ ```
+
+
+ ```javascript
+ const resp = await fetch(
+ "https://psxdata-api.fastapicloud.dev/stocks/ENGRO/quote",
+ { headers: { "X-PSX-Proxy": "http://user:pass@proxy.example.com:8080" } }
+ );
+ const body = await resp.json();
+ if (!resp.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
+ console.log(body.data);
+ ```
+
+
+
+
+ Browsers send custom headers only after a CORS preflight. The API allows
+ any request header, so `X-PSX-Proxy` works from the browser too. Keep proxy
+ credentials out of frontend code, though: call the API from your backend if
+ your proxy needs a password.
+
+
+---
+
+## See also
+
+- [Python SDK proxy guide](/sdk/guides/proxy): use a proxy directly from Python, without the REST API.
+- [Caching](/rest-api/introduction#caching): freshness rules and the `X-Cache` header.
diff --git a/rest-api/introduction.mdx b/rest-api/introduction.mdx
index 3c94cbc..4ece62a 100644
--- a/rest-api/introduction.mdx
+++ b/rest-api/introduction.mdx
@@ -62,6 +62,10 @@ Every `/historical` response tells you where it came from:
`start` and `end` must be ISO dates in `YYYY-MM-DD` form, and `start` must not be after `end`. Anything else returns `422 bad_request`.
+## Proxy passthrough
+
+Send an optional `X-PSX-Proxy` header (`http://`, `socks5://` or `socks5h://`, with an explicit port) to have the API fetch PSX data through your own proxy. Cached data is served without contacting your proxy; it's only used when PSX has to be fetched, and the result is cached for everyone. The server has to enable this feature. See the [proxy passthrough guide](/rest-api/guides/proxy) for supported URLs, the checks the API applies, and its limits.
+
## Response format
Every response — success or error — is a JSON object with a single top-level key.
@@ -123,7 +127,8 @@ Every response — success or error — is a JSON object with a single top-level
| `400` | `bad_request` | Invalid query parameters or request body. |
| `404` | `not_found` | Symbol or resource not found. |
| `422` | `bad_request` | Request validation failed (e.g. missing required field, wrong type, or a `start`/`end` date that is not `YYYY-MM-DD`). |
-| `429` | `rate_limited` | 60 requests per minute per IP exceeded. |
+| `429` | `rate_limited` | 60 requests per minute per IP exceeded, or a [proxy passthrough](/rest-api/guides/proxy) limit was hit. |
+| `502` | `proxy_unreachable` | The proxy in your `X-PSX-Proxy` header did not accept a connection within 5 seconds. |
| `503` | `psx_unavailable` | The PSX website was unreachable, or PSX is rate-limiting the API. When PSX is rate-limiting, the response includes a `Retry-After` header (seconds to wait). |
| `500` | `internal_error` | Unexpected server error. |