API keys

An API key is the credential that authenticates every request to this API. It is pinned to one team, carries a set of abilities, and can be restricted to specific ontologies. Keys are a resource of the API itself, so an integration can mint the credential its own workers use, narrow one that turned out to be over-granted, revoke a colleague's when they leave, and — most importantly — rotate itself on a schedule with no human in the console.

The API key model

  • Name
    id
    Type
    string
    Description

    Unique identifier. Unlike every other object in this API it carries no type prefix — keys predate the Snowflake ids. It is a decimal integer rendered as a string, because a bare JSON number would be parsed into a float by enough clients to matter. Safe to log.

  • Name
    object
    Type
    string
    Description

    Always api_key.

  • Name
    name
    Type
    string
    Description

    Human label, at most 255 characters. Not unique and not addressable — it exists so an operator reading the list can tell one credential from another.

  • Name
    secret
    Type
    string
    Description

    The plaintext key, synex_sk_ followed by a random string. Present only on create, roll and self-roll — every other response omits the field entirely. Omitted, not null: a null would read as "this key has no secret", and a client that stored it would overwrite a working credential with nothing.

  • Name
    abilities
    Type
    array
    Description

    The abilities this key grants. Always a list, empty for a key that grants nothing — such a key still authenticates and can still roll itself, but every gated endpoint answers 403 insufficient_permissions.

  • Name
    scopes
    Type
    object
    Description

    Per-ability ontology restrictions, as { "<ability>": { "ontologies": [...] } }. Null when the key is unrestricted.

  • Name
    metadata
    Type
    object
    Description

    Caller-owned metadata bag. Always present, {} when empty.

  • Name
    last_used_at
    Type
    string
    Description

    When this key last authenticated a request, or null if it never has. The field to sweep for — a null or long-stale value is a credential nobody is using.

  • Name
    expires_at
    Type
    string
    Description

    When the key stops authenticating, or null for a perpetual key. Set at creation and never movable afterwards — PATCH ignores it. Rolling writes it on the retiring key, to the end of the overlap window.

  • Name
    rolled_at
    Type
    string
    Description

    When this key was rolled and began its overlap window. Null on a key that has never been rolled, including a freshly minted replacement.

  • Name
    rolled_to
    Type
    string
    Description

    The id of the replacement issued for this key, or null. Names the most recent replacement when a key has been rolled more than once.

  • Name
    rolled_from
    Type
    string
    Description

    The id of the key this one replaced, or null for a key created rather than rolled.

  • Name
    created_at
    Type
    string
    Description

    ISO 8601 timestamp of creation.

The security model

A session in the Synex application belongs to a human who already holds every ability their team has, so the console's key page has nothing to constrain. This surface has no such luxury: the thing creating a key is itself a key. A create endpoint that did not constrain its result would be a privilege-escalation primitive — one leaked read-only key would be one POST away from an unlimited one, and the whole ability system would be advisory rather than enforced.

One rule governs everything here: a key may never produce a key broader than itself, along any axis. Four constraints implement it.

Abilities are subset-constrained

Every entry in abilities must be one the calling key already holds. Anything else is 400 ability_not_grantable with param set to abilities. The same check runs on PATCH, so a key cannot raise another key above its own set either.

Rolling is subset-constrained too

This is easy to mistake for excess caution, and it is not. Rolling returns the replacement's secret, so being able to roll a key is exactly equivalent to being handed that key's abilities. Without the check the subset rule on create would be decorative: a key holding api_keys:write and nothing else could not mint passports:write, but could roll a team key that has it and read the new secret straight out of the response.

So the same question create asks — could the caller have granted this? — is asked of the target's existing abilities and scopes. If the answer is no the request is refused with 403 key_not_rollable on api_key, and nothing is written: the target keeps its secret and its rolled_at stays null.

The status code differs from the create rule on purpose. Minting an ability you do not hold is a bad request (400); rolling a key you are not entitled to is a permission failure (403). Rolling yourself is always allowed and skips the check entirely.

Scopes narrow, never widen

If the calling key is ontology-scoped on an ability, a key it creates must name a subset of the caller's ontologies for that ability. Refusals are 400 scope_not_grantable on scopes.

