Zeli AvatarDeveloper docs

API reference / API keys

API keys

Self service key management, served from the always on portal plane. This is where the credential every other page in this reference assumes comes from. Comparable APIs keep key creation in a web console with no route behind it; ours is an API, so it is documented.

A different plane, a different credential

These routes take a signed in person, by main pool identity token or by the portal's session cookie, not an API key. They are callable from a browser only same origin. The key they hand back is what the box routes then accept.

MethodPathPurpose
GET/auth/keysThis person's keys, without secrets
POST/auth/keysCreate one. 201, and the only response that carries plaintext
DELETE/auth/keys/{id}Revoke one
POST/auth/session/credentialMint a short lived box credential for a first party page

List keys

GET/auth/keys

Takes no parameters. The acting identity is the partition: the request names no owner, which is what makes another developer's keys unreachable rather than merely filtered.

{
  "keys": [
    {
      "id": "3fa7c1...",
      "name": "production backend",
      "hint": "zsk_live_...c41f",
      "last4": "c41f",
      "created_at": 1769900000,
      "created_by": "you@example.com",
      "revoked": false,
      "revoked_at": null,
      "revoked_by": null
    }
  ]
}

id is the key's digest, not the key. Revoked keys stay in the list with revoked: true and the time and actor recorded, because a key that vanishes leaves nothing to audit against.

Create a key

POST/auth/keys
namestringRequired

A label you will recognise later. An invalid one is a 400 with code invalid_name.

One developer can hold at most 100 keys, and revoked keys count towards that number. Creating one more is a 409 with code key_limit_reached and a message you can show as it is.

Response 201. The same record as above, plus one field:

{
  "key": {
    "id": "3fa7c1...",
    "name": "production backend",
    "hint": "zsk_live_...c41f",
    "last4": "c41f",
    "created_at": 1769900000,
    "created_by": "you@example.com",
    "revoked": false,
    "secret": "zsk_live_..."
  }
}
Reveal once

secret appears in this response and in no other. It is not stored in a form anything can read back and it is not re derivable. Copy it now, or create another key.

Revoke a key

DELETE/auth/keys/{id}

Response 200: { "ok": true }.

A 404 with code key_not_found means no such key of yours, and it is also the answer for an id that never existed. That is deliberate: a 403 would confirm the id belongs to somebody, which turns this route into a probe for other developers' key ids.

A short lived box credential

POST/auth/session/credential

The one bridge between the two authentication planes. It issues a signed in person a credential the box accepts, so a first party page never has to ask for a pasted key.

A POST and not a GET, for the same reason key creation is: minting a live credential must not be reachable by a navigation, a link prefetch or an image tag. It sits under /auth/session/ rather than /auth/keys/ so it can never be confused with a person's own key management.

Response 201:

{
  "credential": {
    "secret": "zsk_...",
    "expires_at": 1769903600,
    "expires_in": 900,
    "scope": "full"
  }
}

Both clocks are given on purpose. expires_at is absolute epoch seconds, which is what the box enforces; expires_in is seconds from now, which is what a browser should time against, so a skewed client clock cannot renew late.

How a key is presented

Once you hold one, every box route accepts it in one of three forms, in this order of preference:

curl "https://avatar.zelibot.xyz/api/avatars" -H "X-Api-Key: zsk_live_..."
curl "https://avatar.zelibot.xyz/api/avatars" -H "Authorization: Bearer zsk_live_..."

The third, ?session_token= on the URL, exists only because a browser WebSocket upgrade cannot set headers. Use it there and nowhere else: a credential in a query string lands in access logs, referrer headers and browser history.

For a browser, do not send a full key at all. Mint a stream only token on your server with POST /v1/streaming.create_token and send that. See Authentication for the whole picture.

Signing in

Three more routes exist on the portal plane and are part of the sign in flow rather than of this API: POST /auth/request-link starts a magic link sign in and always answers the same neutral message so it cannot be used to discover which addresses have accounts, GET /auth/callback redeems the emailed link, and POST /auth/logout clears the session. POST /auth/session/exchange trades a main pool identity token for the portal's own cookies.

Zeli Avatar · real-time avatars over WebRTC · self-hostable · AU data residency · source