Project and keys

Everything below happens on two surfaces. The dashboard at app.sch3ma.com is for people. The API at admin.sch3ma.com is for scripts, CI and coding agents, and is frozen from its first release.

IN THE DASHBOARD
  1. Sign in at app.sch3ma.com. An account is created on your first completed sign-in; there is no other path.
  2. Create a project. It is one database of its own.
  3. Under Keys, mint a publishable key for your pages and a secret key for scripts.

A key is shown once, when it is minted. A publishable key ships in your page by design and carries no authority of its own: the rules on each collection decide what it may do. A secret key carries full project authority and must never reach a browser.

# every admin call below uses the secret key export SK=sk_live_… export P=prj_01J6WM4T8QX2…

Origins

A project's origin list names the sites that may reach it from a browser. Visitors can only sign in from these. Replacing the list is one of four operations that take two calls, because dropping an origin by leaving it out can lock out every visitor.

IN THE DASHBOARD
  1. Open the project, edit the Origins box, one per line, then Replace list.
  2. If the change removes an origin, type the count it shows before it commits.
# first call: commits nothing, reports what it would remove curl -X PUT "https://admin.sch3ma.com/$P/_origins" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"origins":["https://example.com"]}' # {"data":null,"confirm_required":["https://old.example.com"], # "report":{"confirm_phrase":"1","confirm_token":"cft_…"}} # second call: identical body, plus the token curl -X PUT "https://admin.sch3ma.com/$P/_origins?_confirm=cft_…" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"origins":["https://example.com"]}'
The four two-call operations are: replace the origin list, change a collection, delete a collection, and purge a record. The flow never varies: the first call reports, the second carries _confirm. Never replay the token automatically; a person is meant to read the report.

First collection

A collection is created with one PUT. It answers 201 and needs no confirmation, because there is nothing yet to destroy.

curl -X PUT "https://admin.sch3ma.com/$P/_schemas/reviews" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{ "prefix": "rev", "rules": { "read": "public", "create": "authenticated", "update": "owner:author" }, "fields": { "rating": { "type": "integer", "min": 1, "max": 5, "required": true, "visible": "public" }, "body": { "type": "text", "maxLength": 500, "required": true, "visible": "public" }, "email": { "type": "text", "visible": "none" }, "author": { "type": "reference", "to": "users" } } }'

prefix is the short tag on every record id in the collection, so an id reads rev_01J8ZK9V…. Every record also carries five system columns you never declare: id, created_at, updated_at, version and deleted_at.

A collection can also hold no field map at all, in which case it accepts any JSON document. Declare fields later to harden it. Both directions are supported on purpose.

Field types

TYPEHOLDS
textA string. Takes pattern, enum and maxLength.
integerA whole number. Takes min and max.
numberA number with a fractional part. Takes min and max.
booleanTrue or false.
timestampAn RFC 3339 instant.
jsonAny JSON value, stored whole and never indexed.
referenceA pointer at another collection. Takes to, on_delete and touch.

Field options

OPTIONMEANS
requiredA create without it is refused.
defaultFilled in when the field is absent on create.
uniqueNo two live records may share the value, per project.
visibleWho may read the field. See below.
patternA regular expression the value must match.
enumA closed list of allowed values.
min / maxNumeric bounds.
maxLengthLongest allowed string.
toThe collection a reference points at.
on_deletecascade, set_null or restrict when the target is destroyed.
touchBumps the referenced record's updated_at on write.
Declaring a scalar or reference field indexes it, in the same call. A json field is never indexed and cannot be filtered or sorted on.

Access rules

Four rules per collection, one per operation. This is the part that makes a publishable key safe in a page: the key says which project, the rules say what any caller may do.

VALUESATISFIED BY
publicAnyone holding a publishable key, with or without an identity.
authenticatedAny identity, anonymous or verified.
verifiedOnly an identity that completed a magic link.
owner:<field>Only the identity stored in that field on the record.
grant:<name>Only an identity holding that grant.

An absent rule denies everyone below project authority. A collection with only create declared, as a waitlist wants, accepts writes from anyone and lets nobody read the list back.

# a public comment wall "rules": { "read": "public", "create": "authenticated", "update": "owner:author", "delete": "grant:moderate" } # private per-visitor settings "rules": { "read": "owner:visitor", "create": "authenticated", "update": "owner:visitor" } # a waitlist: anyone may join, nobody may read "rules": { "create": "public" }
A rule change is live on the next request. Flipping a collection from public to authenticated is an instant leak-stopper, and it never touches a stored record.

