deploro.com Open dashboardDashboard

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.

MethodPathAuthDescription
POST/api/auth/setuppublicOne-time admin bootstrap: blocked once any user exists
POST/api/auth/loginpublicSign in with email + password → sets session cookie
POST/api/auth/logoutpublicClears the session cookie
GET/api/auth/sessionpublicCurrent user from the JWT, or null
POST/api/auth/request-otppublicEmail a 6-digit passwordless sign-in code (existing users only)
POST/api/auth/verify-otppublicVerify the code → session cookie, marks email verified
GET/api/auth/oauth/statuspublicWhich platform OAuth providers (google/apple/linkedin) are configured
GET/api/auth/oauth/:provider/startpublicReturns the provider's consent-screen URL
GET/POST/api/auth/oauth/:provider/callbackpublicExchanges the code and signs in, but only if the email already has an account
GET/api/auth/usersadminList platform users
DELETE/api/auth/users/:idadminDelete a user
PATCH/api/auth/users/:id/roleadminSet a user's role
PATCH/api/auth/users/:id/banadminBan/unban: takes effect immediately, no session revocation needed
POST/api/auth/inviteadminCreate an invite → returns acceptUrl
POST/api/auth/accept-invitepublicAccept an invite token, create an account
GET/api/auth/sessionsadminList sessions (always [], since sessions are stateless JWTs)
DELETE/api/auth/sessions/:idadminRevoke a session (no-op for the same reason)
POST/api/auth/loginpublic

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":"..."}'
PATCH/api/auth/users/:id/banadmin

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.

MethodPathAuthDescription
GET/api/projectsanyList your projects
POST/api/projectsanyCreate a project (no database until provisioned)
GET/api/projects/:idanyProject details
PATCH/api/projects/:idadminUpdate project settings
DELETE/api/projects/:idadminDelete a project and its database
GET/api/projects/:id/membersanyList members
POST/api/projects/:id/inviteadminInvite a member by email
DELETE/api/projects/:id/members/:uidadminRemove a member
POST/api/projectsany

Body fields (Zod-validated): name (1-50 chars), slug (2-30 chars, lowercase alphanumeric + hyphens), optional git_repo_url, git_branch.

git_repo_url is rejected if it contains embedded credentials (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"}'
POST/api/projects/:id/databaseany

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).

MethodPathAuthDescription
GET/api/db/tablesanyList project tables
POST/api/db/tablesadminCreate table (structured column defs → CREATE TABLE)
PATCH/api/db/tables/:nameadminRename table
GET/api/db/tables/:nameanyPaginated rows
DELETE/api/db/tables/:nameadminDrop table
GET/api/db/schema/:tableanyColumn info
POST/api/db/queryadminRaw SQL: read-only SELECT only, DDL blocked, audit logged
POST/api/db/rows/:tableanyInsert row
PATCH/api/db/rows/:table/:idanyUpdate row
DELETE/api/db/rows/:table/:idanyDelete row
These routes act on the active project resolved from your session or token; they are not prefixed with /api/projects/:id.
POST/api/db/queryadmin

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%"]}'
POST/api/db/rows/:tableany

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

MethodPathAuthDescription
GET/api/projects/:id/migrationsanyList migrations
POST/api/projects/:id/migrationsanyCreate a migration (name, sql_up, optional sql_down)
POST/api/projects/:id/migrations/:mid/applyanyApply: runs sql_up in a transaction
POST/api/projects/:id/migrations/:mid/rollbackanyRollback: runs sql_down
DELETE/api/projects/:id/migrations/:midanyDelete an unapplied migration
POST/api/projects/:id/migrationsany
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.

