NullSpend Docs

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
}
FieldTypeRequiredDescription
customerIdstringyesStable customer identifier. Pattern [a-zA-Z0-9._:-]+, max 256 chars.
planRefstringyesPlan label (your namespace, e.g. pro_monthly_v1). Max 256 chars.
budgetCapintegeryesBudget cap in microdollars (1 USD = 1,000,000). Non-negative.
marginTargetPercentinteger | nullnoTarget 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: true header.
  • 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
}
FieldTypeRequiredDescription
customerIdstringyesCustomer to gate. Must be bound via /v1/bind or be checkable against an existing budget.
estimatedCostMicrodollarsintegeryesPre-call cost estimate in microdollars. Must be a positive integer (zero is rejected as invalid_estimate).
featurestring | nullnoFree-form feature label for analytics. Max 256 chars.
sendEventbooleannoDefault false. When true, atomically records the spend (durable enforcement). When false, advisory check only. See semantics below.
withPreviewbooleannoDefault 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"
  }
}
FieldWhenDescription
allowedalwaystrue if the action would succeed; false on denial.
reasondenialsOne of budget_exceeded, bind_not_found. (Sprint 2 may add feature_disabled, plan_limit.)
remainingwhen knownMicrodollars remaining on the customer's budget after this decision.
recoverydenialsMachine-readable hints. owner_action_required: true means the customer must upgrade or wait for period reset.
previewdenials with withPreview: truePaywall-renderable shape. currentBalance + requiredBalance + upgradeUrl.
decisionIdalwaysStable 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: true header).
  • 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"
  }
}
FieldDescription
bindingThe customer's current bind. Fields mirror the /v1/bind response.
budgetLive budget state from the orchestrator. propagated: false when the binding row has not yet loaded into the orchestrator (read-after-write window).
cost.lifetimeCostMicrodollarsSum 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.eventCountTotal cost_events rows for this customer.
latestBudgetCheck.decisionMost 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.atTimestamp 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 allowedCustomers scope on the API key → 404 not_found (NOT 403, to prevent enumeration).
  • Auth missing or invalid → 401 unauthorized or 403 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
  }
}
CodeHTTPEndpointWhen
invalid_customer_id400bind, gate, unit-economicscustomerId missing, malformed, or exceeds 256 chars
invalid_plan_ref400bindplanRef missing or exceeds 256 chars
invalid_budget_cap400bindbudgetCap not a non-negative integer
invalid_margin_target400bindmarginTargetPercent outside 0–100
invalid_estimate400gateestimatedCostMicrodollars not a positive integer (zero rejected)
invalid_feature400gatefeature empty or exceeds 256 chars
invalid_idempotency_key400bind, gateIdempotency-Key exceeds 256 chars or contains non-printable-ASCII
customer_data_unsupported400bindcustomer_data / customerData field present (Sprint 2 scope)
forbidden403bind, gate, unit-economicsAPI key has no associated organization
customer_not_allowed403bind, gatecustomerId outside the API key's allowedCustomers scope
not_found404unit-economicsCustomer not bound under the auth-resolved org (also covers cross-org / disallowed-customer to prevent enumeration)
binding_inactive409bindExisting binding has status = "suspended" or "archived"; reactivation requires admin path
idempotency_conflict409bind, gateSame Idempotency-Key reused with a different request body
internal_error500bind, gateDatabase write failed (PG unreachable on bind upsert, gate cost_events write)
idempotency_unavailable503bind, gatePostgres-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.


On this page