S
supero.docs
Developers/API Reference/CRUD Endpoints

CRUD Endpoints

The auto-generated Create, Read, Update, and Delete endpoints every schema type gets on api.supero.dev, plus bulk reads, rich queries, and batch writes.

Overview

Every object type in your project's schema — whether it's a built-in type like project or tenant, or a custom type you define like product or booking — automatically gets a full set of REST endpoints under /api/v1/crud. There's no code to write: upload a schema through the Schema API and the endpoints exist.
All CRUD endpoints live under a single base path scoped by your domain name:
text
https://api.supero.dev/api/v1/crud/{domain}/{type}
https://api.supero.dev/api/v1/crud/{domain}/{type}/{uuid}
{domain} is your Supero domain name (not a hostname), and {type} is the schema type name — either bare (product) or namespace-qualified (billing:customer) if the schema was uploaded into a namespace. Records are identified by uuid, never id.
OperationMethodEndpoint
CreatePOST/api/v1/crud/{domain}/{type}
Batch create / upsertPOST/api/v1/crud/{domain}/{type}/batch
Get by UUIDGET/api/v1/crud/{domain}/{type}/{uuid}
ListGET/api/v1/crud/{domain}/{type}
Update (partial)PUT/api/v1/crud/{domain}/{type}/{uuid}
DeleteDELETE/api/v1/crud/{domain}/{type}/{uuid}
Bulk list with filtersPOST/api/v1/crud/{domain}/list-bulk
Rich queryPOST/api/v1/crud/{domain}/query
bash
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/product" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "wireless-keyboard",
    "title": "Wireless Keyboard",
    "price": 79.99,
    "in_stock": true
  }'

ℹ️ name vs. title

Every record needs a unique name field — a short, URL-safe identifier used internally to build the record's fq_name. It's separate from any human-readable title/display_name field your schema defines. Omit fq_name and Supero derives it from your session context (domain/project/tenant) plus name.

Authentication and Scope

CRUD endpoints accept either a user JWT or an API key:
  • •Authorization: Bearer <jwt> — from POST /api/v1/auth/login, for user-session calls.
  • •X-API-Key: ak_... — an opaque API key, for server-to-server and integration calls. Never send an API key as a Bearer token.
Read/write scope (which domain, project, and tenant a request can see) comes entirely from the JWT or API key you authenticate with — there are no separate required scoping headers to set. If your key or session spans more than one project or tenant, narrow a request with the optional project or tenant query parameter; you cannot use them to widen scope beyond what your token already grants.

⚠️ Heads up

Requesting a UUID that exists but belongs to a tenant or owner you can't see returns 404, not 403 — this avoids leaking the existence of records outside your scope.

Creating Records

POST a JSON body with your schema's fields. name is required; fq_name, parent_type, and parent_uuid are optional — omit them for a top-level record and Supero derives fq_name from your session. For a child record (e.g. a booking under a specific customer), pass parent_uuid and parent_type explicitly.
http
POST /api/v1/crud/acme-corp/product HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "wireless-keyboard",
  "title": "Wireless Keyboard",
  "price": 79.99,
  "in_stock": true
}
json
{
  "success": true,
  "data": {
    "product": {
      "uuid": "9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10",
      "fq_name": ["acme-corp", "default-project", "northwind", "wireless-keyboard"],
      "name": "wireless-keyboard",
      "created_at": "2026-07-18T14:30:00Z",
      "updated_at": "2026-07-18T14:30:00Z"
    }
  },
  "uuid": "9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10",
  "fq_name": ["acme-corp", "default-project", "northwind", "wireless-keyboard"]
}

ℹ️ Follow up with a GET

The create response mirrors uuid and fq_name at the top level for convenience, but data.<type> on a create response isn't guaranteed to carry back every field you sent. Treat the response as confirmation that you have a uuid; call GET /api/v1/crud/{domain}/{type}/{uuid} right after if you need the full record, including any server-computed or default values.
On failure, the response includes a structured error_code so clients don't have to string-match the message: ALREADY_EXISTS (409), PARENT_NOT_FOUND (400), VALIDATION_FAILED (422), or PERMISSION_DENIED (403).

Batch Create and Upsert

