Docs · API reference

The REST API.

Your audit trail, library, tool registry, usage and connected clients over plain HTTP. JSON in, JSON out, one bearer key, no SDK required.

https://omniio.dev/api/v1

Overview

Every endpoint is under https://omniio.dev/api/v1, takes and returns application/json, and is scoped to the account the key belongs to. There is no account or workspace id in any path — the key is the scope.

The API is deliberately narrow. Five resources are readable and exactly one field is writable, because the things worth doing from a script are reporting and deployment checks, and the things that hand Omniio a credential belong behind a session where a person can see what they are connecting.

GET /activity
Tool calls, newest first, as far back as your retention reaches.
GET /servers
Your library, with each server's real resolved status.
GET · PATCH /servers/{slug}
One server, and the one thing about it a program may change.
GET /tools
Every qualified tool name your endpoint currently answers.
GET /usage
This month's calls, the plan behind them, and when the counter rolls.
GET /clients
Clients holding a credential for this account, and ones that ever called.

Authentication

One header. Keys are created at settings → API keys, shown once, and stored as a SHA-256 digest — we cannot show you an existing key, only issue another.

bash
curl -H "Authorization: Bearer omn_…" \
"https://omniio.dev/api/v1/usage"
Authorizationrequired
Bearer omn_…. A missing or unparseable header answers 401 with WWW-Authenticate: Bearer.
Content-Typewrites only
application/json on PATCH. Reads take no body.
The OAuth token your MCP client holds is not accepted here. It is audience-bound to https://mcp.omniio.dev under RFC 8707, and honouring that binding is the whole point of having it — a token minted for one resource must not be spendable at another. Keys work the other way round too: an Omniio API key cannot call the MCP endpoint.

Every request stamps its key’s last used time, so a key nothing has touched in months is visible as such on the settings screen. Revoking one takes effect on the next request.

Rate limits

Counted per key, per minute, in a fixed window aligned to the clock. Every plan’s ceiling:

Free
120 requests a minute per key · audit retention 7 days
Pro
300 requests a minute per key · audit retention 30 days
Scale
600 requests a minute per key · audit retention 3 months
Business
1,200 requests a minute per key · audit retention 6 months
Enterprise
Negotiated. No counter runs and no rate-limit headers are sent.

This is a different budget from the burst limit your agents call through, on purpose. That one is per account because it protects the upstream servers everybody shares; this one is per key because nothing behind a read is shared — it is your own rows out of your own database. Counting them together would let a script looping on /activity refuse an agent’s tool call, which is the opposite of what a limit is for. API requests are not billed as calls.

Every metered response carries where you stand:

http
HTTP/1.1 200 OK
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 1760000040
X-RateLimit-Limitinteger
Requests this key may make in a window.
X-RateLimit-Remaininginteger
How many are left in the current one. Floors at zero rather than going negative.
X-RateLimit-Resetinteger
When the window rolls, as seconds since the Unix epoch — the same convention GitHub and Stripe use, so existing back-off code reads it without a special case.
Retry-Afterinteger
Seconds to wait. Sent only with a 429.

Over the line, the answer is 429 with the wait in both a header and the body:

json
{
"error": "rate_limited",
"message": "Rate limit reached: the Pro plan serves 300 API requests per minute per key, and this key has spent this minute's. Retry after 12 seconds. This limit is per key and separate from your MCP burst limit — your agents are unaffected, and nothing was billed. Raise it at https://omniio.dev/pricing.",
"retryAfter": 12
}

Errors

Every failure has the same shape: a stable error code to branch on and a message written for whoever has to work out why the nightly sync stopped. The HTTP status agrees with the code; neither is ever the only signal.

json
{
"error": "invalid_request",
"message": "\"limit\" must be a whole number between 1 and 200."
}
unauthorized401
No bearer credential, or one that is not a live Omniio key. Revoked keys answer this too.
invalid_request400 · 409
A parameter or body field is wrong (400), or the change asked for conflicts with the state of the thing (409). The message names the field.
not_found404
No such resource on this account. Slugs are checked against your library, so a server you have not added reads as missing rather than forbidden.
rate_limited429
This key has spent its minute. Wait the number of seconds in Retry-After and repeat the request unchanged.
method_not_allowed405
The endpoint does not answer that verb. The Allow header lists the ones it does.
server_error500
Something broke on our side. Safe to retry; if it persists, tell us and quote the time.

429 is the only status worth retrying automatically, and only after the wait it names. Retrying a 400 unchanged will fail identically.

Pagination

Two schemes, because two different kinds of set are being walked.

Cursors, for the audit trail

/activity returns nextCursor: pass it back as ?cursor= for the next page, and stop when it is null. The cursor is the timestamp of the oldest row you were given, so new calls arriving mid-walk cannot shift rows into a page you have already read — which is exactly what an offset would do on a table that grows while you page through it.

Offsets, for the registry

/tools returns nextOffset and total. This set is small, finite and not appended to while you read it, so an offset is honest here and simpler to resume.

Neither, for the short lists

/servers and /clients return everything with a total beside it. Both are lists in the low hundreds at most; saying so plainly beats leaving you to infer it from a missing cursor.

Out-of-range parameters are refused, not clamped. Asking for limit=10000 answers 400 rather than quietly serving 200 rows — being handed a hundredth of what you asked for and told nothing is how a report ends up wrong.

GET/activity

Tool calls on this account, newest first. The same rows the activity screen shows and the same rows a webhook delivers, so a receiver that missed a delivery catches up here.

bash
curl -H "Authorization: Bearer omn_…" \
"https://omniio.dev/api/v1/activity?limit=50&client=claude-code"

Query parameters

limitinteger · default 50
Rows to return, 1 to 200.
cursorISO 8601
The previous response's nextCursor. Anything unparseable as a date is refused.
clientstring
Narrow to one client id, as reported by /clients.

Response

json
{
"data": [
{
"id": "0f4a6f0c-1b7e-4a3f-9c21-6d8a1f5b2c34",
"createdAt": "2026-01-14T09:31:04.812Z",
"clientId": "claude-code",
"server": "GitHub",
"serverSlug": "github",
"tool": "create_pull_request",
"qualifiedName": "github__create_pull_request",
"ok": true,
"durationMs": 812,
"error": null,
"arguments": {
"owner": "acme",
"repo": "web",
"title": "Fix login"
},
"result": {
"url": "https://github.com/acme/web/pull/412"
}
}
],
"nextCursor": "2026-01-14T09:12:55.104Z",
"retentionDays": 30
}
data[]array
The calls. Fields below.
nextCursorstring | null
Pass back as ?cursor= for the next page. Null at the end of the window.
retentionDaysinteger
How far back this plan serves. Rows older than this are pruned nightly and are never returned, so this is how far a full walk can reach.

A call

idstring
The audit row's UUID. Stable, and unique across the account — use it to deduplicate.
createdAtstring
ISO 8601 UTC, when the call finished. This is the value nextCursor pages on.
clientIdstring | null
Which connected client made the call. Null for calls made before client identification, or from a client that registered without one.
serverstring
The upstream server's display name, as the library shows it.
serverSlugstring
Its slug — the half of a qualified name before the separator, and what ?server= takes.
toolstring
The upstream tool's own name, unqualified.
qualifiedNamestring
The name your agent called, "slug__tool".
okboolean
Whether the upstream returned a result. A refusal by Omniio — quota, burst limit, a policy — is false with the reason in error.
durationMsnumber | null
How long the upstream took, in milliseconds. Null where the call never reached one.
errorstring | null
The failure, in a sentence. Null when ok is true.
argumentsobject | string | null
What was sent upstream, parsed. A payload past the 64 KB storage cap arrives as a string with a sentence saying where it was cut.
resultobject | string | null
What came back, under the same rule.
Discovery — search_tools and describe_tool — is counted for billing but not stored as audit rows. This endpoint is a record of what your agent did, not of what it looked at, so its counts will not match /usage.

Servers

GET/servers

Your library, with defaults already applied: a server you have never explicitly toggled reports the status it would actually answer with, not the catalog’s opinion of it. That is the difference between an API a deployment check can trust and one that has to reimplement the resolution rules to be believed.

Query parameters

status"enabled" | "pending" | "disabled"
Filter by resolved status. Anything else is refused.
categorystring
Filter by catalog category, as it appears on any server.

Response

json
{
"data": [
{
"slug": "github",
"name": "GitHub",
"description": "Repositories, issues and pull requests.",
"category": "development",
"status": "enabled",
"authType": "oauth",
"endpoint": "https://api.githubcopilot.com/mcp/",
"toolCount": 41,
"lastAttemptAt": "2026-01-14T09:30:58.204Z",
"lastError": null,
"owned": false
}
],
"total": 1
}
slugstring
The stable identifier. This is what a qualified tool name is built from and what the path takes.
namestring
Display name.
descriptionstring
One line, from the catalog.
categorystring
The catalog category. Any value here is valid as ?category=.
status"enabled" | "pending" | "disabled"
As this account would actually answer, defaults resolved. Pending means it is on but has not been catalogued yet.
authType"none" | "bearer" | "oauth"
What it takes to connect. Only none can be switched with PATCH.
endpointstring
The upstream MCP URL Omniio calls.
toolCountnumber
Tools in the cached catalog. Zero on a server that has never been reached.
lastAttemptAtstring | null
ISO 8601, the last time Omniio tried to catalogue it.
lastErrorstring | null
Why that attempt failed, if it did. This is the field a health check should read.
ownedboolean
True for a server this account added itself rather than one from the catalog.

GETPATCH/servers/{slug}

GET returns one server in the shape above, or 404 if this account has no server by that slug.

PATCH is the API half of the library toggle and shares its implementation, so enabled means exactly what it means on the screen. The response is a fresh read of the server, not an echo of what you sent.

bash
curl -X PATCH \
-H "Authorization: Bearer omn_…" \
-H "Content-Type: application/json" \
-d '{"enabled": true}' \
"https://omniio.dev/api/v1/servers/astro-docs"

Body

enabledboolean · required
The only field this endpoint changes. Any other key is ignored; a body without this one is refused.
Only servers whose authType is none can be switched this way. Anything that needs a token or an OAuth grant answers 409 invalid_request — it is enabled by connecting a credential, and there is no way to hand a secret to an endpoint like this that would not amount to accepting credentials in a JSON body.

Enabling a server starts a catalog refresh in the background. A server that has never been reached comes back pending with toolCount: 0; poll GET until status is enabled, or read lastError to see why it will not be.

GET/tools

Every qualified tool name your endpoint currently answers — the registry screen’s data, without having to speak MCP to ask for it. This is what makes does our deployment still expose github__create_pull_request a question you can fail a build over.

Served from the cache, with any stale server refreshed after the response is sent: a caller polling this gets an answer in milliseconds rather than waiting on however many upstreams are cold.

Query parameters

limitinteger · default 100
Tools to return, 1 to 500.
offsetinteger · default 0
How many to skip, up to 100,000.
serverstring
Narrow to one server slug.
schemaboolean · default false
Set to "true" to include each tool’s full JSON input schema. Off by default because it is a large body and almost never what a list is for.

Response

json
{
"data": [
{
"qualifiedName": "github__create_pull_request",
"name": "create_pull_request",
"serverSlug": "github",
"server": "GitHub",
"title": "Create pull request",
"description": "Opens a pull request on a repository."
}
],
"total": 214,
"nextOffset": 100
}
data[].qualifiedNamestring
The name an agent calls, "slug__tool".
data[].namestring
The upstream tool's own name.
data[].serverSlugstring
Which server it comes from.
data[].serverstring
That server's display name.
data[].titlestring | null
The upstream's human title, where it publishes one.
data[].descriptionstring | null
What the tool does, as the upstream describes it.
data[].inputSchemaobject
JSON Schema for the tool's arguments. Present only when ?schema=true.
totalinteger
Tools matching the filter, before limit and offset.
nextOffsetinteger | null
Pass as ?offset= for the next page. Null on the last one.

GET/usage

What this account has spent this month and what it is allowed — the number on the settings screen, reachable from a dashboard or an alerting rule. Team-aware in the same way the screen is: a member reads the team’s pooled total, because that is the number that will actually stop their agents.

json
{
"plan": {
"id": "pro",
"name": "Pro",
"includedCalls": 50000,
"hardLimit": false,
"overageEurPer1k": 2,
"burstPerMinute": 120,
"apiPerMinute": 300,
"auditRetentionDays": 30
},
"period": "2026-01",
"calls": 18402,
"includedCalls": 50000,
"blocked": false,
"resetsAt": "2026-02-01T00:00:00.000Z",
"team": null
}
planobject
The plan this account is on, with every published ceiling on it.
plan.includedCallsinteger | null
Calls included each month. Null on a negotiated plan.
plan.hardLimitboolean
True where exceeding the allowance refuses calls rather than billing them.
plan.overageEurPer1knumber | null
What a thousand calls past the allowance costs, in euro. Null where there is no overage.
plan.burstPerMinuteinteger | null
MCP calls a minute, per account. This is the agents' limit, not this API's.
plan.apiPerMinuteinteger | null
REST requests a minute, per key. The limit this endpoint is counted against.
plan.auditRetentionDaysinteger
How long tool calls are kept.
periodstring
The billing month being reported, "YYYY-MM".
callsinteger
Calls made in it so far, pooled across the team where there is one.
includedCallsinteger | null
The allowance in force, which is the plan's unless a negotiated one overrides it.
blockedboolean
True when a hard-limited plan has spent its allowance and is refusing calls right now. This is the field an alert should watch.
resetsAtstring
ISO 8601, when the counter rolls.
teamstring | null
The team the allowance is pooled with, or null on a solo account.

