List customers
GET/v1/customers
| Name | Type | Description |
|---|---|---|
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
external_id | stringin query | Filter by exact external_id. |
status | enumin query | Values: |
Preview. Usage Billing v1 is in development, and demos run on sample data.Read the guide
The Waffo Usage Billing API manages customers, contracts, seats, balances, and invoices, and decides whether each model call may run. This page is generated from the contract, so the fields and errors below match it.
https://api.example.invalidSend Authorization: Bearer sk_test_xxx. Tenant secret key, or a portal token from POST /v1/portal_sessions. The tenant comes from the credential.
Key scopes nest: admin includes read, and read includes ingest. A portal token is bound to one customer and can call only the endpoints marked as allowed. Anywhere else it gets 403 insufficient_scope, and another customer's resource is 404 portal_scope_violation.
Every POST, PATCH, and DELETE needs an Idempotency-Key header. Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict. Replaying a key with the same body returns the stored response.
POST /v1/authorize: request_id in the body, keyed on (tenant, customer_id, request_id). No Idempotency-Key header.POST /v1/ingest: transaction_id per event, deduped on (tenant, transaction_id) for 34 days. No Idempotency-Key header.Success is {data, meta}, and lists add meta.next_page. Every success is HTTP 200, including creates and idempotent replays. Optional fields are omitted when unset. The invoice PDF is the one response without an envelope.
Errors are {error} with a code, message, retryable flag, request id, and the offending field when one applies.
credit_type_id. USD amounts are integer *_cents fields. A float in a money field, including 1999.0, is 400 validation_failed.GET/v1/customers
| Name | Type | Description |
|---|---|---|
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
external_id | stringin query | Filter by exact external_id. |
status | enumin query | Values: |
POST/v1/customers
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/customers/{id}
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
POST/v1/customers/{id}/archive
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/customers/{id}/state
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
GET/v1/products
| Name | Type | Description |
|---|---|---|
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
POST/v1/products
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/credit_types
Defines a custom pricing unit. The credit-to-USD rate lives on the contract, not here.
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/rate_cards
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/rate_cards/{id}/rates
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
product_id | stringin query | |
at | timestampin query | Return rates in effect at this time. |
POST/v1/rate_cards/{id}/rates
Rates are versioned by their time window. A new rate never rewrites history.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/contracts
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/contracts/{id}/edits
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
POST/v1/contracts/{id}/edits
Edits append a new version and never rewrite historic prices. A portal token may send only the fields listed under Portal tokens.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/contracts/{id}
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
GET/v1/customers/{id}/contracts
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
POST/v1/contracts/{id}/end
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/contracts/{id}/archive
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/contracts/{id}/seats
Convenience wrapper for POST /v1/contracts/{id}/edits seat_updates.add_seat_ids. Proration and credit grant follow the contract's subscription and recurring credit settings.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
PATCH/v1/contracts/{id}/seats/{seat_id}
An upgrade adds the credit difference at once. A downgrade takes effect next period.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
seat_idrequired | stringin path | Tenant-side seat id, URL encoded. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
DELETE/v1/contracts/{id}/seats/{seat_id}
Follows the contract's seat_removal_timing. Removal voids the seat's unused credit, and a refund happens only when refund_on_seat_removal is true.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
seat_idrequired | stringin path | Tenant-side seat id, URL encoded. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/contracts/{id}/seat_balances
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
seat_id | stringin query | Filter to one seat. |
GET/v1/customers/{id}/balance
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
include_seats | booleanin query | Also return per-seat balances. Default |
POST/v1/contracts/{id}/commits
Prepaid money lives in the customer's one wallet. A gated commit stays pending_payment until collection succeeds, and funds the wallet only then.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/contracts/{id}/credits
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/commits/{id}/manual_entries
Records an offline payment or a positive adjustment.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/authorize
Decides admission in one database transaction. Business denial is HTTP 200 with allowed false and a reason. Only auth, parameter, and server failures use an error envelope.
The request carries the hold inputs: customer_id, seat_id, request_id, and the estimate. When allowed, the response carries hold_id, hold_status, hold_expires_at, and the legs held. Pass hold_id to POST /v1/ingest to settle the hold. A hold not settled by hold_expires_at expires, and ingest arriving later is billed without it.
The drawdown order is seat bucket, pool buckets by priority, prepaid, then overage when the contract sets allow_overage. Member, pool, and customer hard limits count the non-seat legs only. The first reason that applies is returned.
Replay on (tenant, customer_id, request_id) returns the first decision with replayed true. Retrying after a denial therefore needs a new request_id.
Overshoot bound: settled spend on a hard limit L stays under L + Σ max(actual - estimate, 0) over holds admitted before L was reached.
POST/v1/ingest
One transaction per event. Rating happens once. A named hold is settled, then the drawdown re-walks with the actual amount. Usage is never refused for lack of funds, because the work already happened. The prepaid leg is queued for the wallet after commit.
Per-event results, in request order:
transaction_id and same body within 34 days. Not billed again.transaction_id as an event from an earlier batch with a different body. Not stored or billed, and the batch still returns 200.transaction_id.The same transaction_id with different bodies inside one batch refuses the whole batch with 409 idempotency_conflict_in_batch and takes priority over everything else in the batch. The same transaction_id with the same body inside one batch is duplicate.
Late events inside the 34-day window whose period is closed are accepted and carried forward, with warning billed_via_carry_forward_adjustment and adjustment_invoice_id when known. The closed invoice never changes.
GET/v1/customers/{id}/usage
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
starting_atrequired | timestampin query | |
ending_beforerequired | timestampin query | |
group_by | array of enumin query | Grouping dimensions. Values: |
window_size | enumin query | Values: |
GET/v1/alerts
| Name | Type | Description |
|---|---|---|
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
customer_id | stringin query | Filter by customer. A portal token sees only its own customer. |
POST/v1/alerts
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/alerts/{id}/archive
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/customers/{id}/invoices
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
status | enumin query | Values: |
GET/v1/customers/{id}/invoices/preview
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
starting_at | timestampin query | Start of the billing period. Defaults to the current period. |
GET/v1/invoices/{id}
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
GET/v1/invoices/{id}/pdf
The one binary response in this API, so it has no JSON envelope. The request id is in the X-Request-Id header.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
POST/v1/invoices/{id}/void
Only a finalized invoice can be voided. Void and collection race on the same invoice, and the first commit wins.
Voidable collection statuses: none, not_required, collection_queued (also cancels the queued request with no payment call), and failed (also cancels the payment).
Voiding a commit purchase invoice before payment cancels the commit.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/invoices/{id}/credit_notes
Finalized invoices never change, so a correction is a credit note. Collection and refunds stay with the payment provider or you.
| Name | Type | Description |
|---|---|---|
idrequired | stringin path | Id of the resource named in the path. |
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
POST/v1/webhook_endpoints
Deliveries are at-least-once and HMAC signed. The signing secret is returned only in this response.
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |
GET/v1/events
Pull view of the events sent to webhooks. Async failures with no synchronous error appear here.
| Name | Type | Description |
|---|---|---|
limit | integerin query | Page size. Default |
next_page | stringin query | Opaque cursor from meta.next_page. |
type | enumin query | Values: |
customer_id | stringin query |
POST/v1/portal_sessions
The tenant's server calls this and hands the short-lived token to the customer's browser. The tenant secret key is never exposed.
| Name | Type | Description |
|---|---|---|
Idempotency-Keyrequired | stringin header | Unique per logical operation. Lives 48 hours. The same key with a different body is 409 idempotency_conflict.Min length |