POST /api/v1/crud/{domain}/{type}/batch creates many records in one call, and can upsert (insert-or-update) against a matching key instead of always inserting.
json
{
  "records": [
    {"name": "record-1", "title": "First"},
    {"name": "record-2", "title": "Second"}
  ],
  "upsert": false,
  "match_fields": ["external_id"]
}
json
{
  "success": true,
  "created": 10,
  "updated": 5,
  "failed": 0,
  "total_submitted": 15,
  "errors": []
}
  • •Recommended maximum batch size: 1000 records per call.
  • •Records are processed concurrently; processing stops after 100 accumulated errors.
  • •If a record omits name, Supero auto-generates one from external_id or its position in the batch.
  • •Set "upsert": true with match_fields to update existing rows that match instead of failing on a duplicate.

Reading Records

Get a single record

http
GET /api/v1/crud/acme-corp/product/9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10 HTTP/1.1
Authorization: Bearer <token>
json
{
  "success": true,
  "data": {
    "product": {
      "uuid": "9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10",
      "fq_name": ["acme-corp", "default-project", "northwind", "wireless-keyboard"],
      "name": "wireless-keyboard",
      "title": "Wireless Keyboard",
      "price": 79.99,
      "in_stock": true,
      "created_at": "2026-07-18T14:30:00Z",
      "updated_at": "2026-07-18T14:30:00Z"
    }
  }
}

List records

http
GET /api/v1/crud/acme-corp/product?limit=20&sort_by=created_at&sort_order=desc HTTP/1.1
Authorization: Bearer <token>
json
{
  "success": true,
  "obj_type": "product",
  "results": [
    {"uuid": "abc-001", "name": "widget-a", "created_at": "2026-07-15T10:30:00Z"},
    {"uuid": "abc-002", "name": "widget-b", "created_at": "2026-07-14T09:00:00Z"}
  ],
  "result_count": 20,
  "pagination": {
    "limit": 20,
    "has_more": true,
    "next_cursor": "eyJ1dWlkIjoiYWJjLTAyMCJ9"
  }
}
ParameterTypeDescription
limitintegerPage size. Default 50, maximum 1000.
offsetintegerSkip N records (offset pagination). Mutually exclusive with cursor — if both are sent, cursor wins.
cursorstringOpaque cursor from a previous response’s pagination.next_cursor (cursor pagination). Fast at any page depth; prefer it for iteration and infinite scroll.
sort_bystringField to sort on. Safe on any record: created_at, updated_at, uuid, name.
sort_orderstring"asc" or "desc".
include_totalbooleanInclude pagination.total (a full collection count). Costs a full scan — request it on the first page only, then omit it on later pages.
parent_idstringRestrict to children of a given parent UUID (also accepted as parent_uuid).
Send no pagination parameters at all and list behaves like a plain list: it returns every matching record and the response has no pagination key.

⚠️ GET does not filter by field

The GET list endpoint accepts pagination, sorting, and parent_id — it does not accept arbitrary field filters in the query string. To filter by field value, use POST /list-bulk or POST /query (next section).

⚠️ A bare list is scoped to your token’s project

GET /api/v1/crud/{domain}/{type} on one of your own schema types narrows the read to the single project your token was issued for — including when you are a domain_admin. Records of that type in your other projects are simply absent from the response. There is no error and no warning: the call returns 200 with a shorter results array, which is the most common reason a list looks unexpectedly empty. To read a type across the whole domain, use POST /api/v1/crud/{domain}/list-bulk with an fq_name_prefix of just the domain name.
bash
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/list-bulk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "product",
    "fq_name_prefix": ["acme-corp"],
    "detail": true,
    "limit": 200
  }'
json
{
  "success": true,
  "results": [
    {"uuid": "abc-001", "name": "widget-a", "fq_name": ["acme-corp", "storefront", "northwind", "widget-a"]},
    {"uuid": "abc-042", "name": "widget-z", "fq_name": ["acme-corp", "warehouse", "northwind", "widget-z"]}
  ],
  "result_count": 2
}
A one-segment fq_name_prefix constrains only the domain, so records from every project and tenant under it come back. Widening this way requires an admin role — for other roles fq_name_prefix can still narrow scope, but a prefix broader than the token's own scope is clamped back down rather than honoured. Built-in domain-level types (project, audit_log, and similar) are already domain-wide on a bare list; the project clamp applies to the schema types you define yourself.

Filtering, Bulk Reads, and Rich Queries

Two POST endpoints cover what GET can't: filtering by field value, fetching a specific set of UUIDs, and reading children by parent or reference. Querying & Pagination documents both endpoints in full, along with the filter operators and cursor paging.

