Bring your own code

Connect an app you host yourself to a DontCode project: authentication, database, file storage, payments, email, shared key-value state, and realtime over plain HTTP, with a single project API key.

10 min read
v1 API
About DontCode Backend

What this is

The public API lets apps you host yourself use DontCode’s platform services (auth, database, file storage, payments, transactional email, a shared key-value store, and realtime pub/sub) over plain HTTP with a project API key. It speaks the same wire protocol as the dontcode SDK, so an app can move between that SDK and raw HTTP without changes on the platform side.

The contract is stable: fields, status codes, and shapes only change additively. How the services work internally is not part of the contract.

New to DontCode? The full product documentation lives at dontcode.co/docs

Authenticating requests

Every request carries a project API key as a Bearer token:

Authorization: Bearer dc_<key>

Keys are managed per project (console → project → API keys). The plaintext is shown once at creation; only a hash is stored. Revocation takes effect immediately.

Point your app at the gateway with two environment variables: copy them straight from the key-reveal panel when you create a key:

DONTCODE_API_URL=https://backend.dontcode.co
DONTCODE_API_KEY=dc_…

Rate limits are per project and per namespace, so a chatty realtime loop never starves your database or auth calls. Budget follows the project, not the key: minting or rotating keys does not hand out more of it.

BudgetDefault
/api/v1/realtime1200 requests / min
/api/v1/cache1000 requests / min
/api/v1/db reads900 requests / min
/api/v1/db writes300 requests / min
/api/v1/auth300 requests / min
/api/v1/payments120 requests / min
/api/v1/storage120 requests / min
/api/v1/notifications60 requests / min
/api/v1/db/migrate10 requests / min

/api/v1/db is budgeted by direction. find, findMany, findFirst, findOne and count spend from the read budget (db/read); insert, update and delete spend from the write budget (db/write). They are separate counters, so a polling loop that exhausts its reads still gets its writes through: a read is cheap and safe to retry, a lost write is lost data.

Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Scope, so you can pace yourself before you are ever blocked. Read RateLimit-Scope rather than guessing the budget from the path: one path can spend from more than one budget. Going over returns 429 { error, rate_limit: true, timeleft, scope }, where scope names the budget that ran out, plus a Retry-After header holding the same number of seconds as timeleft.

TypeScript SDK: dontcode

Prefer not to hand-roll HTTP? dontcode is a thin, typed wrapper over every endpoint on this page. It speaks the same wire protocol and the same shapes, so you can drop back to raw HTTP at any time. It runs anywhere fetch does (Node 18+, the browser) and ships no runtime dependencies.

pnpm add dontcode

Construct a client. apiKey defaults to DONTCODE_API_KEY and the gateway origin to DONTCODE_API_URL, the same two variables above, so dontcode() needs no arguments when those are set. With no key in either place, the gateway returns its 401 Missing API key.

import { dontcode } from 'dontcode'

const client = dontcode() // reads DONTCODE_API_KEY / DONTCODE_API_URL

// Auth: branch on verification_required / mfa_required, like the raw API
await client.auth.signup({ email, password, role: 'editor' })
const login = await client.auth.login({ email, password })
const { user } = await client.auth.me({ accessToken })

// Database: db.<table> or db('table'); migrations via db.migrate
const rows = await client.db.profiles.find({ where: { ownerId }, limit: 20 })
await client.db.profiles.insert({ name })
await client.db.migrate({ sql })

// Storage: public / private buckets
await client.storage.private.upload('docs/a.pdf', bytes, 'application/pdf')
const { url } = await client.storage.public.getUrl('img/logo.png')

