Endpoints
Reference for the public /api/v1/* routes — grouped by area (accounts, subscription and billing, signing keys, artifacts), each with its method, path, authentication requirement, request and response shapes, and the error responses a caller has to handle.
All paths are relative to https://hub.sevorix.com. Conventions, the shape of an error body, and the meaning of each status code are on the Overview; the rules behind 401, 403, and 404 are on Authentication.
Every endpoint below can also return 429 (rate limited) and 500 (unhandled server error); those are not repeated in the per-endpoint error tables.
Accounts
POST /api/v1/register
Create an account. Unauthenticated.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Stored as given. Must be unique. |
password | string | yes | 8–1024 bytes. See password rules. |
{ "email": "you@example.com", "password": "correct horse battery staple" }Response — 201 Created
{
"id": "6f1c0e2a-4b9d-4a1f-8f0c-2f4a9d3e7b11",
"email": "you@example.com"
}No token is issued. Call POST /api/v1/login next. The new account starts unapproved, which means publishing and endorsing return 403 until it is approved.
Errors
| Status | Condition |
|---|---|
400 | email or password empty; password shorter than 8 bytes or longer than 1024. |
409 | "email '…' is already registered". |
429 | Registration is limited to a 5-request burst refilling at 1/minute per IP. |
POST /api/v1/login
Exchange credentials for a bearer token. Unauthenticated.
Request
| Field | Type | Required |
|---|---|---|
email | string | yes |
password | string | yes |
Response — 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"email": "you@example.com",
"is_endorsed": false,
"require_email_update": false
}require_email_update is true for accounts whose address was backfilled by a migration; such an account should call PATCH /api/v1/me/email before doing anything else.
Errors
| Status | Condition |
|---|---|
401 | "invalid credentials" — returned identically for an unknown address and a wrong password, so the endpoint does not reveal which accounts exist. |
429 | 10 requests/minute per IP. |
GET /api/v1/me
The authenticated caller's own profile. Requires a bearer token.
Response — 200 OK
{
"id": "6f1c0e2a-4b9d-4a1f-8f0c-2f4a9d3e7b11",
"email": "you@example.com",
"is_endorsed": false,
"is_approved": true,
"created_at": "2026-03-14T09:21:07.481293Z"
}| Field | Type | Meaning |
|---|---|---|
is_endorsed | boolean | The account is a recognised publisher; surfaced next to its artifacts as owner_is_endorsed. |
is_approved | boolean | The account may publish and endorse. false until approved. |
Errors
| Status | Condition |
|---|---|
401 | Missing, malformed, invalid, or expired token; or a token naming an account that no longer exists. |
PATCH /api/v1/me/email
Change the authenticated caller's email address. Requires a bearer token.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | Trimmed and lowercased before use. Must contain @. |
Response — 200 OK
{
"email": "new@example.com",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}A new token is issued and returned, because the email claim inside the old one is now stale. Replace your stored token with this one; the old token remains technically valid until it expires but carries the wrong address.
Errors
| Status | Condition |
|---|---|
400 | Empty address, or one with no @. |
401 | Missing or invalid token. |
409 | "email already in use" — another account already has that address. |
PATCH /api/v1/me/password
Change the authenticated caller's password, proving knowledge of the current one. Requires a bearer token.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
current_password | string | yes | At most 1024 bytes. Verified, never stored. |
new_password | string | yes | 8–1024 bytes; must differ from the current one. |
Response — 200 OK
{ "status": "password updated" }No token is issued or invalidated. Existing tokens — including any an attacker may hold — keep working until they expire. See token properties.
Errors
| Status | Condition |
|---|---|
400 | Either field over 1024 bytes; new_password under 8 bytes; new_password equal to current_password. |
401 | Missing or invalid bearer token — or a correct token with the wrong current_password ("invalid credentials"). |
429 | 10 requests/minute per IP. |
The new_password checks run only after current_password verifies, so their messages cannot be used to probe whether a guessed current password was right.
POST /api/v1/password-reset/request
Start a password reset. Unauthenticated.
Request
| Field | Type | Required |
|---|---|---|
email | string | yes |
Response — 202 Accepted
{ "status": "if that address is registered, a reset link has been sent" }Identical for a registered address, an unregistered one, and an account that has exhausted its 3-per-hour allowance.
Errors
| Status | Condition |
|---|---|
400 | The request body could not be deserialized. |
503 | This instance has no email provider configured, so no link can be sent. |
429 | 5-request burst per IP, refilling slowly. |
See Password reset for token lifetime and per-account limits.
POST /api/v1/password-reset/confirm
Redeem a reset token and set a new password. Unauthenticated.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
token | string | yes | The value from the reset link. At most 256 bytes. |
new_password | string | yes | 8–1024 bytes. |
Response — 200 OK
{ "status": "password updated" }Errors
| Status | Condition |
|---|---|
400 | "invalid or expired reset token" — one message for unknown, expired, already-used, and concurrently-redeemed tokens alike, and also for a token over the length bound. |
400 | new_password fails the length rules. Checked only once the token is known good, and before it is consumed. |
429 | 10 requests/minute per IP. |
GET /api/v1/users/:user_id
A user's public profile. Unauthenticated.
Path parameters
| Name | Type |
|---|---|
user_id | UUID |
Response — 200 OK — the same shape as GET /api/v1/me.
Errors
| Status | Condition |
|---|---|
400 | user_id is not a well-formed UUID. Produced by the framework's path parsing, so the body is plain text rather than the usual {"error": …}. |
404 | "user not found". |
Subscription and billing
POST /api/v1/me/checkout-session
Create a Stripe Checkout Session for the authenticated caller and return its hosted URL to redirect a browser to. Requires a bearer token.
Takes no request body.
Response — 200 OK
{ "url": "https://checkout.stripe.com/c/pay/cs_live_a1..." }The session is created with the hub's own user id as client_reference_id, so the resulting Stripe customer is linked back to this account by a value the hub controls rather than one the client supplies. There is no endpoint for self-reporting a Stripe customer id.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token. |
500 | Stripe is not configured on this instance, or the call to Stripe failed. The body is the generic "internal server error". |
GET /api/v1/me/subscription
The authenticated caller's cached subscription status, signed. Requires a bearer token.
This reads the status the hub last recorded from Stripe; it deliberately does not call Stripe live.
Response — 200 OK
{
"status": "active",
"updated_at": "2026-03-12T17:03:55.204881Z",
"subscription_id": "sub_1PabcDEFghIJklmn",
"expires_at": 1773480127,
"signature": "8Wm5b0v0Yc2u1H1oQ0V0aFq0m3n5oS0F0aQ2c..."
}| Field | Type | Notes |
|---|---|---|
status | string | The Stripe subscription status as last seen (active, past_due, canceled, …), or the account's default when it has never had one. |
updated_at | string | null | When the status was last written. null if never. |
subscription_id | string | null | Stripe subscription id, if one is linked. |
expires_at | integer | Unix seconds. 15 minutes after the response was produced. |
signature | string | Base64 Ed25519 signature over the SHA-256 of the canonical JSON encoding of {status, subscription_id, expires_at}. |
signature is the point of this endpoint. The Sevorix daemon verifies it against a public key compiled into its own binary rather than trusting the response body, so pointing the daemon at a look-alike hub — via a hub-URL override or DNS spoofing — does not yield a valid entitlement. expires_at bounds how long a captured genuine response stays usable after a subscription is cancelled; a consumer must reject the token past it even though the signature still verifies.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token, or a token naming an account that no longer exists. |
Signing keys
Artifacts may be signed with an Ed25519 key. The private half never leaves the client; the hub stores only the public key, and identifies it by fingerprint.
| Term | Definition |
|---|---|
public_key | Base64 (standard alphabet) of the raw 32 bytes of an Ed25519 public key. Not PEM, not SSH format. |
fingerprint | Lowercase hex SHA-256 of those same 32 raw bytes — 64 characters. Computed by the hub, never taken from the request. |
POST /api/v1/me/signing-keys
Register a signing key for the authenticated caller. Requires a bearer token.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
public_key | string | yes | Base64 of the raw 32-byte Ed25519 public key. |
label | string | null | no | A human-readable name for your own use. |
{ "public_key": "0h1PZmn6M5G0J1p7pAzQpQ4o9x5jvB3qE5cJb2mCn0A=", "label": "laptop" }Response — 201 Created
{
"id": "e7b4d0a1-5f6c-4c2e-9a03-1d8b7c4e2f55",
"fingerprint": "9f2c1a...64 hex characters...",
"label": "laptop",
"created_at": "2026-03-14T10:02:19.771004Z"
}Errors
| Status | Condition |
|---|---|
400 | public_key is not valid base64, does not decode to exactly 32 bytes, or is not a valid Ed25519 point. |
401 | Missing or invalid token. |
409 | "a key with this fingerprint already exists". Fingerprints are unique across the hub, not just within an account. |
GET /api/v1/me/signing-keys
List the authenticated caller's signing keys. Requires a bearer token.
Response — 200 OK — a JSON array, newest first.
[
{
"id": "e7b4d0a1-5f6c-4c2e-9a03-1d8b7c4e2f55",
"user_id": "6f1c0e2a-4b9d-4a1f-8f0c-2f4a9d3e7b11",
"fingerprint": "9f2c1a...",
"label": "laptop",
"created_at": "2026-03-14T10:02:19.771004Z",
"revoked_at": null
}
]Revoked keys are not filtered out; they are returned with a non-null revoked_at. The public_key itself is deliberately omitted from this response.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token. |
DELETE /api/v1/me/signing-keys/:fingerprint
Revoke one of the authenticated caller's signing keys. Requires a bearer token.
Revocation is a soft delete: the row stays, with revoked_at set. Artifacts already signed with the key keep their signature on record, but it stops counting as valid — see signature_valid.
Response — 204 No Content.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token. |
404 | "signing key not found or already revoked" — one message covering a fingerprint that does not exist, one belonging to another account, and one already revoked. Revoking twice is therefore a 404, not a no-op success. |
Artifacts
An artifact is a named, versioned JSON document — a policy, or a set that groups other artifacts by dependency. name@version is unique across the hub and immutable once published: there is no update endpoint, only a new version.
Visibility is one of:
| Value | Who can read it |
|---|---|
public | Anyone, including anonymous callers. The default. |
private | The owner only. Everyone else gets 404. |
draft | The owner only. Same treatment as private on every read path. |
POST /api/v1/artifacts
Publish an artifact. Requires a bearer token, and an approved account.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–128 chars, must start alphanumeric, then [a-zA-Z0-9\-_.]. |
version | string | yes | 1–64 chars, must start alphanumeric, then [a-zA-Z0-9\-_.+]. |
content | string | yes | The artifact body, as a JSON string containing JSON — not a nested object. Must parse. Default size cap 256 KB. |
description | string | null | no | At most 1000 characters. |
tags | string[] | no | At most 20 tags, each at most 64 characters. |
visibility | string | no | "public" (default), "private", or "draft". |
artifact_type | string | no | "artifact" (default) or "set". A set must declare at least one dependency. |
dependencies | object[] | no | Each { "name": …, "version": …, "required": true }. required defaults to true. Every entry must already exist. |
schema | object | no | A JSON Schema. When present, content is validated against it at push time and the schema is stored alongside the artifact. |
signature | string | null | no | Base64 Ed25519 signature. Must be sent together with key_fingerprint. |
key_fingerprint | string | null | no | Fingerprint of a registered, unrevoked key belonging to you. |
What is signed. The message is the 64-character lowercase hex SHA-256 of the content bytes, signed as UTF-8 text — not the raw digest bytes and not the content itself.
{
"name": "net-egress",
"version": "1.2.0",
"description": "Default egress policy for agent shells",
"tags": ["network", "baseline"],
"visibility": "public",
"content": "{\"rules\":[{\"action\":\"deny\",\"dst\":\"0.0.0.0/0\"}]}"
}Response — 201 Created
{
"id": "b1c2d3e4-5f60-4718-9a2b-3c4d5e6f7081",
"name": "net-egress",
"version": "1.2.0",
"description": "Default egress policy for agent shells",
"owner": "you@example.com",
"tags": ["network", "baseline"],
"visibility": "public",
"downloads": 0,
"created_at": "2026-03-14T10:15:44.902117Z",
"content_hash": "3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855e",
"content_schema": null,
"signed": false,
"key_fingerprint": null,
"dependencies": [],
"artifact_type": "artifact"
}signed is read back from what was actually persisted, so it is never true for a signature that was not verified and stored.
Errors
| Status | Condition |
|---|---|
400 | name or version fails its pattern or length rule. |
400 | More than 20 tags, or a tag over 64 characters. |
400 | description over 1000 characters. |
400 | content over the instance's size cap (256 KB by default). |
400 | content is not valid JSON. |
400 | content does not conform to the supplied schema, or schema is not a valid JSON Schema. |
400 | visibility is not one of public, private, draft. |
400 | artifact_type is "set" with no dependencies. |
400 | A declared dependency does not exist ("dependency 'x@1.0.0' does not exist"). |
400 | The declared dependencies would form a cycle back to this artifact. |
400 | signature and key_fingerprint were not supplied together. |
400 | The named key is unknown, revoked, or belongs to another account. |
400 | The signature does not verify against the content hash. |
400 | This instance requires signed artifacts and none was supplied. |
401 | Missing or invalid token. |
403 | "your account is pending admin approval". |
409 | "artifact 'name@version' already exists". |
Every one of those checks runs before anything is persisted, so a rejected push leaves nothing published — a 400 here always means the artifact does not exist.
GET /api/v1/artifacts/:name/:version
Fetch an artifact and its content. Token optional — required only to reach your own private or draft artifacts.
A successful fetch increments the artifact's download counter. A fetch rejected by the visibility check does not.
Response — 200 OK
{
"id": "b1c2d3e4-5f60-4718-9a2b-3c4d5e6f7081",
"name": "net-egress",
"version": "1.2.0",
"description": "Default egress policy for agent shells",
"owner": "you@example.com",
"owner_is_endorsed": false,
"tags": ["network", "baseline"],
"downloads": 41,
"visibility": "public",
"created_at": "2026-03-14T10:15:44.902117Z",
"content": { "rules": [{ "action": "deny", "dst": "0.0.0.0/0" }] },
"content_hash": "3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855e",
"content_schema": null,
"signed": true,
"key_fingerprint": "9f2c1a...",
"signature_valid": true,
"artifact_type": "artifact",
"yanked": false,
"yanked_reason": null,
"dependencies": [{ "name": "base-deny", "version": "1.0.0", "required": true }]
}Note that content here is a parsed JSON value, not the string that was pushed.
signed and signature_valid are different questions. Do not treat them as one flag:
signed | signature_valid | Meaning |
|---|---|---|
false | null | Published without a signature. |
true | true | Signed, and the signature verifies against the bytes in this response, with a key that is still valid. |
true | false | Claims a signature that does not check out: it fails verification, the key was revoked, or the key can no longer be resolved. |
signature_valid is recomputed on every fetch against the exact content being served — it is never a boolean cached at push time — so it describes what you actually received. A revoked key yields false, because a revoked key can no longer vouch for provenance.
Yanked artifacts are still served with yanked: true and a yanked_reason, so existing consumers keep working while being told the version is withdrawn. They are excluded from search.
Errors
| Status | Condition |
|---|---|
404 | No such name@version, or it is private/draft and not yours. The two are indistinguishable by design. |
500 | The stored content no longer matches its recorded hash. The response is refused rather than serving bytes that failed an integrity check. |
GET /api/v1/artifacts/search
Search artifacts. Token optional — supplying one widens the result set to include your own private and draft artifacts.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
q | string | — | Case-insensitive substring match against name and description. |
tag | string | — | Exact tag match. Takes precedence over q if both are given. |
limit | integer | 20 | Clamped to 1–100. |
offset | integer | 0 | Negative values are treated as 0. |
include_yanked | boolean | false | Accepted, but has no effect for ordinary accounts: yanked artifacts are excluded. |
Results are ordered by created_at descending.
Response — 200 OK
{
"results": [
{
"id": "b1c2d3e4-5f60-4718-9a2b-3c4d5e6f7081",
"name": "net-egress",
"version": "1.2.0",
"description": "Default egress policy for agent shells",
"owner": "you@example.com",
"owner_is_endorsed": false,
"tags": ["network", "baseline"],
"downloads": 41,
"visibility": "public",
"created_at": "2026-03-14T10:15:44.902117Z",
"yanked": false,
"artifact_type": "artifact"
}
],
"total": 128
}total is not a match count
total is the number of artifacts visible to you, not the number matching q or tag. It does not change as you vary the query, so it cannot be used to size a result set or drive pagination against a filtered search. Page by requesting until a short page comes back.
Summaries omit content, content_hash, signature fields, and dependencies — fetch the artifact itself for those.
GET /api/v1/artifacts/:name/:version/members
List the members of an artifact set. Unauthenticated, and no visibility check is applied.
Response — 200 OK
{
"set_name": "baseline-policies",
"set_version": "2026.03",
"members": [
{ "name": "net-egress", "version": "1.2.0", "required": true },
{ "name": "shell-guard", "version": "0.9.1", "required": false }
]
}Errors
| Status | Condition |
|---|---|
400 | The artifact exists but is not a set. |
404 | No such name@version. |
GET /api/v1/artifacts/:name/:version/resolve
Walk an artifact's dependency graph transitively. Token optional — required only for your own private or draft artifacts.
Response — 200 OK
{
"root": "baseline-policies@2026.03",
"dependencies": [
{ "name": "net-egress", "version": "1.2.0", "required": true, "depth": 1, "found": true },
{ "name": "base-deny", "version": "1.0.0", "required": true, "depth": 2, "found": true },
{ "name": "legacy-shim", "version": "0.1.0", "required": false, "depth": 2, "found": false }
]
}| Field | Meaning |
|---|---|
depth | 1 for a direct dependency, incrementing per hop. |
found | Whether that name@version currently exists on the hub. A dangling reference is reported as found: false, not as an error. |
Each name@version appears at most once, at the first depth it was reached. Traversal stops at depth 50. Visibility is checked on the root artifact only; the walk itself reports names and versions, not content.
Errors
| Status | Condition |
|---|---|
404 | No such root name@version, or it is not yours to see. |
POST /api/v1/artifacts/:artifact_id/yank
Withdraw a published artifact. Requires a bearer token; you must own the artifact.
Yanking does not delete anything. The artifact stays fetchable by exact name@version — flagged yanked: true — and drops out of search. This is the "stop new adopters without breaking existing ones" operation.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
reason | string | null | no | Surfaced as yanked_reason on fetch. Send {} to omit it. |
Response — 204 No Content.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token. |
403 | You do not own the artifact. |
404 | No artifact with that id. |
DELETE /api/v1/artifacts/:artifact_id/yank
Reverse a yank: clears yanked and yanked_reason. Requires a bearer token; you must own the artifact. Takes no body.
Response — 204 No Content. Same errors as POST.
Endorsements
An endorsement is one account vouching for one artifact. Each account may endorse a given artifact at most once.
| Level | Value |
|---|---|
| Verified | "verified" (the default) |
| Trusted author | "trusted_author" |
| Official | "official" |
POST /api/v1/artifacts/:artifact_id/endorsements
Endorse an artifact. Requires a bearer token, and an approved account.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
level | string | null | no | One of the three values above. Defaults to "verified". Send {} for the default. |
Response — 201 Created
{
"id": "a2b3c4d5-6e7f-4081-9203-4a5b6c7d8e9f",
"artifact_id": "b1c2d3e4-5f60-4718-9a2b-3c4d5e6f7081",
"user_id": "6f1c0e2a-4b9d-4a1f-8f0c-2f4a9d3e7b11",
"email": "you@example.com",
"user_is_endorsed": false,
"level": "verified",
"created_at": "2026-03-14T11:44:02.118337Z"
}Errors
| Status | Condition |
|---|---|
400 | level is not one of the three accepted values. |
401 | Missing or invalid token. |
403 | "your account is pending admin approval". |
404 | No artifact with that id. |
409 | "you have already endorsed this artifact". |
Note that the existence check here does not apply artifact visibility, unlike the fetch endpoints.
GET /api/v1/artifacts/:artifact_id/endorsements
List an artifact's endorsements. Unauthenticated.
Response — 200 OK — a JSON array of the object shown above, newest first.
Errors
| Status | Condition |
|---|---|
404 | No artifact with that id. |
DELETE /api/v1/artifacts/:artifact_id/endorsements/:endorsement_id
Withdraw your own endorsement. Requires a bearer token.
Response — 204 No Content.
Errors
| Status | Condition |
|---|---|
401 | Missing or invalid token — and also when the endorsement is not yours. See the note below. |
404 | No endorsement with that id on that artifact. |
Status-code exception
Deleting an endorsement that belongs to someone else answers 401, where every comparable ownership check in this API answers 403. Documented as it behaves, and may change in a future release. A client should not treat this particular 401 as a stale-session signal.
Not documented here
Some routes the hub serves are deliberately absent from this reference:
POST /api/v1/webhooks/stripe— called by Stripe, never by an API consumer. It authenticates byStripe-Signaturerather than a bearer token, and there is nothing a client can usefully do with it.GET /checkout/success,GET /checkout/cancel,GET /password-reset— HTML pages the hub serves to a browser as the destination of a Stripe redirect or a reset email link. They are user-facing pages, not API endpoints.