Unit Economics API
Per-customer budget enforcement and dollar-denominated paywall flows via /v1/bind, /v1/gate, and /v1/customers/:id/unit-economics.
Three endpoints power per-customer budget enforcement and dollar-denominated paywall flows: bind, gate, and the unit-economics aggregator.
Base URL: https://proxy.nullspend.dev
All three endpoints require an X-NullSpend-Key header. Unlike the dashboard control-plane API, these live on the proxy worker for low-latency enforcement.
Concepts
- Bind — link a customer to a plan + budget cap. One-time per customer (or whenever the plan changes).
- Gate — ask whether a customer-scoped action would be allowed at this moment. With
sendEvent: true, atomically records the spend. - Unit economics — read aggregate state for a single customer (binding + budget + lifetime cost + latest budget check).
The customer ID is a string the caller controls. Use your app's user ID, your Stripe customer ID, or any stable identifier. Pattern: [a-zA-Z0-9._:-]+ (max 256 chars).
See the per-customer budgets guide for a narrative walkthrough.
POST /v1/bind
Link a customer to a plan + budget cap. Idempotent on the Idempotency-Key header.
Request
POST /v1/bind HTTP/1.1
Host: proxy.nullspend.dev
X-NullSpend-Key: ns_live_sk_...
Content-Type: application/json
Idempotency-Key: bind-alice-pro-v1 # optional but recommended
{
"customerId": "alice",
"planRef": "pro_monthly_v1",
"budgetCap": 15000000,
"marginTargetPercent": 25
}| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | yes | Stable customer identifier. Pattern [a-zA-Z0-9._:-]+, max 256 chars. |
planRef | string | yes | Plan label (your namespace, e.g. pro_monthly_v1). Max 256 chars. |
budgetCap | integer | yes | Budget cap in microdollars (1 USD = 1,000,000). Non-negative. |
marginTargetPercent | integer | null | no | Target gross margin on this customer (0–100). Used by the unit-economics aggregator. |
Response (200)
{
"bindingId": "f8a4c2e1-9b3d-4e5f-8a6b-1c2d3e4f5a6b",
"customerId": "alice",
"planRef": "pro_monthly_v1",
"budgetCapMicrodollars": 15000000,
"marginTargetPercent": 25,
"status": "active"
}status is one of active, suspended, or archived. The route returns 200 only when status is active. A bind against a non-active row returns 409 binding_inactive (see Errors below).
Idempotency
When Idempotency-Key is supplied:
- Same key + same body → cached response (replayed). The response carries
Idempotent-Replayed: trueheader. - Same key + different body →
409 idempotency_conflict. - Same key + Postgres unavailable →
503 idempotency_unavailable(caller may retry without the key).
Keys persist for 24 hours. Bodies are deduplicated at the data layer via UNIQUE(org_id, customer_id) on customer_bindings, so concurrent same-key callers converge on a single row.
Reactivating archived bindings
When an existing binding has status = "archived" or "suspended", the route returns 409 binding_inactive and does not mutate plan_ref, budget_cap_microdollars, or margin_target_percent. Reactivation requires an explicit admin path (Sprint 2). Until then, an archived binding is a tombstone that bind() cannot resurrect.
Customer data passthrough
The optional customer_data / customerData field is reserved for Sprint 2 (when Stripe revenue sync ships). Bodies containing it currently return 400 customer_data_unsupported.
POST /v1/gate
Decide whether a customer-scoped action would be allowed. Always returns 200 OK; allowed: false is a normal denial, NOT an error. Use this for paywall flows and per-action enforcement.
Request
POST /v1/gate HTTP/1.1
Host: proxy.nullspend.dev
X-NullSpend-Key: ns_live_sk_...
Content-Type: application/json
Idempotency-Key: gate-alice-req-7281 # optional but recommended for sendEvent: true
{
"customerId": "alice",
"estimatedCostMicrodollars": 200000,
"feature": "chat_completion",
"sendEvent": true,
"withPreview": true
}| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | yes | Customer to gate. Must be bound via /v1/bind or be checkable against an existing budget. |
estimatedCostMicrodollars | integer | yes | Pre-call cost estimate in microdollars. Must be a positive integer (zero is rejected as invalid_estimate). |
feature | string | null | no | Free-form feature label for analytics. Max 256 chars. |
sendEvent | boolean | no | Default false. When true, atomically records the spend (durable enforcement). When false, advisory check only. See semantics below. |
withPreview | boolean | no | Default false. When true, denial responses include a preview field for paywall rendering. |
Response (200)
{
"allowed": true,
"remaining": 4800000,
"decisionId": "dec_a8f7d6c5-1b2e-3f4a-9d8c-7e6b5a4c3d2e"
}Denial:
{
"allowed": false,
"reason": "budget_exceeded",
"remaining": 0,
"decisionId": "dec_b9e8c7d6-2a3f-4e5b-8c7d-6e5f4a3b2c1d",
"recovery": {
"retryable": false,
"owner_action_required": true,
"retry_after_seconds": null,
"docs": null
},
"preview": {
"scenario": "usage_limit",
"title": "You've reached your plan limit",
"message": "Customer alice would exceed the plan's usage limit. Upgrade to continue.",
"customerId": "alice",
"currentBalance": 0,
"requiredBalance": 200000,
"upgradeUrl": "https://yourapp.com/upgrade?customer=alice"
}
}| Field | When | Description |
|---|---|---|
allowed | always | true if the action would succeed; false on denial. |
reason | denials | One of budget_exceeded, bind_not_found. (Sprint 2 may add feature_disabled, plan_limit.) |
remaining | when known | Microdollars remaining on the customer's budget after this decision. |
recovery | denials | Machine-readable hints. owner_action_required: true means the customer must upgrade or wait for period reset. |
preview | denials with withPreview: true | Paywall-renderable shape. currentBalance + requiredBalance + upgradeUrl. |
decisionId | always | Stable identifier for log correlation. Format: dec_<uuid>. |
sendEvent semantics
sendEvent: true (recommended for enforcement) — atomic check + durable spend.
The customer's budget moves on every allowed call: a cost_events row is written with cost_microdollars = estimatedCostMicrodollars and provider = "gate", and the underlying budget reservation is reconciled into spend. Two concurrent calls cannot both succeed past the cap. Idempotent on the Idempotency-Key header (or auto-generated key when using the SDK) via cost_events.UNIQUE(request_id, provider) — retries dedup at the PG layer.
Matches Autumn's check + send_event=true contract. Refund a recorded spend by calling POST /api/cost-events with a negative costMicrodollars.
sendEvent: false (default, preview-only) — read-only check.
Reads current budget state without writing or reserving. Two concurrent calls can both see allowed: true even when the sum of their estimates would exceed the cap. Use ONLY for read-only paywall rendering after enforcement has already happened elsewhere (proxy LLM calls, prior gate({ sendEvent: true })).
bind_not_found denial
If the customer has no customer_bindings row, has status \u2260 "active", or the budget row has not yet propagated to the budget orchestrator, gate returns allowed: false with reason: "bind_not_found". The preview.scenario is feature_flag (paywall framing: "this feature is not available on your plan").
This is the read-after-write window for newly-bound customers. The proxy auth + budget caches typically converge within ~120s. Avoid calling gate(customerId) immediately after bind(customerId) from a different process.
Idempotency
When Idempotency-Key is supplied:
- Same key + same body → cached response replayed (
Idempotent-Replayed: trueheader). - Same key + different body →
409 idempotency_conflict. - Same key + Postgres unavailable →
503 idempotency_unavailable.
Double-fire safety: webhook events (customer_budget.exceeded) fire only after the response is durably persisted. A 503 idempotency_unavailable does NOT fire the webhook — the caller's retry produces a fresh denial that fires it once.
GET /v1/customers/:customerId/unit-economics
Read aggregate state for a single customer.
Request
GET /v1/customers/alice/unit-economics HTTP/1.1
Host: proxy.nullspend.dev
X-NullSpend-Key: ns_live_sk_...The :customerId segment is URL-encoded. Same character class as bind / gate ([a-zA-Z0-9._:-]+, max 256 chars).
Response (200)
{
"customerId": "alice",
"binding": {
"bindingId": "f8a4c2e1-9b3d-4e5f-8a6b-1c2d3e4f5a6b",
"planRef": "pro_monthly_v1",
"budgetCapMicrodollars": 15000000,
"marginTargetPercent": 25,
"status": "active"
},
"budget": {
"maxMicrodollars": 15000000,
"spendMicrodollars": 11400000,
"remainingMicrodollars": 3600000,
"propagated": true
},
"cost": {
"lifetimeCostMicrodollars": 11400000,
"eventCount": 487
},
"latestBudgetCheck": {
"decision": "approved",
"at": "2026-05-04T09:42:11.823Z"
}
}| Field | Description |
|---|---|
binding | The customer's current bind. Fields mirror the /v1/bind response. |
budget | Live budget state from the orchestrator. propagated: false when the binding row has not yet loaded into the orchestrator (read-after-write window). |
cost.lifetimeCostMicrodollars | Sum of cost_events.cost_microdollars across the customer's history. Computed from event-time-immutable cost_events.customer_id, so rebinding does NOT rewrite history. |
cost.eventCount | Total cost_events rows for this customer. |
latestBudgetCheck.decision | Most recent cost_events.budget_status value across all surfaces (proxy LLM calls, MCP tool calls, /v1/gate). One of approved, denied, skipped, or null (no history). |
latestBudgetCheck.at | Timestamp of the most recent budget check (any surface). |
Cross-org and missing-customer responses
- Customer not bound under the auth-resolved org →
404 not_found. - Customer outside
allowedCustomersscope on the API key →404 not_found(NOT403, to prevent enumeration). - Auth missing or invalid →
401 unauthorizedor403 forbidden.
Historical cost is computed from cost_events.customer_id, which is set at event-write time and never rewritten. A customer can be unbound and rebind under a new plan; their lifetime cost in this aggregator stays attributed to them.
Margin
The revenue half (margin = revenue − cost) is NOT included in Sub-PR 1 because per-customer revenue requires Stripe revenue sync (Sprint 2). marginTargetPercent from the binding is surfaced for dashboards that want to compare actual cost against a planned threshold.
Errors
All errors follow the canonical envelope:
{
"error": {
"code": "binding_inactive",
"message": "Binding for customer alice is archived; explicit reactivation required (Sprint 2).",
"details": null
}
}| Code | HTTP | Endpoint | When |
|---|---|---|---|
invalid_customer_id | 400 | bind, gate, unit-economics | customerId missing, malformed, or exceeds 256 chars |
invalid_plan_ref | 400 | bind | planRef missing or exceeds 256 chars |
invalid_budget_cap | 400 | bind | budgetCap not a non-negative integer |
invalid_margin_target | 400 | bind | marginTargetPercent outside 0–100 |
invalid_estimate | 400 | gate | estimatedCostMicrodollars not a positive integer (zero rejected) |
invalid_feature | 400 | gate | feature empty or exceeds 256 chars |
invalid_idempotency_key | 400 | bind, gate | Idempotency-Key exceeds 256 chars or contains non-printable-ASCII |
customer_data_unsupported | 400 | bind | customer_data / customerData field present (Sprint 2 scope) |
forbidden | 403 | bind, gate, unit-economics | API key has no associated organization |
customer_not_allowed | 403 | bind, gate | customerId outside the API key's allowedCustomers scope |
not_found | 404 | unit-economics | Customer not bound under the auth-resolved org (also covers cross-org / disallowed-customer to prevent enumeration) |
binding_inactive | 409 | bind | Existing binding has status = "suspended" or "archived"; reactivation requires admin path |
idempotency_conflict | 409 | bind, gate | Same Idempotency-Key reused with a different request body |
internal_error | 500 | bind, gate | Database write failed (PG unreachable on bind upsert, gate cost_events write) |
idempotency_unavailable | 503 | bind, gate | Postgres-side idempotency persistence failed; retry without Idempotency-Key or retry later |
See Errors for the full error catalog.
Versioning
These endpoints are pinned to 2026-04-01 in the API version registry. Future breaking changes register a new dated version with transformResponse to project the new shape back to existing pins. SDK 0.5.0 ships pinned to 2026-04-01. See Versioning.
Related
- Per-Customer Budgets Guide — narrative walkthrough
- SDK README on npm:
@nullspend/sdk— TypeScript SDK reference - Errors — full error catalog
- Authentication — API key lifecycle
- Custom Headers — request and response headers