This guide runs the gateway on your machine, links a workspace to the upstream and makes a first API call.
- Node 22.12 or newer
- pnpm 10
- Docker (for Postgres)
- An upstream: the one in
services/upstream, the fake one the tests use, or your own (step 3)
docker compose up -dThis starts Postgres 17 on port 5434 (user, password and database are all gateway). A second database,
gateway_test, is created for the test suite.
cp .env.example .envSet these values in .env:
| Variable | What to put there |
|---|---|
BETTER_AUTH_SECRET |
A random secret of at least 16 characters. openssl rand -base64 32 works. |
UPSTREAM_URL |
The upstream service's origin. Defaults to http://localhost:3001. |
UPSTREAM_TOKEN |
The bearer token the gateway sends on every upstream call (at least 16 characters; letters, digits and -._~ only, e.g. openssl rand -hex 32, because it also travels as a WebSocket subprotocol). Use dev-upstream-token with the fake upstream. |
.env.example already fills in the rest for local use. APP_URL is the dashboard origin
(http://localhost:5173) and BETTER_AUTH_URL is where the API serves sign-in (http://localhost:4000).
DATABASE_URL points at the Postgres from step 1. The API exits on start with a list of problems if a
required value is missing or invalid.
Sign-up needs no email: registering signs you in at once. Mail is used only for password reset: Forgot password?
on the login page sends a link to APP_URL/reset-password that is valid for 1 hour and works once. Sending it
requires SMTP_* (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL, SMTP_FROM_NAME;
e.g. mail.privateemail.com, port 587 for STARTTLS or 465 for TLS). Without SMTP_HOST no mail is sent and
the link is printed in the API logs instead, which is enough for local use. If the mail does not arrive, the operator
can print a user's link with pnpm --filter @gateway/api auth:reset-link <email> (on a VPS:
docker compose exec gateway node dist/cli/reset-link.js <email>).
REGISTRATION_OPEN=false refuses new sign-ups; existing users still sign in.
pnpm install
pnpm db:migrate
pnpm devThe API listens on http://localhost:4000 and the dashboard on http://localhost:5173. In development the
dashboard forwards /api and /v1 to the API.
Both options below listen on port 3001 (the default UPSTREAM_URL), so run one or the other.
The fake upstream needs no OF account. In a second terminal:
UPSTREAM_TOKEN=dev-upstream-token pnpm --filter @gateway/api exec tsx test/fake-upstream.tsIt listens on 0.0.0.0:3001 (FAKE_UPSTREAM_PORT to change that), requires
authorization: Bearer dev-upstream-token and serves one account with id 1.
The real upstream (services/upstream) talks to OF. The root compose file runs it under the upstream
profile on 127.0.0.1:3001, with its sessions in the upstream-data volume. It takes UPSTREAM_TOKEN from
your shell or the root .env (default dev-upstream-token):
docker compose --profile upstream up -d --build upstreamConnect an account with a curl copied from Chrome (DevTools → Network → a request to onlyfans.com/api2/v2/… →
Copy as cURL (bash); it must carry the cookies as -b '…') and a proxy for that account. The upstream refuses an
account without a proxy only when OF_REQUIRE_PROXY=true; by default an account with no proxy connects from the server's own IP.
From the dashboard (once your workspace is signed up and linked, §4): Accounts → Connect account, paste the
cURL, optionally a nickname, and the proxy URL (http://user:pass@host:port or socks5h://…). The gateway
forwards it to the upstream's /session/bootstrap and never stores the cURL in the request log.
The row's actions menu has Edit (label, proxy) and Disconnect, which deletes the account's session on the
upstream: it stops receiving events and API calls for it fail. Its analytics history is kept, and this workspace can
connect it again later.
Or from the command line. An account connected this way belongs to no workspace, so no workspace sees it until it is
claimed (link-team prints the workspace id):
docker compose exec -T upstream python connect.py --nickname Main --proxy-url socks5h://user:pass@host:port < curl.txt
pnpm --filter @gateway/api analytics:claim --team <teamId> --account <accountId>OF's static signing parameter comes from --static-param <value> on that command, or from STATIC_PARAM in
the upstream's environment (STATIC_PARAM=… docker compose --profile upstream up -d upstream). The deploy
equivalent is in deploy/hostinger.md §7.
Events. To feed webhooks and the /v1/ws socket (realtime.md), point the gateway at the
upstream's event socket in .env and restart pnpm dev. Both upstreams above serve it:
UPSTREAM_WS_URL=ws://localhost:3001/wsLeft empty, there is no event source and /v1/ws answers 503.
Open http://localhost:5173 and register; you are signed in at once. A workspace is created for you and linked to the upstream automatically.
An unlinked workspace gets 409 team_not_linked on calls that would reach the upstream. If Settings shows
yours as not linked (the automatic link failed; the API log says why), copy the slug from there and run:
pnpm --filter @gateway/api link-team <workspace-slug>Running the command again is harmless. Accounts themselves are stored in the upstream service; the gateway records which workspace each belongs to (the one that connected it), and a workspace sees only its own.
Go to API keys and create a key. Pick a scope (read or write) and, if you like, limit it to some
accounts. The secret is shown once, so copy it now. See authentication.md for details.
export API_KEY=gk_...curl -H "Authorization: Bearer $API_KEY" http://localhost:4000/v1/whoami
curl -H "Authorization: Bearer $API_KEY" http://localhost:4000/v1/accounts/v1/whoami shows the workspace and key. /v1/accounts lists the accounts the key can use. Take an id
from that list (the Accounts page shows the same list) and call the upstream through the gateway:
export ACCOUNT_ID=123
curl -H "Authorization: Bearer $API_KEY" http://localhost:4000/v1/$ACCOUNT_ID/meAnything under /v1/{account}/ is forwarded to the upstream's account API for that account. The upstream's
response, including errors, comes back unchanged.
- Playground (
/playgroundin the dashboard): pick an operation (grouped by tag, with search) and an account, edit path parameters, query rows and a JSON body, then press Send. You see the status, latency, headers and JSON response, and a curl snippet that uses$API_KEY. Requests use your dashboard session, so no key is needed in the browser; account operations sent from it are logged with the key "Dashboard". The dashboard's own calls to gateway routes (such as/v1/webhooks) are not logged. GET /openapi.json: the OpenAPI spec. It holds the gateway's own/v1routes plus the upstream's account operations, mapped to/v1/{account}/...under theplatformtag. If the upstream's spec can't be fetched, you get the gateway routes only and the response headerx-upstream-spec: unavailable. The spec is cached for 10 minutes.GET /docs: an interactive API reference built from/openapi.json. Both are public.
- Logs lists every authenticated
/v1request with its status, latency, key and account. Open a row for headers and, when stored, bodies. "Open in playground" fills the playground with the same method, path, query and body. - Usage charts requests and errors per day and breaks them down by key, account or endpoint.
Bodies are stored only for failed requests (status 400 and up) by default. Set LOG_BODIES to all or
none to change that. Logs are kept for LOG_RETENTION_DAYS days (14 by default).
pnpm build
NODE_ENV=production pnpm --filter @gateway/api startpnpm build builds both apps. With NODE_ENV=production the API also serves the dashboard from
apps/web/dist, so the API and dashboard share one origin. Set APP_URL and BETTER_AUTH_URL to that
public URL, and TRUST_PROXY=1 if one reverse proxy sits in front (a hop count or the proxies' IPs/CIDRs;
true would trust a client-sent X-Forwarded-For).