The case worth internalising: omitting the scope is rejected too. An unscoped ability reaches every ontology in the team, so silence is the widest possible request rather than the narrowest. A caller restricted to one ontology cannot mint an unrestricted passports:write key by leaving scopes out, and cannot unscope an existing one by sending "scopes": null on a PATCH.

A child may not outlive its parent

Calling keyexpires_at on create
ExpiresRequired, and no later than the caller's own deadline
PerpetualOptional; if given, at most two years out

A perpetual child of a two-week key is a two-week key with an indefinite escape hatch — revoking the parent would leave the child running. Every rejection here is 400 parameter_invalid on expires_at, whatever the reason: malformed, in the past, beyond the caller's deadline, or omitted where required.

Expiry is set once. PATCH does not accept it — a deadline that can be pushed back is not a deadline. Sending expires_at on a PATCH is ignored, not rejected, so do not read the 200 as confirmation that it moved.

The team is inherited, never requested

There is no team field on any request here. A created key is pinned to the calling key's team, so a key cannot mint into a tenant it cannot already reach. Sending team or team_id in the body changes nothing — the value is ignored.

Team-scoped here, per-user in the console

Synex application/v1/api_keys
ListsOnly the keys you createdEvery key on the team
Can revokeOnly your ownAny key on the team

Both are correct for what they are. The console page is a personal credential-management screen; the API is a team-administration surface. Scoping the API per-user would mean a departed colleague's key could never be revoked by an integration, which is exactly when you most want to.

Two consequences: GET /v1/api_keys will normally return more rows than the console shows you, so do not treat the console as the authoritative inventory; and a key with api_keys:write can revoke any of the team's keys, including yours and including itself. That is the main reason the ability is flagged sensitive and sits outside the default set.

A key belonging to another team is 404 resource_missing on every addressable route, never 403 — a team must not be able to probe whether an id exists in another tenant. A non-numeric id reads the same way.


GET/v1/api_keys

List API keys

Every API key belonging to the team, newest first. Requires api_keys:read, which is not in a key's default ability set. Returns a paginated list.

Secrets are omitted from every row — the secret key is absent, not null.

last_used_at is the field to sweep for. A key with a null or long-stale value is one nobody is using, and a key nobody is using is a credential you are carrying for no reason.

Optional attributes

  • Name
    limit
    Type
    integer
    Description

    Page size, 1–100 (default 10).

  • Name
    cursor
    Type
    string
    Description

    A pagination cursor from a previous response's next_cursor.

Request

GET
/v1/api_keys
curl https://api.synexcloud.com/v1/api_keys \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "object": "list",
  "data": [
    {
      "id": "4418",
      "object": "api_key",
      "name": "Kestrel ingest worker",
      "abilities": [
        "passports:read",
        "passports:write",
        "imports:read",
        "imports:write"
      ],
      "scopes": {
        "passports:write": {
          "ontologies": ["ontology_7d1f4a2b9c3e5081"]
        }
      },
      "metadata": { "service": "kestrel-ingest" },
      "last_used_at": "2026-07-28T09:12:44Z",
      "expires_at": "2027-01-14T00:00:00Z",
      "rolled_at": null,
      "rolled_to": null,
      "rolled_from": "4401",
      "created_at": "2026-07-14T11:03:19Z"
    },
    {
      "id": "4402",
      "object": "api_key",
      "name": "Kestrel reporting reader",
      "abilities": ["passports:read", "ontologies:read"],
      "scopes": null,
      "metadata": {},
      "last_used_at": null,
      "expires_at": null,
      "rolled_at": null,
      "rolled_to": null,
      "rolled_from": null,
      "created_at": "2026-06-02T08:41:55Z"
    }
  ],
  "has_more": false,
  "next_cursor": null,
  "url": "/v1/api_keys"
}

POST/v1/api_keys

Create an API key

Mints a key and returns its secret. Requires api_keys:write — a sensitive ability, excluded from the default set and granted deliberately.

This response is one of only three that ever carries secret, alongside the two roll operations. Capture it now: only a hash is stored, so no later read can return it and there is no recovery path.

Every request here is subject to the four minting constraints.

Required attributes

  • Name
    name
    Type
    string
    Description

    Human label for the key, at most 255 characters.

  • Name
    abilities
    Type
    array
    Description

    The abilities the new key will hold, at least one. Must be a subset of the calling key's own, or the request is 400 ability_not_grantable.