Field visibility

Rules decide who reaches a record. visible decides who reaches a field inside it. A field the caller may not read is absent from the response rather than blanked, so nothing about it is inferable.

VISIBLERETURNED TO
publicEveryone who may read the record.
authenticatedAny identity.
verifiedOnly a verified identity.
owner:<field>Only the record's owner.
noneOnly project authority, meaning a secret key or the dashboard.

Changing a collection

A change over live data takes two calls. The first reports what it would do and commits nothing; the second repeats the body with the token. Adding a field is a one-call-shaped change that still runs the same flow, so no client meets the destructive path for the first time in production.

# add a field and tighten a rule curl -X PATCH "https://admin.sch3ma.com/$P/_schemas/reviews" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"fields":{"title":{"type":"text","maxLength":80}}}' # the report names what would break, per field and per constraint # repeat with ?_confirm=<report.confirm_token> to commit

A tightening validates forward only: stored records that would now fail are grandfathered, and the report counts them. Add ?_strict=true to refuse the change instead. A dropped field keeps a shadow copy for 30 days, so an accidental drop is recoverable. Deleting a collection is DELETE /_schemas/:name, also two calls, and restorable for 30 days with POST /_schemas/:name/_restore.

References

A reference field points at another collection and is resolved server-side. A reference always reads as an object carrying at least id, never a bare string, so a client reading row.author.id keeps working whether or not the reference was expanded.

# expand the author, and the author of each answer curl "https://admin.sch3ma.com/$P/reviews?_include=author" \ -H "Authorization: Bearer $SK"

An expansion adds keys; it never changes a type. A field the caller may not see on the target stays absent on the expanded object too, so an include cannot be used to read around a visibility rule.

Grants

A grant is a named capability you hand to an identity, for the moderator and editor cases that owner: cannot express. Declare the names first on the reserved _grants collection.

curl -X PUT "https://admin.sch3ma.com/$P/_schemas/_grants" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"grants":{"comments.moderate":{"mintable_by":"comments.moderate"}}}' # hand it to an identity curl -X POST "https://admin.sch3ma.com/$P/_grants" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"user":{"id":"usr_01J7QB2M…"},"grant":"comments.moderate"}'

mintable_by says which grant lets a holder mint that grant for someone else, so moderation can be delegated without a secret key. Undeclaring a grant revokes it on the next request: authority is never grandfathered.

Writing records

# create curl -X POST "https://admin.sch3ma.com/$P/reviews" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"rating":5,"body":"Set it up in an afternoon."}' # update: a merge, so an absent key is left alone curl -X PATCH "https://admin.sch3ma.com/$P/reviews/rev_01J8ZK…" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"rating":4}' # soft delete curl -X DELETE "https://admin.sch3ma.com/$P/reviews/rev_01J8ZK…" \ -H "Authorization: Bearer $SK"
Validation runs over the whole record before any statement executes. If one field fails, nothing is written, no timestamp moves and the version does not advance. A 400 lists one entry per failing constraint per field, so a form can mark every problem at once.

Reading records

Filters ride as one JSON object under _filter, so a customer's field name can never collide with a parameter name. A bare value means equality; an object takes one or more predicates. Every term is ANDed.

# _filter={"rating":{"gte":4},"status":"open"} curl -G "https://admin.sch3ma.com/$P/reviews" \ -H "Authorization: Bearer $SK" \ --data-urlencode '_filter={"rating":{"gte":4}}' \ --data-urlencode '_sort=-created_at' \ --data-urlencode '_limit=25'
PREDICATEMEANS
eq / neEqual, not equal. A bare value is eq. null tests SQL NULL.
lt / lte / gt / gteOrdered comparison.
in / ninIn, or not in, a list.
prefixStarts with, on a text field.

_sort takes up to three keys with an optional leading -; id is appended as a tiebreaker so paging never loses a record. A list answers data, has_more and cursor; pass the cursor back as _cursor. There is no offset and no total. For a count, GET /:collection/_count takes _filter and nothing else and answers {"count":842,"exact":true}.

A read that would examine more than 10,000 rows is refused with 400 read_too_expensive rather than running on. Narrow the filter, or add a field the filter can use.

Idempotency

