Skip to content

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 no Bearer scheme. The bare form exists so a client can place the key in whatever auth header its provider SDK normally uses (e.g. Anthropic’s x-api-key); the gateway strips an optional Bearer prefix 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.
EndpointScope
GET /gw/meany valid key
GET /gw/modelsany valid key
GET /gw/ceilingkeys:manage
GET /gw/keyskeys:manage
POST /gw/keyskeys:manage
DELETE /gw/keys/{id}keys:manage
GET /gw/subaccountskeys:manage
POST /gw/subaccountskeys:manage
PATCH /gw/subaccounts/{id}keys:manage
GET /gw/budgetskeys:manage
PUT /gw/budgetskeys:manage
GET /gw/brandingkeys:manage
PUT /gw/brandingkeys:manage
POST /gw/billing/checkoutkeys:manage
GET /gw/statsstats:read
GET /gw/usagestats:read
GET /gw/asyncstats:read
GET /gw/evidencestats:read
GET /gw/stripe/configstats:read
PUT /gw/stripe/configkeys:manage
PUT /gw/stripe/mappings/{sub_account_id}keys:manage
DELETE /gw/stripe/mappings/{sub_account_id}keys:manage
POST /gw/stripe/customerskeys:manage
GET /gw/stripe/eventsstats:read
POST /gw/stripe/events/{usage_record_id}/retrykeys:manage

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) — provider is the inference provider, model_pattern is a glob over the provider’s native model id.
  • axis: "vendor"provider is a glob over the catalog vendor (the author-part of the slug) and model_pattern a 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"provider is a glob over the catalog kind (modality: llm | image | video | 3d | embedding | rerank | audio) and model_pattern a glob over the model name. Kind rules also only apply to catalog-known slugs.

Policy is default-deny, and deny wins across all axes.

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.

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"}
]
}

List your organization’s keys (no hashes, no plaintext).

ParamDefaultValues
limit— (all)1..1000 (out of range or non-integer → 400)
cursoropaque 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.

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} (see GET /gw/me). axis may be omitted (defaults to provider); any other value than provider/vendor/kind400.
  • Omitting entitlements entirely (field absent) makes the key inherit the full ceiling; an explicit [] is a deliberate deny-all for inference.
  • sub_account_id is optional: it binds the key to one of your sub-accounts (which must exist and be active, otherwise 403).
  • expires_in_seconds is optional: it sets a TTL — the key stops authenticating that many seconds after issuance (after which it returns 401, exactly like a revoked key). Omit it (or send nothing) for a non-expiring key. Must be between 1 and 315360000 (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-axis deny rows are always accepted — they can only narrow access.
  • vendor-axis allow with 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-axis allow with a glob pattern must be present in the ceiling exactly (vendor-axis glob grants are issued by the operator).
  • kind-axis deny rows are always accepted — they can only narrow access.
  • kind-axis allow rows must be present in the ceiling exactly (kind grants are always issued by the operator; there is no self-service resolve path for the kind axis, unlike vendor’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.

Revoke a key (soft delete — usage history is preserved). 204 No Content on success, 404 if the key does not belong to your organization.

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.

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}.

Partial update — any subset of:

FieldValues
statusactive | suspended
namenon-empty string
markup_pct0..10000

All provided fields are applied atomically. 204 No Content on success, 404 if the sub-account does not belong to your organization.

All budgets of your organization with current spend. Budgets are hierarchical: clientsub_accountkey; 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.

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.

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.

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
}
FieldConstraint
portal_slug[a-z0-9-]{1,64}, globally unique (conflict → 409)
product_name1..100 characters
logo_urloptional; must start with https://; empty string clears it
accent_coloroptional; #RRGGBB; empty string clears it
allow_self_issuelets 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).

Aggregated requests / tokens / cost, grouped by a dimension.

ParamDefaultValues
group_byproviderprovider, 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.
since30d30d, 7d, 24h, 90md suffix for days, otherwise Go duration
providerfilter 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.

Raw usage rows, newest first.

ParamDefaultValues
providerfilter by provider (exact match)
parse_statusok, partial, unknown; async also provisional, failed, cancelled, expired
since30d, 24h, …
limit1001..1000 (out of range or non-integer → 400)
cursoropaque 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.

Your async job ledger — see async jobs for semantics.

ParamDefaultValues
scopekeykey (this key’s jobs) or client (whole organization) — any other value → 400
statuspending, succeeded, failed, cancelled, expired
kindvideo, image, 3d, audio
created_after / created_beforeRFC3339
limit1001..500 (clamped, not 400)
cursoropaque cursor from next_cursor

scope is the only strictly-validated value (a value other than key/client400). 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, provider is the catalog vendor (e.g. bytedance, alibaba) and model is the inference-side native model id — not the catalog slug you submit to the door. The internal routing provider is never exposed here.

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.

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.

RouteScopeBody / QueryResult
GET /gw/stripe/configstats: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/configkeys: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:manageUnmap (stops enqueuing new events; already-queued events still send). 204 / 404
POST /gw/stripe/customerskeys: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/eventsstats:readstatus?=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}/retrykeys:manageRe-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