Every method maps one-to-one to a route below: client.auth.* to /api/v1/auth/*, client.db.<table> and client.db.migrate to /api/v1/db, and client.storage.public|private to /api/v1/storage. Pass the end-user token as accessToken on signed-in calls (me, the mfa.* methods) and it rides in X-Access-Token.

Non-2xx responses throw a DontCodeError carrying status, code, and the full body. The “one more step” auth states (verification_required, mfa_required) are successful responses, not errors, so branch on the resolved value.

Every SDK request now carries a timeout (default 10s, set timeoutMs to change it), so a slow gateway fails fast instead of hanging. To keep users signed in across navigations without a round-trip on every request, see Sessions below.

Database: POST /api/v1/db

Structured queries only: raw SQL is never accepted here. The body is the structured-query protocol:

{ "operation": "find", "tableName": "maps", "options": { … } }
operationoptionsreturns (data)
find / findManywhere, select, orderBy, limit, offsetrows (max 1000 per call)
findOne / findFirstwhere, select, orderByrow or null
insertdata{ id }
updatewhere (required), data{ count }
deletewhere (required){ count }
countwherenumber

Where clauses: direct equality, null (IS NULL), arrays (IN), operator objects (equals, not, gt, gte, lt, lte, in, notIn, contains, startsWith, endsWith, mode: "insensitive"), and AND / OR / NOT compounds. All values are parameterized server-side.

Errors are { error: string }. Unique/foreign-key conflicts return 409, the supported idempotency pattern: insert, and on 409 treat the row as existing (or re-read it). include (relations) is not yet available on the public API.

Migrations: POST /api/v1/db/migrate

{ "sql": "CREATE TABLE IF NOT EXISTS profiles (…);" }

The one place DDL enters a project database from outside. Statements are validated and sanitized by the same pipeline the console uses: single-schema isolation, no role/permission management, no extensions, and the platform-managed users table is protected. Returns { success, executedStatements, warnings } or 400 { success: false, error }.

Auth: POST /api/v1/auth/{endpoint}

Fronts DontCode Auth with stable request/response shapes. Endpoints: signup, login, me, verify-email, forgot-password, reset-password, mfa/challenge, mfa/enroll, mfa/enroll/confirm, mfa/disable.

Two tokens, never mixed

  • The API key (Authorization: Bearer dc_…) identifies the *project*. It is on every request, including the auth calls below.
  • The end-user access token rides in the X-Access-Token header. Send it on any call that acts as a signed-in user (me, mfa/enroll, mfa/enroll/confirm, mfa/disable). Your app owns the session; store the token in an httpOnly cookie.
Two behaviours below are project settings, not API flags, and your app must handle both states because the owner can flip them: email verification and MFA. Don’t assume signup returns tokens, and don’t assume login is one step.

Sign up: signup

{ email, password, name?, role?, accept_as_typed? }. The response depends on whether the project requires email verification:

// verification ON: NO tokens yet; the user must enter a code
{ "success": true, "userId": "…", "verified": false,
  "verification_required": true,
  "message": "User created. Please verify your email." }

// verification OFF: the account is immediately usable; log in next
{ "success": true, "userId": "…", "verified": true,
  "verification_required": false }

When verification_required is true, your app must collect a code. DontCode emails the new user a 6-digit code (e.g. 174829). Render a code-entry form and POST it to verify-email; signup alone does not finish the flow.

DontCode checks the address before creating anything. One that cannot receive mail, or a throwaway inbox, fails with 400 { code: "UndeliverableEmailException" }. A likely typo of a working domain (gmai.com) pauses with 422 { code: "EmailTypoSuspected" }. Both can carry did_you_mean: offer it as a one-tap fix. To keep a paused address as typed, send the signup again with accept_as_typed: true; that never gets an undeliverable address through.

// likely typo: nothing created yet
422 { "success": false, "code": "EmailTypoSuspected",
      "did_you_mean": "jane@gmail.com" }

// cannot receive mail / throwaway inbox
400 { "success": false, "code": "UndeliverableEmailException",
      "did_you_mean": "jane@naver.com" }   // when there is a fix

Verify the emailed code: verify-email

{ code }: the 6-digit code from the email. (email is accepted but ignored; the code alone resolves the user.)

{ "success": true, "message": "Email verified successfully" }  // → now call login
// bad/expired code → 400 { "success": false, "error": "…" }

There is no separate “resend” on the public API yet; re-running signup for an unverified email re-sends the code.

Log in: login

{ email, password }. Two possible successes; branch on mfa_required:

// no MFA → you have a session
{ "success": true, "userId": "…", "mfa_offered": false,
  "mfa_enabled": false,
  "tokens": { "AccessToken": "…", "ExpiresIn": 3600 } }

// MFA enabled → NOT logged in yet; you hold a short-lived challenge
{ "success": true, "userId": "…", "mfa_required": true,
  "challenge_token": "…", "challenge_expires_in": 300 }

A login attempt before the email is verified fails with 403 { code: "EmailNotVerified" }; route the user back to the verify-email step.

Complete an MFA login: mfa/challenge

When login returned mfa_required, exchange the challenge plus the user’s authenticator code for real tokens: { challenge_token, code } (or { challenge_token, recovery_code } to spend a recovery code).

{ "success": true, "tokens": { "AccessToken": "…", "ExpiresIn": 3600 } }
// expired → 401 { "code": "ChallengeExpired" }  → start login over

Enroll / disable MFA (signed-in user; send X-Access-Token)

MFA is per-user and opt-in. The project’s MFA-offered setting only makes enrollment available; it does not force anyone. A user starts seeing the mfa_required login branch only after they finish mfa/enroll/confirm. If the owner hasn’t enabled MFA for the project, mfa/enroll returns 403 { code: "MfaNotOffered" }.

  1. mfa/enroll (no body) → { success, secret, otpauth_url }. Render otpauth_url as a QR code (or show secret) for the user’s authenticator app. Enrollment is pending; mfa_enabled does not flip yet.
  2. mfa/enroll/confirm { code }: the first code from the authenticator. On success → { success, recovery_codes: [...] }. Show the recovery codes once and tell the user to save them; they’re never returned again.
  3. mfa/disable { code } or { recovery_code }: proves possession of the second factor before turning MFA off → { success, message }.

Current user: me

No body. Send the user’s token in X-Access-Token → { user: { id, email, role, claims } }, or 401 { user: null } when the token is missing/expired.

Wiring it into your UI

Your app builds, by hand, the two-step signup and login screens a correct auth flow needs. The rule they follow: a single submit can return a “you’re done” state or a “one more step” state. Branch on the response, never assume one round-trip. Mishandling this is the #1 BYOC bug: the signup form forgets the code step, and the login form forgets the MFA step.

After this callResponse signalYour UI must
signupverification_required: trueswap to a 6-digit code form → verify-email, then login
signupverified: trueaccount is usable → send the user to login
logintokens presentstore the session; done
loginmfa_required: true (+ challenge_token)swap to an authenticator-code form → mfa/challenge
loginmfa_offered: true, mfa_enabled: falseoptionally prompt “turn on two-step verification” → enroll flow
me401 / user: nulltreat as signed out

Making MFA mandatory: the server never forces MFA; login only returns mfa_required for users who have already enrolled. To require it for everyone, gate your app after login: when mfa_offered: true and mfa_enabled: false, route the user through the enroll flow before letting them proceed.

Sessions: stay logged in without stalling

These are SDK conveniences, not new endpoints; the wire contract is unchanged. A session is still just the access token your app stored. They exist to solve one trap: checking auth by calling me on every navigation makes a network round-trip on the hot path, and if the gateway is slow that hung request reads as “signed out” and bounces the user to login. The helpers let you gate routes locally and verify only when it matters. They are framework-agnostic: they take a token or a cookie header and return plain data, so they drop into Next middleware, SvelteKit hooks, Express, or a worker with no adapter.

Store the token

serializeSessionCookie(token, options?) returns a Set-Cookie value; clearSessionCookie() returns one that logs out; readSessionToken(cookieHeader) reads it back. Your framework applies the strings. Defaults match how DontCode’s own apps store it: httpOnly (so client JavaScript cannot read the token), Secure, SameSite=Lax, path /, name dc_access_token, 7-day lifetime. Cross-site setups need sameSite: "none", which forces Secure on.

import { serializeSessionCookie, clearSessionCookie } from 'dontcode'

// after a successful login (tokens.ExpiresIn keeps cookie + token in lockstep)
headers.append('Set-Cookie', serializeSessionCookie(login.tokens.AccessToken, {
  maxAge: login.tokens.ExpiresIn,
}))

// on logout
headers.append('Set-Cookie', clearSessionCookie())

Gate routes with getSession

client.auth.getSession({ accessToken, mode }) (or sessionFromCookies(cookieHeader, { mode }), which reads the cookie for you) resolves a token into { status, user, verified, expiresAt }. status is active, expired, anonymous, or unavailable.

// route guard: instant, no network, runs on every navigation
const session = await client.auth.sessionFromCookies(cookieHeader)
if (session.status !== 'active') return redirect('/login')

// before a sensitive action: confirm against the gateway
const checked = await client.auth.getSession({ accessToken, mode: 'verified' })
if (checked.status !== 'active') return redirect('/login')

How optimistic mode works (and its limits)

  • optimistic (the default) decodes the token’s claims locally and trusts them. No signature check, no network, instant. Right for gating page loads and driving UI state.
  • It cannot detect a server-side revocation (logout-everywhere, password change) until the token’s own exp. Tokens are signed with a secret the gateway never shares, so local code cannot prove a token is genuine either; only the gateway can.
  • verified is the authority. It calls me once, caches the answer for ttlMs (default 60s), and times out after verifyTimeoutMs (default 5s). On a timeout or outage it returns status: "unavailable" with the optimistically-decoded user, so you decide whether to fail open instead of locking everyone out. A real 401 is reported as anonymous: the user is genuinely signed out.
modenetworkdetects revocationuse for
optimistic (default)noneno, until expgating page loads, UI state
verifiedme, cached + timed outyes, within ttlMssensitive actions, authorization
Our recommendation: gate page loads with optimistic, and re-check with verified before anything sensitive (payments, account or security changes, admin actions). Keep token lifetime short so optimistic and reality never drift far. Never make an authorization decision that must be airtight on optimistic claims alone.

Storage: /api/v1/storage

POST with { operation, bucket: "public" | "private", … }:

operationparamsreturns
listprefix?{ objects, folders, prefix, truncated, continuationToken }
removepaths[]{ deleted }
movefrom, to{ object }
createFolderpath{ created }
downloadpath{ body (base64), contentType, size } (≤ 8 MB)
getUrlpath (public bucket only){ url }
getTemporaryUrlpath, expiresIn?{ url, expiresIn }
presignUploadpath, contentType?{ url, key, expiresIn }

PUT with multipart/form-data (file, bucket, path, contentType?) uploads directly (≤ 100 MB) and returns { object }. Larger files: presignUpload, then PUT the file to the returned URL. All paths are scoped to the project automatically.

Payments: /api/v1/payments

Accept one-time payments and run subscriptions, with a plan and feature catalog and entitlement checks. This is the server control plane: your server verifies a completed charge, drives the subscription lifecycle, and reads who is entitled to what. The customer-facing payment popup runs in the browser against the payment provider; the gateway is what your server calls to make each result real. Amounts are integers in the plan currency (KRW by default), and everything is scoped to your project automatically.

Only the project API key reaches these endpoints, never an end-user token. Your server orchestrates every operation and passes the acting userId explicitly. The operation is part of the path, not a body field, and maps one-to-one to client.payments.* in the SDK.

One-time payments

method + pathbodyreturns
POST /verify{ paymentId, expectedAmount, currency, description?, userId? }{ receipt }
POST /refund{ paymentId, amount?, reason }{ refundedAmount, status, already_refunded }

verify records a charge the customer just completed; call it before you grant anything. Omit amount on refund for a full refund; refunds are idempotent.

Subscriptions

method + pathbodyreturns
POST /subscribe{ plan, userId, method, billingKey }{ subscription }
POST /subscribe-reserve{ plan, userId, method }{ subscription_id, storeId, channelKey }
POST /subscribe-confirm{ subscription_id, billing_key }{ subscription }
POST /subscribe-abort{ subscription_id }{}
POST /cancel-subscription{ subscription, atPeriodEnd }{ subscription }
POST /update-subscription-status{ subscription, status }{ subscription }
POST /get-subscription{ userId } or { subscriptionId }{ subscription } or null
POST /list-active-subscriptions{ userId }{ subscriptions }

Two ways to start: single-call subscribe when you already hold a billing key, or the split flow subscribe-reserve, open the provider popup in the browser with the returned config, then subscribe-confirm (or subscribe-abort if the popup is dismissed). cancel-subscription defaults to a soft cancel (access through period end) unless atPeriodEnd: false.

Entitlements and catalog

method + pathbody / queryreturns
POST /has-feature{ userId, featureKey }{ ok }
POST /list-user-features{ userId }{ features }
GET /plans?includeInactive=true{ plans }
POST /plans{ plans }{ plans }
PATCH /plans{ planId, active }{}
DELETE /plans?planId={}
GET /features{ features }
POST /features{ features }{ features }
DELETE /features?featureKey={}
GET /plan-features?planId={ planFeatures }
PUT /plan-features{ planId, features }{}

Plans are priced tiers, features are named capabilities, and plan-features maps which plan grants which feature (per-plan limits live in metadata). has-feature is the gate for paid capabilities; re-check it on the server for anything that must be airtight, the same rule as sessions.

Admin

method + pathbodyreturns
POST /admin/list-subscriptions{ status?, planId?, userId?, limit?, offset? }{ subscriptions, total }
POST /admin/grant-subscription{ userId, planId, periodStart?, periodEnd? }{ subscription }
POST /charge-subscription{ subscription, plan }{ receipt }

grant-subscription comps a subscription without a payment (no auto-renewal). charge-subscription triggers an off-cycle charge; you rarely need it, renewals run automatically. Any path outside this contract is a 404.

Errors and what to do

A failed payment call throws a DontCodeError carrying status, a stable code, and the full body. The message is written for a buyer to read, so surface it instead of collapsing every failure into one generic message. A 402 is not a bug: it usually means setup is not finished, or the buyer needs to retry.

codestatusmeaningwhat to do
PAYMENTS_DISABLED402Payment processing is not turned on for this project yet. It is checked before everything below.Nothing to change in your code: charges start working once payments are enabled for the project. If it persists, contact hello@dontcode.co.
BANK_ACCOUNT_REQUIRED402The app has no payout bank account, so it cannot accept payments yet.App owner: add a payout account in the project Payments tab, then wait for it to verify.
BANK_ACCOUNT_PENDING_VERIFICATION402The payout account is still being verified.Wait for verification to finish; payments resume automatically.
BANK_ACCOUNT_GRACE_EXPIRED402The payout account stayed unverified past its grace window.App owner: re-verify the account in the project Payments tab.
BANK_ACCOUNT_SUSPENDED402The payout account is suspended.Contact support.
CHARGE_FAILED402The payment was declined.Buyer: retry or use a different payment method.
PAYMENT_VERIFICATION_FAILED402The completed payment could not be verified against the recorded amount.Check the amount and currency match the plan, then retry.
SUBSCRIPTION_NOT_FOUND404No subscription for that id or user, or it has no saved billing method.Re-reserve the subscription before confirming or charging.
BILLING_KEY_MISSING400The operation needs a saved billing method that is not present.Run reserve then confirm so a billing method is stored.
RATE_LIMITED429Too many payment requests for this project.Back off and retry after `timeleft` seconds.
A brand-new project returns BANK_ACCOUNT_REQUIRED on every charge until its owner adds a payout account in the Payments tab. A newly added account can take payments during its verification grace period; if it is still unverified when that ends, charges return BANK_ACCOUNT_PENDING_VERIFICATION. This is expected, not a failure: show the message so the owner knows to finish setup, rather than showing the buyer a dead end.

Cache: /api/v1/cache

A project-scoped key-value store for shared, short-lived state: one place every instance of your app reads and writes, no matter where or how it is hosted. Serverless and multi-instance hosting cannot share process memory, so this is where cross-instance state lives: rate-limit counters, one-time codes and login nonces, webhook idempotency guards and run-once locks, anonymous carts and multi-step wizard sessions, and cached results of genuinely expensive work (large aggregates, paid third-party APIs, AI calls). Values survive your deploys and are shared between your web app and any workers or crons using the same key.

method + pathbody / queryreturns
GET /kv/{key}{ value } or 404
PUT /kv/{key}body = the raw JSON value; ?ttl=(s), ?nx=true{ ok, set }
DELETE /kv/{key}{ ok, deleted }
POST /expire/{key}{ ttl } (seconds, or null to clear){ ok, applied }
POST /hset/{key}{ fields: { … } }{ ok, written }
GET /hgetall/{key}{ value } or 404
POST /sadd/{key}{ members: string[] }{ ok, added }
GET /smembers/{key}{ value } or 404
POST /srem/{key}{ members: string[] }{ ok, removed }

ttl is in seconds. ?nx=true on PUT sets only if the key is absent and returns { set: false } when it already existed; that one flag is the building block for locks, once-only jobs, and webhook dedupe. A GET on a missing or expired key is a 404, which the SDK turns into null (or [] for set reads). String, hash, and set keyspaces are distinct. Keys are scoped to the project automatically, so never send a prefix of your own.

This is ephemeral state, not a database: values may be evicted and are not durable, so keep your system of record in /api/v1/db. Skip it for cheap reads, too; a cache round trip costs about as much as a database query, so cache work that is expensive, not queries that are already fast.

Realtime: /api/v1/realtime

Realtime pub/sub over WebSockets for live features (chat, presence, live updates). The gateway is the control plane: your server mints short-lived connection tokens, publishes messages, and reads presence. Browsers open the WebSocket with the minted token and never hold the project API key. Channels are scoped to the project automatically.

method + pathbodyreturns
POST /token{ channels: string[], identity?, ttl? }{ token, url }
POST /publish{ channel, payload }{ delivered }
GET /channels/{channel}/presence{ presence: [{ id, identity? }] }

Your server authenticates the end user, mints a token for the channels that user may use, and hands { token, url } to the browser, which connects to ${url}?token=${token}. Delivery is fire-and-forget (no history or replay), so persist anything that must survive to /api/v1/db.

Notifications: /api/v1/notifications

Send transactional email to your users. Recipients and sender identity are scoped to your project automatically. Only the project API key reaches these endpoints, never an end-user token: your server decides who gets notified. Maps to client.notifications.email.send in the SDK.

method + pathbodyreturns
POST /email{ to, subject, markdownText }{ success, messageId? } or { success: false, error }
  • to is one recipient email or an array of them.
  • markdownText is the whole body, written in GitHub-flavored Markdown. There is no html, body, or text field and no templates: the Markdown you send is the email. Content is sanitized on the server.
  • Check success before assuming delivery.

Only email exists today. The path segment is the channel, so later channels slot in without changing this contract; any other channel is a 404.

Connecting your app

  1. Create a project through the normal DontCode console flow: this provisions the project row, database, and auth pool exactly like any other project.
  2. Mint an API key: console → project → API keys.
  3. Configure the app with DONTCODE_API_URL and DONTCODE_API_KEY (copy the env block from the key-reveal panel).
  4. Apply the app’s schema via /api/v1/db/migrate.
  5. The app signs users up/in through /api/v1/auth/*, reads/writes through /api/v1/db, and uploads through /api/v1/storage. No Postgres connection string, no auth provider, no storage credentials.