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:
- create or revoke API keys, or rotate the webhook signing secret,
- change the plan, buy allowance, or read and write the billing address and invoices,
- invite somebody, change a role, or remove an account,
- sign anybody in, or act on sessions,
- reach the platform side, or any workspace but its own.
Money and people are not permissions you can tick. A credential that can change what a workspace pays, or create a way in for a person, is one that can cost real money or open a door, 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.
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, which 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"]
}'
{
"key": {
"id": "01M2SRXSGEPAJ7VY8W8M3EXHT0",
"name": "Docs examples",
"hint": "rw_0...0000",
"permissions": ["ingest:write", "posts:read", "media:write"]
},
"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.
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": null,
"createdAt": "2026-09-18T08:08:01.807Z"
}
]
}
Keys do not expire on their own: expiresAt is null. POST /v1/keys/{keyId}/revoke stops one
immediately, and the row stays in the list with lastUsedAt, because that is the evidence you
wanted when you revoked it.
PATCH /v1/keys/{keyId} renames a key or changes what it may do 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, along with
/v1/health, /v1/ready and /v1/_meta. Refusing it would mean a key could not check its own
permissions, which is the first thing a well-written client does.
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"
}
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 no key may use gets a different sentence:
{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "Sign in to manage credentials. An API key cannot manage API keys.",
"requestId": "01M2SQPYK8ZC7G18BEQ4PHAQB8",
"details": []
}
}
429 is a rate limit, counted per workspace where the caller is authenticated and per address
otherwise, so one noisy tenant cannot exhaust another's budget. It carries retryAfterSeconds.
See Errors for the whole envelope.