POST /list-bulk — pagination plus simple equality filters

filters can be a JSON object of exact-match field:value pairs, or the equivalent comma-separated string field==value,field2==value2.
bash
curl -X POST "https://api.supero.dev/api/v1/crud/acme-corp/list-bulk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "product",
    "filters": "status==active,category==electronics",
    "limit": 20,
    "sort_by": "name",
    "sort_order": "asc"
  }'
json
{
  "success": true,
  "results": [
    {"uuid": "abc-010", "name": "usb-c-hub", "status": "active", "category": "electronics"}
  ],
  "result_count": 1,
  "pagination": {"limit": 20, "has_more": false}
}
list-bulk also accepts obj_uuids (fetch a specific known set of records, still paginated) and parent_id / back_ref_id (comma-separated UUIDs) to scope by hierarchy or reference.

POST /query — the same filtering, framed as a read query

json
{
  "type": "product",
  "filters": {"status": "active", "category": "electronics"},
  "limit": 20,
  "offset": 0,
  "sort": [{"field": "price", "order": "asc"}]
}
json
{
  "success": true,
  "results": [
    {"uuid": "abc-010", "name": "usb-c-hub", "status": "active", "category": "electronics", "price": 49.99}
  ],
  "result_count": 1
}

ℹ️ Query uses offset, not cursor

POST /query doesn't support cursor pagination — page it with limit/offset, the same as pre-pagination clients. Cursor pagination is only available on GET list and POST /list-bulk.

Updating and Deleting Records

PUT is a partial update: send only the fields you want to change, and everything else is left as-is.
http
PUT /api/v1/crud/acme-corp/product/9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10 HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json

{
  "price": 69.99,
  "in_stock": false
}
json
{
  "success": true,
  "data": {
    "product": {
      "uuid": "9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10",
      "price": 69.99,
      "in_stock": false,
      "updated_at": "2026-07-18T15:05:00Z"
    }
  }
}
The system fields uuid, fq_name, created_at, updated_at, parent_type, and parent_uuid are immutable on update — include them in the body and they're silently dropped, keeping the platform's own values.
http
DELETE /api/v1/crud/acme-corp/product/9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10 HTTP/1.1
Authorization: Bearer <token>
json
{
  "success": true,
  "message": "product 9f2b1c3a-8e6d-4b21-9a1f-0c7e2d4b5f10 deleted"
}

Error Handling

Errors return a non-2xx status with a JSON body containing at least error and message. Create failures also include a structured error_code (see Creating Records above). Errors & Status Codes carries the full catalog, and Rate Limits covers throttling responses.
json
{
  "error": "Forbidden: Domain access denied",
  "message": "Domain access denied"
}
StatusMeaningWhen you’ll see it
400Bad RequestMalformed body, missing required name, or an invalid fq_name shape.
401UnauthorizedMissing or invalid token/API key.
403ForbiddenAuthenticated, but not permitted for this domain/project/tenant or operation.
404Not FoundNo record with that UUID — or a record you don’t have access to (returned as 404, not 403).
409ConflictA record with that name/fq_name already exists.
422Unprocessable EntityBody failed schema validation (missing required field, wrong type, etc.).
500Internal Server ErrorUnexpected server-side error.

Using the Python SDK

The Python SDK wraps these same endpoints behind a synchronous org.crud.* API, so you don't have to hand-build requests.
bash
pip install supero
python
from supero import login

org = login(domain_name="acme-corp", email="[email protected]", password="...")

# Create
product = org.crud.create("product", name="wireless-keyboard", title="Wireless Keyboard", price=79.99)

# Get / List / Update / Delete
product = org.crud.get("product", product["uuid"])
products = org.crud.list("product", limit=50)
org.crud.update("product", product["uuid"], price=69.99)
org.crud.delete("product", product["uuid"])

# Query builder — filter, order, limit
active = (
    org.crud.query("product")
    .filter(status="active")
    .order_by("-price")
    .limit(10)
    .all()
)
You can also connect with an API key instead of a user login:
python
from supero import connect

org = connect(domain_name="acme-corp", api_key="ak_...")

💡 Handling errors

Catch AuthenticationError, AuthorizationError, and RateLimitError from supero for auth and rate-limit failures. The client is synchronous — there is no async client.

Next Steps

Related reading: