Scoping an API key to one job
Give each Versely API key only the scopes its job needs. The eight-scope catalog and longest-prefix matching make that split practical.
A Versely API key that can generate video, post to social, connect accounts, and read analytics is one leak away from doing all four. The catalog is eight named scopes. Longest-prefix matching maps each request path to one of them. Empty scope arrays are rejected. "all" expands to the explicit list at creation time, so a key minted today does not silently pick up a scope you add next month. Per-job keys are the default that those four facts make cheap, not a ceremony you skip until something goes wrong.
Keys are minted at POST /api/v1/auth/api-keys. The raw value is vsk_ plus 40 hex characters, returned once, hashed with SHA-256, and identified later by a 12-character key_prefix. The developer page is the entry point for the REST surface those keys call.
Eight scopes, one owner per path
GET /api/v1/auth/api-keys/scopes returns the catalog so a dashboard does not have to hardcode it. The payload includes match_strategy: "longest_prefix" and sentinel: "all". The eight names, and the prefixes each one owns:
| Scope | What it is for | Path prefixes |
|---|---|---|
generate |
New media | /generate, /suno, /audio, /runpod, /replicate, /movie, /lyria, /avatar, /inworld, /broll, /ltx-batch, /dubbing |
post |
Create, schedule, list, fetch, delete posts | /social/posts, /social/preview |
manage_accounts |
Connect, refresh, list, disconnect accounts | /social/accounts, /social/auth-url |
slideshow |
Slideshows, overlays, conversion to video | /slideshow |
ugc |
UGC overlays, captions, TTS voiceover, background removal | /ugc, /captions |
workflows |
Saved and scheduled workflows, templates, assets | /workflows, /video-workflows, /workflow-assets, /templates, /public-workflows, /app-assets |
analytics |
Social analytics, trend analysis, trending feed | /social-analytics, /trend-analysis, /trending-feed |
read |
Profile, model registry, agentic, media library, fonts, features | /user, /ai-models, /agentic, /media, /fonts, /features |
A request is allowed when the key's scopes array contains the name that owns the path. A generate worker that posts because someone pasted "all" is not a product limitation. It is a grant you made.
generate and post are separate on purpose. A leaked render key should not be able to publish. A leaked poster should not be able to mint video. The same split exists between post and manage_accounts: scheduling a caption is not the same permission as connecting an Instagram account. If the job is text-to-video, grant generate. If the job is posting or scheduling, grant post. If the job is connecting a social account, grant manage_accounts. Those three should almost never share a key.
Longest prefix is the whole trick
Matching is longest-prefix-first. When two prefixes could both match, the longer one wins. /social/posts is post. /social/accounts is manage_accounts. Without the length rule, those two would collapse into one social blob and you could not give a scheduler the ability to publish without also giving it the ability to reconnect the brand account.
Paths that no scope owns skip the scope check after the key itself has authenticated. GET /api/v1/status/:requestId is one of those: a generate-only key can poll a job it submitted. Key management under /auth/api-keys is another. Do not read "unscoped" as "public." The key still has to be valid, unrevoked, and unexpired. It just is not asked for a catalog name on that path.
The other consequence of longest-prefix matching is that you do not invent nested scope names. There is no generate.video and no post.schedule. You grant generate or you do not. If a job needs two names, you pass both in the array. If it needs every name, you pass "all" and the server stores the eight strings, not the sentinel.
A working per-job map
Name the key after the job, not after the environment. prod is not a job. nightly-hooks is.
| Job | Scopes | Why those and not more |
|---|---|---|
| Nightly image/video worker | generate, read |
generate covers /generate and the other media prefixes. read covers GET /user/me (balance) and /ai-models (catalog and credit estimates). |
| Social scheduler | post |
Can create and list posts. Cannot connect accounts, cannot generate. |
| Account connector used at onboarding | manage_accounts |
Can OAuth a channel. Cannot publish. |
| Slideshow pipeline | slideshow, read |
Stays inside /slideshow. Add generate only if the same process also hits /generate. |
| UGC caption/overlay job | ugc |
/ugc and /captions only. |
| Saved workflow runner | workflows, read |
Templates and scheduled runs. Add generate if the runner also calls generate endpoints directly. |
| Analytics pull | analytics, read |
Trend and social stats, including checking social performance. Cannot post. |
Two grants people get wrong.
read is not a spectator credential. Its prefixes include /agentic and /features. Agent chat and the editor export live there, and both spend credits. A key that only needs GET /user/me still receives those prefixes if you grant read. That is still the right grant for a generate worker that prices a batch before dispatch. It is the wrong grant for a key whose job is "look up the balance and stop." There is no narrower catalog name. Mitigate with a low per-key rate limit and a dispatcher that never calls /agentic or /features.
Do not mint "all" because the first integration is exploratory. "all" expands to the current eight names and stores them. A later ninth name will not appear on that key, which is correct, but the key can already generate, post, manage accounts, run workflows, and hit agentic. Exploration belongs on a key named scratch with generate and read, revoked when the worker's real shape is known.
The CLI is a reasonable way to mint the first long-lived credential on a machine. After that, mint per-job keys from a session and put each raw value in that job's secret store. One key shared by CI, staging, and the production poster is the opposite of this map.
Minting the narrow key
Creation requires a name (max 100 characters) and a non-empty scopes array. rate_limit_rpm defaults to 60 and is clamped to 1–1000. expires_at is optional. A body that works for a generate worker:
POST /api/v1/auth/api-keys
Authorization: Bearer <session JWT>
Content-Type: application/json
{
"name": "nightly-hooks",
"scopes": ["generate", "read"],
"rate_limit_rpm": 20,
"expires_at": "2027-02-01T00:00:00.000Z"
}
The 201 body includes api_key.key (the only time the raw value is returned) and a warning to save it. Store it, then confirm the grant:
GET /api/v1/auth/api-keys
Authorization: Bearer <session JWT>
You get id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, expires_at, revoked_at. You never get the hash or the raw key again. If scopes on the new row is not exactly what you sent (after "all" expansion), revoke it and mint again. There is no PATCH. Changing a grant is a rotation.
A call that is missing a name returns 400. A call with scopes: [] returns 400 listing the valid names. A call with a made-up name in the array returns 400 the same way. A later request that hits a prefix the key does not own returns 403 with API key lacks required scope: <name>. That 403 is the system working. If you "fix" it by granting "all", you threw away the catalog.
Use the narrow key against the route the job actually needs. A generate worker should call POST /api/v1/generate/image or POST /api/v1/generate/video, which is the same catalog the AI video generator uses, not /agentic/chat. Agent chat is a read prefix. Putting a worker on chat because it felt easier is how a "generate" key becomes an agent.
FAQ
Can I grant only generate and skip read?
Yes, if the worker never reads /user, /ai-models, /media, /agentic, or /features. Polling GET /api/v1/status/:requestId does not require read. Checking the live credit balance does. A worker that prices a batch before submit needs read for /ai-models and /user/me. A worker that only fires generate and polls status can live on generate alone.
What happens if I pass "all" and "generate" in the same array?
"all" expands to the full list of eight names and that list is stored. The extra "generate" is redundant. The stored grant is every scope, not a union you can later trim without minting a new key.
Why did a /social/accounts call 403 on a key that has post?
post owns /social/posts and /social/preview. Account connect, refresh, list, and disconnect live under /social/accounts and /social/auth-url, which belong to manage_accounts. Longest-prefix matching is what keeps those apart. Grant manage_accounts on a dedicated connector key, not on the poster.
Is there a scope that can only read and never spend credits?
No. The catalog name read owns prefixes that include credit-spending routes (/agentic, /features). Treat read as "registry, library, agentic, and editor," not as a read-only role. Keep spend off that key by never calling those routes from it, and by giving it a tight rate_limit_rpm.