Admin (platform operators)
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
Separate trust domain
The /v1/admin/* surface authenticates with the platform-operator bearer (ADMIN_API_TOKEN), not with tenant API keys. Tenants cannot call these endpoints; they are documented for completeness.
Platform-admin surface (separate trust domain, ADMIN_API_TOKEN).
Endpoints
Provision a tenant (onboarding backbone)
POST /v1/admin/tenantsProvisions a new tenant + its first admin agent + a bootstrap API key in one call. SEPARATE trust domain (it predates any tenant), gated by the platform-admin bearer (ADMIN_API_TOKEN). FAIL-CLOSED: when the admin token is unset, every /v1/admin/* request is 401. The returned token is the tenant's first credential — plaintext shown ONCE. 400/422 on an invalid payload (no tenant is created).
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | |
tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
voice_ai_enabled | boolean | no | Offered on the tiers whose catalog entry declares a voice-AI allowance (growth, scale, enterprise, and the retired compliance_pro / audit_shield); enabling it on a tier that does not offer it — starter or dialer_core — is rejected. |
seats | integer | no | |
admin | object | yes | |
admin.name | string | yes | |
admin.email | string (email) | yes | |
admin.sip_extension | string | no |
{
"name": "Acme Collections",
"tier": "scale",
"voice_ai_enabled": false,
"seats": 10,
"admin": {
"name": "Owner",
"email": "[email protected]"
}
}Responses
201
Tenant + first admin agent + bootstrap key (plaintext ONCE).
| Field | Type | Required | Description |
|---|---|---|---|
tenant | object (Tenant) | yes | |
tenant.id | string (uuid) | no | |
tenant.name | string | no | |
tenant.status | active · suspended | no | |
tenant.retention_months | integer | no | |
tenant.seats | integer | no | |
tenant.tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tenant.voice_ai_enabled | boolean | no | |
tenant.ai_monthly_budget_usd | number | null | no | Cost-integrity ceiling (admin-set; null = no cap). |
tenant.ai_max_call_seconds | integer | null | no | Per-AI-call hard duration ceiling (seconds) set by the admin. null means the PLATFORM default applies — never "no ceiling" (core#922, ADR #115). |
tenant.ai_max_call_seconds_effective | integer | no | The ceiling FreeSWITCH is actually handed for this tenant's AI legs: ai_max_call_seconds when set, else the cell's platform default (AI_MAX_CALL_SECONDS_DEFAULT, compiled fallback 900). Always present, never null. Read-only (derived). |
tenant.sms_monthly_budget_usd | number | null | no | Month-to-date SMS spend cap. |
tenant.human_initiated_enabled | boolean | no | D17 human-initiated consent posture (admin-set; default false). When true, a LIVE preview/manual/CTI voice dial with no artificial and no AI voice does not require prior express consent (no ATDS). Every other gate — DNC, quiet hours, Reg F, state matrix, artificial voice — still applies unchanged. |
tenant.max_cps | integer | null | no | Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR |
tenant.max_concurrent_channels | integer | null | no | Per-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR |
tenant.abandon_seller_name | string | null | no | Seller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14. |
tenant.abandon_seller_phone | string | null | no | E.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14. |
tenant.tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_region | string | null | no | State/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_postal_code | string | null | no | Postal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.created_at | string (date-time) | no | |
tenant.updated_at | string (date-time) | no | |
admin_agent | object (Agent) | yes | |
admin_agent.id | string (uuid) | no | |
admin_agent.tenant_id | string (uuid) | no | |
admin_agent.name | string | no | |
admin_agent.email | string | no | |
admin_agent.role | agent · supervisor · admin | no | |
admin_agent.status | active · inactive | no | Roster flag — NOT presence. |
admin_agent.sip_extension | string | null | no | |
admin_agent.device_mode | browser · external | no | |
admin_agent.login_enabled | boolean | no | Widget self-login state — true iff a password credential is set. The password hash itself is never exposed. |
admin_agent.presence | offline · available · ringing · on_call · wrap_up · paused | no | Live seat presence from the in-memory AgentFSM. wrap_up is after-call work (entered on hangup when the tenant's agent_wrap_up_seconds cap is above 0); paused is a seat taken out of rotation with a reason (ADR #101 D1, MT-ROAD-R11). |
admin_agent.created_at | string (date-time) | no | |
admin_agent.updated_at | string (date-time) | no | |
api_key | object | yes | |
api_key.token | string | yes | Bootstrap bearer, shown exactly once. |
{
"tenant": {
"id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"name": "Acme Collections",
"status": "active",
"tier": "scale",
"voice_ai_enabled": false,
"seats": 10,
"retention_months": 84,
"ai_monthly_budget_usd": null,
"ai_max_call_seconds": null,
"ai_max_call_seconds_effective": 900,
"sms_monthly_budget_usd": null,
"created_at": "2026-07-05T10:00:00Z",
"updated_at": "2026-07-05T10:00:00Z"
},
"admin_agent": {
"id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"tenant_id": "0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10",
"name": "Owner",
"email": "[email protected]",
"role": "admin",
"status": "active",
"sip_extension": null,
"device_mode": "external",
"presence": "offline",
"created_at": "2026-07-05T10:00:00Z",
"updated_at": "2026-07-05T10:00:00Z"
},
"api_key": {
"token": "dd_example_bootstrap_shown_once"
}
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Change plan / billing-bearing fields
PATCH /v1/admin/tenants/{id}The billing-bearing fields a tenant must NOT be able to self-edit (under-pay vector): tier, voice_ai_enabled, seats, plus the cost-integrity ceilings (ai_monthly_budget_usd, ai_max_call_seconds, sms_monthly_budget_usd; null = no cap — except ai_max_call_seconds, where null = the platform default applies, core#922). The Tenant changeset still enforces the tier vocabulary and the voice-AI eligibility rule.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | |
tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
voice_ai_enabled | boolean | no | |
seats | integer | no | |
ai_monthly_budget_usd | number | null | no | |
ai_max_call_seconds | integer | null | no | null clears the tenant's own ceiling and puts it back on the platform default (the response's ai_max_call_seconds_effective shows the number that will apply). There is no "no ceiling". |
sms_monthly_budget_usd | number | null | no | |
human_initiated_enabled | boolean | no | D17 human-initiated consent posture (default false). ADMIN-ONLY: it is compliance-loosening, so it is never writable on the tenant self-edit PATCH /v1/tenant. |
max_cps | integer | null | no | Per-tenant CPS ceiling (ADR |
max_concurrent_channels | integer | null | no | Per-tenant concurrent-channel ceiling (ADR |
abandon_seller_name | string | null | no | Seller name spoken by the FTC TSR §310.4(b)(4)(iii) abandoned-call identification message (ADR #14). Admin-only. Null = not configured; the predictive over-dial stays dark (seat-first) until BOTH abandon_seller_name and abandon_seller_phone are set. |
abandon_seller_phone | string | null | no | E.164 callback number spoken by the abandoned-call identification message (must accept do-not-call requests during business hours). Admin-only; null = not configured (over-dial stays dark). |
tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (MT-BIL-07 (3), ADR #77). Admin-only: a tenant that could declare its own jurisdiction could pick its own tax. The shape is validated, the list of countries is NOT. Captured only — no invoice amount changes because of this field. |
tax_region | string | null | no | State/province code of the sales-tax jurisdiction (ADR #77). Admin-only; null = not declared. |
tax_postal_code | string | null | no | Postal code of the sales-tax jurisdiction (ADR #77). Admin-only; null = not declared. |
{
"tier": "enterprise",
"voice_ai_enabled": true,
"seats": 25,
"abandon_seller_name": "Acme Recovery LLC",
"abandon_seller_phone": "+18005550123"
}Responses
200
Updated tenant.
| Field | Type | Required | Description |
|---|---|---|---|
tenant | object (Tenant) | yes | |
tenant.id | string (uuid) | no | |
tenant.name | string | no | |
tenant.status | active · suspended | no | |
tenant.retention_months | integer | no | |
tenant.seats | integer | no | |
tenant.tier | starter · growth · scale · enterprise · dialer_core · compliance_pro · audit_shield | no | |
tenant.voice_ai_enabled | boolean | no | |
tenant.ai_monthly_budget_usd | number | null | no | Cost-integrity ceiling (admin-set; null = no cap). |
tenant.ai_max_call_seconds | integer | null | no | Per-AI-call hard duration ceiling (seconds) set by the admin. null means the PLATFORM default applies — never "no ceiling" (core#922, ADR #115). |
tenant.ai_max_call_seconds_effective | integer | no | The ceiling FreeSWITCH is actually handed for this tenant's AI legs: ai_max_call_seconds when set, else the cell's platform default (AI_MAX_CALL_SECONDS_DEFAULT, compiled fallback 900). Always present, never null. Read-only (derived). |
tenant.sms_monthly_budget_usd | number | null | no | Month-to-date SMS spend cap. |
tenant.human_initiated_enabled | boolean | no | D17 human-initiated consent posture (admin-set; default false). When true, a LIVE preview/manual/CTI voice dial with no artificial and no AI voice does not require prior express consent (no ATDS). Every other gate — DNC, quiet hours, Reg F, state matrix, artificial voice — still applies unchanged. |
tenant.max_cps | integer | null | no | Per-tenant CPS ceiling (admin-set; null = no ceiling). ADR |
tenant.max_concurrent_channels | integer | null | no | Per-tenant concurrent-channel ceiling (admin-set; null = no ceiling). ADR |
tenant.abandon_seller_name | string | null | no | Seller name for the FTC TSR abandoned-call identification message (admin-set; null = not configured, predictive over-dial stays dark). ADR #14. |
tenant.abandon_seller_phone | string | null | no | E.164 callback number for the abandoned-call identification message (admin-set; null = not configured). ADR #14. |
tenant.tax_country | string | null | no | ISO-3166-1 alpha-2 country of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_region | string | null | no | State/province code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.tax_postal_code | string | null | no | Postal code of the client's sales-tax jurisdiction (admin-set; null = not declared). ADR #77. |
tenant.created_at | string (date-time) | no | |
tenant.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Assign a HOUSE number to a tenant
POST /v1/admin/tenants/{id}/didsSame metadata surface as the tenant-facing POST /v1/dids EXCEPT origin/status: origin is pinned to house by the context and a fresh assignment is always active. The tenant-facing route can only ever create origin: customer — rentable inventory is admin-minted ONLY. A number already active in ANY sede is a clean 409 (global partial unique index).
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
e164 | string | yes | |
npa | string | no | |
us_state | string | no | |
attestation | A · B · C · unknown | no | |
labels | array of string | no | |
notes | string | no |
{
"e164": "+13125550600",
"us_state": "IL",
"attestation": "A",
"labels": [
"house-pool"
]
}Responses
201
Assigned house DID (origin: house, status: active).
| Field | Type | Required | Description |
|---|---|---|---|
did | object (DID) | yes | |
did.id | string (uuid) | no | |
did.tenant_id | string (uuid) | no | |
did.e164 | string | no | Immutable after creation. |
did.npa | string | null | no | |
did.us_state | string | null | no | |
did.attestation | A · B · C · unknown | no | STIR/SHAKEN level the carrier signs. |
did.status | active · quarantine · retired | no | |
did.origin | house · customer | no | house rows are admin-minted platform inventory; the tenant surface only creates customer. |
did.labels | array of string | no | |
did.notes | string | null | no | |
did.created_at | string (date-time) | no | |
did.updated_at | string (date-time) | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}The tenant's plan anchor
GET /v1/admin/tenants/{id}/planstatus is the field that decides whether the tenant may place traffic: anything other than active is dark. The paid-period fields stay null until a payment lands, so "opened but not paid" is distinguishable from "paid" at a glance. 404 no_plan when the plan has never been opened.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The plan anchor row.
| Field | Type | Required | Description |
|---|---|---|---|
plan | object (TenantPlan) | yes | The plan anchor (ADR #42). status is what PlanGate reads: only active lets the tenant place traffic. |
plan.scheme | subscription · prepaid · postpaid | no | |
plan.status | draft · pending_approval · pending_deposit · pending_payment · active · suspended | no | |
plan.tier_row_id | string (uuid) | null | no | The SELLABLE tier version this plan committed to (subscription only). |
plan.current_period_start | string (date) | null | no | |
plan.current_period_end | string (date) | null | no | |
plan.period_price_usd | string | null | no | Decimal STRING (money is never a JSON float). |
plan.auto_renew | boolean | no | |
plan.enforcement_mode | hard_block · graduated | no | What a spend-threshold crossing does: graduated notifies the tenant, hard_block only records it. Neither posture changes WHETHER the tenant is blocked at 100% — that is unconditional. |
plan.alert_thresholds | array of integer | no | % points of the plan limit at which the tenant is warned. |
plan.alert_email | string (email) | null | no | Where spend alerts are e-mailed. null ⇒ no e-mail destination (the emitter falls back to log-only), which is the state of every plan opened before this column existed. |
plan.alert_webhook_url | string (uri) | null | no | POST target for graduated-mode notifications. null ⇒ log-only. Admin surface only — it can carry a secret path token. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Open the tenant's plan and choose its billing scheme
POST /v1/admin/tenants/{id}/planCreates the plan anchor if absent (idempotent draft) and commits the billing scheme. subscription and prepaid land on pending_payment; postpaid opens an operator approval and lands on pending_approval. The plan is NOT active yet — POST /v1/admin/tenants/{id}/payments activates a pending_payment plan.
tier_key is required for subscription and must name a SELLABLE, priced tier; a retired or unpriced key is refused 422 rather than silently selecting a different one.
Admin-only, like every plan-bearing surface: a tenant must never choose its own billing posture.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
scheme | subscription · prepaid · postpaid | yes | |
tier_key | string | no | Required for subscription; ignored otherwise. |
{
"scheme": "subscription",
"tier_key": "scale"
}Responses
201
Plan opened and scheme committed.
| Field | Type | Required | Description |
|---|---|---|---|
plan | object (TenantPlan) | yes | The plan anchor (ADR #42). status is what PlanGate reads: only active lets the tenant place traffic. |
plan.scheme | subscription · prepaid · postpaid | no | |
plan.status | draft · pending_approval · pending_deposit · pending_payment · active · suspended | no | |
plan.tier_row_id | string (uuid) | null | no | The SELLABLE tier version this plan committed to (subscription only). |
plan.current_period_start | string (date) | null | no | |
plan.current_period_end | string (date) | null | no | |
plan.period_price_usd | string | null | no | Decimal STRING (money is never a JSON float). |
plan.auto_renew | boolean | no | |
plan.enforcement_mode | hard_block · graduated | no | What a spend-threshold crossing does: graduated notifies the tenant, hard_block only records it. Neither posture changes WHETHER the tenant is blocked at 100% — that is unconditional. |
plan.alert_thresholds | array of integer | no | % points of the plan limit at which the tenant is warned. |
plan.alert_email | string (email) | null | no | Where spend alerts are e-mailed. null ⇒ no e-mail destination (the emitter falls back to log-only), which is the state of every plan opened before this column existed. |
plan.alert_webhook_url | string (uri) | null | no | POST target for graduated-mode notifications. null ⇒ log-only. Admin surface only — it can carry a secret path token. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Set the tenant's spend-alert thresholds and destinations
PUT /v1/admin/tenants/{id}/plan/alertsThe tenant is warned as its spend crosses thresholds (% points of the plan limit). The customer tunes WHEN it is warned, never WHETHER it is blocked — 100 is always implied by the emitter even if removed here, and blocking is decided by the enforcement posture, not by this route.
thresholds is REQUIRED. Each destination is OPTIONAL and the asymmetry is deliberate:
- omit
email/webhook_url→ the stored value is PRESERVED, so retuning thresholds can never blind a tenant's alerting; - send it as
null→ the destination is CLEARED; - send an address / URL → it is written.
Both destinations null means log-only, which is the state of every plan opened before the alert e-mail landed. A malformed address is refused 422 and NOTHING is written — not the thresholds either.
Only an active plan can be tuned; anything else is 409. Note the request/response asymmetry: the body names the destinations email and webhook_url, while the plan echoes the stored columns alert_email and alert_webhook_url.
Admin-only, like every plan-bearing surface. There is no screen for it: the dashboard proxy denies the whole /v1/admin prefix, so this is runbook surface.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
thresholds | array of integer | yes | Non-empty, strictly ascending, every element in 1..100. |
email | string (email) | null | no | Omit to preserve; null to clear. |
webhook_url | string (uri) | null | no | Omit to preserve; null to clear. |
{
"thresholds": [
80,
90,
100
],
"email": "[email protected]"
}Responses
200
Alert config stored; the plan echoes it back.
| Field | Type | Required | Description |
|---|---|---|---|
plan | object (TenantPlan) | yes | The plan anchor (ADR #42). status is what PlanGate reads: only active lets the tenant place traffic. |
plan.scheme | subscription · prepaid · postpaid | no | |
plan.status | draft · pending_approval · pending_deposit · pending_payment · active · suspended | no | |
plan.tier_row_id | string (uuid) | null | no | The SELLABLE tier version this plan committed to (subscription only). |
plan.current_period_start | string (date) | null | no | |
plan.current_period_end | string (date) | null | no | |
plan.period_price_usd | string | null | no | Decimal STRING (money is never a JSON float). |
plan.auto_renew | boolean | no | |
plan.enforcement_mode | hard_block · graduated | no | What a spend-threshold crossing does: graduated notifies the tenant, hard_block only records it. Neither posture changes WHETHER the tenant is blocked at 100% — that is unconditional. |
plan.alert_thresholds | array of integer | no | % points of the plan limit at which the tenant is warned. |
plan.alert_email | string (email) | null | no | Where spend alerts are e-mailed. null ⇒ no e-mail destination (the emitter falls back to log-only), which is the state of every plan opened before this column existed. |
plan.alert_webhook_url | string (uri) | null | no | POST target for graduated-mode notifications. null ⇒ log-only. Admin surface only — it can carry a secret path token. |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Set the plan's enforcement posture
PUT /v1/admin/tenants/{id}/plan/enforcement-modeChooses what happens when the tenant's spend crosses one of its alert thresholds:
graduated→ the crossing NOTIFIES the tenant (e-mail / webhook, perPUT .../plan/alerts);hard_block→ the crossing is recorded and nothing is sent.
The threshold history is written in BOTH postures; only the notification differs. What this route does NOT do is as important as what it does: it never moves a limit, never retunes a threshold and never softens the 100% verdict — spend enforcement blocks at the limit in both postures. There is no "off".
mode is REQUIRED and the vocabulary is closed: anything else — an absent key, a wrong word, a non-string — is refused 422 and the plan is left untouched. Only an active plan has a posture to set; anything else is 409, and a tenant whose plan was never opened is 404 no_plan.
Admin-only, like every plan-bearing surface. There is no screen for it: the dashboard proxy denies the whole /v1/admin prefix, so this is runbook surface.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
mode | hard_block · graduated | yes |
{
"mode": "graduated"
}Responses
200
Posture stored; the plan echoes it back.
| Field | Type | Required | Description |
|---|---|---|---|
plan | object (TenantPlan) | yes | The plan anchor (ADR #42). status is what PlanGate reads: only active lets the tenant place traffic. |
plan.scheme | subscription · prepaid · postpaid | no | |
plan.status | draft · pending_approval · pending_deposit · pending_payment · active · suspended | no | |
plan.tier_row_id | string (uuid) | null | no | The SELLABLE tier version this plan committed to (subscription only). |
plan.current_period_start | string (date) | null | no | |
plan.current_period_end | string (date) | null | no | |
plan.period_price_usd | string | null | no | Decimal STRING (money is never a JSON float). |
plan.auto_renew | boolean | no | |
plan.enforcement_mode | hard_block · graduated | no | What a spend-threshold crossing does: graduated notifies the tenant, hard_block only records it. Neither posture changes WHETHER the tenant is blocked at 100% — that is unconditional. |
plan.alert_thresholds | array of integer | no | % points of the plan limit at which the tenant is warned. |
plan.alert_email | string (email) | null | no | Where spend alerts are e-mailed. null ⇒ no e-mail destination (the emitter falls back to log-only), which is the state of every plan opened before this column existed. |
plan.alert_webhook_url | string (uri) | null | no | POST target for graduated-mode notifications. null ⇒ log-only. Admin surface only — it can carry a secret path token. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Record a MANUAL payment (wire/cash/check) for a tenant
POST /v1/admin/tenants/{id}/paymentsAn operator records an off-platform payment the tenant made; the durable payments audit fact lands (exactly-once on payment_ref) and, if the plan accepts it, the plan activates / the wallet is credited (Dialer.Payments.confirm_manual/3). Idempotent on payment_ref — a replay neither double-records nor double-credits. amount_usd MUST be a decimal STRING (a JSON number is refused 400). A refused edge (below the plan minimum; a renewal on an already-active subscription) still records the audit fact and returns 422 — never a wrong money movement.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
payment_ref | string | yes | OUR-side idempotency key (the wire/SPEI/check reference). |
amount_usd | string | yes | Decimal STRING (never a JSON number). |
kind | subscription_period · prepaid_topup · postpaid_deposit | yes | |
currency | string | no |
{
"payment_ref": "wire-2026-07-07-001",
"amount_usd": "50.00",
"kind": "prepaid_topup"
}Responses
201
Payment recorded and applied.
| Field | Type | Required | Description |
|---|---|---|---|
payment | object | yes | |
payment.status | string | no | |
payment.plan_status | string | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422 — Unprocessable
Shape is fine, semantics are not (broken domain rule).
{
"error": {
"code": "unprocessable",
"message": "scheduled_at must be at least 10 minutes out"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Effective governed config for a tenant
GET /v1/admin/tenants/{id}/configFor every key declared in the governed catalog: the value its CONSUMER actually sees, with its provenance (source), WHERE it is read (consumer), whether a per-tenant override applies at all (override_honoured), and any stored override that is NOT being applied (ignored_override).
Not every declared key resolves through the override table. A key whose consumer still reads application config reports the application-config value, and a row stored against it is reported as ignored_override rather than as the effective value — so a knob can never read back as armed while the code acting on it sees something else. For a key that IS resolved here, precedence is catalog default <- segment <- tenant.
The catalog default is also the FAIL-SAFE value: if the config table cannot be read, resolution falls back to it rather than raising or enabling anything.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes |
Responses
200
The effective catalog for this tenant.
| Field | Type | Required | Description |
|---|---|---|---|
config | array of ConfigEntry | yes | |
config[].key | string | yes | |
config[].value | boolean | integer | string | null | yes | The value the code acting on this key actually sees. For a key whose consumer is resolver that is the resolved override chain; for a key read from application config it is the application-config value, reported RAW — a misconfigured deployment shows up here rather than being sanitised into a value nothing acts on. NEVER a stored override that the consumer does not read. |
config[].source | default · override · app_config | yes | default = the compile-time platform default; override = a stored per-tenant value that IS honoured; app_config = an explicitly set application-config value on the deployment. |
config[].type | boolean · integer | yes | |
config[].default | boolean | integer | yes | The platform default, which is also the fail-safe value. |
config[].sensitivity | compliance_loosening · money · security · operational | yes | Who may write this override. Everything except operational is admin-only forever: a tenant must never grant itself a laxer compliance posture or a higher spend ceiling. |
config[].consumer | string | yes | WHERE this key is really read. resolver = through the governed catalog, so a per-tenant override is honoured. app_config:<key> = the consumer reads that application-config key directly, so an override would be inert — and <key> is the exact name to set on the deployment instead. |
config[].override_honoured | boolean | yes | Whether writing a per-tenant override for this key would change anything. false means PUT on this key is refused with 409. |
config[].ignored_override | object,null | no | A row that EXISTS in the override table and is NOT reflected in value — residue to clean up with DELETE. Null when there is none. |
config[].ignored_override.value | boolean | integer | string | null | yes | The stored value that is not being applied. |
config[].ignored_override.reason | consumer_not_migrated · invalid_value | yes | consumer_not_migrated — the consumer reads application config, so nothing reads this row. invalid_value — the stored value does not match the declared type, so resolution falls back to the default. |
config[].doc | string | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Set a per-tenant override for a governed key
PUT /v1/admin/tenants/{id}/config/{key}Stores a tenant override for a DECLARED catalog key and records the change in the append-only audit trail (the write and its audit row commit together: if the trail cannot be written, the override does not happen).
The key must exist in the catalog (422 unknown_config_key), the value must match its declared type (422 invalid_config_value), and the key's consumer must actually READ the override table (409 config_override_not_honoured).
That last one is the point: storing an override nobody reads is not a harmless no-op — it made this endpoint report a money brake as armed while the code spending the money still saw it off. A write that cannot change behaviour is refused rather than accepted with a caveat. Check override_honoured on the GET before writing; when it is false, set the application-config key named in consumer on the deployment instead.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | |
key | path | string | yes | A key declared in the governed catalog. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
value | boolean | integer | yes | Must match the key's declared type (boolean or integer). |
{
"value": true
}Responses
200
Override stored; the whole effective catalog is returned.
| Field | Type | Required | Description |
|---|---|---|---|
config | array of ConfigEntry | yes | |
config[].key | string | yes | |
config[].value | boolean | integer | string | null | yes | The value the code acting on this key actually sees. For a key whose consumer is resolver that is the resolved override chain; for a key read from application config it is the application-config value, reported RAW — a misconfigured deployment shows up here rather than being sanitised into a value nothing acts on. NEVER a stored override that the consumer does not read. |
config[].source | default · override · app_config | yes | default = the compile-time platform default; override = a stored per-tenant value that IS honoured; app_config = an explicitly set application-config value on the deployment. |
config[].type | boolean · integer | yes | |
config[].default | boolean | integer | yes | The platform default, which is also the fail-safe value. |
config[].sensitivity | compliance_loosening · money · security · operational | yes | Who may write this override. Everything except operational is admin-only forever: a tenant must never grant itself a laxer compliance posture or a higher spend ceiling. |
config[].consumer | string | yes | WHERE this key is really read. resolver = through the governed catalog, so a per-tenant override is honoured. app_config:<key> = the consumer reads that application-config key directly, so an override would be inert — and <key> is the exact name to set on the deployment instead. |
config[].override_honoured | boolean | yes | Whether writing a per-tenant override for this key would change anything. false means PUT on this key is refused with 409. |
config[].ignored_override | object,null | no | A row that EXISTS in the override table and is NOT reflected in value — residue to clean up with DELETE. Null when there is none. |
config[].ignored_override.value | boolean | integer | string | null | yes | The stored value that is not being applied. |
config[].ignored_override.reason | consumer_not_migrated · invalid_value | yes | consumer_not_migrated — the consumer reads application config, so nothing reads this row. invalid_value — the stored value does not match the declared type, so resolution falls back to the default. |
config[].doc | string | no |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409
config_override_not_honoured — the key is declared and the value is well typed, but this key's consumer reads application config, so the override would be inert and is refused. Distinct from the 422s on purpose: nothing is wrong with the request, the target's current state cannot accept it. Migrating the consumer makes the identical request succeed.
{
"error": {
"code": "config_override_not_honoured",
"message": "this key's consumer reads application config (:spend_guard_enforce), not the per-tenant override table, so the override would be inert and is refused. Set it through the deployment's application config instead."
}
}422
unknown_config_key — the key is not declared in the catalog, or invalid_config_value — the value does not match its declared type.
{
"error": {
"code": "unknown_config_key",
"message": "no such key in the governed catalog"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Drop a per-tenant override (back to the catalog default)
DELETE /v1/admin/tenants/{id}/config/{key}Deactivates the override so the key resolves to its catalog default. The row is DEACTIVATED, never deleted (DELETE is revoked for the app role): the record that this tenant once carried an override is itself evidence. Idempotent — dropping an absent override is a 200.
Unlike PUT, this is allowed even when override_honoured is false: it is the only way to clear a row that is reported as ignored_override, and gating removal on the same condition that makes a row inert would strand exactly the rows that need clearing.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | |
key | path | string | yes |
Responses
200
Override dropped (or already absent); effective catalog returned.
| Field | Type | Required | Description |
|---|---|---|---|
config | array of ConfigEntry | yes | |
config[].key | string | yes | |
config[].value | boolean | integer | string | null | yes | The value the code acting on this key actually sees. For a key whose consumer is resolver that is the resolved override chain; for a key read from application config it is the application-config value, reported RAW — a misconfigured deployment shows up here rather than being sanitised into a value nothing acts on. NEVER a stored override that the consumer does not read. |
config[].source | default · override · app_config | yes | default = the compile-time platform default; override = a stored per-tenant value that IS honoured; app_config = an explicitly set application-config value on the deployment. |
config[].type | boolean · integer | yes | |
config[].default | boolean | integer | yes | The platform default, which is also the fail-safe value. |
config[].sensitivity | compliance_loosening · money · security · operational | yes | Who may write this override. Everything except operational is admin-only forever: a tenant must never grant itself a laxer compliance posture or a higher spend ceiling. |
config[].consumer | string | yes | WHERE this key is really read. resolver = through the governed catalog, so a per-tenant override is honoured. app_config:<key> = the consumer reads that application-config key directly, so an override would be inert — and <key> is the exact name to set on the deployment instead. |
config[].override_honoured | boolean | yes | Whether writing a per-tenant override for this key would change anything. false means PUT on this key is refused with 409. |
config[].ignored_override | object,null | no | A row that EXISTS in the override table and is NOT reflected in value — residue to clean up with DELETE. Null when there is none. |
config[].ignored_override.value | boolean | integer | string | null | yes | The stored value that is not being applied. |
config[].ignored_override.reason | consumer_not_migrated · invalid_value | yes | consumer_not_migrated — the consumer reads application config, so nothing reads this row. invalid_value — the stored value does not match the declared type, so resolution falls back to the default. |
config[].doc | string | no |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}422
unknown_config_key — the key is not declared in the catalog.
429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Who changed which governed knob, from what to what
GET /v1/admin/tenants/{id}/config-auditThe append-only trail of config changes for this tenant, newest first. Append-only at the DATABASE level (a trigger rejects UPDATE/DELETE and the grants revoke them), so the process that writes the trail cannot rewrite it.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes |
Responses
200
The change trail.
| Field | Type | Required | Description |
|---|---|---|---|
audit | array of ConfigAuditEntry | yes | |
audit[].key | string | yes | |
audit[].old_value | boolean | integer | null | no | |
audit[].new_value | boolean | integer | null | no | |
audit[].active | boolean | yes | false = this change DEACTIVATED the override (back to the default). |
audit[].actor | string | null | no | |
audit[].changed_at | string (date-time) | yes |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}The tenant's design-partner discount history, plus the grant in force
GET /v1/admin/tenants/{id}/design-partner-discountsEvery grant ever appended for this tenant, newest first, plus the one in force today (null when there is none).
The superseded rows ARE the audit trail — a month that already closed was billed against the grant in force then — so the history is served whole rather than filtered down to what applies now. in_force is DERIVED on every read and never stored, so it cannot drift from it.
Admin-only, like every surface that decides an invoice.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Responses
200
The grants.
| Field | Type | Required | Description |
|---|---|---|---|
discounts | array of DesignPartnerDiscount | yes | |
discounts[].id | string (uuid) | yes | |
discounts[].tenant_id | string (uuid) | yes | |
discounts[].percent | string | yes | Percentage points off the voice-AI subtotal, as a decimal string. |
discounts[].effective_from | string (date) | yes | The first day the grant applies. |
discounts[].effective_to | string (date) | yes | The LAST day the grant applies (inclusive). |
discounts[].granted_by | string | yes | Who granted it, and on what basis. Never machine-inferred. |
discounts[].note | string | no | |
discounts[].inserted_at | string (date-time) | yes | |
in_force | DesignPartnerDiscount | null | yes | The grant applying today, or null. |
as_of | string (date) | yes | The date in_force was resolved at. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Grant the design-partner discount on the voice-AI add-on
POST /v1/admin/tenants/{id}/design-partner-discountsAPPENDS a discount grant on the voice-AI add-on (core#220). The pricing page promises design partners «20% off for 6 months»; this is the only surface that can give it to anybody.
ADMIN-ONLY, and for the reason the whole /v1/admin/tenants/{id} family exists: a discount decides the invoice as directly as the tier does, so a tenant that could write its own would be under-paying by API. PATCH /v1/tenant accepts name and nothing else, and a discount field sent there is ignored like every other billing-bearing key.
There is deliberately no PUT and no DELETE: ending a grant EARLY is a new row with percent: 0 and a later effective_from, because a month that already closed has to keep reading the grant that applied to it.
The window is closed at BOTH ends and it is REQUIRED. Send exactly one of effective_to (an explicit end, for a negotiated term) or months (the published duration counted from effective_from); neither is a 400 rather than a grant with no end, and both is a 400 rather than one of the two silently winning.
Auth: platform-admin bearer (ADMIN_API_TOKEN) — not a tenant API key.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string (uuid) | yes | Resource UUID. Malformed or cross-tenant ids read as 404. |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
percent | string | yes | Percentage points off the voice-AI subtotal, as a DECIMAL STRING (never a JSON float: binary floats are refused outright by the changeset, as everywhere else money is handled). 0 is legal and is how a grant is ended early; outside [0, 100] is a 400. |
effective_from | string (date) | no | The day the grant starts applying. Defaults to today. A future date schedules it without retro-applying it. |
effective_to | string (date) | no | The LAST day the grant applies (inclusive). Mutually exclusive with months. |
months | integer | no | The grant's duration in calendar months from effective_from, the way the published offer states it (6). Mutually exclusive with effective_to. No maximum is declared because the real ceiling is not a constant: effective_from + months must land inside the range a date column holds (PostgreSQL: 4713 BC .. 5874897 AD), so how large months may be depends on the start date. A value that pushes effective_to past it is a 400 invalid_months naming the year it refused. |
granted_by | string | yes | Who granted it and on what basis. Required: a discount moves money, so no row is unsigned and the machine never grants one. |
note | string | no |
Responses
201
The grant as stored.
| Field | Type | Required | Description |
|---|---|---|---|
discount | object (DesignPartnerDiscount) | yes | One append-only design-partner discount grant (core#220): from effective_from through effective_to INCLUSIVE, the tenant's voice-AI subtotal is reduced by percent points. Rows are never edited or deleted; ending a grant early appends a percent: 0 row with a later effective_from, and the superseded rows are the audit trail. |
discount.id | string (uuid) | yes | |
discount.tenant_id | string (uuid) | yes | |
discount.percent | string | yes | Percentage points off the voice-AI subtotal, as a decimal string. |
discount.effective_from | string (date) | yes | The first day the grant applies. |
discount.effective_to | string (date) | yes | The LAST day the grant applies (inclusive). |
discount.granted_by | string | yes | Who granted it, and on what basis. Never machine-inferred. |
discount.note | string | no | |
discount.inserted_at | string (date-time) | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Every tax-nexus declaration, plus the subset in force today
GET /v1/admin/tax-nexusThe jurisdictions where the company has declared tax nexus (ADR #80 decision 3). Returns the WHOLE append-only history in nexus, newest first, and the derived subset in force today in in_force.
The superseded rows are the audit trail — a month that already closed was closed against the declaration in force then, so hiding them would defeat the reason the table is append-only. in_force is DERIVED on every read, never stored, so it cannot drift from the history.
Admin-only, with no tenant-facing counterpart even for reading: nexus is a fact about the COMPANY, not about a customer, so exposing it per tenant would publish the platform's fiscal posture to every account.
An EMPTY list means "no nexus declared anywhere", which is the truth until the founder declares the first state — never "unknown".
Responses
200
The declarations.
| Field | Type | Required | Description |
|---|---|---|---|
nexus | array of TaxNexusState | yes | |
nexus[].id | string (uuid) | yes | |
nexus[].country | string | yes | ISO-3166 alpha-2, stored upcased. |
nexus[].region | string | yes | State/province code, stored upcased. |
nexus[].effective_from | string (date) | yes | The day this declaration starts applying. |
nexus[].active | boolean | yes | false = nexus ENDED on effective_from. |
nexus[].declared_by | string | yes | Who declared it, and on what basis. Never machine-inferred. |
nexus[].note | string | no | |
nexus[].inserted_at | string (date-time) | yes | |
in_force | array of TaxNexusState | yes | |
in_force[].id | string (uuid) | yes | |
in_force[].country | string | yes | ISO-3166 alpha-2, stored upcased. |
in_force[].region | string | yes | State/province code, stored upcased. |
in_force[].effective_from | string (date) | yes | The day this declaration starts applying. |
in_force[].active | boolean | yes | false = nexus ENDED on effective_from. |
in_force[].declared_by | string | yes | Who declared it, and on what basis. Never machine-inferred. |
in_force[].note | string | no | |
in_force[].inserted_at | string (date-time) | yes | |
as_of | string (date) | yes | The date in_force was resolved at. |
401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}Declare tax nexus in a jurisdiction
POST /v1/admin/tax-nexusAPPENDS a declaration (ADR #80 decision 3). There is deliberately no PUT and no DELETE: ending a nexus is a NEW row with active: false and a later effective_from, because a month that already closed has to keep reading the declaration that was in force when it closed.
The machine never infers nexus from revenue thresholds — that would be a tax engine that is wrong silently — so declared_by is required and must name the human or the ruling behind the row.
country and region are upcased before storing, so ca and CA are the same jurisdiction and collide instead of becoming two rows.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
country | string | yes | ISO-3166 alpha-2. Shape, not a closed list. |
region | string | yes | State/province code as the jurisdiction writes it. |
effective_from | string (date) | no | The day the declaration starts applying. Defaults to today. A future date schedules the change without retro-applying it. |
active | boolean | no | false declares that nexus ENDED on effective_from. |
declared_by | string | yes | Who declared it, and on what basis. |
note | string | no |
Responses
201
The declaration as stored.
| Field | Type | Required | Description |
|---|---|---|---|
nexus | object (TaxNexusState) | yes | One append-only tax-nexus declaration (ADR #80 decision 3). A row says that on effective_from the company DOES (active: true) or NO LONGER DOES (active: false) have nexus in country/region. Rows are never edited or deleted; the superseded ones are the audit trail. |
nexus.id | string (uuid) | yes | |
nexus.country | string | yes | ISO-3166 alpha-2, stored upcased. |
nexus.region | string | yes | State/province code, stored upcased. |
nexus.effective_from | string (date) | yes | The day this declaration starts applying. |
nexus.active | boolean | yes | false = nexus ENDED on effective_from. |
nexus.declared_by | string | yes | Who declared it, and on what basis. Never machine-inferred. |
nexus.note | string | no | |
nexus.inserted_at | string (date-time) | yes |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}409 — Conflict
Invalid state transition, uniqueness conflict, or a seat that is busy/reserved.
{
"error": {
"code": "conflict",
"message": "invalid state transition"
}
}429 — AdminAuthThrottled
Too many FAILED platform-admin authentications from this origin inside the fixed window (retryable later). code rate_limited. Keyed on the calling origin, not on the token, and evaluated BEFORE the token is compared — so a 429 never reveals whether the presented token was the right one. A request carrying the correct token is not counted, so a healthy operator cannot reach this state.
{
"error": {
"code": "rate_limited",
"message": "too many failed admin authentications; retry later"
}
}