API keys: scopes, expiry, revocation
sandbox-api.k8s.tangovision.dev takes two credentials: an org-scoped tvk_… bearer token, and the session token of a signed-in portal user (which is also what npx @tv/extension-sdk login stores — see CLI sign-in). Keys are for CI and anything else non-interactive. You mint them in the developer portal; the plaintext is shown exactly once. This page is the reference for how they behave.
Two invariants first:
- A key can never mint another key. Minting requires a signed-in portal session, so a leaked key can't multiply itself.
- Up to 5 active keys per org. Revoked and expired keys don't count — revoke what you don't use.
Scopes
Every key carries one scope, chosen at mint time:
| Scope | Allows | Use it for |
|---|---|---|
sandbox (default) | The sandbox lifecycle API (/api/v1/sandboxes*): create, list, extend, reset, delete | Day-to-day module development, CI that provisions sandboxes |
publish | The module-publish surface (arrives with tv-sdk publish; no endpoint accepts it yet) | Release pipelines, once publishing ships |
full | Everything above, plus key listing and revocation over the API | Break-glass automation; prefer the narrower scopes |
A request outside your key's scope fails with 403 and the error code SCOPE_DENIED — mint a key with the right scope rather than widening an existing workflow's key. Keys minted before scopes existed behave as full.
The whole key-management surface needs full
/api/v1/keys* requires full, listing included. A sandbox-scoped key gets 403 SCOPE_DENIED even on GET /api/v1/keys — which is deliberate, but surprises people who expect a key to at least be able to see itself.
Expiry
Self-service keys always expire — after 90 days by default. The portal mints with that default; call the API directly from your portal session to pick a different lifetime:
curl -X POST https://sandbox-api.k8s.tangovision.dev/api/v1/keys \
-H "Authorization: Bearer $PORTAL_JWT" \
-H "Content-Type: application/json" \
-d '{"label": "ci", "scope": "sandbox", "expiresInDays": 30}'Note the Bearer $PORTAL_JWT: that is not a stylistic choice. Minting always requires a portal session — a request authenticated with a tvk_… key is refused with 403 KEY_MINT_REQUIRES_PORTAL_SESSION, which is the first invariant above, enforced.
expiresInDays accepts 1–365; anything else is a 400 INVALID_EXPIRY. A label over 100 characters is likewise a 400. An expired key gets 401 on its next request; mint a replacement in the portal (the key list shows each key's expiry date, and its status flips to expired). Non-expiring keys exist only as operator-minted credentials — self-service requests for one are refused with 403 NON_EXPIRING_DENIED — so ask developers@tango.vision if a service genuinely needs one.
The mint response is 201 with the shape { token, key }: token is the plaintext you will never see again, key is the metadata that also appears in the portal list.
Revocation
Revoke in the portal, or over the API:
curl -X DELETE https://sandbox-api.k8s.tangovision.dev/api/v1/keys/<keyId> \
-H "Authorization: Bearer $PORTAL_JWT"Revocation returns 200 { "revoked": true } and is effective on the key's next request — every request re-checks the key store directly, with no cache in between, so the observed propagation delay is however long your next request takes to arrive. (The platform's formal commitment is under 5 minutes globally; in practice it's seconds.) Revoking a key you don't own, or one already revoked, is a 404.
If a key leaks, revoke it immediately and mint a replacement — and tell us, so we can check the audit trail with you.
Audit trail
Every key event is recorded: issuance (who minted, which scope, what expiry), use (each authenticated request: key, route, source IP, user agent), denials (revoked/expired/unknown keys, scope violations), and revocation. Records are kept for 18 months.
From a portal session you can query your org's trail:
curl "https://sandbox-api.k8s.tangovision.dev/api/v1/keys/audit?limit=50" \
-H "Authorization: Bearer $PORTAL_JWT"Filters: action (key.issued | key.used | key.revoked), keyId, and limit, which defaults to 100 and is clamped to 500 rather than rejected. An unknown action is a 400 INVALID_ACTION. The response is { events: [...] }.
As an external developer you read the trail from a portal session: a tvk_… key is refused with 403 AUDIT_REQUIRES_PORTAL_SESSION. The rows carry IP addresses and user agents, which have no business sitting behind a CI secret. (Internal operator credentials are the exception — they can read the trail directly, and across orgs, which is what incident response needs.)