Optional attributes

  • Name
    scopes
    Type
    object
    Description

    Per-ability ontology restrictions. Required — and required to be a subset — for any ability the caller is itself scoped on.

  • Name
    expires_at
    Type
    string
    Description

    When the key stops authenticating, as ISO 8601. Optional when the calling key is perpetual, in which case at most two years out. Required when the calling key expires, and then no later than the caller's own deadline.

  • Name
    metadata
    Type
    object
    Description

    Caller-owned metadata bag.

Request

POST
/v1/api_keys
curl https://api.synexcloud.com/v1/api_keys \
  -H "Authorization: Bearer $SYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Kestrel ingest worker",
    "abilities": ["passports:read", "passports:write", "imports:read", "imports:write"],
    "scopes": { "passports:write": { "ontologies": ["ontology_7d1f4a2b9c3e5081"] } },
    "expires_at": "2027-01-14T00:00:00Z",
    "metadata": { "service": "kestrel-ingest" }
  }'

Response

{
  "id": "4418",
  "object": "api_key",
  "name": "Kestrel ingest worker",
  "secret": "synex_sk_9f4b2e7a0c6d1358a2e5b8c3d0f7469125ae8b3c",
  "abilities": [
    "passports:read",
    "passports:write",
    "imports:read",
    "imports:write"
  ],
  "scopes": {
    "passports:write": {
      "ontologies": ["ontology_7d1f4a2b9c3e5081"]
    }
  },
  "metadata": { "service": "kestrel-ingest" },
  "last_used_at": null,
  "expires_at": "2027-01-14T00:00:00Z",
  "rolled_at": null,
  "rolled_to": null,
  "rolled_from": null,
  "created_at": "2026-07-28T09:12:44Z"
}

GET/v1/api_keys/:id

Retrieve an API key

Reads one of the team's keys. Requires api_keys:read.

The secret key is absent from this response — not null.

Any key on the calling key's team is addressable here, including one a colleague created. An id belonging to another team reads as 404 resource_missing rather than 403, so this endpoint cannot be used to probe whether an id exists in another tenant. A non-numeric id is also 404 rather than a server error.

Request

GET
/v1/api_keys/4418
curl https://api.synexcloud.com/v1/api_keys/4418 \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "id": "4418",
  "object": "api_key",
  "name": "Kestrel ingest worker",
  "abilities": [
    "passports:read",
    "passports:write",
    "imports:read",
    "imports:write"
  ],
  "scopes": {
    "passports:write": {
      "ontologies": ["ontology_7d1f4a2b9c3e5081"]
    }
  },
  "metadata": { "service": "kestrel-ingest" },
  "last_used_at": "2026-07-28T09:12:44Z",
  "expires_at": "2027-01-14T00:00:00Z",
  "rolled_at": null,
  "rolled_to": null,
  "rolled_from": "4401",
  "created_at": "2026-07-14T11:03:19Z"
}

PATCH/v1/api_keys/:id

Update an API key

Changes a key's name, abilities, ontology scopes or metadata. Requires api_keys:write. Fields you omit are left alone; metadata merges per key, with a null value deleting that key.

There is no expires_at here, deliberately. Sending one is ignored rather than rejected, and the stored value is unchanged — do not read a 200 as confirmation that the expiry moved.

The subset rules from create apply again whenever a patch touches authority. A patch that touches only name or metadata skips those checks, so a caller holding fewer abilities than the key it is labelling can still write metadata onto it. It cannot raise it, which is the property that matters.

Abilities and scopes are validated as a pair even when you send only one of them, because dropping an ability would otherwise strand the scope that narrowed it. Removing a scoped ability while leaving its scope stored is 400 parameter_invalid on scopes — send abilities and scopes together to make that change.

Changes take effect on the key's next request. There is no propagation delay, and no way to re-widen a key you have just narrowed except by patching it again.

Optional attributes

  • Name
    name
    Type
    string
    Description

    Replacement label, at most 255 characters.

  • Name
    abilities
    Type
    array
    Description

    Replaces the ability set outright — this is not a merge. Must still be a subset of the calling key's own.

  • Name
    scopes
    Type
    object
    Description

    Replacement ontology scopes. An ontology-scoped caller cannot widen a target's scopes, including by sending null, which is an unscoping and therefore a widening.

  • Name
    metadata
    Type
    object
    Description

    Merged per key; a null value deletes that key.