Every write may carry _idempotency_key. A repeat with the same key and the same request replays the stored status and body under Sch3ma-Replay: true, so a double-submitted form writes once. The same key with a different request is 409 idempotency_conflict. The window is 24 hours.

curl -X POST "https://admin.sch3ma.com/$P/reviews?_idempotency_key=7Hq2vZk9Lm3Np4Qr5St6Uv" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"rating":5,"body":"Once, however many times this is sent."}'

Versions

Every record carries version, an integer that starts at 1 and rises by exactly one on every accepted write, soft delete and restore included. Pass _version on an update and the write is refused if the record moved underneath you.

curl -X PATCH "https://admin.sch3ma.com/$P/reviews/rev_01J8ZK…?_version=3" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"body":"Edited, only if nobody else did first."}'

Delete and restore

An ordinary delete is soft: it sets deleted_at, bumps the version and hides the record everywhere. A secret key can list deleted records with _deleted=true, restore one, or destroy it for good.

# bring it back curl -X POST "https://admin.sch3ma.com/$P/reviews/rev_01J8ZK…/_restore" -H "Authorization: Bearer $SK" # destroy it: two calls, like every destructive operation curl -X POST "https://admin.sch3ma.com/$P/reviews/rev_01J8ZK…/_purge" -H "Authorization: Bearer $SK"

A purge runs the on_delete edges: cascade destroys the records that point at it, set_null clears their field, and restrict refuses the purge while any record still points at it. A soft delete never consults those edges.

Identity settings

One document per project, merged with a single PATCH. An absent key is unchanged; an explicit null returns that key to its default.

IN THE DASHBOARD
  1. Open the project and edit the Identity section: anonymous identities, the conflict policy, the landing URL, and the Turnstile pair.
curl -X PATCH "https://admin.sch3ma.com/$P/_identity" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{ "anonymous": true, "on_email_conflict": "signin", "landing_url": "https://example.com/signin", "turnstile": { "site_key": "0x4…", "secret": "0x4…" } }'
KEYMEANS
anonymousDefault true. A visitor gets an identity on their first write with no sign-in. Set false and a magic link is the only way in.
on_email_conflictsignin or refuse, when a sign-in lands on an anonymous identity that already owns records.
landing_urlWhere a magic link sends the visitor. Its origin must be on the origin list. Sending links requires it.
turnstileA widget's site_key and secret, or null. Set, every sign-in send must carry a passing challenge. Read back as the site key alone.

Export

One call returns a gzipped tar of the whole project: a file per collection as NDJSON, the identity records, the credentials, and a manifest that replays the schema. Once per hour per project.

curl -X POST "https://admin.sch3ma.com/$P/_export" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{}' -o project.tar.gz # one collection as CSV instead curl -X POST "https://admin.sch3ma.com/$P/_export" \ -H "Authorization: Bearer $SK" -H "Content-Type: application/json" \ -d '{"collection":"reviews","format":"csv"}'

Above 10 MB or 50,000 records the call answers a job instead. Poll GET /_export/:id until it is ready, then fetch the single-use URL it names. No secret ever appears in an archive.

Usage

The same counters that enforce the quota are the ones you can read, so the numbers never disagree. The response carries this project's meters, the account's totals, the allowance and the current rung.

curl "https://admin.sch3ma.com/$P/_usage" -H "Authorization: Bearer $SK"
Quotas meter per account, not per project, so splitting work across projects does not multiply an allowance. The dashboard's account page shows the same figures with the allowance beside them.

Errors

A response carrying data succeeded. Every failure is {"error":{"code":…,"message":…}} with a stable machine code. Branch on the code, never the message.

CODEMEANS
validation_failed400, with fields: one entry per failing constraint per field.
not_found404. Also what a record you may not read looks like: a denial and a missing record are the same answer.
identity_required401 on a write against authenticated or owner: with no identity. Establish a session and retry once.
verification_required401 where only a verified identity will do. A magic link is the only remedy; do not retry.
origin_not_allowed403. The caller's origin is not on the project's list.
idempotency_conflict409. The key matched a different request.
read_too_expensive400. The read would examine more than 10,000 rows.
quota_exhausted429. The account's monthly allowance is spent.

Reads never answer 401. A list under a rule you do not satisfy is an empty array, and a collection you may not read is the same 404 a misspelling gets, so no response is a probe for what exists.