Self-service API reference (`/gw`)
Management and analytics API for your organization. All endpoints:
- Base URL:
https://api.sociaro.com/gw - Auth:
Authorization: Bearer gw_live_… - Auth header leniency (by design). The gw-key is accepted either as
Authorization: Bearer <key>(canonical) or as a bare header value with noBearerscheme. The bare form exists so a client can place the key in whatever auth header its provider SDK normally uses (e.g. Anthropic’sx-api-key); the gateway strips an optionalBearerprefix and accepts the key either way. - Tenant-scoped: your organization is derived from the key — it cannot be passed (or spoofed) via parameters.
- Errors use the standard envelope.
| Endpoint | Scope |
|---|---|
GET /gw/me | any valid key |
GET /gw/models | any valid key |
GET /gw/ceiling | keys:manage |
GET /gw/keys | keys:manage |
POST /gw/keys | keys:manage |
DELETE /gw/keys/{id} | keys:manage |
GET /gw/subaccounts | keys:manage |
POST /gw/subaccounts | keys:manage |
PATCH /gw/subaccounts/{id} | keys:manage |
GET /gw/budgets | keys:manage |
PUT /gw/budgets | keys:manage |
GET /gw/branding | keys:manage |
PUT /gw/branding | keys:manage |
POST /gw/billing/checkout | keys:manage |
GET /gw/stats | stats:read |
GET /gw/usage | stats:read |
GET /gw/async | stats:read |
GET /gw/evidence | stats:read |
GET /gw/stripe/config | stats:read |
PUT /gw/stripe/config | keys:manage |
PUT /gw/stripe/mappings/{sub_account_id} | keys:manage |
DELETE /gw/stripe/mappings/{sub_account_id} | keys:manage |
POST /gw/stripe/customers | keys:manage |
GET /gw/stripe/events | stats:read |
POST /gw/stripe/events/{usage_record_id}/retry | keys:manage |
GET /gw/me
Section titled “GET /gw/me”Introspect the current key: its scopes and model entitlements.
{ "scopes": ["inference:use", "stats:read"], "entitlements": [ {"axis": "provider", "provider": "openai", "model_pattern": "gpt-4o*", "effect": "allow"}, {"axis": "vendor", "provider": "alibaba", "model_pattern": "qwen3-*", "effect": "allow"} ]}Each entitlement tuple is {axis, provider, model_pattern, effect}:
axis: "provider"(the default when omitted) —provideris the inference provider,model_patternis a glob over the provider’s native model id.axis: "vendor"—provideris a glob over the catalog vendor (the author-part of the slug) andmodel_patterna glob over the model name (the part of the catalog slug after the first/). Vendor rules follow the catalog: they apply wherever the model is actually hosted, without exposing the inference route.axis: "kind"—provideris a glob over the catalog kind (modality:llm|image|video|3d|embedding|rerank|audio) andmodel_patterna glob over the model name. Kind rules also only apply to catalog-known slugs.
Policy is default-deny, and deny wins across all axes.
GET /gw/models
Section titled “GET /gw/models”The model catalog: every model the gateway can serve, identified by its
canonical slug author/model (author = who made the model, e.g.
alibaba/wan2.6-t2v — regardless of where inference runs). The catalog itself
is global, not tenant-specific; combine with your entitlements to
see what a given key may call.
{ "models": [ {"slug": "anthropic/claude-sonnet-4-6", "display_name": "claude-sonnet-4-6", "author": "anthropic", "kind": "llm", "allowed": true}, {"slug": "alibaba/wan2.6-t2v", "display_name": "wan2.6-t2v", "author": "alibaba", "kind": "video", "allowed": false} ]}kind is llm | image | video | 3d | embedding | rerank | audio; LLM
entries may carry context_length (omitted for every other kind).
allowed says whether your organization’s ceiling would permit calling the
model — computed server-side, since the evaluation may depend on the inference
route, which is never exposed. It reflects the ceiling, not the calling key’s
own entitlements (a key may still be scoped more narrowly); use it to know
which models child keys can be issued for.
An OpenAI-compatible view of the same catalog is served at
GET https://api.sociaro.com/v1/models
({"object":"list","data":[{"id":"alibaba/wan2.6-t2v","object":"model","created":1780315200,"owned_by":"alibaba"}]}),
so official SDKs pointed at the gateway can list models natively.
GET /gw/ceiling
Section titled “GET /gw/ceiling”Your organization’s ceiling: the maximum scopes and entitlement patterns that child keys may be issued with.
{ "max_scopes": ["inference:use", "stats:read", "keys:manage"], "entitlements": [ {"axis": "provider", "provider": "openai", "model_pattern": "gpt-*", "effect": "allow"}, {"axis": "provider", "provider": "anthropic", "model_pattern": "claude-*", "effect": "allow"} ]}GET /gw/keys
Section titled “GET /gw/keys”List your organization’s keys (no hashes, no plaintext).
| Param | Default | Values |
|---|---|---|
limit | — (all) | 1..1000 (out of range or non-integer → 400) |
cursor | — | opaque pagination cursor (see below); malformed → 400 |
Without limit the endpoint returns all keys (backward-compatible). With
limit it returns one keyset page (newest first); when more keys exist the
response carries an X-Next-Cursor header — pass it back as ?cursor=… for
the next page (absent on the last page). The response body is always a flat
array. The cursor is opaque; a malformed cursor returns 400.
[ { "id": "<uuid>", "key_prefix": "gw_live_abc1", "status": "active", "scopes": ["inference:use"], "sub_account_id": null, "created_at": "2026-05-28T00:00:00Z", "last_used_at": "2026-06-09T18:12:44Z", "expires_at": null }]sub_account_id is non-null for keys bound to a sub-account.
expires_at is non-null only for keys issued with a TTL (see below); after that
instant the key stops authenticating (401, invalid gateway key) — the row is
kept so its usage history survives.
POST /gw/keys
Section titled “POST /gw/keys”Issue a child key. The plaintext is returned exactly once.
Request:
{ "scopes": ["inference:use", "stats:read"], "entitlements": [ {"axis": "provider", "provider": "openai", "model_pattern": "gpt-4o*", "effect": "allow"}, {"axis": "vendor", "provider": "alibaba", "model_pattern": "qwen3.7-max", "effect": "allow"} ], "sub_account_id": "<uuid>", "expires_in_seconds": 86400}- Each entitlement tuple is
{axis, provider, model_pattern, effect}(seeGET /gw/me).axismay be omitted (defaults toprovider); any other value thanprovider/vendor/kind→400. - Omitting
entitlementsentirely (field absent) makes the key inherit the full ceiling; an explicit[]is a deliberate deny-all for inference. sub_account_idis optional: it binds the key to one of your sub-accounts (which must exist and beactive, otherwise403).expires_in_secondsis optional: it sets a TTL — the key stops authenticating that many seconds after issuance (after which it returns401, exactly like a revoked key). Omit it (or send nothing) for a non-expiring key. Must be between1and315360000(10 years); outside that range →400. Keys are immutable, so the TTL is fixed at issuance; to extend, issue a new key.- Each organization has a cap on the number of active keys (default
1000; per-client, raised by your operator). At the cap the request is
rejected with
403(active key limit reached for this client: revoke unused keys or ask the operator to raise max_keys) and no key is created. Revoked keys do not count — revoking frees a slot immediately.
Validation is atomic against your ceiling, per tuple:
provider-axis rows must be present in the ceiling exactly (same tuple), as before.vendor-axisdenyrows are always accepted — they can only narrow access.vendor-axisallowwith a concrete slug (no glob characters, e.g.alibaba+qwen3.7-max) is resolved through the catalog: it is accepted if your ceiling allows the model’s current inference route. A slug not in the catalog →403. The check is point-in-time: if the catalog later remaps the model, the grant follows the model.vendor-axisallowwith a glob pattern must be present in the ceiling exactly (vendor-axis glob grants are issued by the operator).kind-axisdenyrows are always accepted — they can only narrow access.kind-axisallowrows must be present in the ceiling exactly (kind grants are always issued by the operator; there is no self-service resolve path for thekindaxis, unlikevendor’s concrete-slug case above).
On violation → 403, no key created.
Response (sub_account_id and expires_at are echoed only when applicable):
{"api_key_id": "<uuid>", "key": "gw_live_<48 hex>", "scopes": ["inference:use", "stats:read"], "sub_account_id": "<uuid>", "expires_at": "2026-06-17T14:52:00Z"}Keys are immutable — to change permissions, revoke and re-issue. Ceiling changes are not retroactive: already-issued keys keep their permissions.
DELETE /gw/keys/{id}
Section titled “DELETE /gw/keys/{id}”Revoke a key (soft delete — usage history is preserved).
204 No Content on success, 404 if the key does not belong to your
organization.
GET /gw/subaccounts
Section titled “GET /gw/subaccounts”List your sub-accounts (end customers of a reseller).
[ { "id": "<uuid>", "client_id": "<uuid>", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "markup_pct": 20, "created_at": "2026-06-11T00:00:00Z" }]markup_pct (0..10000) is your resale markup in percent: the billed price shown
to the end customer is cost × (1 + markup_pct/100), computed on the fly —
nothing is materialized in storage.
POST /gw/subaccounts
Section titled “POST /gw/subaccounts”Create a sub-account.
{"name": "Acme Corp", "slug": "acme-corp"}slug must match [a-z0-9-]{1,64} and be unique within your organization
(duplicate → 409). name is required (non-empty after trimming).
Returns the full created sub-account object — the same shape as a
GET /gw/subaccounts row:
{ "id": "<uuid>", "client_id": "<uuid>", "name": "Acme Corp", "slug": "acme-corp", "status": "active", "markup_pct": 0, "created_at": "2026-06-11T00:00:00Z"}New sub-accounts start active with markup_pct 0 (no resale markup). Set a
markup later via PATCH /gw/subaccounts/{id}.
PATCH /gw/subaccounts/{id}
Section titled “PATCH /gw/subaccounts/{id}”Partial update — any subset of:
| Field | Values |
|---|---|
status | active | suspended |
name | non-empty string |
markup_pct | 0..10000 |
All provided fields are applied atomically. 204 No Content on success,
404 if the sub-account does not belong to your organization.
GET /gw/budgets
Section titled “GET /gw/budgets”All budgets of your organization with current spend. Budgets are hierarchical:
client → sub_account → key; a request is rejected with 402 when any
applicable level is exceeded (if budget enforcement is enabled).
[ {"scope": "client", "period": "month", "limit_usd": 500, "spend_usd": 123.45}, {"scope": "sub_account", "id": "<uuid>", "period": "month", "limit_usd": 100, "spend_usd": 80.10}, {"scope": "key", "id": "<uuid>", "period": "day", "limit_usd": 10, "spend_usd": 0.42}]id is omitted for the client scope.
PUT /gw/budgets
Section titled “PUT /gw/budgets”Upsert a budget at one level:
{"scope": "sub_account", "id": "<uuid>", "period": "month", "limit_usd": 100}scope is client (no id allowed), sub_account or key (id required);
period is month or day; limit_usd must be positive. 204 on success,
404 if the target does not belong to your organization.
GET /gw/branding
Section titled “GET /gw/branding”Your white-label portal settings. 404 (type: not_found) if branding is not
configured yet.
{ "client_id": "<uuid>", "portal_slug": "acme", "product_name": "Acme AI", "logo_url": "https://cdn.example.com/logo.png", "accent_color": "#3366ff", "allow_self_issue": false, "created_at": "2026-06-11T00:00:00Z", "updated_at": "2026-06-11T00:00:00Z"}logo_url and accent_color are null when unset (cleared by sending an empty
string to PUT /gw/branding). PUT returns
this exact same object after writing.
PUT /gw/branding
Section titled “PUT /gw/branding”Configure the white-label end-customer portal (served by the dashboard at
/p/{portal_slug}):
{ "portal_slug": "acme", "product_name": "Acme AI", "logo_url": "https://cdn.example.com/logo.png", "accent_color": "#3366ff", "allow_self_issue": false}| Field | Constraint |
|---|---|
portal_slug | [a-z0-9-]{1,64}, globally unique (conflict → 409) |
product_name | 1..100 characters |
logo_url | optional; must start with https://; empty string clears it |
accent_color | optional; #RRGGBB; empty string clears it |
allow_self_issue | lets portal users issue their own keys |
Returns the stored branding object — the full
GET /gw/branding shape (including client_id,
created_at, updated_at). A portal_slug already taken by another
organization → 409 (type: conflict).
GET /gw/stats
Section titled “GET /gw/stats”Aggregated requests / tokens / cost, grouped by a dimension.
| Param | Default | Values |
|---|---|---|
group_by | provider | provider, model, model_slug, author, day, region (rows recorded before region attestation fall into an unknown bucket), or any attribution key (project, sub_account, team, …). Dimension names are reserved: an attribution key spelled like one of them (e.g. X-Attr-Author) cannot be grouped on — the built-in dimension wins. |
since | 30d | 30d, 7d, 24h, 90m — d suffix for days, otherwise Go duration |
provider | — | filter by provider |
Response (sorted by total_cost_usd desc; group_by=day is chronological):
[ {"key": "openai", "requests": 1240, "total_tokens": 815000, "total_cost_usd": 42.17}, {"key": "anthropic", "requests": 311, "total_tokens": 99000, "total_cost_usd": 8.05}]An empty "key": "" bucket collects records missing the requested attribution
key.
author and model_slug bucket by the model catalog: the key is
the model’s author (e.g. alibaba) or its catalog slug (e.g.
alibaba/qwen3.7-max), regardless of which provider served the request. Records
whose route is not in the catalog fall into an other bucket.
GET /gw/usage
Section titled “GET /gw/usage”Raw usage rows, newest first.
| Param | Default | Values |
|---|---|---|
provider | — | filter by provider (exact match) |
parse_status | — | ok, partial, unknown; async also provisional, failed, cancelled, expired |
since | — | 30d, 24h, … |
limit | 100 | 1..1000 (out of range or non-integer → 400) |
cursor | — | opaque pagination cursor (see below); malformed → 400 |
Only limit is range-validated (an out-of-range value returns 400 rather than
silently clamping). provider and parse_status are matched verbatim and not
validated against a fixed set: an unrecognized value is accepted and simply
returns no matching rows. since must parse as a duration (30d, 24h, 90m)
or 400.
Pagination (keyset). The response body stays a flat array. When more rows
exist beyond limit, the response carries an X-Next-Cursor header; pass
its value back as ?cursor=… (keeping the same provider/parse_status/since)
to fetch the next page. The header is absent on the last page. The cursor is
opaque (keyset on created_at, id) — do not construct it yourself; a malformed
cursor returns 400. Rows are ordered created_at DESC, id DESC.
Unlike GET /gw/stats, this endpoint does not filter by
attribution keys — only provider, parse_status, since and limit are
honored. Each row still carries its full attribution map, so you can filter
client-side, or use /gw/stats?group_by=<key> to aggregate by an attribution
dimension server-side.
[ { "id": "<uuid>", "client_id": "<uuid>", "provider": "openai", "model": "gpt-4o", "model_slug": "openai/gpt-4o", "author": "openai", "cost_usd": 0.00075, "total_tokens": 150, "status_code": 200, "latency_ms": 840, "parse_status": "ok", "via_sdk": false, "attribution": {"project": "alpha"}, "created_at": "2026-05-28T00:00:00Z" }]via_sdk is a provenance marker: true when the request arrived through an
official Sociaro SDK, inferred from the request’s User-Agent. It is a display
hint only — the User-Agent is client-controlled and can be spoofed, so do not
treat it as a guarantee of how the request was made or as an input to billing.
model_slug and author identify the model in the
catalog; both are omitted when the request’s route is not
catalogued. provider and model are inference-side details — the route the
request actually took, which for catalogued models may differ from the
author (e.g. xai/grok-4 served through an aggregator) — and are kept
on the wire for compatibility.
GET /gw/async
Section titled “GET /gw/async”Your async job ledger — see async jobs for semantics.
| Param | Default | Values |
|---|---|---|
scope | key | key (this key’s jobs) or client (whole organization) — any other value → 400 |
status | — | pending, succeeded, failed, cancelled, expired |
kind | — | video, image, 3d, audio |
created_after / created_before | — | RFC3339 |
limit | 100 | 1..500 (clamped, not 400) |
cursor | — | opaque cursor from next_cursor |
scope is the only strictly-validated value (a value other than key/client
→ 400). Like GET /gw/usage, status and kind are matched
verbatim and not validated against the lists above: an unrecognized value is
accepted and returns no matching rows. limit is silently clamped to
[1, 500] here (out-of-range values do not error), in contrast to /gw/usage,
where an out-of-range limit returns 400. A malformed created_after /
created_before is ignored (the bound is dropped) rather than rejected.
{ "data": [ { "task_id": "video_2026...", "provider": "bytedance", "model": "seedance-native", "kind": "video", "status": "succeeded", "provisional_cost_usd": 1.25, "final_cost_usd": 1.10, "expiration_reason": null, "attribution": {"project": "alpha"}, "created_at": "2026-06-10T12:00:00Z", "finalized_at": "2026-06-10T12:04:31Z" } ], "next_cursor": ""}In the async ledger,
provideris the catalog vendor (e.g.bytedance,alibaba) andmodelis the inference-side native model id — not the catalog slug you submit to the door. The internal routing provider is never exposed here.
GET /gw/evidence
Section titled “GET /gw/evidence”Tamper-evident NDJSON export of your usage and audit records (hash-chained, bodies never included) for auditors and procurement — see evidence export & retention for the format, verification and retention policy.
/gw/stripe/*
Section titled “/gw/stripe/*”Self-service management of the Stripe Token Billing export
(meter events for per-sub-account resale invoicing). Seven routes; the raw
Stripe API key is write-only — no endpoint ever returns it (not the key, not
its length, not a last4). The conceptual model, payload shape, retry/backoff
and troubleshooting live on the Stripe page; the route
contract is repeated here so the reference is self-contained.
| Route | Scope | Body / Query | Result |
|---|---|---|---|
GET /gw/stripe/config | stats:read | — | {"enabled", "configured", "mappings": [{"sub_account_id", "stripe_customer_id"}]}. Not-configured is a valid 200 (enabled:false, configured:false), never 404 |
PUT /gw/stripe/config | keys:manage | {"enabled": bool, "api_key"?: "rk_…"} | With api_key: stores it encrypted (requires the gateway master key, else 503). Without api_key: toggles enabled only (400 when enabling with no key configured; disabling with no config is an idempotent 204). 204 |
PUT /gw/stripe/mappings/{sub_account_id} | keys:manage | {"stripe_customer_id": "cus_…"} | Map a sub-account to an existing Stripe customer. 204; 404 for a foreign/unknown sub-account |
DELETE /gw/stripe/mappings/{sub_account_id} | keys:manage | — | Unmap (stops enqueuing new events; already-queued events still send). 204 / 404 |
POST /gw/stripe/customers | keys:manage | {"sub_account_id", "name"?} | Create a Stripe customer with your stored key and save the mapping (name defaults to the sub-account name). Returns {"stripe_customer_id"}. Sub-account must be active; Stripe errors → 502 bad_gateway |
GET /gw/stripe/events | stats:read | status?=pending|sent|failed, limit (default 50, 1..200) | {"counts": {"pending","sent_24h","failed"}, "oldest_pending_age_seconds", "events": [...]} |
POST /gw/stripe/events/{usage_record_id}/retry | keys:manage | — | Re-queue a parked failed event (attempts reset; Stripe dedups by usage_record_id, so no double-billing). 204; 404 if not found / not yours / not failed |