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, show again, revoke or remove API keys, or rotate or show again the webhook signing secret,
- set or remove a channel's platform connector credentials (a key reads them masked with Channels: read, and tests them with Channels: write),
- send a test event from a custom feed's Test tool (
POST /v1/streams/{id}/test): a key sends events throughPOST /v1/stream, with Custom feeds - Send raw data, - 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, choose its industries, 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 Administration
permission, 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. For the same
reason an AI assistant's connection holds it only where the person connecting it left that switch on: see
An assistant's connection.
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 Administration permission (admin among their powers). 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",
"remindExpiry": false
},
"secret": "rw_0000000000000000000000000000000000"
}
201, with secret in it. Every request is checked against the key's hash alone. The key is also
kept encrypted, so that a person who may make keys can see it again behind their own password (see
Showing a key again), but no script can ask for it back: put it in your secret
manager at the moment you create it. The owner, and whoever made the key, are emailed that it exists;
the email names the key and never carries its secret.
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. remindExpiry: true emails the owner and everybody with Administration a week before and a
day before it expires; it needs an expiresAt, and PATCH /v1/keys/{keyId} changes it like the rest.
A workspace holds a limited number of keys that still work, 25 unless Reelwire has raised it on
request. Past it, creating one is a 409 conflict that says so; a revoked or expired key does not
count, so revoking one makes room. An AI assistant's connection is a key too, and counts the same way.
A workspace whose subscription was cancelled makes no new keys (409) until it subscribes again, on any
plan, Free included.
Two permissions are given only by somebody who holds what they open: approvals:write by a person
with the Approvals permission, and plan:read by the owner. Asking for either otherwise is a 403
naming it, on POST /v1/keys and on PATCH /v1/keys/{keyId}, where a key keeps one it already
holds. GET /v1/keys/permissions marks them for you with withheld ("read" or "write") and
withheldWhy, and leaves them out of the presets.
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",
"remindExpiry": false,
"expired": false,
"state": "ACTIVE",
"connection": false,
"revealable": true,
"createdAt": "2026-09-18T08:08:01.807Z"
}
]
}
revealable says whether the key was kept to be shown again: true for a key made since keys were
kept, false for one made before (its hash is all there is) and for an assistant's connection.
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. A revoked or 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: see
An assistant's connection below.
PATCH /v1/keys/{keyId} renames a key, changes what it may do, or sets, moves or removes its
expiresAt (null for never) and its remindExpiry, without handing out a new secret, and answers
{ "key": { ... } }. Rotating a key means touching every system that holds it, so narrowing or
widening one has to be possible on its own.
Showing a key again
curl http://localhost:4000/v1/keys/01M2SRXSGEPAJ7VY8W8M3EXHT0/reveal \
-H "Authorization: Bearer rws_your_session_token" \
-H "Content-Type: application/json" \
-d '{"password":"..."}'
{
"key": { "id": "01M2SRXSGEPAJ7VY8W8M3EXHT0", "name": "Docs examples", "hint": "rw_0...0000" },
"secret": "rw_0000000000000000000000000000000000"
}
POST /v1/keys/{keyId}/reveal hands a key back to the person asking, once the password they sign in
with checks out. It takes the same right as making a key, the owner's or Administration's, and a
session: a key can never read another key. A wrong password is a 403 rather than a 401, because the
session itself is fine, and tries are held to the sign-in's limit, 10 a minute from one address, so a
password cannot be guessed here faster than at the sign-in. Every key shown is written
to the workspace's audit trail (key.reveal) with who asked, since a key read back is a key that may
now be somewhere else.
Only a key that still works and was kept: a revoked or expired key is a 409, and so is a key with
revealable: false, which has only its hash. Make a new key, put it where the old one is used, then
revoke the old one. The webhook signing secret is shown again the same way, by
POST /v1/webhook/secret/reveal with the same body and the same rules: see
Webhooks.
An assistant's connection
An AI assistant that connects to Reelwire's MCP server by signing in (OAuth) rather than with a
pasted key is given an ordinary rw_ key of its own. It is bound to that one MCP server, which
resource on /v1/whoami names, and it lives an hour and is renewed in place by the assistant, so
one connection is one row in GET /v1/keys, with connection: true and no expiresAt (an
expiresAt sent for it is ignored). What it may do is the Read only or the Run the whole
workspace preset, whichever the person allowed, less the two permissions above that this person
may not give a key. A connection allowed to act holds Team: write and Webhook: write only where the
person left that switch on, and Posts - Approvals: write the same way, both on until they turn them off.
A connection made before November 2026 has neither mark and keeps what it was given until it is made again. That is worked out
again at every renewal, so somebody who loses Approvals takes it from their assistant within the
hour. Behind such a connection stands the person who made it, so a few routes refused to every other key answer it as that person: the
owner's invoices and commission, the list of keys for somebody holding Administration, and the rest under
What it will refuse. Connecting needs the same right as making a key by hand. Revoking the row ends the connection,
and removing it afterwards ends it for good. See
Connecting your assistant for the consent page.
Its token works only through Reelwire's MCP server: sent to the API directly, from anywhere outside Reelwire, it is refused with a 401 that says so, which keeps a token copied out of an assistant from being a key to the whole API. A renewal token presented a second time, a minute or more after it was used, ends the connection, and so does a sign-in code exchanged twice: one of the two holding it is not the assistant. A second use inside that minute is only refused, because an assistant that lost the answer to its own renewal and asks again is far more common than a thief that fast.
Reelwire's sign-in follows the standards a stock client expects, so nothing about Reelwire has to be
taught to it: the authorization code with PKCE (S256 only), its answers naming the server (iss, RFC
9207), an assistant's own loopback address accepted on any port, an app's own address scheme
accepted (such as the cursor:// an editor falls back to when its local port is taken), and an
assistant that names itself by the address of a document about itself (a Client ID Metadata Document)
accepted instead of registering.
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, /v1/_meta and /v1/_routes need no token at all,
and a key sent to them is not refused either. /v1/version answers { "version": "...", "commit": "..." }, the release and the commit the system runs; /v1/_meta carries the same two beside
contractSha, apiVersion, startedAt and catalogueVersion; /v1/_routes lists every route with
the permission a key needs for it, which is what Every endpoint is made from.
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"],
"permissions": [
{ "scope": "ingest:write", "label": "Custom feeds - Send raw data: write" },
{ "scope": "posts:read", "label": "Posts: read" },
{ "scope": "media:write", "label": "Media: write" }
],
"kind": "apikey",
"resource": null
}
kind is apikey or session. permissions, for a key, says each permission it holds in the words
the key picker and every refusal use. 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, and never includes sending raw data (ingest:write): that is a key's job, so a stolen
dashboard session cannot push data in, and a leaked producer key reads nothing it was not given.
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",
"optionalMail": true,
"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. Without it, while the
other session was used in the last 15 minutes, the answer is a question rather than a session:
{ "status": "in-use", "lastSeenAt": "...", "minutes": 15 }, and no token until you send the same
sign-in again with takeOver: true. 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.
On reelwire.io, signing in, signing up and asking for a password reset are also for a browser: each carries a reCAPTCHA token the page mints, and a request without one is refused with 403 "We could not verify that this came from a browser". One more reason a script holds a key.
A wrong address or password is a 401, "Invalid email or password", the same for both on purpose.
With two-factor sign-in on (each person switches it on under My account), a right password earns a
ticket instead of a session: { "status": "two-factor", "ticket": "...", "expiresAt": "...", "minutes": 5 }.
Send the ticket with the code the person's authenticator app shows, or one of their recovery codes, to
POST /v1/auth/login/two-factor, with takeOver as above; the answer is the session, or the in-use
question, asked before the code is spent:
curl http://localhost:4000/v1/auth/login/two-factor \
-H "Content-Type: application/json" \
-d '{"ticket":"...","code":"123456","takeOver":true}'
The ticket works for five minutes and stops working if the password changes meanwhile. A wrong code is
a 401; ten wrong codes in a quarter of an hour make that person wait fifteen minutes. A code is
accepted once. Switching two-factor on and off and making new recovery codes are a signed-in person's
alone: POST /v1/auth/two-factor/setup, /enable, /disable and /recovery-codes.
GET /v1/auth/session describes the session you are holding, including the plan and its
capabilities. A session lasts 12 hours, and ends sooner after an hour in which nobody did anything:
POST /v1/auth/heartbeat says somebody still is, and answers the new deadline (idleUntil); the
session response says how long idle it may be and how often to send one (idle.minutes,
idle.heartbeatSeconds). POST /v1/auth/logout with the token ends that session.
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, has expired or belongs to another workspace, on purpose: distinguishing them tells an attacker which guesses were close. The one 401 with more to say is an AI assistant's token sent to the API directly: "This token belongs to an assistant's connection and works only through Reelwire's MCP server."
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": []
}
}
A route in no permission area answers a key "An API key cannot use this endpoint", and a change in
an area a key may only read, such as POST /v1/licence under Plan, says that area "is read-only for
an API key". Both are 403, and no permission changes either answer.
A few routes check for one exact right before anything else, and answer in older words: "This credential lacks the credentials:write scope" from listing, making, changing, showing, revoking or removing a key and from the webhook secret routes, to any key and to a person without Administration, and "This credential lacks the team:write scope" from inviting, changing or removing a colleague, to a key without Team: write and to a person without Administration. They mean what the sentences above mean.
See Errors for the whole envelope.
Rate limits
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, counted per address:
| Route | Limit |
|---|---|
| Signing in, signing up, confirming an address, both password-reset steps | 10 a minute |
| Showing a key or the webhook signing secret again | 10 a minute, as for signing in |
Sending a syndication test email (POST /v1/syndication/sender/test) | 10 a minute |
A rate limit's 429 carries retryAfterSeconds and a Retry-After header. Every response counted
against a limit also says where it stands, in x-ratelimit-limit, x-ratelimit-remaining and
x-ratelimit-reset (seconds until it refills); on a route with a limit of its own, those describe that
limit.