MethodPathAuthDescription
GET/api/projects/:id/storageanyList R2 objects
POST/api/projects/:id/storage/uploadadminUpload a file: 100MB max, multipart/form-data, optional bucket field
POST/api/projects/:id/storage/uploadsadminStart a resumable upload (up to 5GB): {key, bucket?, size, contentType?, partSize?}. See Resumable uploads
PUT/api/projects/:id/storage/uploads/:uploadId/parts/:nadminSend part n as the raw request body
GET/api/projects/:id/storage/uploads/:uploadIdadminParts received and parts missing
POST/api/projects/:id/storage/uploads/:uploadId/completeadminAssemble the parts into the file
DELETE/api/projects/:id/storage/uploads/:uploadIdadminAbort; the parts are deleted
GET/api/projects/:id/storage/download?key=...anyDownload a file (key as query param)
DELETE/api/projects/:id/storage?key=...adminDelete a file (key as query param)
GET/api/projects/:id/storage/bucketsanyList buckets
POST/api/projects/:id/storage/bucketsadminCreate a bucket {name, public?}
PATCH/api/projects/:id/storage/buckets/:nameadminToggle {public}
DELETE/api/projects/:id/storage/buckets/:nameadminRemove the policy (keeps files)
POST/api/projects/:id/storage/buckets/:name/syncadminCover files already in the bucket by storage policies (see End-user files). Returns {scanned, added}
POST/api/projects/:id/storage/signed-urlany{key, expiresIn?}: time-limited public URL (max 7d, default 1h)
GET/files/:slug/:keypublic

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">
POST/api/projects/:id/storage/uploadadmin
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"
POST/api/projects/:id/storage/bucketsadmin
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}'
POST/api/projects/:id/storage/signed-urlany

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

MethodPathAuthDescription
GET/api/projects/:id/deploymentsanyDeployment history (Worker/Pages only)
POST/api/projects/:id/deploymentsanyTrigger a manual deploy
GET/api/projects/:id/deployments/:didanyGet a deployment + logs (resolves Worker/Pages or VPS)
GET/api/projects/:id/deployments/allanyUnified 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.

MethodPathAuthDescription
POST/webhooks/githubpublicInbound GitHub push handler: verified against the project's own webhook secret
GET/api/projects/:id/github/webhookanyThis project's webhook URL + secret (generated lazily), plus auto_deploy_enabled
POST/api/projects/:id/github/webhook/rotateanyIssue a new secret, invalidating the old one immediately
OAuth is the primary connect path. Settings → "Connect GitHub" in the dashboard drives a full GitHub App/OAuth flow (repo picker, branch selector, auto-created webhook) rather than pasting a repo URL. Toggle auto-deploy per project with 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.

MethodPathAuthDescription
GET/api/projects/:id/domainsanyList custom domains
POST/api/projects/:id/domainsanyAdd a domain → returns CNAME + TXT instructions
POST/api/projects/:id/domains/:did/verifyanyVerify TXT → registers CF custom hostname (auto DV SSL)
GET/api/projects/:id/domains/:did/statusanyPoll SSL status (self-heals failed registration)
DELETE/api/projects/:id/domains/:didanyRemove a domain
POST/api/projects/:id/provisional-domainany(Re)provision the {slug}.deploro.app subdomain, idempotent
POST/api/projects/:id/domainsany

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.

