A self-hosted API gateway and dashboard for a creator-platform service, with the upstream service included.
Deploy in one line, as root on a fresh Docker VPS, with your domain filled in (details):
HTTPS_PORT=443 HTTP_PORT=80 DOMAIN=gw.example.com bash -c 'curl -fsSLo /tmp/install.sh https://raw.githubusercontent.com/BlackPhoenixSlo/open_onlyfansapi/master/deploy/install.sh && bash /tmp/install.sh'The dashboard's root / is the landing page, with screenshots and a tutorial: signed-out visitors get Log in and
Register there, signed-in users an Open dashboard button. The dashboard home is /dashboard.
OpenOnlyfansAPI sits in front of an upstream service, the one in services/upstream or your own. Each workspace creates
its own scoped API keys and calls /v1/{account}/.... OpenOnlyfansAPI checks the key, forwards the call with one upstream
bearer token, and records it. Your upstream stays private, your users never see its credential, and you get logs,
usage, a playground and an API reference without building them.
Not affiliated. This project is unofficial and is not affiliated with, endorsed by or connected to any content platform or its operators. You are responsible for complying with the terms of the platforms you use.
Accounts · API keys · Playground · Home · Usage · API reference · Video overview
- API keys.
gk_keys, shown once and stored as SHA-256 hashes. Create, rename, rescope and revoke from the dashboard; revocation takes effect on the next request. - Scopes and account allowlists. A
readkey may onlyGETandHEAD; awritekey may use any method. Each key can be limited to a list of accounts. - Proxy.
/v1/{account}/<path>goes to the upstream with your token. Status and body come back unchanged, along withcontent-type,retry-afterandx-ratelimit-*. Per-key rate limit, 120 requests a minute by default. - Logs with redaction. Every authenticated
/v1request, with status, latency, key and account. Credential headers are never stored; secret-looking JSON keys and query parameters are masked. Bodies are kept for failed requests by default, and logs for 14 days. - Usage. Requests and errors per day, broken down by key, account or endpoint.
- Playground. Pick an operation and an account, edit parameters and body, send, and copy the curl. It uses your dashboard session, so no key is pasted into the browser.
- Merged OpenAPI reference.
/openapi.jsonholds OpenOnlyfansAPI's own routes plus the upstream's operations rewritten to/v1/{account}/..../docsrenders it as an interactive reference. - Workspace isolation. Keys, logs and usage belong to one workspace, and every query is scoped to it. So do
OF accounts: a workspace sees and uses only the accounts it connected, with their events and stats.
Sign-up is open and links each new workspace to the upstream automatically;
link-teamrepairs one left unlinked.
client (curl, script, browser)
│ HTTPS · Authorization: Bearer gk_... · REST and /v1/ws
▼
┌──────────────┐ http ┌────────────────────────┐ Bearer UPSTREAM_TOKEN ┌────────────────────────┐
│ Caddy (TLS) │───────▶ │ OpenOnlyfansAPI :4000 │───────────────────────▶ │ upstream :8000 │
└──────────────┘ │ API + dashboard │◀──── events (/ws) ──────│ services/upstream │
│ webhooks, /v1/ws │ │ OF sessions, proxies │
└───────────┬────────────┘ └───────────┬────────────┘
│ │ one proxy
┌───────────▼────────────┐ │ per account
│ Postgres 17 │ ▼
│ keys, sessions, logs, │ OF
│ event ledger │
└────────────────────────┘
The upstream lives in services/upstream. It holds each OF account's session and proxy, signs
the account's calls to OF, and streams its events to OpenOnlyfansAPI over one WebSocket, which OpenOnlyfansAPI fans out to
webhooks and /v1/ws clients. The deploy stack runs it under the upstream compose profile with no published
port; accounts are connected to it from the VPS shell, not in OpenOnlyfansAPI
(deploy guide §7). Any service that answers the same calls can
replace it. Each call carries Authorization: Bearer $UPSTREAM_TOKEN:
| Call | OpenOnlyfansAPI sends | Upstream returns |
|---|---|---|
listAccounts |
GET /of-accounts |
A JSON array of accounts, each with a numeric id. |
forward |
<METHOD> /of/<rest> with x-of-proxy-account-id: <account> |
Anything. It is passed through unchanged. |
openapi (optional) |
GET /docs/json |
An OpenAPI 3 document. Operations under /of/ tagged of-api are merged into /openapi.json. |
events (optional, UPSTREAM_WS_URL) |
WebSocket GET /ws, subprotocols bearer + bearer.<token>; the server must select bearer |
One JSON event per text frame (plan-v4 §2.2) and a heartbeat frame at least every 90 s (the gateway reconnects after 90 s of silence). |
Everything a client sends to /v1/{account}/<rest> lands on /of/<rest>, and OpenOnlyfansAPI refuses any path that
would leave that prefix. The client's authorization, cookie and host headers are never forwarded. The
interface is specified in docs/spec/plan-v2.md §4
and docs/spec/plan-v4.md §2, and implemented
in apps/api/src/lib/upstream.ts.
apps/api/test/fake-upstream.ts is a small working example of the contract.
What you get. Your own server with the dashboard and the API on your domain over HTTPS: connect your accounts, create API keys, see every request in the logs, try calls in the playground, and receive events by webhook. Nothing goes through a third party; the server is yours, and so is the data.
You need a Docker VPS with 2 GB RAM or more (the image builds on the box), a DNS record for your hostname pointing at it, and ports 80 and 443 open. With those ready, most of the ten minutes is the first build.
No server yet? A Hostinger KVM 2 with the Ubuntu 24.04 + Docker template is what the video uses: hostinger.com/pricing (referral link, 20% off).
One line. As root on a fresh VPS (Hostinger's Ubuntu 24.04 + Docker template works as is):
HTTPS_PORT=443 HTTP_PORT=80 DOMAIN=gw.example.com bash -c 'curl -fsSLo /tmp/install.sh https://raw.githubusercontent.com/BlackPhoenixSlo/open_onlyfansapi/master/deploy/install.sh && bash /tmp/install.sh'Change the three values to taste. No domain yet? Use the hostname the provider gave the VPS, which already
points at it: DOMAIN=$(hostname -f). Ports 80/443 taken? Any other pair works, e.g. HTTPS_PORT=7443 HTTP_PORT=7080
(the certificate is then self-signed and the browser warns once). A Traefik container on them is detected by the same
line, which routes the domain through it with a trusted certificate; when nginx or another proxy owns them, let that
proxy serve the domain: GATEWAY_PORT=5454 instead of the two port values. This lists the ports already in use:
ss -Hltn | awk '{print $4}' | sed 's/.*://' | sort -un | tr '\n' ' '; echoThe script clones the repo into /opt/gateway, writes deploy/.env with fresh secrets, starts the stack and
waits for https://$DOMAIN/healthz; re-running it later is the update. Then skip to "Open" below. The steps it
automates, for doing them by hand:
git clone https://github.com/BlackPhoenixSlo/open_onlyfansapi.git /opt/gateway
cd /opt/gateway/deploy
cp .env.example .env && chmod 600 .envFill in four values in deploy/.env (the rest default to the bundled upstream):
| Variable | Value |
|---|---|
PUBLIC_URL |
https://gw.example.com: your hostname, no trailing slash |
POSTGRES_PASSWORD |
output of openssl rand -hex 24 |
BETTER_AUTH_SECRET |
output of openssl rand -base64 32 |
UPSTREAM_TOKEN |
output of openssl rand -hex 32 (letters, digits and -._~ only, so not base64) |
Start the stack. Migrations run on every start, and Caddy fetches a certificate on first boot.
docker compose up -d --build --wait
curl -fsS https://gw.example.com/healthzOpen https://gw.example.com and sign up; you are signed in at once, and that creates a workspace and links it to
the upstream. If Settings shows it as
not linked, copy its slug from there and link it from the VPS shell:
docker compose exec gateway node dist/db/link-team.js <slug>Connect an OF account from Accounts → Connect account with a curl copied from your browser and a proxy of its
own; it then belongs to your workspace, and no other workspace sees it. An account connected from the VPS shell
instead belongs to no workspace until you claim it for one (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
docker compose exec gateway node dist/cli/analytics-claim.js --team <teamId> --account <accountId>The full deploy guide covers capturing the curl, account statuses, the firewall, updates, backups and restore (the upstream volume holds credentials), and troubleshooting. To expose no ports at all, run behind a Cloudflare Tunnel instead of Caddy.
Requires Node 22.12 or newer, pnpm 10 and Docker.
pnpm install
docker compose up -d # Postgres 17 on 127.0.0.1:5434, plus a gateway_test database
cp .env.example .env # set BETTER_AUTH_SECRET, and UPSTREAM_TOKEN=dev-upstream-token
pnpm db:migrate
pnpm dev # API on http://localhost:4000, dashboard on http://localhost:5173No upstream at hand? In a second terminal, run the fake one the tests use. It listens on port 3001, the
default UPSTREAM_URL, and serves one account with id 1.
UPSTREAM_TOKEN=dev-upstream-token pnpm --filter @gateway/api exec tsx test/fake-upstream.tsRegister at http://localhost:5173; you are signed in at once. The new workspace is linked automatically; if Settings shows it as not linked, copy its slug and link it by hand:
pnpm --filter @gateway/api link-team <workspace-slug>Checks and build:
pnpm -r typecheck
pnpm -r test # uses the gateway_test database
pnpm build # both apps; in production the API serves the dashboard from apps/web/distdocs/getting-started.md walks through the same steps in more detail.
Create a key on the API keys page and copy it; it is shown once. GET /v1/accounts lists the account ids
the key may use, and GET /v1/whoami shows the key's scope and allowlist. Then call the upstream through
OpenOnlyfansAPI:
curl -H "Authorization: Bearer gk_..." https://gw.example.com/v1/123/meOpenOnlyfansAPI's own errors look like { "error": { "code": "...", "message": "...", "requestId": "req_..." } }.
Upstream responses, errors included, come back as the upstream sent them. Every response carries an
x-request-id header.
A running OpenOnlyfansAPI serves the full reference at /docs and the spec at /openapi.json, with no login needed.
docs/authentication.md covers keys, scopes, sessions, rate limits and every error
code. docs/webhooks.md covers signed event delivery to your endpoints, and
docs/realtime.md the /v1/ws event socket.
- The upstream token lives only in the server's environment. OpenOnlyfansAPI adds it to outbound calls only, never logs it, and never forwards the client's own credentials upstream.
- API keys are stored as SHA-256 hashes, so the database alone does not yield usable keys.
- Dashboard writes need both an
OriginmatchingAPP_URLand anx-dashboard: 1header, and a bearer key always wins over a cookie with no fallback.
Report vulnerabilities privately as described in SECURITY.md, not in a public issue.
Issues and pull requests are welcome. CONTRIBUTING.md lists the checks a pull request must pass and the commit style.
AGPL-3.0-only. If you run a modified version as a service that others use over a network, you must offer those users the source of your version.
Paste this section, or the whole README, into the assistant. It holds everything needed to deploy without guessing.
- Goal: the stack running at
https://<domain>and the person registered on it. - Inputs to collect first: (1) a VPS running Ubuntu 24.04 with Docker, 2 GB RAM or more, root shell;
(2)
DOMAIN: a hostname whose A record points at the VPS, or the provider's hostname (hostname -fon the box); (3) whether ports 80 and 443 are free on the box:ss -Hltn | awk '{print $4}' | sed 's/.*://' | sort -un. - The command, run as root on the VPS, with the three values filled in:
HTTPS_PORT=443 HTTP_PORT=80 DOMAIN=<domain> bash -c 'curl -fsSLo /tmp/install.sh https://raw.githubusercontent.com/BlackPhoenixSlo/open_onlyfansapi/master/deploy/install.sh && bash /tmp/install.sh' - Decision table: ports 80/443 free: run as is (Caddy, Let's Encrypt). A Traefik container on 80/443: run as
is; the script detects it and routes the domain through it. Another proxy on 80/443 (nginx): add
GATEWAY_PORT=5454instead of the two port values, then route the domain to127.0.0.1:5454in that proxy. Ports taken by something else: pick two free ports (HTTPS_PORT=7443 HTTP_PORT=7080); the certificate is then self-signed and the browser warns once. - The script stops with a plain message when: not root,
DOMAINhas a scheme or port, the A record points elsewhere (SKIP_DNS_CHECK=1bypasses, only if the record is truly right), a chosen port is in use, or Docker is missing. Fix the named thing and run the same line again; re-running is safe and is also how updates work. - Success looks like:
Done: https://<domain> is up.followed by numbered next steps. Verify withcurl -fsS https://<domain>/healthz→{"ok":true}. - After it is up: the person opens the URL and registers; they are signed in at once, no email step. Then set
REGISTRATION_OPEN=falsein/opt/gateway/deploy/.envand runcd /opt/gateway/deploy && docker compose up -dso nobody else can register. Connect an account from Accounts → Connect account in the dashboard. - When something fails:
cd /opt/gateway/deploy && docker compose ps && docker compose logs --tail 40 gateway. The full guide with a troubleshooting section is deploy/hostinger.md. - Never: run
docker compose down -von an existing install (it deletes the database and the connected accounts), paste secrets fromdeploy/.envanywhere, or use plainhttp://asPUBLIC_URL(sign-in needs HTTPS).
