Skip to main content

Authentication

Every request carries a bearer token:

Authorization: Bearer rw_...

There are two kinds of caller, and they are deliberately not the same thing.

API keys​

A key belongs to a workspace, not to a person. It carries permissions and holds only what it was given.

Whatever a key holds, no key can:

  • list, create, change or revoke API keys, or rotate the webhook signing secret,
  • read, save or remove the workspace's keys for AI services,
  • change the plan, buy, keep or drop allowance, split the storage, or read and write the billing address, invoices and affiliate commission,
  • rename the workspace, or leave it,
  • sign anybody in, or act on sessions,
  • reach the platform side, or any workspace but its own.

Secrets and money are not permissions you can tick. A credential that can mint another credential, or change what a workspace pays, is one that can open a door or cost real money, and both of those decisions belong to somebody who signed in and can be named in the audit trail. They are not offered in the picker at all.

People are the one exception, and it is worth a second look. team:write lets a key invite colleagues, change what each may do and remove them, so that a workspace set up by an assistant can have its colleagues added the same way. An invited colleague can be given the admin power, which manages API keys, so a key holding team:write is a way into the workspace and not only a way to read it. Tick it only for a key whose job is setting workspaces up.

Everything else on those paths belongs to no permission area, and a request to one from a key is refused rather than allowed by omission: forgetting to classify an endpoint fails closed, which is the only safe direction.

Creating one​

A key can only be minted by a signed-in person with the right to manage credentials, which is the owner and anybody holding the admin power. That is why the call below uses a session token.

curl http://localhost:4000/v1/keys \
-H "Authorization: Bearer rws_your_session_token" \
-H "Content-Type: application/json" \
-d '{
"name": "Docs examples",
"permissions": ["ingest:write", "posts:read", "media:write"],
"expiresAt": "2026-12-31T23:00:00Z"
}'
{
"key": {
"id": "01M2SRXSGEPAJ7VY8W8M3EXHT0",
"name": "Docs examples",
"hint": "rw_0...0000",
"permissions": ["ingest:write", "posts:read", "media:write"],
"expiresAt": "2026-12-31T23:00:00.000Z"
},
"secret": "rw_0000000000000000000000000000000000"
}

201, and secret exists in exactly one response. Reelwire stores only its hash, so nothing can show it to you again. Put it in your secret manager at the moment you create it.

name is up to 80 characters and defaults to "API key". permissions needs at least one, and every entry is checked against the catalogue: an unknown string would read as a permission on the screen and grant nothing, which is the worst kind of wrong. GET /v1/keys/permissions serves that catalogue, with ready-made presets, so a client never hard-codes the list. expiresAt is optional: an instant in the future (ISO 8601, with its offset), or null or left out for a key that never expires.

You cannot bring your own secret. A value chosen elsewhere is one Reelwire can promise nothing about, it arrives in a request body and in logs on the way here, and one customer's choice could collide with another's.

Listing and revoking​

curl http://localhost:4000/v1/keys \
-H "Authorization: Bearer rws_your_session_token"
{
"keys": [
{
"id": "01M2SRXSGEPAJ7VY8W8M3EXHT0",
"name": "Docs examples",
"hint": "rw_0...0000",
"permissions": ["ingest:write", "posts:read", "media:write"],
"enabled": true,
"lastUsedAt": "2026-09-18T08:08:07.898Z",
"expiresAt": "2026-12-31T23:00:00.000Z",
"expired": false,
"state": "ACTIVE",
"connection": false,
"createdAt": "2026-09-18T08:08:01.807Z"
}
]
}

POST /v1/keys/{keyId}/revoke stops a key immediately and answers { "revoked": true, "id": "..." }. The row stays in the list with lastUsedAt, because that is the evidence you wanted when you revoked it. POST /v1/keys/{keyId}/remove then deletes the row of a revoked or expired key and answers { "removed": true, "id": "..." }; its last use is kept in the audit trail. A key that still works is a 409: revoke it first.

