API Reference
Base URL https://api.deploro.com. Every request except the ones marked
public needs a session cookie
or a Authorization: Bearer <token> header (see
Authentication). Routes marked
admin require an admin-role
account or an admin-scoped PAT.
Platform auth
Custom PBKDF2 + JWT auth (no external auth provider). Sessions are stateless JWTs in an
httpOnly deploro_session cookie, 7-day expiry.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/setup | public | One-time admin bootstrap: blocked once any user exists |
| POST | /api/auth/login | public | Sign in with email + password → sets session cookie |
| POST | /api/auth/logout | public | Clears the session cookie |
| GET | /api/auth/session | public | Current user from the JWT, or null |
| POST | /api/auth/request-otp | public | Email a 6-digit passwordless sign-in code (existing users only) |
| POST | /api/auth/verify-otp | public | Verify the code → session cookie, marks email verified |
| GET | /api/auth/oauth/status | public | Which platform OAuth providers (google/apple/linkedin) are configured |
| GET | /api/auth/oauth/:provider/start | public | Returns the provider's consent-screen URL |
| GET/POST | /api/auth/oauth/:provider/callback | public | Exchanges the code and signs in, but only if the email already has an account |
| GET | /api/auth/users | admin | List platform users |
| DELETE | /api/auth/users/:id | admin | Delete a user |
| PATCH | /api/auth/users/:id/role | admin | Set a user's role |
| PATCH | /api/auth/users/:id/ban | admin | Ban/unban: takes effect immediately, no session revocation needed |
| POST | /api/auth/invite | admin | Create an invite → returns acceptUrl |
| POST | /api/auth/accept-invite | public | Accept an invite token, create an account |
| GET | /api/auth/sessions | admin | List sessions (always [], since sessions are stateless JWTs) |
| DELETE | /api/auth/sessions/:id | admin | Revoke a session (no-op for the same reason) |
Signs in and sets the deploro_session cookie. Use credentials: "include" from a browser.
curl -X POST https://api.deploro.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}'Body: { banned, reason?, expiresAt? }. auth-guard checks banned/banExpires on every request, so a ban blocks the next request immediately.
curl -X PATCH https://api.deploro.com/api/auth/users/USER_ID/ban \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"banned":true,"reason":"abuse","days":7}'Projects
A project bundles a database, a deploy target, storage, and team access. Creating one does not create its database; that's a separate provisioning step.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects | any | List your projects |
| POST | /api/projects | any | Create a project (no database until provisioned) |
| GET | /api/projects/:id | any | Project details |
| PATCH | /api/projects/:id | admin | Update project settings |
| DELETE | /api/projects/:id | admin | Delete a project and its database |
| GET | /api/projects/:id/members | any | List members |
| POST | /api/projects/:id/invite | admin | Invite a member by email |
| DELETE | /api/projects/:id/members/:uid | admin | Remove a member |
Body fields (Zod-validated): name (1-50 chars), slug (2-30 chars, lowercase alphanumeric + hyphens), optional git_repo_url, git_branch.
https://user:token@github.com/...). Connect GitHub through OAuth for private repos instead.curl -X POST https://api.deploro.com/api/projects \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"name":"My App","slug":"my-app"}'Provisions the project's isolated Postgres database, idempotently. Until this runs, every /api/db/* route for the project returns 409 {"code":"NO_DATABASE"}.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/database \
-H "Authorization: Bearer deploro_pat_..."Database
Each project's tables live in its own database's public schema. All traffic to project databases is proxied through gallium-sql-proxy (Workers can't open raw TCP connections).
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/db/tables | any | List project tables |
| POST | /api/db/tables | admin | Create table (structured column defs → CREATE TABLE) |
| PATCH | /api/db/tables/:name | admin | Rename table |
| GET | /api/db/tables/:name | any | Paginated rows |
| DELETE | /api/db/tables/:name | admin | Drop table |
| GET | /api/db/schema/:table | any | Column info |
| POST | /api/db/query | admin | Raw SQL: read-only SELECT only, DDL blocked, audit logged |
| POST | /api/db/rows/:table | any | Insert row |
| PATCH | /api/db/rows/:table/:id | any | Update row |
| DELETE | /api/db/rows/:table/:id | any | Delete row |
/api/projects/:id.
Body: { sql: string, params?: (string | number | boolean | null)[] }. One read-only statement starting with SELECT, WITH, VALUES or TABLE; it runs in a read-only transaction, so writes and DDL fail. Use migrations for schema changes.
Pass values as params and refer to them as $1, $2, and so on, rather than writing them into the SQL. Quoted values and comments never trip the check, so a search for '%do%' works. Functions that reach other sessions or run SQL held in a string (pg_terminate_backend, pg_stat_activity, set_config, query_to_xml, advisory locks) and U&"..." identifiers are refused. A rejected statement returns 400 with the reason in detail.
curl -X POST https://api.deploro.com/api/db/query \
-H "Authorization: Bearer deploro_pat_..." \
-H "x-project-id: <project id>" \
-H "Content-Type: application/json" \
-d '{"sql":"select id, full_name from student where full_name ilike $1 limit 10","params":["%do%"]}'Body: { data: { ...columns } }.
curl -X POST https://api.deploro.com/api/db/rows/todos \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"data":{"title":"Ship the docs site","done":false}}'Migrations
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/migrations | any | List migrations |
| POST | /api/projects/:id/migrations | any | Create a migration (name, sql_up, optional sql_down) |
| POST | /api/projects/:id/migrations/:mid/apply | any | Apply: runs sql_up in a transaction |
| POST | /api/projects/:id/migrations/:mid/rollback | any | Rollback: runs sql_down |
| DELETE | /api/projects/:id/migrations/:mid | any | Delete an unapplied migration |
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/migrations \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{
"name": "add_todos_table",
"sql_up": "create table todos (id serial primary key, title text not null, done boolean default false);",
"sql_down": "drop table todos;"
}'Storage
R2 object storage, namespaced per project as {slug}/{filename} in a shared bucket. This is where a project's uploaded files belong, not in a bytea column and not on the VPS filesystem, neither of which the dashboard's Storage tab can see.
Two ways to read a file. /storage/download is authenticated and replies with Content-Disposition: attachment, which is right for documents, exports, and anything only project members should see. GET /files/{slug}/{key} is unauthenticated and serves inline, so it can be used directly as an <img src>, but only when a signed URL, a bucket's own public flag, or the project-wide flag says it's allowed (checked in that order). Everything is private by default.
Buckets are named groupings with their own public/private flag, finer-grained than the single project-wide toggle. Upload with a bucket field to store at {slug}/{bucket}/{key} instead of the project root; existing root files are unaffected either way. Signed URLs grant temporary access to one object regardless of its bucket's or the project's own flag, which is useful for sharing a private file without changing any policy.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/storage | any | List R2 objects |
| POST | /api/projects/:id/storage/upload | admin | Upload a file: 100MB max, multipart/form-data, optional bucket field |
| POST | /api/projects/:id/storage/uploads | admin | Start a resumable upload (up to 5GB): {key, bucket?, size, contentType?, partSize?}. See Resumable uploads |
| PUT | /api/projects/:id/storage/uploads/:uploadId/parts/:n | admin | Send part n as the raw request body |
| GET | /api/projects/:id/storage/uploads/:uploadId | admin | Parts received and parts missing |
| POST | /api/projects/:id/storage/uploads/:uploadId/complete | admin | Assemble the parts into the file |
| DELETE | /api/projects/:id/storage/uploads/:uploadId | admin | Abort; the parts are deleted |
| GET | /api/projects/:id/storage/download?key=... | any | Download a file (key as query param) |
| DELETE | /api/projects/:id/storage?key=... | admin | Delete a file (key as query param) |
| GET | /api/projects/:id/storage/buckets | any | List buckets |
| POST | /api/projects/:id/storage/buckets | admin | Create a bucket {name, public?} |
| PATCH | /api/projects/:id/storage/buckets/:name | admin | Toggle {public} |
| DELETE | /api/projects/:id/storage/buckets/:name | admin | Remove the policy (keeps files) |
| POST | /api/projects/:id/storage/buckets/:name/sync | admin | Cover files already in the bucket by storage policies (see End-user files). Returns {scanned, added} |
| POST | /api/projects/:id/storage/signed-url | any | {key, expiresIn?}: time-limited public URL (max 7d, default 1h) |
Unauthenticated inline read of one object, granted one of three ways, checked in order: a valid ?sig=&exp= signed-URL token (bypasses everything else); the key's first path segment matching a registered bucket, governed by that bucket's own public flag; or, failing both, the project-wide public_storage_enabled flag. Anything else answers 404, indistinguishable from a project that does not exist. Supports HEAD, ETag/If-None-Match revalidation, and Range requests. There is no public listing form; objects are reachable by exact key only.
# turn on the project-wide root flag (project admin)
curl -X PATCH https://api.deploro.com/api/projects/PROJECT_ID -H "Authorization: Bearer $DEPLORO_TOKEN" -H "Content-Type: application/json" -d '{"public_storage_enabled": true}'
# then anyone can read a stored object inline
<img src="https://api.deploro.com/files/my-project/vehicle-images/4/11.png">curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/storage/upload \
-H "Authorization: Bearer deploro_pat_..." \
-F "file=@./avatar.png" \
-F "bucket=avatars"curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/storage/buckets \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"name": "avatars", "public": true}'HMAC-signed and stateless. There's no token table, so nothing can be revoked early short of rotating the platform's own signing secret. Works on a private root file or a private bucket's file alike.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/storage/signed-url \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"key": "invoices/2026-03.pdf", "expiresIn": 3600}'
# => { "url": "https://api.deploro.com/files/my-project/invoices/2026-03.pdf?exp=...&sig=...", "expiresAt": "..." }Deployments
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/deployments | any | Deployment history (Worker/Pages only) |
| POST | /api/projects/:id/deployments | any | Trigger a manual deploy |
| GET | /api/projects/:id/deployments/:did | any | Get a deployment + logs (resolves Worker/Pages or VPS) |
| GET | /api/projects/:id/deployments/all | any | Unified Worker/Pages + VPS timeline, target-tagged |
deployments (Worker/Pages) and vps_deployments (VPS compute, see
VPS compute) are separate tables. /deployments/all unions both
into one chronological, target-tagged list. Use it for a general "what's deployed" view
instead of picking one table.
GitHub integration
Per-user OAuth connection + per-project repo link. Auto-deploys on push once linked.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /webhooks/github | public | Inbound GitHub push handler: verified against the project's own webhook secret |
| GET | /api/projects/:id/github/webhook | any | This project's webhook URL + secret (generated lazily), plus auto_deploy_enabled |
| POST | /api/projects/:id/github/webhook/rotate | any | Issue a new secret, invalidating the old one immediately |
PATCH /api/projects/:id body {"auto_deploy_enabled": false}.
Domains
Self-service custom domains via Cloudflare SSL for SaaS. Customer DNS CNAMEs to sites.deploro.online, which proxies by hostname to the project's deployment.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/domains | any | List custom domains |
| POST | /api/projects/:id/domains | any | Add a domain → returns CNAME + TXT instructions |
| POST | /api/projects/:id/domains/:did/verify | any | Verify TXT → registers CF custom hostname (auto DV SSL) |
| GET | /api/projects/:id/domains/:did/status | any | Poll SSL status (self-heals failed registration) |
| DELETE | /api/projects/:id/domains/:did | any | Remove a domain |
| POST | /api/projects/:id/provisional-domain | any | (Re)provision the {slug}.deploro.app subdomain, idempotent |
Body: { hostname: "app.example.com" }. Returns the CNAME (sites.deploro.online) and TXT verification record to create at your DNS provider, then poll /status or call /verify once they propagate.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/domains \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"hostname":"app.example.com"}'Email sending
Send your project's sign-in emails and your app's own email from addresses at your domain. The domain must be a managed domain (its nameservers point at Deploro, see deploro zones add): Deploro then adds the DKIM, SPF and bounce records itself. Set it up under Domains → your domain → Email, or with deploro email enable.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/email/sending | any | Sending domains, sender addresses, and today's usage against the daily limit |
| POST | /api/projects/:id/zones/:zoneId/email/sending | admin | Set up sending: {subdomain?}. Empty sends from the domain itself, "mail" from mail.yourdomain.com |
| POST | /api/projects/:id/zones/:zoneId/email/sending/:sid/check | any | Re-check verification |
| DELETE | /api/projects/:id/zones/:zoneId/email/sending/:sid | admin | Stop sending from that domain. Its sender addresses are removed |
| POST | /api/projects/:id/email/senders | admin | Add a sender address: {sending_domain_id, local_part, display_name?} |
| PATCH | /api/projects/:id/email/senders/:senderId | admin | {display_name?, auth_sender?}. auth_sender: true sends the project's sign-in, magic-link, confirmation and password-reset emails from this address |
| DELETE | /api/projects/:id/email/senders/:senderId | admin | Remove a sender address |
| POST | /api/projects/:id/email/send | admin | Send an email (below) |
| GET | /api/projects/:id/email/log | any | Recent sends through the API (kept 30 days), ?limit= up to 200 |
Body: { from, to, cc?, bcc?, reply_to?, from_name?, subject, html?, text? }. from must be one of the project's sender addresses on a verified domain. to, cc and bcc take one address or a list, 50 recipients per message in total. Give html, text, or both (both is better for deliverability). Other fields, including custom headers, are refused. Returns { delivered, queued, permanent_bounces }.
Call it from your server with a personal access token (write scope, limited to this project) kept in an environment variable. Never from browser code: anyone holding the token could send mail as your domain.
Limits: each project can send to 100 recipients a day by default (429 DAILY_LIMIT). App mail also shares a platform-wide daily quota that is kept partly free for sign-in emails (429 PLATFORM_QUOTA). An address that isn't a verified sender returns 403 SENDER_NOT_ALLOWED.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/email/send -H "Authorization: Bearer deploro_pat_..." -H "Content-Type: application/json" -d '{"from":"hello@example.com","to":"customer@example.org","subject":"Your order shipped","text":"It is on the way.","html":"<p>It is on the way.</p>"}'Turnstile
Bot checks for your project's forms with Cloudflare Turnstile. Deploro creates the widget and keeps its secret: your app renders the widget with the site key, and your server sends the visitor's token to Deploro to check. Each project gets one widget, which covers every form; tell forms apart with the widget's action. Create it with deploro turnstile create.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/turnstile/widgets | any | The project's widgets (never the secret), its widget limit, and the hostnames a widget may use |
| POST | /api/projects/:id/turnstile/widgets | admin | Create: {name, domains?, mode?, test?}. domains defaults to the project's own hostnames, at most 10, and each must be the project's (its .deploro.app subdomain, a verified custom domain, or a managed domain). mode: managed (default), non-interactive, invisible. test: "pass" or "fail" creates a local-development widget on Cloudflare's test keys instead |
| GET | /api/projects/:id/turnstile/widgets/:sitekey | any | One widget |
| PUT | /api/projects/:id/turnstile/widgets/:sitekey | admin | {domains?, name?, mode?}. domains replaces the whole list |
| POST | /api/projects/:id/turnstile/widgets/:sitekey/rotate-secret | admin | {immediate?}. Issues a new secret; the old one keeps working for about 2 hours unless immediate. Nothing changes in your app |
| DELETE | /api/projects/:id/turnstile/widgets/:sitekey | admin | Delete the widget |
| GET | /api/projects/:id/turnstile/log | any | Recent checks: outcome, error codes, hostname, action (kept 30 days; never the token or the visitor's IP) |
| POST | /turnstile/:slug/verify | server token | Check a visitor's token (below) |
Headers: X-Deploro-Server-Token (required: a personal access token with write scope, limited to this project) and X-Deploro-Client-IP (optional: the visitor's IP, passed to Cloudflare). Body: { token, expected_action?, sitekey? }, where token is the form's cf-turnstile-response field. sitekey is only needed if the project has more than one widget.
Three outcomes, which your app should handle differently:
- Passed: 200
{ success: true, hostname, action, challenge_ts }. - Failed (the visitor failed, or the token is missing, expired, already used, from a hostname that isn't the project's, or for a different action): 200
{ success: false, error_codes: [...] }. Ask the visitor to try again. - Couldn't check (Cloudflare or Deploro unreachable, or the widget isn't set up): 503
{ success: false, code: "TURNSTILE_UNAVAILABLE" | "TURNSTILE_NOT_CONFIGURED", error }. Tell the visitor the form is unavailable rather than that they failed, and don't accept the submission.
A token can be checked once and expires after 5 minutes. Test widgets add test: true to every answer and skip the hostname check. Call this from your server, never from the browser. Through the DEPLORO_API binding, call env.DEPLORO_API.fetch("https://api.deploro.com/turnstile/SLUG/verify", ...).
The widget itself can't go through Deploro: the page has to load https://challenges.cloudflare.com/turnstile/v0/api.js straight from Cloudflare (it can't be proxied or cached), and the challenge runs between the visitor's browser and Cloudflare. If your site sends a Content-Security-Policy, allow https://challenges.cloudflare.com in script-src and frame-src. On a Worker deploy, Deploro sets TURNSTILE_SITE_KEY as a plain variable (and in the build environment, with NEXT_PUBLIC_TURNSTILE_SITE_KEY) unless your config sets it.
curl -X POST https://api.deploro.com/turnstile/my-project/verify -H "X-Deploro-Server-Token: deploro_pat_..." -H "X-Deploro-Client-IP: 203.0.113.9" -H "Content-Type: application/json" -d '{"token":"0.abc...","expected_action":"register"}'Realtime
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/realtime | any | WebSocket upgrade, one Durable Object per project:table, cookie-authed |
Row mutations through /api/db/rows/* publish to connected clients on this channel automatically, with no separate publish call.
Webhooks
Outbound event webhooks, HMAC-signed, fired on data/deploy events for a project.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/webhooks | any | List webhooks |
| POST | /api/projects/:id/webhooks | any | Create a webhook |
| PATCH | /api/projects/:id/webhooks/:wid | any | Update a webhook |
| DELETE | /api/projects/:id/webhooks/:wid | any | Delete a webhook |
| POST | /api/projects/:id/webhooks/:wid/test | any | Fire a test payload |
| GET | /api/projects/:id/webhooks/:wid/deliveries | any | Last 30 delivery records |
Body: { url, events: string[], secret? }. url must be a public HTTP(S) URL with no embedded credentials. secret, if given, is 16-256 chars and used to HMAC-sign deliveries.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/webhooks \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/deploro","events":["row.insert","deploy.success"]}'API Studio
Every table in your project database gets a REST API automatically, plus a generated OpenAPI 3.1 spec, with no code to write.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/studio/spec | any | OpenAPI 3.1 spec generated from the current schema |
| GET | /api/projects/:id/studio/:table | any | List rows (filterable via query params) |
| GET | /api/projects/:id/studio/:table/:id | any | Get one row by primary key |
| POST | /api/projects/:id/studio/:table | any | Insert a row |
| PATCH | /api/projects/:id/studio/:table/:id | any | Update a row |
| DELETE | /api/projects/:id/studio/:table/:id | any | Delete a row |
| POST | /api/projects/:id/studio/rpc/:function | any | Call a SQL function in the public schema, with { "arg_name": value } as the body |
/api/db/* vs. /api/projects/:id/studio/*: both read/write the same
tables. /api/db/* operates on your session's active project; the Studio routes
are project-scoped by :id in the path and are what the generated OpenAPI spec
describes. Prefer Studio routes for external integrations that reference a specific project.
409 with
the Postgres error code and the constraint's name, for example
{"error":"duplicate key value violates unique constraint \"seats_email_key\"","code":"23505","constraint":"seats_email_key","table":"seats"}.
Use the constraint name to show your own message. Codes: 23505 unique,
23503 foreign key, 23514 check, 23P01 exclusion.
A missing required value (23502) or a value of the wrong type (class 22)
returns 400 in the same shape. The offending row's values are never included.
The same applies to the RPC route and to /data/:slug/*.
select to a list
or get-by-id request (and to GET /data/:slug/:table) to pick columns and pull in
related rows in the same request, following your foreign keys:
?select=id,title,author:users(name),comments(id,body,author:users(name)),tags(name).
- If the table you're reading points at the other one (
posts.author_idtousers), the embed is one object, ornull. - If the other table points at it (
comments.post_idtoposts), the embed is an array, empty when there are none. - Many-to-many works through a junction table that points at both, as long as both of its
foreign-key columns are in its primary key or one unique constraint
(
post_tagswith primary key(post_id, tag_id)). The embed is an array. - Embeds nest up to 4 levels deep.
- Rename any column or embed with
alias:name. - When more than one relationship joins two tables, pick one with a hint: the foreign-key
column, the constraint name, or the junction table, as in
users!editor_id(name)ortags!post_tags(name). - A table that points at itself needs a direction:
parent:categories!one(name)for the row it points at,children:categories!many(name)for the rows pointing at it.
filter[comments.body]=ilike.%hello%, filter[comments.author.name]=eq.Ann,
comments.order=created_at.desc, comments.limit=5,
comments.offset=10. Filters take the same operators as the table's own
filter[col]. A filter narrows the embedded rows but keeps every parent row; add
!inner (comments!inner(id)) to keep only parent rows that have a
matching embedded row. Each array embed returns at most 1,000 rows, so use
limit and offset to page past that.
Either/or filters.
or=(age.lt.18,age.gt.65) keeps rows matching
any condition. Each condition is column.operator.value with the same operators as
filter[col]; put not. before the operator to negate it
(name.not.ilike.%bot%), and nest groups with and(...) and
or(...), optionally not.and(...). Double-quote a value that contains
a comma or parenthesis: name.eq."Smith, J". On an embed, use its path:
comments.or=(body.ilike.%25hi%25,id.eq.3).
Sorting by a related row.
order=author(name).desc sorts the rows by
a column of a single-row (many-to-one) embed that is in select. Every sort key also
takes .nullsfirst or .nullslast.
On /data/:slug, Row Level Security applies at every level, so a related row the
signed-in user can't see is left out. GET /data/:slug/:table also takes
filter[col] and order on the table itself, like the Studio route. An
unknown column there returns 400 instead of being ignored.
End-user data (Row Level Security)
The same API for your app's own signed-in users. Each request runs as a role that doesn't own your tables, so your RLS policies decide what it can see and change.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /data/:slug/:table | end user | List the rows the user's policies allow |
| POST | /data/:slug/:table | end user | Insert a row (checked by WITH CHECK) |
| PATCH | /data/:slug/:table/:id | end user | Update a row by primary key |
| DELETE | /data/:slug/:table/:id | end user | Delete a row by primary key |
| POST | /data/:slug/rpc/:function | end user | Call a SQL function as the user, so RLS applies to every table it touches |
Authorization: Bearer <session token> (see token mode
under Auth-as-a-Service), or the session cookie from /auth/:slug/* when your app
serves the API from its own origin. Browser calls are allowed from any origin, without
cookies. The JavaScript client, deploro/client, handles all of this.
Writing policies. Every project database has two helpers:
current_user_id() returns the signed-in user's id as a uuid, and
current_user_email() their email. Both return NULL outside a
/data request (Studio, the dashboard and migrations run as the table owner,
which RLS doesn't restrict). A typical policy:
CREATE POLICY own_notes ON notes FOR ALL USING (user_id = current_user_id()) WITH CHECK (user_id = current_user_id());
For a text column, compare with current_user_id()::text.
current_user_aal() is 'aal2' when the user entered a two-factor code
in this session, so a policy can require it:
USING (user_id = current_user_id() AND current_user_aal() = 'aal2'). If your
database already had a function with any of these names, it was left as it is.
Calling functions.
POST /data/:slug/rpc/:function takes the same
body and returns the same { rows, rowCount } as the Studio RPC route, plus
returns_set: true when the function returns a list.
Functions declared SECURITY DEFINER are refused with 403
("code":"SECURITY_DEFINER"), because they run as the owner and skip RLS. To let
signed-in users run one, call it from an ordinary function and expose that instead.
End-user files (storage policies)
Your app's signed-in users can upload, download, list and delete files in your buckets, under RLS policies you write. Every project database has a table, _deploro_storage_objects, with one row per file in a bucket: bucket, name (the path inside the bucket), owner_id (the end user who uploaded it, a uuid), size, content_type, metadata (JSON the uploader may attach) and timestamps. Each request below runs a statement on that table as the user, the same way /data does, and touches the file only if Postgres allowed it.
| Method | Path | Policy needed | Description |
|---|---|---|---|
| GET | /storage/:slug/list/:bucket | SELECT | Rows the user may see, by name. ?prefix=, ?limit= (max 1000), ?offset= |
| GET | /storage/:slug/info/:bucket/:name | SELECT | One file's row |
| GET | /storage/:slug/object/:bucket/:name | SELECT | The file. A file the user can't read is a 404, like a missing one |
| POST | /storage/:slug/object/:bucket/:name | INSERT | Upload: multipart/form-data with file and optional metadata (a JSON object, 4KB max), 100MB max. owner_id is set to the user. An existing name is a 409 unless ?upsert=true, which needs UPDATE (and SELECT) instead and keeps the original owner |
| DELETE | /storage/:slug/object/:bucket/:name | DELETE + SELECT | Delete the file |
| POST | /storage/:slug/sign/:bucket/:name | SELECT | {expiresIn?}: a signed /files URL for <img src> and links (60s to 7 days, default 1h) |
/files; policies decide everything
else, including uploads to public buckets.
Examples. Each user owns what they upload:
CREATE POLICY own_files ON _deploro_storage_objects FOR ALL USING (bucket = 'avatars' AND owner_id = current_user_id()) WITH CHECK (bucket = 'avatars' AND owner_id = current_user_id());
A folder per user: WITH CHECK (bucket = 'uploads' AND split_part(name, '/', 1) = current_user_id()::text).
Everyone signed in can read a bucket:
CREATE POLICY read_docs ON _deploro_storage_objects FOR SELECT USING (bucket = 'docs' AND current_user_id() IS NOT NULL);
Postgres only lets a user update or delete rows they can also select, so give a SELECT
policy wherever you give UPDATE or DELETE.
Files uploaded from the dashboard or API into a bucket get a row with no owner, so a policy can open them to end users. Files uploaded before storage policies existed have no row, so end users can't see or replace them until you run
POST /api/projects/:id/storage/buckets/:name/sync (or
deploro storage buckets sync <name>, or the sync button on the Storage page).
Files at the project root, outside any bucket, are never reachable this way.
Auth and CORS are the same as
/data: a Bearer session token from
any origin, without cookies. In deploro/client:
deploro.storage.from("avatars").upload(`${user.id}/me.png`, file).
Resumable uploads
For files over 100MB (up to 5GB), or connections you can't count on. The file goes up in parts, one request each, so a dropped connection costs one part, not the whole file. The same flow works for project admins at /api/projects/:id/storage/uploads and for your app's signed-in users at /storage/:slug/uploads.
| Method | Path | Description |
|---|---|---|
| POST | /storage/:slug/uploads | {bucket, name, size, contentType?, metadata?, upsert?, partSize?}. Returns uploadId, partSize, partCount and missingParts |
| PUT | /storage/:slug/uploads/:uploadId/parts/:n | Part n (from 1) as the raw body. Every part but the last is exactly partSize bytes. Sending a part again replaces it |
| GET | /storage/:slug/uploads/:uploadId | uploadedParts and missingParts: where to resume |
| POST | /storage/:slug/uploads/:uploadId/complete | Checks the storage policies and assembles the file. Returns what a one-request upload returns. Calling it again after success returns the same result; with parts missing it's a 409 listing them |
| DELETE | /storage/:slug/uploads/:uploadId | Abort; the parts are deleted |
partSize takes 5 to 32MiB.
Policies are checked when the upload completes, with the same INSERT (or UPDATE, with
upsert) a one-request upload runs; a refused upload is aborted. A
project whose policies allow no writes to _deploro_storage_objects at all is
refused when the upload starts. An upload belongs to the user who started it.
Limits. Unfinished uploads expire after 24 hours and count against the project's storage quota until then. Each user (and the dashboard, per project) can have 10 at once. In
deploro/client, upload() switches to parts above 50MB, and
uploadResumable(name, file, {uploadId}) continues a failed one. The CLI's
deploro storage upload does the same, with --resume <uploadId>.
Observability
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/projects/:id/observability/audit | any | Paginated SQL audit log: every query run against /api/db/query |
Hosting
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/hosting/status | any | Worker info + VPS health (admins also get untrackedContainers) |
| GET | /api/hosting/metrics | any | Last 60 VPS metric snapshots (cpu/mem/disk) |
| GET | /api/hosting/env | any | List Worker secret key names: values are never returned |
| POST | /api/hosting/env | any | Set a Worker secret via the Cloudflare API |
VPS compute hosting
For workloads Workers can't run: a real Postgres connection pool, a real Redis connection for BullMQ, or a headless-Chromium process. Provisions raw Postgres/Redis containers and runs a project's own docker-compose stack on the shared VPS.
403 before anything is provisioned,
deployed, or otherwise written to the VPS. There's no API call or dashboard action that
adds a project to that list; it's a manual, individually-justified exception. Every other
Deploro project is fully managed through the Worker/Pages deploy
pipeline and the proxy-managed db; nothing about it ever touches the
VPS directly.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/projects/:id/vps/database | any | Provision raw Postgres: idempotent; connection string shown once |
| GET | /api/projects/:id/vps/database | any | Raw Postgres status (no password) |
| POST | /api/projects/:id/vps/database/rotate | any | Rotate the raw Postgres password |
| POST | /api/projects/:id/vps/redis | any | Provision raw Redis: idempotent |
| GET | /api/projects/:id/vps/redis | any | Raw Redis status (no password) |
| POST | /api/projects/:id/vps/redis/rotate | any | Rotate the raw Redis password (recreates the container) |
| GET | /api/projects/:id/vps/allowlist | any | List IP/CIDR entries allowed to reach raw Postgres/Redis |
| POST | /api/projects/:id/vps/allowlist | any | Add an entry: re-syncs the VPS firewall |
| DELETE | /api/projects/:id/vps/allowlist/:eid | any | Remove an entry: re-syncs the firewall |
| GET | /api/projects/:id/vps/env | any | List VPS-scoped env var names |
| POST | /api/projects/:id/vps/env | any | Set a VPS-scoped env var (written into the compute .env on deploy) |
| POST | /api/projects/:id/vps/deploy | any | Build + start the project's docker-compose stack |
| GET | /api/projects/:id/vps/deployments | any | VPS deploy history |
| GET | /api/projects/:id/vps/deployments/:did | any | VPS deployment status + logs (live while building) |
| GET | /api/projects/:id/vps/status | any | Running compute services + raw Postgres/Redis container status |
| GET | /api/projects/:id/vps/logs | any | Tail logs for a compute service (or postgres/redis) |
drizzle-kit needs a real connection string.
The connection string is returned once, in the response body. Store it immediately.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/vps/database \
-H "Authorization: Bearer deploro_pat_..."Body: { cidr, label? }, for example 203.0.113.4 or 203.0.113.0/24.
curl -X POST https://api.deploro.com/api/projects/PROJECT_ID/vps/allowlist \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"cidr":"203.0.113.4","label":"my laptop"}'Auth-as-a-Service
Sign-in for your app's own end users, separate from platform auth above. Mounted at
/auth/:slug/*, where :slug is your project's slug. Supports
email OTP, email+password, and 15 OAuth providers.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /auth/:slug/session | public | Validate an end-user's session. Also returns aal, next_level and mfa_required (see two-factor below) |
| POST | /auth/:slug/logout | public | Clear the end-user session |
| GET | /auth/:slug/verify-email | public | Confirm a manually-added user's email → redirects to Site URL |
| POST | /auth/:slug/email/request-otp | public | Email a 6-digit code (if the 'email' provider is enabled) |
| GET | /auth/:slug/copy-code | public | The page a sign-in code email's "Copy code" button opens: it shows the code with a Copy button. Link to it with the {{copy_code_url}} variable, which carries the code after the #, so the code is never sent to the server. The page doesn't sign anyone in |
| POST | /auth/:slug/email/verify-otp | public | Verify the code → session cookie, marks email verified |
| POST | /auth/:slug/email/request-magic-link | public | { email, redirect_to? } → emails a one-click sign-in link (same rules as request-otp, always {ok:true}). redirect_to is a path on your Site URL, like /dashboard |
| GET/POST | /auth/:slug/magic-link/:token | public | The emailed link. GET shows a "Sign in" button (so email scanners that open links can't use it up); the button's POST signs in once and redirects to your Site URL with ?magic_link=1. Links expire after 15 minutes |
| POST | /auth/:slug/email-password/signup | public | { email, name? } → confirmation email. Clicking it signs the user in and redirects to your reset-password page (?reset_token=…&verified=1) to choose a password; no password is taken at signup. Independent identity from OTP |
| POST | /auth/:slug/email-password/login | public | { email, password } → session cookie |
| POST | /auth/:slug/email-password/request-reset | public | Email a password-reset link (always {ok:true}, so it can't reveal which emails exist) |
| GET | /auth/:slug/reset-password/:token | public | Validates the link, redirects to your app's reset-password page |
| POST | /auth/:slug/email-password/reset | public | { token, password }: revokes every existing session for that identity |
| GET | /auth/:slug/mfa/factors | end user | This user's authenticator apps and how many recovery codes are left |
| POST | /auth/:slug/mfa/enroll | end user | { friendly_name? } → a pending factor with totp.secret, totp.uri and totp.qr_code (an SVG data URI you can put straight in an <img>) |
| POST | /auth/:slug/mfa/verify | end user | { factor_id?, code } → confirms a pending factor or completes sign-in (without factor_id, any of the user's apps); the session becomes aal2. The first confirmed factor also returns 10 recovery_codes, shown once |
| POST | /auth/:slug/mfa/recover | end user | { code } → spends a recovery code instead of an app code; the session becomes aal2. The user is emailed (template mfa_recovery_code_used) |
| POST | /auth/:slug/mfa/recovery-codes | end user (aal2) | Replace the recovery codes |
| GET | /auth/:slug/mfa/setup?return_to=/path | end user (page) | Hosted page: turn two-factor on (QR code, first code, recovery codes). Send the signed-in user's browser here; it returns to return_to on your Site URL |
| GET | /auth/:slug/mfa/challenge?return_to=/path | end user (page) | Hosted page: enter a code (or a recovery code) after signing in. Magic-link and social sign-ins of users with two-factor on land here automatically |
| DELETE | /auth/:slug/mfa/factors/:factorId | end user | Remove a factor (a confirmed one needs aal2). Removing the last one turns two-factor off |
| GET | /auth/:slug/:provider | public | Initiate OAuth: google/github/facebook/twitter/discord/microsoft/linkedin/slack/gitlab/twitch/spotify/reddit/dropbox/notion/apple/upwork. Optional return_to (a path) and, in token mode, code_challenge |
| POST | /auth/:slug/token | public | { code, code_verifier }: finishes a token-mode OAuth or magic-link sign-in → session_token |
| GET/POST | /auth/:slug/:provider/callback | public | Exchange code, upsert user, issue session |
| GET | /api/projects/:id/auth/providers[/:provider] | any | Configure per-project provider credentials |
| PUT | /api/projects/:id/auth/providers/:provider | any | Set a provider's client id/secret |
| GET | /api/projects/:id/auth/users | any | List this project's end users |
| POST | /api/projects/:id/auth/users | any | Manually add an email-identity end user (sends a confirmation email) |
| DELETE | /api/projects/:id/auth/users/:userId | any | Delete an end user |
| DELETE | /api/projects/:id/auth/users/:userId/mfa | admin | Reset two-factor for a user who lost their app and recovery codes: removes every factor and recovery code and signs them out |
| GET | /api/projects/:id/auth/settings | any | The project's Auth Site URL, whether temporary email addresses are blocked, and the size and date of the list |
| PUT | /api/projects/:id/auth/settings | admin | { site_url?, block_temporary_emails? }: only the fields you send change. site_url is where auth emails redirect end users back to; see Blocking temporary email addresses |
| GET | /api/projects/:id/auth/temporary-email-check?email= | any | Would this address be refused? → { temporary, matched_domain, blocking_enabled } |
| GET | /api/projects/:id/auth/stats | any | Totals for the dashboard |
api.deploro.com never reaches your app's domain, because browsers don't send it
across sites. Send X-Deploro-Session: token with sign-in requests instead: the
response then includes session_token (and expires_in, in seconds) and
no cookie is set. Send it as Authorization: Bearer from then on, including to
/auth/:slug/logout. These JSON routes answer browser calls from any origin.
OAuth and magic links end with a redirect, which can't carry a JSON body. Start them with a PKCE challenge (
code_challenge, the base64url SHA-256 of a random verifier your app keeps):
as a query parameter on /auth/:slug/:provider, or in the
request-magic-link body. The user then comes back to your Site URL with
?deploro_code=...; trade it at POST /auth/:slug/token with the verifier
within 5 minutes. A code works once and only with its verifier, so open a magic link in the
browser that asked for it. In token mode two-factor is asked by your app (the session says
mfa_required), not by the hosted challenge page.
The JavaScript client does all of this:
npm install deploro.
import { createClient } from "deploro/client";
const deploro = createClient({ project: "my-app" });
await deploro.auth.signInWithPassword({ email, password });
const { data } = await deploro.from("notes").select("id, body").order("id");X-Deploro-Server-Token, a personal access token with write scope for
this project, and X-Deploro-Client-IP, the end user's IPv4 or IPv6 address.
Without a valid token the IP header is ignored. An invalid token returns 401,
a token for another project 403, and a malformed address 400.
The per-account limit (10 failed attempts per 15 minutes) applies either way, and all
server-forwarded requests for a project share a ceiling of 6,000 per 15 minutes.
Keep the token on your server; never send it to a browser.
curl -X POST https://api.deploro.com/auth/my-app/email-password/login \
-H "Content-Type: application/json" \
-H "X-Deploro-Server-Token: deploro_pat_..." \
-H "X-Deploro-Client-IP: 203.0.113.9" \
-d '{"email":"user@example.com","password":"..."}'wrangler.json or wrangler.jsonc gets a
service binding named DEPLORO_API. Call env.DEPLORO_API.fetch(...)
with the same URL, method, headers and body you would send to
https://api.deploro.com. Requests are authenticated exactly the same way, and
they skip the public internet, so they are faster. Your end users' IP addresses still need
X-Deploro-Client-IP with your server token, as above. Keep the plain
fetch as a fallback for local development, where the binding doesn't exist.
Projects using wrangler.toml don't get the binding.
// Next.js on Workers (OpenNext): read the binding from the Cloudflare context
import { getCloudflareContext } from "@opennextjs/cloudflare";
async function deploroFetch(path: string, init?: RequestInit) {
const url = `https://api.deploro.com${path}`;
const api = getCloudflareContext().env.DEPLORO_API;
return api ? api.fetch(url, init) : fetch(url, init);
}deploro auth temporary-emails on, and new sign-ups from
disposable inboxes (mailinator.com, 10minutemail.com, yopmail.com and about 10,000 more) are refused. The list is
the community-maintained
disposable-email-domains
list, and subdomains of a listed domain count too. It applies to every way an account gets created:
email/signup, email-password/signup, a social or SSO sign-in that would create a new
account, and adding a user from the dashboard or CLI. People who already have an account keep signing in.
A refused signup gets
400 with
{ "error": "Temporary email addresses aren't accepted. Please use a permanent email address.", "code": "TEMPORARY_EMAIL" },
so your form can show the message. A refused social sign-in shows a page saying the same. With the setting off,
a short built-in list of the most common temporary domains is still dropped silently.
curl -X PUT https://api.deploro.com/api/projects/$PROJECT_ID/auth/settings -H "Authorization: Bearer $DEPLORO_TOKEN" -H "Content-Type: application/json" -d '{"block_temporary_emails": true}'Silently no-ops for disposable email domains. Requires the email provider to be enabled for the project.
The email uses the project's otp template (Auth page, Email templates), with the variables {{code}}, {{project_name}} and {{copy_code_url}}. Email can't copy anything to the clipboard by itself, so a "Copy code" button links to {{copy_code_url}}: a Deploro page that shows the code with a working Copy button. In the template editor, set a button's When clicked to Copy the code to do this, or to Open a link for any other address. Gmail and iPhones also offer their own copy control on the code itself.
curl -X POST https://api.deploro.com/auth/my-app/email/request-otp \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com"}'Requires the email provider, and works only for users who have already confirmed their email (via /email/signup or an admin invite). At most one link per user per minute. The email uses the project's magic_link template.
curl -X POST https://api.deploro.com/auth/my-app/email/request-magic-link \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","redirect_to":"/dashboard"}'Two-factor sign-in with an authenticator app (Google Authenticator, 1Password, Authy and others).
Every sign-in method first gives a one-factor session (aal1). For a user who has two-factor on,
the sign-in response and GET /auth/:slug/session say "mfa_required": true: ask for
the 6-digit code and send it here with the factor's id (from GET /auth/:slug/mfa/factors).
Until then /data/:slug answers 401 with "code":"MFA_REQUIRED".
No screens of your own? Send the browser to /auth/:slug/mfa/setup?return_to=/settings
to turn it on, and to /auth/:slug/mfa/challenge?return_to=/dashboard when mfa_required is
true. These Deploro-hosted pages use the session cookie and return to your Site URL. Magic-link and social
sign-ins already go to the challenge page on their own.
Your own screens: POST /mfa/enroll, show totp.qr_code as an image
(and totp.secret for people who can't scan), then send the first code to /mfa/verify. That response has the user's 10 recovery codes;
show them once and ask the user to keep them. A user who already has two-factor on must complete it in the
current session before adding an app, removing one or replacing recovery codes.
Completing the step replaces the session token. Cookie sessions get the new cookie; callers using
Authorization: Bearer get it back as session_token and must use it from then on.
Each code works once, and 10 wrong codes in 15 minutes lock the step for that user. Signing in with a
recovery code emails the user, so they notice if it wasn't them.
curl -X POST https://api.deploro.com/auth/my-app/mfa/verify \
-H "Authorization: Bearer SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"factor_id":"FACTOR_ID","code":"123456"}'Body: { client_id, client_secret, enabled, extra_config? }. Apple additionally needs extra_config.team_id and extra_config.key_id, with the .p8 private key contents as client_secret.
curl -X PUT https://api.deploro.com/api/projects/PROJECT_ID/auth/providers/google \
-H "Authorization: Bearer deploro_pat_..." \
-H "Content-Type: application/json" \
-d '{"client_id":"...","client_secret":"...","enabled":true}'