Request

PATCH
/v1/api_keys/4418
curl -X PATCH https://api.synexcloud.com/v1/api_keys/4418 \
  -H "Authorization: Bearer $SYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Kestrel ingest worker (EU)",
    "abilities": ["passports:read", "passports:write"],
    "scopes": { "passports:write": { "ontologies": ["ontology_7d1f4a2b9c3e5081"] } },
    "metadata": { "region": "eu-west" }
  }'

Response

{
  "id": "4418",
  "object": "api_key",
  "name": "Kestrel ingest worker (EU)",
  "abilities": ["passports:read", "passports:write"],
  "scopes": {
    "passports:write": {
      "ontologies": ["ontology_7d1f4a2b9c3e5081"]
    }
  },
  "metadata": {
    "service": "kestrel-ingest",
    "region": "eu-west"
  },
  "last_used_at": "2026-07-28T09:12:44Z",
  "expires_at": "2027-01-14T00:00:00Z",
  "rolled_at": null,
  "rolled_to": null,
  "rolled_from": "4401",
  "created_at": "2026-07-14T11:03:19Z"
}

DELETE/v1/api_keys/:id

Revoke an API key

Revokes a key. Requires api_keys:write.

This is a hard delete, and it is immediate — the very next request carrying that secret fails with 401. Revocation is only worth anything if the credential stops resolving, so there is no "revoked" status and no grace period. There is no undo: a revoked key cannot be restored, and creating a replacement means a new id and a new secret.

Because keys are team-scoped here, this is how a departed colleague's credential gets killed by an integration rather than only by them.

Request

DELETE
/v1/api_keys/4418
curl -X DELETE https://api.synexcloud.com/v1/api_keys/4418 \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "object": "api_key",
  "id": "4418",
  "deleted": true
}

POST/v1/api_keys/self/roll

Roll the calling key

Rotates the key making the request and returns the replacement with its secret.

This is the one operation in the whole API that requires no ability. A replacement carries identical abilities, so nothing is escalated by calling it — and a key that suspects it is compromised must never be blocked from rotating by a permission it was not granted. A key holding no abilities at all can still roll itself.

That makes this the recommended rotation path. Scheduled hygiene rotation should call it rather than rolling by id, because it needs no api_keys:write on the credential doing the rotating — the key being rotated is the only authority involved, and no api_keys:write key has to exist in your deployment just to turn secrets over.

self is not an id and can never collide with one: key ids are numeric, and this route is declared ahead of the parameterised one.

Because the caller is the key being retired, the replacement inherits the caller's expires_at. An expiring key rolls into one with the same deadline — self-roll cannot be the loophole that turns every expiring key perpetual. This is the one place this API and the console's roll button disagree: the console mints a perpetual replacement, because it has no calling key to bound anything by.

The old secret keeps authenticating for the overlap, so the calling process does not lose its own credential mid-rotation.

Request

POST
/v1/api_keys/self/roll
curl -X POST https://api.synexcloud.com/v1/api_keys/self/roll \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "id": "4432",
  "object": "api_key",
  "name": "Kestrel ingest worker",
  "secret": "synex_sk_6b1e948d0f2a37c5be804d1a7f3c92e60b58da47",
  "abilities": [
    "passports:read",
    "passports:write",
    "imports:read",
    "imports:write"
  ],
  "scopes": {
    "passports:write": {
      "ontologies": ["ontology_7d1f4a2b9c3e5081"]
    }
  },
  "metadata": { "service": "kestrel-ingest" },
  "last_used_at": null,
  "expires_at": "2027-01-14T00:00:00Z",
  "rolled_at": null,
  "rolled_to": null,
  "rolled_from": "4418",
  "created_at": "2026-07-28T10:04:02Z"
}

POST/v1/api_keys/:id/roll

Roll an API key by id

Rotates a named key on the team. Requires api_keys:write and that the target does not reach further than the caller — see rolling is subset-constrained. A target that reaches further is 403 key_not_rollable on api_key, and nothing is written.

Prefer self-roll for scheduled rotation. Use this one when you are rotating a credential that is not the one making the call — for instance retiring a worker's key from an operator tool.

Everything carries over: name, team, owning user, abilities, scopes and metadata. A roll is a rotation, not a transfer — rolling a colleague's key leaves it theirs.