A key with an expiresAt stops working at that instant, exactly as a revoked one does: a request carrying it gets 401. Its row stays too, and state tells the two apart: REVOKED when somebody turned it off, EXPIRED when its own time ran out, ACTIVE otherwise. An expired key cannot be changed or brought back (409); create a new one. connection: true marks an AI assistant's connection over MCP, which renews its own token and shows no expiry.

PATCH /v1/keys/{keyId} renames a key, changes what it may do, or sets, moves or removes its expiresAt (null for never), without handing out a new secret. Rotating a key means touching every system that holds it, so narrowing or widening one has to be possible on its own.

Which key am I holding​

GET /v1/whoami is the one call every key may make, whatever its permissions. Refusing it would mean a key could not check its own permissions, which is the first thing a well-written client does. /v1/health, /v1/ready, /v1/version and /v1/_meta need no token at all, and a key sent to them is not refused either.

curl http://localhost:4000/v1/whoami \
-H "Authorization: Bearer rw_your_key_here"
{
"accountId": "01M1VFRWE3A35H892VAAEKKDA6",
"accountName": "Enterprise 2",
"scopes": ["ingest:write", "posts:read", "media:write"],
"kind": "apikey",
"resource": null
}

kind is apikey or session. resource names the MCP server a key was issued for when an AI assistant's connection made it, and is null for a key made by hand.

Sessions​

A signed-in person gets a session token, rws_..., which is what the dashboards use and what the few person-only routes need. A session carries what that person's role allows rather than per-area permissions.

curl http://localhost:4000/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"...","takeOver":true}'
{
"status": "signed-in",
"token": "rws_...",
"expiresAt": "2026-09-18T19:44:11.500Z",
"expiresAtEpoch": 1789760651,
"verified": true,
"user": {
"id": "01M1VFRWEN9BM7GW2YVJNMZAZK",
"email": "you@example.com",
"displayName": "Alex Fischer",
"role": "OWNER",
"scope": "TENANT",
"powers": ["operate", "approve", "admin"],
"tenantId": "01M1VFRWE3A35H892VAAEKKDA6"
}
}

Reelwire allows one live session per person. Signing in somewhere else ends the first one, and the first one is told why. takeOver: true is how you say you mean to do that. This is why a session token is the wrong thing to put in a script: two scripts sharing an account take turns logging each other out. Use a key.

GET /v1/auth/session describes the session you are holding, including the plan and its capabilities. POST /v1/auth/heartbeat keeps it alive; the session response says how long idle it may be and how often to send one.

What a refusal looks like​

A missing token and an invalid one are both 401, and the message says which:

{
"error": {
"type": "https://reelwire.io/errors/unauthenticated",
"code": "unauthenticated",
"message": "Provide a bearer token",
"requestId": "01M2SR17V1VCT4R68VQXE5PXGH",
"details": []
}
}
{
"error": {
"type": "https://reelwire.io/errors/unauthenticated",
"code": "unauthenticated",
"message": "Invalid credentials",
"requestId": "01M2SR17SJRFQ844N1FX9FFSKJ",
"details": []
}
}

"Invalid credentials" is the same answer whether the key never existed, was revoked or belongs to another workspace, on purpose: distinguishing them tells an attacker which guesses were close.

A valid credential that is not allowed the request is 403, and that message is as specific as it can safely be, because it is for you rather than for a stranger:

{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "This key may not read here. Give it the \"Team: read\" permission, or use a key that has it.",
"requestId": "01M2SR17R25EKN6DM1H9DVDS0D",
"details": []
}
}

A key reaching a route that belongs to a signed-in person gets a different sentence:

{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "Sign in to do this. Secrets and money are for a signed-in person: an API key cannot mint a credential, change what a workspace pays, or buy allowance. Nor can it rename this workspace or leave it, because a key holds no role and is nobody's membership.",
"requestId": "01M2SQPYK8ZC7G18BEQ4PHAQB8",
"details": []
}
}

429 is a rate limit. Every request carrying a key or a session counts against that one credential, 600 a minute whichever route it calls, so one busy integration cannot use up another's budget. A few routes add a tighter limit of their own, such as signing in and sending a message to the AI assistant. A 429 carries retryAfterSeconds.

See Errors for the whole envelope.