substack-gateway-worker
by iamfiscus
README.md
# substack-gateway-worker
A TypeScript port, for Cloudflare Workers, of
[substack-gateway-oss](https://github.com/jakub-k-slys/substack-gateway-oss) by
Jakub Slys (MIT, Copyright (c) 2025 Jakub Slys), pinned at commit `bc2e875`.
Behaviour, messages and defaults follow upstream; port changes are listed under
"Differences from upstream" and in `NOTICE.md`. Port copyright (c) 2026 Jd Fiscus.
Unofficial. Not affiliated with Substack.
[](https://deploy.workers.cloudflare.com/?url=https://github.com/iamfiscus/substack-gateway-worker)
Deploy URL: `https://deploy.workers.cloudflare.com/?url=https://github.com/iamfiscus/substack-gateway-worker`
It exposes 51 REST endpoints under `/api/v1` (OpenAPI at `/api/openapi.json`,
Swagger UI at `/api/docs`) and 49 MCP tools at `/mcp`. See
[API reference](docs/api-reference.md), [MCP](docs/mcp.md),
[Authentication](docs/authentication.md), [Draft Markdown](docs/drafts-markdown.md),
[Extensions](docs/extensions.md) and [Deploy](docs/deploy.md).
## Credential modes
**Pass-through (default).** No secrets. Each caller sends their own Substack
credentials: `x-gateway-token` on REST, `token` argument on MCP, as
base64-encoded JSON `{publication_url, substack_sid, connect_sid}`. With
`GATEWAY_API_KEYS` unset the Worker is open, exactly like upstream.
**Owner mode.** Set `GATEWAY_API_KEYS` (comma list of `label:key` or
`label:sha256:<hex>`) plus `OWNER_PUBLICATION_URL`, `OWNER_SUBSTACK_SID` and
`OWNER_CONNECT_SID` as Worker secrets. Callers send `Authorization: Bearer <key>`
and use your cookies without ever seeing them. Everything except
`/api/v1/health/live` and `/` needs the key. An explicit token still wins and is
not feature-gated. Resolved credentials are never logged or echoed (`show=true`
returns `403`).
Owner feature list (`OWNER_ALLOWED_FEATURES`, comma list, unset = default): by
default only reads (`get`/`list` and all stats), `drafts:create`,
`drafts:update`, `drafts:prepublish`, `drafts:ai-detection`, image upload and
`health:ready` work with your cookies, as `api:` and `mcp:` names. Creating
notes, comments, likes, deletes, scheduling and restacking are off until you list
them. Prefix entries with `+` to add them to the default, for example
`+api:notes:create,+mcp:notes:create`; a list without `+` replaces the default
entirely. Mixing the two forms is rejected as a misconfiguration. See
[Authentication](docs/authentication.md). The Worker never publishes or
schedules anything on its own, and has no crons.
## Getting your cookies
Log into Substack in a browser, open developer tools, Application (or Storage),
Cookies, and copy the values of `substack.sid` and `connect.sid`.
**Warning: these are full-account credentials.** Anyone holding them can act as
you on Substack: read, post, change settings. Never commit them, paste them into
issues or chats, or send them to a Worker you do not control. Prefer a
throwaway publication while you try this out.
## Terms of Service risk
This uses Substack's internal, undocumented API with your session cookies. It is
not an official integration. Substack's Terms of Service (check
`substack.com/tos` yourself; this is not legal advice) restrict crawling and
scraping, copying or storing significant portions of content, processes that run
while you are not logged in, and sharing account credentials. Violations can
lead to account termination. Automation that runs on a schedule with your
cookies, bulk downloads and handing your cookies to other people are the
likeliest to cross those lines. This project ships no crons and no cookie-health
checker for that reason; keep volume low and human-triggered. Internal endpoints
can also change or vanish without notice. You use this at your own risk.
## Trust model
In pass-through mode the operator's Worker sees callers' cookies: it decodes
every request's token in memory to call Substack. That is upstream's trust model
too. Only send your cookies to a Worker you run. In owner mode your cookies live
in Worker secrets (encrypted, not readable back through wrangler) and callers
never see them, but anyone with a gateway key can use the allowed features as
you. MCP tool arguments can land in agent transcripts, so avoid passing `token`
from an MCP client.
## Limits
Measured in workerd by `test/workers/runtime.test.ts`, with the Cloudflare plan
limits as published at
<https://developers.cloudflare.com/workers/platform/limits/> (checked
2026-11-07; re-check, they change):
| Item | Measured or counted | Workers Free | Workers Paid |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------------- |
| CPU to convert a ~5,000-word draft (`markdownToDraftDoc`) | about 2 to 3 ms (the workerd timer is coarse, treat as approximate) | 10 ms CPU per request | 30 s default, up to 5 min |
| Subrequests per draft write | createDraft: N `POST image` + 1 `user-settings` (byline) + 1 draft POST; updateDraft: N `POST image` + optional 1 GET + 1 PUT | 50 per request | 10,000 default, up to 10M |
| 1 MB multipart image upload (`POST /api/v1/images`) | 1 subrequest to Substack | counts against 50 | counts against 10,000 |
| Memory per isolate | image bytes are base64-encoded in memory | 128 MB | 128 MB |
So creating a draft with 12 external images makes 12 + 1 + 1 = 14 subrequests (updating one makes 12 + 1 GET when `expected_updated_at` is sent + 1 PUT = 14, or 13 without it), which fits
the free plan's 50 but not far beyond it. The free plan's 10 ms CPU cap leaves
little room above the converter's few ms plus parsing and JSON; large drafts may
need Workers Paid. The maintainer's instance runs on Workers Paid.
## Rotating cookies
Cookies expire or get rotated, and static secrets go stale. When requests start
returning `401`, log into Substack, copy fresh values and run:
```sh
npx wrangler secret put OWNER_SUBSTACK_SID
npx wrangler secret put OWNER_CONNECT_SID
```
A `403` from Substack can be a bot-challenge page rather than an expired
session; try the same cookies in a browser first. Rotate `GATEWAY_API_KEYS` the
same way. Note: a raw gateway key that literally starts with `sha256:` is read as
a hash entry, so choose keys that do not.
## Differences from upstream
Dropped (cannot work in a bundled Worker or ToS-driven):
- lifespan hooks (Workers have no lifespan)
- httpx connection pool settings
- loading extensions from environment variables or entry points (extensions are
listed statically in `src/index.ts`)
- the optional cookie-health cron (it would be a process running while you are
not logged in)
Changed behaviour:
- Malformed Substack responses (HTML bot-challenge pages, empty bodies, non-JSON)
surface as `502`, not upstream's `500`.
- Note content is capped at 50,000 characters (`422` on REST, a tool error on
MCP), because pathological Markdown costs real CPU on Workers.
- Replying to a target that has no post returns a distinct `404`.
- Redirects are never followed, so cookies are never sent off-host.
- MCP tool-argument validation wording differs (zod, not pydantic).
- Draft dialect, JS number limits: `1.0` round-trips as `1`, integers above 2^53
lose precision, and attribute keys that look like integers are ordered first.
- Owner mode, the feature gate, hashed gateway keys and the optional KV caches are
additions.
- `POST /api/v1/images` and the `upload_image` MCP tool refuse images over
10 MB (`413 {"detail":"Image too large (max 10 MB)"}` on REST, checked from
`Content-Length` before the body is read and again on the file size; an upload
without a numeric `Content-Length`, such as a chunked one, is
`411 {"detail":"Content-Length required"}`; a tool
error on MCP, whose transport already caps a request at 4 MB). Upstream has
no cap.
- An incoming `X-Request-ID` is reused only if it is at most 128 characters of
`[A-Za-z0-9._-]`; anything else gets a fresh UUID.
- `scheduled_at` given as a numeric string (`"1767225600"`) is a `422`; pydantic
accepts it as a unix timestamp. Send a JSON number or an ISO-8601 string.
- A Substack user-settings entry without a `type` is accepted (only `user_id` is
read); upstream's model requires `type` and answers `500`.
- With a value cache installed (for example `kvValueCache`), the public profile
lookup behind the profile routes is fetched anonymously (empty cookies, as the
MCP profile tools do), so a cached profile never depends on one caller's
session and one caller's auth error is never served to another. Without a
cache the caller's credentials are used, as upstream does.
## Develop
```sh
npm install
scripts/fetch-upstream.sh # clones the pinned upstream reference into upstream/
npm test && npm run test:workers && npm run typecheck && npm run lint
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessOutdated data