# Aurochs API: the details > What you need beyond the OpenAPI spec (https://aurochs.oxen.dev/api/v1/openapi.json) to build on the REST API: credentials, limits, how data reads back, and automation webhooks. ## Credentials and paths - An `ak_` API key (or a connected app's OAuth token) belongs to one workspace. Send it as `Authorization: Bearer `. - It works on the agent API (`/api/v1/pages`, `/api/v1/databases`, `/api/v1/records`, …), where the workspace comes from the key. - It also works on the client API, `/api/v1/w/{workspaceId}/…`, for its own workspace only. That's where search, single-file reads and `since`-based record changes live. Any other workspace id is refused. - Effective permission is the weaker of the key's scopes (read, write, admin) and, for OAuth, the person's role. ## Rate limits - Per key (per connected app for OAuth): 60 reads and 30 writes per minute, in fixed one-minute windows. - Over the limit you get HTTP 429 `rate_limited` with a `Retry-After` header in seconds. Wait that long, then continue. - Bulk work: insert records in batches (`POST /api/v1/databases/{id}/records` takes a `records` array), and page reads with the largest `limit` an endpoint allows (records: 500). ## Records - `properties` are keyed by field id. Field ids and names come from the database's `schema`. - select and multi_select values read back as option ids. Writes accept option names or ids. Keep an id → name map from the schema; option names can be renamed, ids never change. - Dates: a date-only field holds "YYYY-MM-DD" (a day, no timezone). A field with `config.includeTime` holds a UTC instant; send it with an offset ("2026-09-19T14:00:00-06:00"). A timestamp without an offset is rejected. - `filter` is a URL-encoded JSON array of `{field, op, value}`, AND-ed. `field` is a field name or id. `op` is one of: eq, contains, gt, lt, neq, not_contains, gte, lte, in, not_in, between, empty, not_empty. - On multi-value fields (multi_select, relation, person, files), `contains` / `not_contains` test whether the list holds a given id, e.g. `{"field":"Speaker","op":"contains","value":""}` on a relation. - `GET /api/v1/databases/{id}/records`: `limit` up to 500, `order_by` as field names or ids, comma-separated, `-` for descending (`order_by=Date,-Title`), and `cursor` = the previous page's `next_cursor`. Keep the same `order_by` and `filter` while paging. Without `order_by` you get the table's manual order. - Replace a record's whole body in one call with `PUT /api/v1/records/{id}/body` `{ "blocks": [...] }` (empty clears it). Blocks that line up with the current ones keep their ids; the response says `changed`; the old body stays in version history. `.../body/append` and `.../body/edit` remain for partial changes. - A record's body (`GET /api/v1/records/{id}/body`) is created with one empty paragraph. To ask "is the body empty?", check `text.trim() === ""`, not the number of blocks. ## Files - `GET /api/v1/files` is paged (`limit` up to 500, default 100; `cursor`), newest first. `GET /api/v1/files/{id}` reads one file. - Files are private ("signed": one-hour URLs) unless made public. Make one public with `PATCH /api/v1/files/{id}` `{ "visibility": "public" }` (or `"signed"` to make it private again, which also stops any permanent link already given out). Uploads take `visibility` too. - A file or files field can publish everything put in it: set `config.publicFiles: true` on the field (in the app: the column menu, "Public files"). Turning it on also publishes the files already in that field. - File responses carry `url` and `url_expires_at`. `url_expires_at: null` means the URL is permanent. A timestamp means it's presigned and stops working then, so don't cache it in HTML past that time. A "public" file is only permanent when the workspace's storage has a public domain; otherwise it is presigned like any other. ## Automation webhooks Automations are built in the app (Automations) or over MCP (`create_trigger`). A webhook step POSTs each event to your URL. Delivery: - One POST per event, JSON, 5 second timeout. Any 2xx counts as delivered. - Anything else (an error status, a timeout, no answer) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. A retry sends the same body with the same `x-webhook-id`, signed again with a fresh timestamp. Each attempt shows in the automation's runs. - Make your handler fast (acknowledge, then work) and idempotent on `x-webhook-id`: a delivery your server processed but didn't answer in time will arrive again. Headers: - `x-webhook-id`: unique delivery id (also `id` in the body) - `x-webhook-event`: the event name - `x-webhook-timestamp`: unix seconds - `x-webhook-signature`: `v1=` Verifying the signature: - signature = hex( HMAC-SHA256( key = the signing secret, message = "." ) ) - The key is the whole secret string, `whsec_` prefix included, as UTF-8 bytes. It is NOT base64-decoded, so Svix's libraries won't verify it. Compute the HMAC directly. - Use the raw body bytes as received, before any JSON parsing. Compare in constant time. Reject timestamps more than 5 minutes old to block replays. - Each automation has its own secret. It's shown on the automation's Webhook and HTTP steps in the app, and by MCP `get_trigger`. Body: `{ "id", "event", "workspace_id", "occurred_at", "actor", "data" }`. `actor` is "user:", "key:", "flow:" (another automation's write) or "system" (a date trigger). Events: database.created, database.deleted, page.created, record.created, record.updated, record.deleted, record.body_updated, record.date_reached, comment.created, comment.mention, comment.resolved. One automation can run on several events (one URL, one signing secret): in the app, turn on more than one under "When"; over MCP, pass `event` as a list. Branch on the body's `event` field or the `x-webhook-event` header. `record.date_reached` runs on its own. What `data` carries: - record.created: `{ database, count, records: [{ id, title, properties }] }` (records capped at 100; `count` is the true total). `restored: true` when a record came back from the trash. - record.updated: `{ database, record: { id, title, properties } }`, or `{ database, count, records }` for a bulk edit. A reorder sends `moved: true` with `record.position`. - record.deleted: `{ record: { id, title }, database_id }`. Sent when a record is deleted or moved to the trash. - record.body_updated: `{ database, record: { id, title, properties } }`. A record's page content changed: once per editor save (about 10 seconds after typing stops) or per API body write. No body in the payload; read it from `GET /api/v1/records/{id}/body` if you need it. - record.date_reached: `{ database, record, field: { id, name }, date, fire_at }`. Fires when a record's date field (plus the automation's offset) arrives. A date-only value fires at the automation's chosen time of day in its chosen timezone. - page.created / database.created: `{ page }` / `{ database }` with id and title. database.created carries `restored: true` when a table came back from the trash (on its own or inside a restored page). - database.deleted: `{ database: { id, title } }`. One event per table moved to the trash, whether alone or inside a trashed page. Its records send no events of their own. - comment.created / comment.mention / comment.resolved: `{ node, thread, comment?, author?, mentioned? }`.