OmniSchema
DEVELOPERS

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

New jobs from your website
Your enquiry or booking form sends what the customer typed straight into your Jobs list, so nobody copies emails into OmniSchema any more.
Hours into payroll
When the pay period is approved, your payroll system reads the timesheets itself instead of someone retyping them on a Friday afternoon.
Spend in your own reports
Pull purchase orders and approved bills into Power BI or a spreadsheet, so finance can see where the money goes without signing in.
A nudge when something changes
A webhook tells another system the moment a job is added, changed or closed, so it can send a text, update a dashboard or start its own process.

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

  1. Sign in to OmniSchema as an admin and open Governance, then API keys. Choose Create key.
  2. 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.
  3. 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.
  4. 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.

Treat a key like a password. Keep it on your server, in an environment variable or a secrets manager. Never put it in a web page, a phone app or a shared spreadsheet, where anyone could read it. If you think one has leaked, revoke it and make another. It takes seconds.

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-event and x-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."
  }
}
StatusCodeWhat it means, and what to do
401invalid_keyWe don't recognise the key, or it has been revoked or has expired. Check you copied all of it.
403forbiddenThe key's role isn't allowed to see or do this. Pick a role that can, or make a new key.
403read_only_keyA read only key tried to add or change something.
403plan_requiredYour company's plan doesn't include the API. It comes with Growth and Enterprise.
404not_foundThere's no such list, row or address, or the key's role can't see it.
409conflictThe change clashes with how things are now, for example you've reached your plan's row limit.
409request_in_progressA request with the same Idempotency-Key is still being worked on. Wait a moment and try again.
422validation_failedSomething in what you sent doesn't fit: an unknown column, a date that isn't a date. The message says what.
422idempotency_key_reusedYou used an Idempotency-Key we've seen before, with different content. Use a new key for a new request.
429rate_limitedYou're sending requests too quickly. Wait the number of seconds in the Retry-After header.
429allowance_usedYour company has used this month's calls. They start again on the 1st, or you can upgrade.
503system_offPeople or Purchasing isn't switched on for your company.
503unavailableWe 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

GET/api/v1/api/v1/openapi.jsonThis description of the API (no key needed)
GET/api/v1/api/v1/meThe key, its company and this month's allowance

Operations

GET/api/v1/api/v1/operations/listsLists this key's role can open
GET/api/v1/api/v1/operations/lists/{listId}/rowsRows of a list, oldest first, as this key's role sees them
POST/api/v1/api/v1/operations/lists/{listId}/rowsAdd a row (Read and write keys only)
GET/api/v1/api/v1/operations/lists/{listId}/rows/{rowId}One row
PATCH/api/v1/api/v1/operations/lists/{listId}/rows/{rowId}Change the columns you send; the rest stay as they are (Read and write keys only)

People

GET/api/v1/api/v1/people/peoplePeople, newest first
POST/api/v1/api/v1/people/peopleAdd a person, with their first job if you like (Read and write keys only)
GET/api/v1/api/v1/people/people/{id}One person
PATCH/api/v1/api/v1/people/people/{id}Change a person's contact details (Read and write keys only)
GET/api/v1/api/v1/people/employersEmployers (legal entities), for a new person's job
GET/api/v1/api/v1/people/timesheetsTimesheets, newest first
GET/api/v1/api/v1/people/leaveLeave requests, newest first
POST/api/v1/api/v1/people/leaveRequest leave for a person (Read and write keys only)
POST/api/v1/api/v1/people/time-entriesAdd time to a person's open timesheet for that day (Read and write keys only)
GET/api/v1/api/v1/people/absencesAbsences
GET/api/v1/api/v1/people/qualificationsQualifications
GET/api/v1/api/v1/people/pay-periodsPay periods
GET/api/v1/api/v1/people/pay-periods/{id}/exportDownload a pay period's export (only if the key's role may export pay)

Purchasing

GET/api/v1/api/v1/purchasing/suppliersSuppliers
GET/api/v1/api/v1/purchasing/entitiesBuying entities, for a purchase request (needs a role that manages entities)
GET/api/v1/api/v1/purchasing/purchase-ordersPurchase orders, newest first
GET/api/v1/api/v1/purchasing/purchase-orders/{id}One purchase order, with its lines
POST/api/v1/api/v1/purchasing/purchase-orders/{id}/deliveriesRecord a delivery against an order's lines (Read and write keys only)
GET/api/v1/api/v1/purchasing/requestsRequests to buy, newest first
POST/api/v1/api/v1/purchasing/requestsCreate a request to buy, and send it for approval if you like (Read and write keys only)
GET/api/v1/api/v1/purchasing/billsBills, newest first
GET/api/v1/api/v1/purchasing/reports/spendSpend, grouped

Hooks

GET/api/v1/api/v1/hooksThe hooks this key made
POST/api/v1/api/v1/hooksBe told when a row is added, changed or deleted in a list (any key)
DELETE/api/v1/api/v1/hooks/{hookId}Remove a hook this key made

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.

Get started freeSee the plans