Issue and rotate your API keys
Which credentials exist, what a scope opens, and how you revoke a key.
Authenticate every call with an sk_ key in the Authorization header.
curl https://api.vitrinadev.com/api/v1/locations \
-H "Authorization: Bearer $VITRINA_KEY"The workspace doesn't travel in the URL or in a header of its own. The workspace is the key: every credential is born attached to one and reaches no other. That's why no route takes a tenant parameter, and why a leaked key exposes exactly one workspace.
A key belongs to the workspace, not to a person. It sees everything there is, and it keeps working after whoever minted it leaves. If you want a credential that sees what you see and dies with you, that's a personal token.
Who owns a key
Nobody in particular, and that's the point: the integration using it doesn't break because somebody changed jobs.
When a person leaves the workspace:
- Their API keys stay alive. They're workspace credentials. The invoice that goes out by itself at 3 a.m. keeps going out.
- Their personal tokens and the connected apps in their name are cut. Those really are theirs. They act as that person, with that person's permissions, and without them there's nobody left to represent.
- Admins get a notice in the bell and by email. It lists the keys that survived and when each was last used. It revokes nothing: it tells you what was left ownerless so you can rotate it, revoke it, or leave it alone.
Suspending somebody isn't the same as removing them. While they're suspended their personal tokens answer 401. Reactivate them and the tokens work again on the same secret, with nothing re-minted.
Trap
An ownerless key keeps authenticating, with revoked_at: null
A key that outlives whoever created it isn't marked in any special way: it's
still an ordinary row. The admin notice is the only place where its creator's
departure is written down. So give each key a name that says what it's
for rather than who asked for it.
The shape of a key
sk_ plus 43 characters. The full secret comes back once, when you mint it. After that the API only shows you its prefix, its first few characters:
{
"id": "2e613613-1142-4fea-a979-a9d3f274b07f",
"name": "docs — lectura del workspace",
"prefix": "sk_LV8lX",
"scopes": ["tenant:read"],
"created_at": "2026-09-22T03:16:03.368968+00:00",
"last_used_at": "2026-09-22T03:16:04.211+00:00",
"revoked_at": null,
"rotated_at": null
}The prefix lets you recognise a key in a list without storing the secret anywhere. It's what GET /api-keys returns, and it's what to write down in your own system when you record what each credential was for.
Keep the secret where you keep your other secrets. Don't put it in the front end: anyone who opens the inspector has it, and with it they have the whole workspace.
Scopes
A key carries a list of scopes and no permission outside that list. Each scope is written resource:action.
The four almost every integration starts with:
| Scope | What it allows |
|---|---|
tenant:read | Reading the workspace and its locations. |
tenant:write | Creating, correcting and deactivating locations. |
webhooks:write | Creating and editing event subscriptions. |
api_keys:write | Minting and revoking other keys. |
The full catalogue is generated from the backend and lives in Scopes. It says which operations each permission opens and which roles carry it.
Ask for the scope of the layer you'll use
A permission that sounds like "the whole resource" opens the outer layer. The inner one asks for a different permission, and it works that way in every vertical. The outer layer is the one a website or a partner can be handed; the inner one isn't.
stock:read isn't the stock: it's the public lot, what the dealership shows
on its own site, with no plates and no costs. The inventory from the inside
lives at /vehicles and asks for a different permission. With a key that
carries only stock:read:
{
"error": {
"code": "FORBIDDEN",
"message": "Missing required scope: marketplace:read",
"requestId": "79fae21f-cdfc-45ec-bcb4-89295ccce5cf"
}
}Always ask for the permission of the layer you'll use, and nothing beyond it. The inner one is never inherited.
Minting a key
curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
-H "Authorization: Bearer $VITRINA_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "docs — lectura del workspace", "scopes": ["tenant:read"] }'name and scopes are all it asks for.
expires_at is optional. Leave it out and the key never expires; put it in and the date is honoured to the letter. A key whose expires_at has passed answers 401, with a message that differs from a revoked key's:
{
"error": {
"code": "UNAUTHORIZED",
"message": "API key expired",
"requestId": "6b5e929b-99d6-4f35-af6b-dc8ceff9795d"
}
}It's the 401 you can prevent by looking at your own keys. The other two, and what to do about each, are in Errors.
Trap
Asking for a scope your key doesn't carry answers 403
With a key that carries only api_keys:read and api_keys:write, minting a new
one with tenant:read answers this:
{
"error": {
"code": "FORBIDDEN",
"message": "You cannot grant an API key that includes a permission you do not have (tenant:read)",
"requestId": "cf505046-9b85-424c-b0cf-e76a1ec8ac5c"
}
}A credential can't mint one that outranks it. The "parent" key that mints the rest has to carry every permission it will hand out.
Listing your keys
curl https://api.vitrinadev.com/api/v1/api-keys \
-H "Authorization: Bearer $VITRINA_ROOT_KEY"{
"data": [
{
"id": "2e613613-1142-4fea-a979-a9d3f274b07f",
"name": "docs — lectura del workspace",
"prefix": "sk_LV8lX",
"scopes": ["tenant:read"],
"last_used_at": "2026-09-22T03:16:04.211+00:00",
"revoked_at": null
}
],
"meta": { "total": 6 }
}last_used_at is updated on every call. Use it to find the credentials nobody touches before you revoke them.
The secret never appears here, or in any other response.
Revoking
curl -X DELETE https://api.vitrinadev.com/api/v1/api-keys/2e613613-1142-4fea-a979-a9d3f274b07f \
-H "Authorization: Bearer $VITRINA_ROOT_KEY"204, no body. The same key, used again:
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid API key",
"requestId": "f1673233-4f65-42ec-b73d-92e85eb1bad6"
}
}It's immediate and it doesn't come back. The row stays in the list with revoked_at set, so there's a record of what existed; what dies is the secret.
Repeating the DELETE on an already-revoked key also answers 204, so you can retry a revocation without a special case.
Rotating
Rotating changes a key's secret without changing the key. Same id, same name, same scopes; the secret is the only thing that differs, and with it the prefix.
curl -X POST https://api.vitrinadev.com/api/v1/api-keys/784cecc0-681c-49a1-9188-db9c8326949c/rotate \
-H "Authorization: Bearer $VITRINA_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "grace_period_hours": 24 }'{
"data": {
"id": "784cecc0-681c-49a1-9188-db9c8326949c",
"name": "sitio web — lectura del workspace",
"prefix": "sk_9pQ2R",
"scopes": ["tenant:read"],
"secret": "sk_9pQ2R…",
"grace_period_ends_at": "2026-09-23T20:11:31.575Z",
"rotated_at": "2026-09-22T20:11:31.575Z"
}
}The new secret comes back once, exactly as on mint.
Rotating keeps the id. Everything that pointed at that credential still points at the same place: the audit trail, your own notes, any grant made against that id. Revoke-and-create leaves you another credential under another id. Changing the permissions is revoke-and-create: a key's scopes can't be edited, not even by rotating.
grace_period_hours is optional and ranges from 0 to 72. Leave it out and you get 24 hours.
Rotating asks for api_keys:write and is audited. A revoked key, or one from another workspace, answers 404.
Trap
The old secret keeps working until grace_period_ends_at
Rotating doesn't cut off the previous secret. It keeps authenticating through
the grace period, so you can redeploy your integration without a window in
which nothing authenticates. Past that instant it answers 401 like any
revoked credential.
If the secret leaked, that margin works against you. Rotate with
grace_period_hours: 0 and the old one stops working the same instant.
In the app this is the "Rotar" button, under Configuración › Desarrolladores › API keys, next to "Revocar".
The call ceiling
Every response carries the state of your quota:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119The quota is per credential and it refills continuously. Today the default ceiling is 120 calls per minute. Don't hard-code it: a workspace can have a different ceiling, and the value in force travels in that header on every response.
A call over the limit gets this response:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Retry-After: 1{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests",
"requestId": "dc856e9a-c913-42ba-b80c-a5a88b74689e"
}
}Retry-After is in seconds and the counter that rejected you computes it. Wait it out and retry; don't back off blindly when the response already tells you how long.
The contract for the four endpoints is in Reference · API keys.