GET/clients

Every client that holds a credential for this account, or ever reached the endpoint. Both halves are here for the reason they are on the connections screen: a client that authorized and never called still holds a key, and a revoked one stays listed so the revocation is checkable rather than merely absent.

json
{
"data": [
{
"clientId": "claude-code",
"label": "Claude Code",
"registeredName": "Claude Code",
"active": true,
"authorizedAt": "2026-01-02T11:04:21.000Z",
"firstSeenAt": "2026-01-02T11:04:44.000Z",
"lastSeenAt": "2026-01-14T09:31:04.000Z",
"revokedAt": null,
"calls": 1284,
"lastCallAt": "2026-01-14T09:31:04.812Z"
}
],
"total": 1,
"retentionDays": 30
}

Query parameters

active"true" | "false"
Filter to live credentials or to revoked ones. Omit for both.

A client

clientIdstring
The OAuth client id. This is the value /activity's clientId matches.
labelstring | null
The name you gave it, if any.
registeredNamestring | null
The name the client registered itself under.
activeboolean
False once revoked.
authorizedAtstring | null
ISO 8601, when it completed the OAuth flow.
firstSeenAtstring | null
When it first called the endpoint. Null on a client that authorized and never called — worth reviewing.
lastSeenAtstring | null
When it last called.
revokedAtstring | null
When its credential was retired.
callsinteger
Tool calls attributed to it inside the retention window, so this figure moves as old rows are pruned.
lastCallAtstring | null
The most recent one.
Read-only, deliberately. Revoking a client through an API key would mean one credential can retire another, which is a privilege the key was never issued with — that stays behind a session at settings → connections.

CORS

Every endpoint answers Access-Control-Allow-Origin: * and handles OPTIONS, with a preflight cached for 10 minutes. The rate-limit headers and Retry-After are on the expose list, so a browser client can read them.

Access-Control-Allow-Credentials is deliberately absent. There is no cookie and no ambient credential in play — every request carries a bearer key explicitly — so a page that has not been given one cannot borrow anybody’s session by calling from a browser.

That a browser call works does not make it a good idea. A key in front-end code is a key you have published. Call this from a server, or from a browser extension that holds the key outside the page.

TypeScript client

One file, no dependencies, no build step. It runs anywhere fetch exists — Node 18+, Deno, Bun, Cloudflare Workers — and it handles the two things that are actually awkward about this API: walking a cursor to the end of the retention window, and backing off when a key’s minute is full, using the server’s own Retry-After rather than an invented curve.

It is not published to a package registry. Download it into your project and it is yours — you can read all of it in one sitting, which is more than can be said for most generated clients:

bash
curl -O https://omniio.dev/sdk/omniio.ts
sync.ts
import { Omniio, OmniioError } from "./omniio";
const omniio = new Omniio({ apiKey: process.env.OMNIIO_API_KEY! });
// One page, newest first.
const page = await omniio.activity({ limit: 50 });
// Or the whole retention window, a page at a time, cursor handled for you.
for await (const call of omniio.activityAll()) {
if (!call.ok) console.warn(call.qualifiedName, call.error);
}
try {
await omniio.setServerEnabled("astro-docs", true);
} catch (error) {
if (error instanceof OmniioError && error.code === "invalid_request") {
// That server needs a credential, which is connected in the app.
}
}
activity(params)
One page. Returns data, nextCursor and retentionDays.
activityAll(params)
An async iterator over every call, following the cursor. Yields rows rather than pages, because a busy month held in one array is how a warehouse sync runs out of memory.
servers(params)
The library, filtered by status or category.
server(slug)
One server.
setServerEnabled(slug, enabled)
The PATCH, returning the server as it now stands.
tools(params)
One page of the registry.
toolsAll(params)
An async iterator over every tool, following the offset.
usage()
This month's counts and the plan behind them.
clients(params)
Connected clients.

Failures arrive as an OmniioError carrying code, status and, on a 429, retryAfter — so your retry logic branches on the code while your logs get the sentence. Constructor options are apiKey, baseUrl, fetch and maxRetries (three by default; zero throws the rate limit straight through).

Webhooks: the same rows, pushed →