The OmniSchema API
If you have other software that needs to talk to OmniSchema, this page shows you how. You don't need us to set anything up: one of your admins makes a key, you send it with your requests, and that's it.
The API comes with the Growth and Enterprise plans. Growth includes 100,000 calls a month and Enterprise 1,000,000.
What people use it for
Everything is plain HTTPS and JSON, so any language that can make a web request will do: Node, Python, PHP, C#, or a tool like Power Automate. There's nothing to install.
Your first call, in about five minutes
- Sign in to OmniSchema as an admin and open Governance, then API keys. Choose Create key.
- Give it a name you'll recognise in six months, like “Website form” or “Payroll export”. Choose the role it should act as (more on that below) and leave it as Read only while you try things out.
- Copy the key. It starts with
osk_live_. We only show it once, because we don't keep a copy we could show you again. If you lose it, make a new one and revoke the old. - Ask the API who it thinks you are:
curl https://omnischema.app/api/v1/me \
-H "Authorization: Bearer osk_live_ab12cd34_your_key_here"You should get something like this back:
{
"data": {
"key": { "name": "Website form", "access": "read", "role": { "slug": "office", "label": "Office" } },
"company": { "name": "Brookside Facilities", "plan": "growth" },
"allowance": { "monthly": 100000, "used": 1, "left": 99999 }
}
}If you see that, you're connected. If you get a 401 instead, the key isn't right: check you copied all of it, with no space at either end, and that nobody has revoked it.
Keys, and what a key can see
A key doesn't get any special powers. It acts as one of your roles, so it sees exactly what someone in that role would see in the app: the same lists, the same rows and the same columns. If your Engineer role only sees the jobs assigned to it, a key with that role sees those too, and nothing else. If your Office role can't see costs, its key can't either. We built it that way on purpose: you can think about a key the same way you think about a member of staff.
There are two kinds. A read only key can look but not touch. A read and write key can also add and change rows in your Operations lists, add people, book leave and time in People, and raise purchase requests and record deliveries in Purchasing. Everything goes through the same checks the app makes, and some things are never open to a key at all: approving, paying, closing a pay period, or changing settings, roles and users. Those are always done by someone signed in to OmniSchema.
Keys belong to your company, not to whoever made them, so they keep working when people leave. You can give a key an end date when you make it, and you can revoke it at any time; it stops working on the very next request. Governance shows when each key was last used and how many calls it has made this month, which makes it easy to spot one you'd forgotten about.
Reading data
Addresses are grouped by product: your lists and rows are under /operations, your team under /people, and your buying under /purchasing. Every answer has the useful part in data.
Find your lists
Start here. It tells you each list's id, and the keys of its columns, which you'll need when you read or add rows.
curl https://omnischema.app/api/v1/operations/lists \
-H "Authorization: Bearer $OMNISCHEMA_KEY"{
"data": [
{
"id": "01236d5e-8bc3-4410-8922-22768d824d67",
"name": "Jobs",
"system": { "id": "606f24e0-...", "name": "Service Desk" },
"columns": [
{ "key": "title", "label": "Title", "type": "text", "required": true, "read_only": false },
{ "key": "site", "label": "Site", "type": "text", "required": false, "read_only": false },
{ "key": "due", "label": "Due", "type": "date", "required": false, "read_only": false }
]
}
],
"next_cursor": null
}Read the rows of a list
curl "https://omnischema.app/api/v1/operations/lists/01236d5e-8bc3-4410-8922-22768d824d67/rows?limit=50" \
-H "Authorization: Bearer $OMNISCHEMA_KEY"{
"data": [
{
"id": "7f909e62-c745-431b-84df-4ec102db95af",
"list_id": "01236d5e-8bc3-4410-8922-22768d824d67",
"created_at": "2026-10-12T11:10:31.000Z",
"updated_at": "2026-10-12T11:10:31.000Z",
"values": { "title": "Boiler service", "site": "Elm Park Centre", "due": "2026-10-20" }
}
],
"next_cursor": "WyIyMDI2LTEwLTEy..."
}Each row's values uses the column keys from the list. Columns the key's role can't see simply aren't there. To fetch one row, add its id: /rows/7f909e62-....
People and Purchasing
These work the same way. For example GET /api/v1/people/people lists your team, /people/pay-periods lists your pay periods, and /people/timesheets?period=PAY_PERIOD_ID gives the timesheets in one of them, and /purchasing/purchase-orders lists your orders. For spend, ask the report: /purchasing/reports/spend?basis=ordered&group_by=supplier&start=2026-01-01&end=2026-12-31. Use basis=ordered for orders sent, the same figure as “Ordered this month” on Purchasing's home page, or basis=billed for supplier bills approved. The full list is at the bottom of this page.
The product has to be switched on for your company, and the key's role needs access to it, just as a member of staff would. Otherwise you get a 503 system_off or a 403 forbidden.
Adding and changing rows
With a read and write key you can add a row by sending its values, keyed by column, to the list's rows address:
curl -X POST https://omnischema.app/api/v1/operations/lists/LIST_ID/rows \
-H "Authorization: Bearer $OMNISCHEMA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-4b7d-4c1e-9a55-2f0e8b1d3c77" \
-d '{ "values": { "title": "Leak in plant room", "site": "Elm Park Centre", "due": "2026-10-21" } }'You get the new row back, with its id. To change a row, send a PATCH to /rows/ROW_ID with just the columns you want to change; anything you leave out stays as it was. Columns marked read_only, like an automatic job number, are filled in by OmniSchema and can't be set.
The same rules apply as in the app. If a column needs approval before it changes, the answer lists it under waiting_for_approval and the new value waits for an approver. If you send a column the list doesn't have, you get a 422 that names it.
Why the Idempotency-Key matters
Networks drop connections. When that happens you can't tell whether your request arrived, so the sensible thing is to send it again. Without protection, that could make the same job twice. Send an Idempotency-Key header with every write: any value that is unique to that one change, such as the id of the enquiry that caused it, or a fresh UUID. If we see the same key again within 24 hours, we don't make another row; we send back the answer from the first time, marked Idempotent-Replayed: true.
Adding people, leave, time and purchase requests
Read and write keys can do a handful of everyday things in People and Purchasing. The key's role still decides what's allowed: a key acting as a Manager can only book leave for people a manager could.
Add someone to People
Send their legal name, and anything else you have. If you include job, their first job is set up at the same time; get the employer_id from GET /api/v1/people/employers.
curl -X POST https://omnischema.app/api/v1/people/people \
-H "Authorization: Bearer $OMNISCHEMA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: starter-0457" \
-d '{
"legal_name": "Aisha Rahman",
"preferred_name": "Aisha",
"email": "aisha.rahman@brookside.example",
"job": { "employer_id": "EMPLOYER_ID", "job_title": "Gas engineer", "start_date": "2026-11-02", "contract_type": "permanent", "fte": "1.000" }
}'To change someone's contact details later, PATCH /api/v1/people/people/ID with preferred_name, email, phone or address. Nothing else on someone's record can be changed through the API.
Book leave or add time
POST /api/v1/people/leave takes a person_id, a leave_type (such as annual), a start_date and an end_date. The request then waits for a manager to approve it, exactly as if the person had asked in the app; a key can never approve leave. POST /api/v1/people/time-entries takes a person_id, start_at, end_at and minutes, and adds the time to that day's open timesheet.
Raise a purchase request
Send the request with its lines. Add "submit": true and it goes straight through your spending rules: small ones may be approved and ordered at once, bigger ones wait for an approver. Amounts and quantities are sent as text, like "50.00", so nothing is lost to rounding.
curl -X POST https://omnischema.app/api/v1/purchasing/requests \
-H "Authorization: Bearer $OMNISCHEMA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-4471-parts" \
-d '{
"title": "Parts for boiler repair at Elm Park",
"supplier_id": "SUPPLIER_ID",
"job_ref": "JOB-4471",
"lines": [ { "description": "Diverter valve", "quantity": "1", "uom": "EA", "unit_price": "86.50" } ],
"submit": true
}'The answer tells you where it ended up in status, and the request shows in Purchasing as made by your key. When the goods arrive, record them with POST /api/v1/purchasing/purchase-orders/ID/deliveries, listing each order line's po_line_id and the quantity_received. You'll find the line ids by reading the order first.
Long lists come a page at a time
Anything that returns a list gives you one page at a time, up to 200 items (100 for Purchasing) when you ask with ?limit=. If there's more, the answer has a next_cursor. Send it back as ?cursor= to get the next page, and keep going until next_cursor is null.
One thing that surprises people: on a very large list, a page can come back with fewer rows than you asked for, sometimes none, while next_cursor is still set. That happens when the key's role can only see some of the rows; to keep every request quick we look through at most 5,000 rows per page. It isn't an error. Just carry on with the cursor.
Only what's new
If your program checks for changes every few minutes, don't read the whole list each time. Add updated_since (or created_since) with the time of your last check, and you'll get just the people, leave, timesheets, purchase orders, requests or bills that changed after it, newest first.
curl "https://omnischema.app/api/v1/purchasing/purchase-orders?updated_since=2026-10-12T09:00:00Z" \
-H "Authorization: Bearer $OMNISCHEMA_KEY"The time you give is included, so you may see an item again that you saw last time. Keep the ids you've already handled and skip those. To find one row in an Operations list, ask for it by a column instead: /rows?column=email&value=jo@example.com.
Webhooks: being told when something changes
Asking the API “anything new?” every few minutes works, but it's wasteful. A webhook turns it round: OmniSchema sends a message to your address the moment something happens.
To set one up, open the list in Database Hub, go to its Connections, add a webhook and tick the events you want: a row being added (row.created), changed (row.updated) or deleted (row.deleted). That covers every way a row changes, whether someone edits it, an engineer closes the job on their phone, or it arrives from an import. People has its own webhooks under Settings, Connections, and Purchasing under Settings, Webhooks.
Your address must use HTTPS and be reachable from the internet. We send a POST like this:
{
"event": "row.updated",
"tenantId": "co_brookside-facilities",
"listId": "01236d5e-8bc3-4410-8922-22768d824d67",
"occurredAt": "2026-10-12T09:30:00.000Z",
"data": {
"rowId": "7f909e62-c745-431b-84df-4ec102db95af",
"values": { "title": "Boiler service", "status": "Closed" },
"changed": ["status"]
}
}Check that it really came from us
Anyone who learns your address could send it a fake message, so check the signature before you act on one. Every delivery carries these headers:
x-omnischema-timestamp: when we sent it, in seconds.x-omnischema-signature:v1=followed by an HMAC SHA-256, in hex, of the timestamp, a full stop and the raw body, made with the secret we showed you when you added the webhook.x-omnischema-eventandx-omnischema-delivery: what happened, and a unique id for this delivery.
OmniSchema, People and Purchasing all sign the same way, so one check covers all three. In Node:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body exactly as it arrived, as a string,
// before you JSON.parse it. Parsing and re-encoding changes the bytes.
export function isFromOmniSchema(secret, headers, rawBody) {
const timestamp = headers["x-omnischema-timestamp"];
const signature = headers["x-omnischema-signature"] ?? "";
// Ignore anything more than five minutes old: it could be a replay.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}And in Python:
import hashlib
import hmac
import time
# raw_body is the request body exactly as it arrived, as bytes,
# before you json.loads it.
def is_from_omnischema(secret: str, headers: dict, raw_body: bytes) -> bool:
timestamp = headers.get("x-omnischema-timestamp", "")
signature = headers.get("x-omnischema-signature", "")
# Ignore anything more than five minutes old: it could be a replay.
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)If your server is down
Answer with any 2xx status within five seconds and we'll consider it delivered. If we don't get one, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and finally 24 hours. Every attempt carries the same x-omnischema-delivery, so if you've already handled it, just answer 200 and ignore it. In the webhook's settings you can see the recent deliveries, why any failed, and send yourself a test.
Webhooks made before 12 October 2026 keep their older signature (an HMAC of the body alone, without v1=) so nothing you've already built breaks. Add the webhook again if you'd like the new one.
Adding a webhook from your own code
If you're building an integration, you can set up a webhook with the key itself instead of asking an admin to do it in Database Hub. Send POST /api/v1/hooks with the target_url, the event and the list_id, and remove it with DELETE /api/v1/hooks/ID when you no longer need it. A key can only add webhooks to lists its role can see, the messages only carry what that role can see, and revoking the key removes its webhooks too.
Limits
Each key can send a burst of 100 requests, and then 10 a second after that. That's plenty for almost anything, but a script in a tight loop can hit it. If it does, you'll get a 429 with a Retry-After header saying how many seconds to wait.
Your company also has a monthly allowance, shared between all your keys: 100,000 calls on Growth and 1,000,000 on Enterprise. Every answer tells you where you stand in two headers, X-Allowance-Limit and X-Allowance-Remaining, and Governance shows the same per key. If you run out, calls return 429 allowance_used until the 1st of the next month. If you're regularly getting close, talk to us.
When something goes wrong
Every error comes back in the same shape, with a code your program can check and a message in plain English:
{
"error": {
"code": "validation_failed",
"message": "Unknown column(s): price. GET the list to see its column keys."
}
}| Status | Code | What it means, and what to do |
|---|---|---|
| 401 | invalid_key | We don't recognise the key, or it has been revoked or has expired. Check you copied all of it. |
| 403 | forbidden | The key's role isn't allowed to see or do this. Pick a role that can, or make a new key. |
| 403 | read_only_key | A read only key tried to add or change something. |
| 403 | plan_required | Your company's plan doesn't include the API. It comes with Growth and Enterprise. |
| 404 | not_found | There's no such list, row or address, or the key's role can't see it. |
| 409 | conflict | The change clashes with how things are now, for example you've reached your plan's row limit. |
| 409 | request_in_progress | A request with the same Idempotency-Key is still being worked on. Wait a moment and try again. |
| 422 | validation_failed | Something in what you sent doesn't fit: an unknown column, a date that isn't a date. The message says what. |
| 422 | idempotency_key_reused | You used an Idempotency-Key we've seen before, with different content. Use a new key for a new request. |
| 429 | rate_limited | You're sending requests too quickly. Wait the number of seconds in the Retry-After header. |
| 429 | allowance_used | Your company has used this month's calls. They start again on the 1st, or you can upgrade. |
| 503 | system_off | People or Purchasing isn't switched on for your company. |
| 503 | unavailable | We can't reach that part of OmniSchema just now. Try again in a minute. |
A good rule of thumb: a 4xx means change something before you try again, a 429 or 503 means wait and try again.
Worked examples
Send website enquiries into your Jobs list
Use a read and write key whose role can add jobs. Look up the Jobs list's id and column keys once with GET /operations/lists, keep them in your settings, then call this from your server whenever the form is sent:
// On your web server, when someone sends your enquiry form.
// Never run this in the browser: the key would be visible to anyone.
const response = await fetch(
"https://omnischema.app/api/v1/operations/lists/" + process.env.JOBS_LIST_ID + "/rows",
{
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OMNISCHEMA_KEY,
"Content-Type": "application/json",
// One per enquiry, so a retry can never make a second job.
"Idempotency-Key": enquiry.id,
},
body: JSON.stringify({
values: {
title: enquiry.subject,
site: enquiry.address,
description: enquiry.message,
},
}),
},
);
if (!response.ok) {
const { error } = await response.json();
console.error("OmniSchema said no:", error.code, error.message);
}Collect a pay period's timesheets for payroll
A read only key whose role can see timesheets is all you need. This walks every page so nothing gets missed:
import os
import requests
KEY = os.environ["OMNISCHEMA_KEY"]
BASE = "https://omnischema.app/api/v1"
headers = {"Authorization": f"Bearer {KEY}"}
# Pick the pay period you want. Here we simply take the first one listed.
periods = requests.get(f"{BASE}/people/pay-periods", headers=headers, timeout=30)
periods.raise_for_status()
period_id = periods.json()["data"][0]["id"]
# Fetch every page of that period's timesheets.
sheets, cursor = [], None
while True:
params = {"period": period_id, "limit": 200}
if cursor:
params["cursor"] = cursor
page = requests.get(f"{BASE}/people/timesheets", headers=headers, params=params, timeout=30)
page.raise_for_status()
body = page.json()
sheets.extend(body["data"])
cursor = body["next_cursor"]
if not cursor:
break
print(f"{len(sheets)} timesheets to send to payroll")Text a customer when their job closes
Add a webhook on your Jobs list for row.updated. When a delivery arrives, check the signature, then look at data.changed: if it includes your status column and the new value is “Closed”, send your text. Answer 200 straight away and do the slow part afterwards, so we don't time out and send it again.
Every endpoint
The complete description, with every parameter and every field in every answer, is at /api/v1/openapi.json. It's a standard OpenAPI 3.1 file, so you can load it into Postman or Insomnia to try calls, or into a code generator.
Account
Operations
People
Purchasing
Hooks
Getting help
Stuck, or not sure the API can do what you need? Email hello@omnischema.app and tell us what you're trying to build. We read every email ourselves, and if the API is missing something you need, we'd genuinely like to know.
We won't change how this version works in a way that breaks your code. If we ever have to, we'll email your admins well before it happens.