Request

POST
/v1/api_keys/4418/roll
curl -X POST https://api.synexcloud.com/v1/api_keys/4418/roll \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "id": "4431",
  "object": "api_key",
  "name": "Kestrel ingest worker",
  "secret": "synex_sk_3d0a7c9e2b6f451870ad4c1e9b2f6538c7a04e1b",
  "abilities": [
    "passports:read",
    "passports:write",
    "imports:read",
    "imports:write"
  ],
  "scopes": {
    "passports:write": {
      "ontologies": ["ontology_7d1f4a2b9c3e5081"]
    }
  },
  "metadata": { "service": "kestrel-ingest" },
  "last_used_at": null,
  "expires_at": null,
  "rolled_at": null,
  "rolled_to": null,
  "rolled_from": "4418",
  "created_at": "2026-07-28T10:04:02Z"
}

The rotation workflow

Rolling issues a new secret while the old one keeps working, so a deploy never lands in a window where neither authenticates:

  1. Roll. POST /v1/api_keys/self/roll returns the replacement, secret included. This is the only time that value is ever shown.
  2. Deploy the new secret. Write it wherever your workers read credentials from and restart them. Both secrets authenticate during this step, so ordering does not matter and a partial rollout is safe.
  3. Do nothing. The old secret expires on its own 24 hours after the roll. There is nothing to clean up and no second call to make.

Three details before you automate it:

  • The window never extends a key. The overlap ends at min(now + 24h, the key's existing expiry). A key already due to expire inside the next day keeps its own earlier deadline.
  • A second roll does not restart the window. It mints another replacement and re-applies the same rule against the deadline the first roll wrote, so the original window stands. Rotate at the pace of your deploys, not faster.
  • The replacement inherits the calling key's expiry, not the rolled key's.

Lineage

FieldOn the retiring keyOn the replacement
rolled_atWhen the roll happenednull
rolled_toThe replacement's idnull
rolled_fromnull, unless it was itself a replacementThe retiring key's id
expires_atEnd of the overlap windowInherited from the calling key

The retiring key stays readable at its own id until its window elapses, so both halves are inspectable while the rotation is in flight. That is why the roll response is just the replacement, with no previous_key envelope — the other half is a GET away.

Rolling the same key twice leaves two live replacements but one forward pointer: rolled_to names the most recent. The earlier replacement is not lost — it is still findable by its own rolled_from, which names the same parent. If you walk lineage, walk it backwards from a key rather than forwards from a parent.

The secret is unrecoverable

Only a hash is stored. secret is returned in exactly three responses — create, roll and self-roll — and is omitted from everything else: reads, list rows, PATCH responses, and event payloads.

There is no recovery endpoint and no support path that can retrieve one. A key whose secret is lost must be rolled — which is cheap — or revoked and replaced. Capture the value when it is issued.

Events

Three types land on the change feed, and none of them ever carries a secret — the snapshot is produced by the same code that serves the GET, which structurally cannot render one.

TypeFires when
api_key.createdA key is minted through POST /v1/api_keys.
api_key.rolledA key is rolled, by either roll endpoint. Emitted on the replacement; its rolled_from names the retiring key, so one event gives you both ids.
api_key.revokedA key is revoked. The snapshot is the key's final state — the row itself is gone.

There is deliberately no api_key.updated. A PATCH here changes labelling or narrows authority, and neither is a state change another system needs pushed to it.

Because api_key.revoked describes a row that no longer exists, it is one of the few events whose data.object cannot be re-fetched. Treat the snapshot as the record.

Errors

CodeStatusMeaning
ability_not_grantable400abilities names something the calling key does not hold. param is abilities
scope_not_grantable400The requested ontology scope is wider than the caller's — including omitting it, or unscoping. param is scopes
key_not_rollable403The target key reaches further than the caller, so rolling it would hand over abilities. param is api_key
parameter_invalid400expires_at is malformed, in the past, beyond the caller's deadline, or missing where required; or scopes names a non-scopable ability, an ability the key does not grant, or an empty ontology list
metadata_invalid400The merged metadata bag broke its limits
resource_missing404No such key on your team — cross-team and non-numeric ids read the same way
insufficient_permissions403The calling key lacks api_keys:read or api_keys:write. Never returned by self-roll

See errors for the shared envelope.

Was this page helpful?