MethodPathAuthDescription
GET/api/projects/:id/email/sendinganySending domains, sender addresses, and today's usage against the daily limit
POST/api/projects/:id/zones/:zoneId/email/sendingadminSet up sending: {subdomain?}. Empty sends from the domain itself, "mail" from mail.yourdomain.com
POST/api/projects/:id/zones/:zoneId/email/sending/:sid/checkanyRe-check verification
DELETE/api/projects/:id/zones/:zoneId/email/sending/:sidadminStop sending from that domain. Its sender addresses are removed
POST/api/projects/:id/email/sendersadminAdd a sender address: {sending_domain_id, local_part, display_name?}
PATCH/api/projects/:id/email/senders/:senderIdadmin{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/:senderIdadminRemove a sender address
POST/api/projects/:id/email/sendadminSend an email (below)
GET/api/projects/:id/email/loganyRecent sends through the API (kept 30 days), ?limit= up to 200
POST/api/projects/:id/email/sendadmin

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.

MethodPathAuthDescription
GET/api/projects/:id/turnstile/widgetsanyThe project's widgets (never the secret), its widget limit, and the hostnames a widget may use
POST/api/projects/:id/turnstile/widgetsadminCreate: {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/:sitekeyanyOne widget
PUT/api/projects/:id/turnstile/widgets/:sitekeyadmin{domains?, name?, mode?}. domains replaces the whole list
POST/api/projects/:id/turnstile/widgets/:sitekey/rotate-secretadmin{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/:sitekeyadminDelete the widget
GET/api/projects/:id/turnstile/loganyRecent checks: outcome, error codes, hostname, action (kept 30 days; never the token or the visitor's IP)
POST/turnstile/:slug/verifyserver tokenCheck a visitor's token (below)
POST/turnstile/:slug/verifyserver token

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

MethodPathAuthDescription
GET/api/projects/:id/realtimeanyWebSocket 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.

MethodPathAuthDescription
GET/api/projects/:id/webhooksanyList webhooks
POST/api/projects/:id/webhooksanyCreate a webhook
PATCH/api/projects/:id/webhooks/:widanyUpdate a webhook
DELETE/api/projects/:id/webhooks/:widanyDelete a webhook
POST/api/projects/:id/webhooks/:wid/testanyFire a test payload
GET/api/projects/:id/webhooks/:wid/deliveriesanyLast 30 delivery records
POST/api/projects/:id/webhooksany

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.

MethodPathAuthDescription
GET/api/projects/:id/studio/specanyOpenAPI 3.1 spec generated from the current schema
GET/api/projects/:id/studio/:tableanyList rows (filterable via query params)
GET/api/projects/:id/studio/:table/:idanyGet one row by primary key
POST/api/projects/:id/studio/:tableanyInsert a row
PATCH/api/projects/:id/studio/:table/:idanyUpdate a row
DELETE/api/projects/:id/studio/:table/:idanyDelete a row
POST/api/projects/:id/studio/rpc/:functionanyCall 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.
Write errors. A write that breaks a constraint returns 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/*.
Choosing columns and joining related tables. Add 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_id to users), the embed is one object, or null.
  • If the other table points at it (comments.post_id to posts), 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_tags with 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) or tags!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, sort and page an embed by its name (or alias), with dots for nested embeds: 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.

MethodPathAuthDescription
GET/data/:slug/:tableend userList the rows the user's policies allow
POST/data/:slug/:tableend userInsert a row (checked by WITH CHECK)
PATCH/data/:slug/:table/:idend userUpdate a row by primary key
DELETE/data/:slug/:table/:idend userDelete a row by primary key
POST/data/:slug/rpc/:functionend userCall a SQL function as the user, so RLS applies to every table it touches
Auth. 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.

MethodPathPolicy neededDescription
GET/storage/:slug/list/:bucketSELECTRows the user may see, by name. ?prefix=, ?limit= (max 1000), ?offset=
GET/storage/:slug/info/:bucket/:nameSELECTOne file's row
GET/storage/:slug/object/:bucket/:nameSELECTThe file. A file the user can't read is a 404, like a missing one
POST/storage/:slug/object/:bucket/:nameINSERTUpload: 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/:nameDELETE + SELECTDelete the file
POST/storage/:slug/sign/:bucket/:nameSELECT{expiresIn?}: a signed /files URL for <img src> and links (60s to 7 days, default 1h)
Deny by default. The table is created with RLS on and no policies, so end users can't reach any file until you add one (Database, then Policies, or a migration). Public buckets stay readable by anyone at /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.

MethodPathDescription
POST/storage/:slug/uploads{bucket, name, size, contentType?, metadata?, upsert?, partSize?}. Returns uploadId, partSize, partCount and missingParts
PUT/storage/:slug/uploads/:uploadId/parts/:nPart 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/:uploadIduploadedParts and missingParts: where to resume
POST/storage/:slug/uploads/:uploadId/completeChecks 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/:uploadIdAbort; the parts are deleted
Part 1 first. The file's type is read from its first bytes (and HTML, SVG or script content is refused), so part 1 must arrive before any other; after that, parts can go in parallel. Parts are 8MiB by default; 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

MethodPathAuthDescription
GET/api/projects/:id/observability/auditanyPaginated SQL audit log: every query run against /api/db/query

Hosting

MethodPathAuthDescription
GET/api/hosting/statusanyWorker info + VPS health (admins also get untrackedContainers)
GET/api/hosting/metricsanyLast 60 VPS metric snapshots (cpu/mem/disk)
GET/api/hosting/envanyList Worker secret key names: values are never returned
POST/api/hosting/envanySet 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.

Closed to new projects. Every route below (except the read-only status/logs/list endpoints) requires the project to be on a small, explicitly approved allowlist. Every other project gets 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.
MethodPathAuthDescription
POST/api/projects/:id/vps/databaseanyProvision raw Postgres: idempotent; connection string shown once
GET/api/projects/:id/vps/databaseanyRaw Postgres status (no password)
POST/api/projects/:id/vps/database/rotateanyRotate the raw Postgres password
POST/api/projects/:id/vps/redisanyProvision raw Redis: idempotent
GET/api/projects/:id/vps/redisanyRaw Redis status (no password)
POST/api/projects/:id/vps/redis/rotateanyRotate the raw Redis password (recreates the container)
GET/api/projects/:id/vps/allowlistanyList IP/CIDR entries allowed to reach raw Postgres/Redis
POST/api/projects/:id/vps/allowlistanyAdd an entry: re-syncs the VPS firewall
DELETE/api/projects/:id/vps/allowlist/:eidanyRemove an entry: re-syncs the firewall
GET/api/projects/:id/vps/envanyList VPS-scoped env var names
POST/api/projects/:id/vps/envanySet a VPS-scoped env var (written into the compute .env on deploy)
POST/api/projects/:id/vps/deployanyBuild + start the project's docker-compose stack
GET/api/projects/:id/vps/deploymentsanyVPS deploy history
GET/api/projects/:id/vps/deployments/:didanyVPS deployment status + logs (live while building)
GET/api/projects/:id/vps/statusanyRunning compute services + raw Postgres/Redis container status
GET/api/projects/:id/vps/logsanyTail logs for a compute service (or postgres/redis)
Raw Postgres/Redis are public-by-default the moment an IP is allowlisted (TCP + TLS + a strong per-project password, gated by an IP allowlist at the host firewall). This is more permissive than the Studio-API-only model the regular project database uses, because external tooling like drizzle-kit needs a real connection string.
POST/api/projects/:id/vps/databaseany

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_..."
POST/api/projects/:id/vps/allowlistany

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.

MethodPathAuthDescription
GET/auth/:slug/sessionpublicValidate an end-user's session. Also returns aal, next_level and mfa_required (see two-factor below)
POST/auth/:slug/logoutpublicClear the end-user session
GET/auth/:slug/verify-emailpublicConfirm a manually-added user's email → redirects to Site URL
POST/auth/:slug/email/request-otppublicEmail a 6-digit code (if the 'email' provider is enabled)
GET/auth/:slug/copy-codepublicThe 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-otppublicVerify the code → session cookie, marks email verified
POST/auth/:slug/email/request-magic-linkpublic{ 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/:tokenpublicThe 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/signuppublic{ 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/loginpublic{ email, password } → session cookie
POST/auth/:slug/email-password/request-resetpublicEmail a password-reset link (always {ok:true}, so it can't reveal which emails exist)
GET/auth/:slug/reset-password/:tokenpublicValidates the link, redirects to your app's reset-password page
POST/auth/:slug/email-password/resetpublic{ token, password }: revokes every existing session for that identity
GET/auth/:slug/mfa/factorsend userThis user's authenticator apps and how many recovery codes are left
POST/auth/:slug/mfa/enrollend 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/verifyend 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/recoverend 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-codesend user (aal2)Replace the recovery codes
GET/auth/:slug/mfa/setup?return_to=/pathend 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=/pathend 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/:factorIdend userRemove a factor (a confirmed one needs aal2). Removing the last one turns two-factor off
GET/auth/:slug/:providerpublicInitiate 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/tokenpublic{ code, code_verifier }: finishes a token-mode OAuth or magic-link sign-in → session_token
GET/POST/auth/:slug/:provider/callbackpublicExchange code, upsert user, issue session
GET/api/projects/:id/auth/providers[/:provider]anyConfigure per-project provider credentials
PUT/api/projects/:id/auth/providers/:provideranySet a provider's client id/secret
GET/api/projects/:id/auth/usersanyList this project's end users
POST/api/projects/:id/auth/usersanyManually add an email-identity end user (sends a confirmation email)
DELETE/api/projects/:id/auth/users/:userIdanyDelete an end user
DELETE/api/projects/:id/auth/users/:userId/mfaadminReset 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/settingsanyThe project's Auth Site URL, whether temporary email addresses are blocked, and the size and date of the list
PUT/api/projects/:id/auth/settingsadmin{ 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=anyWould this address be refused? → { temporary, matched_domain, blocking_enabled }
GET/api/projects/:id/auth/statsanyTotals for the dashboard
Apps on their own domain (token mode). A session cookie set on 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");
Signing users in from your own server. Sign-in, signup, code and reset requests are rate limited per IP address. If your backend makes these calls for your users, every user arrives from your server's address and shares one limit. Send two extra headers so each user is counted separately: 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":"..."}'
Calling the API from an app Deploro deploys as a Worker. Every Worker Deploro deploys from a 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);
}
Blocking temporary email addresses. Turn on Block temporary email addresses on the dashboard's Auth page, or run 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}'
POST/auth/:slug/email/request-otppublic

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"}'
POST/auth/:slug/mfa/verifyend user

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"}'
PUT/api/projects/:id/auth/providers/:providerany

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}'
Looking for the same operations from a terminal instead of curl? See the CLI Reference. Every command maps to one or more of the routes above.