HTTP API
REST API reference for all Ledger billing endpoints.
All Ledger HTTP endpoints are served under the /ledger prefix and return JSON. Webhook endpoints are served under /webhooks. Authentication and tenant resolution depend on your middleware configuration (see Multi-tenancy).
Authentication
Ledger does not enforce a specific authentication scheme. Authentication is handled at the middleware layer before requests reach Ledger routes. Common patterns:
- Bearer token -- Pass
Authorization: Bearer <token>header. Your middleware validates the token and injects tenant/app context. - API key -- Pass
X-API-Key: <key>header. Middleware resolves the key to a tenant. - Forge scope -- When running inside the Forgery ecosystem, Forge automatically extracts tenant and app identifiers from the authenticated scope.
All requests must carry tenant context. Requests without valid tenant context receive 401 Unauthorized.
Pagination
List endpoints support cursor-based pagination with limit and offset query parameters.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 20 | Maximum number of results to return (max: 100) |
offset | int | 0 | Number of results to skip |
Response envelope for list endpoints:
{
"data": [],
"pagination": {
"total": 150,
"limit": 20,
"offset": 0,
"has_more": true
}
}Error response format
All error responses follow a consistent JSON structure:
{
"error": {
"code": "subscription_not_found",
"message": "Subscription sub_01h2xcejqtf2nbrexx3vqjhp44 does not exist",
"type": "not_found",
"param": "subscription_id",
"details": {}
}
}| Field | Description |
|---|---|
code | Machine-readable error code (snake_case) |
message | Human-readable error message |
type | Error category: validation, not_found, conflict, payment, provider, server |
param | The parameter that caused the error (optional) |
details | Additional context (optional) |
HTTP status code mapping:
| HTTP status | Condition |
|---|---|
400 Bad Request | Invalid input, missing required fields, validation errors |
401 Unauthorized | Missing or invalid authentication |
402 Payment Required | Payment failed, card declined |
403 Forbidden | Feature not enabled, quota exceeded |
404 Not Found | Resource does not exist for the current tenant |
409 Conflict | Duplicate resource (plan name, email, idempotency key) |
500 Internal Server Error | Store failure, provider API error |
504 Gateway Timeout | Payment provider timeout |
See Error Handling for the full error reference.
Plans
Plans define available features, usage limits, and pricing tiers. See Plans for details.
POST /ledger/plans
Create a new billing plan.
Request body:
{
"name": "Pro",
"slug": "pro",
"description": "Professional plan with advanced features",
"currency": "usd",
"status": "active",
"trial_days": 14,
"features": [
{
"key": "api_calls",
"name": "API Calls",
"type": "metered",
"limit": 100000,
"period": "monthly",
"soft_limit": false
},
{
"key": "seats",
"name": "Team Seats",
"type": "seat",
"limit": 10,
"period": "none"
},
{
"key": "sso",
"name": "Single Sign-On",
"type": "boolean",
"limit": 1,
"period": "none"
}
],
"pricing": {
"base_amount": { "amount": 4999, "currency": "usd" },
"billing_period": "monthly",
"tiers": [
{
"feature_key": "api_calls",
"type": "graduated",
"up_to": 100000,
"unit_amount": { "amount": 0, "currency": "usd" },
"flat_amount": { "amount": 0, "currency": "usd" }
},
{
"feature_key": "api_calls",
"type": "graduated",
"up_to": -1,
"unit_amount": { "amount": 1, "currency": "usd" },
"flat_amount": { "amount": 0, "currency": "usd" }
}
]
},
"metadata": { "tier": "professional" }
}Response 201 Created
{
"id": "plan_01hqx3qfz5ekth2h5y6z6r099x",
"name": "Pro",
"slug": "pro",
"description": "Professional plan with advanced features",
"currency": "usd",
"status": "active",
"trial_days": 14,
"features": [...],
"pricing": {...},
"app_id": "myapp",
"metadata": { "tier": "professional" },
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing required fields (name, currency) |
400 | duplicate_feature | Duplicate feature key in features array |
400 | invalid_pricing | Invalid pricing configuration |
409 | already_exists | Plan with this slug already exists |
GET /ledger/plans
List plans for the current app.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: active, archived, draft |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK
{
"data": [
{
"id": "plan_01hqx3qfz5ekth2h5y6z6r099x",
"name": "Pro",
"slug": "pro",
"status": "active",
"currency": "usd",
"features": [...],
"pricing": {...},
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
],
"pagination": { "total": 3, "limit": 20, "offset": 0, "has_more": false }
}GET /ledger/plans/{name}
Get a plan by slug name.
Response 200 OK -- Plan object.
Errors: 404 Not Found if plan does not exist.
PUT /ledger/plans/{name}
Update a plan by slug name. Only provided fields are updated.
Request body:
{
"description": "Updated description",
"trial_days": 30,
"features": [...],
"pricing": {...},
"metadata": { "tier": "professional", "version": "2" }
}Response 200 OK -- Updated Plan object.
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Invalid field values |
400 | plan_archived | Cannot update an archived plan |
404 | plan_not_found | Plan does not exist |
DELETE /ledger/plans/{id}
Delete a plan by ID. Plans with active subscriptions cannot be deleted; archive them instead.
Response 204 No Content
Errors:
| Status | Code | Condition |
|---|---|---|
400 | plan_in_use | Plan has active subscriptions |
404 | plan_not_found | Plan does not exist |
Subscriptions
Subscriptions connect tenants to plans and track billing periods. See Subscriptions for details.
POST /ledger/subscriptions
Create a new subscription.
Request body:
{
"tenant_id": "acme-corp",
"plan_id": "plan_01hqx3qfz5ekth2h5y6z6r099x",
"status": "active",
"metadata": { "source": "self-serve" }
}Response 201 Created
{
"id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3",
"tenant_id": "acme-corp",
"plan_id": "plan_01hqx3qfz5ekth2h5y6z6r099x",
"status": "active",
"current_period_start": "2025-01-15T10:30:00Z",
"current_period_end": "2025-02-15T10:30:00Z",
"app_id": "myapp",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing tenant_id or plan_id |
404 | plan_not_found | Referenced plan does not exist |
409 | subscription_exists | Tenant already has an active subscription |
GET /ledger/subscriptions
List subscriptions. Filtered by current tenant context.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id | string | Filter by tenant ID |
status | string | Filter by status: active, trialing, past_due, canceled, expired, paused |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Subscription objects.
GET /ledger/subscriptions/{id}
Get a subscription by ID.
Response 200 OK -- Subscription object.
Errors: 404 Not Found if subscription does not exist.
PUT /ledger/subscriptions/{id}
Update a subscription. Use this for metadata changes; use dedicated action endpoints for status changes.
Request body:
{
"metadata": { "support_tier": "premium" }
}Response 200 OK -- Updated Subscription object.
POST /ledger/subscriptions/{id}/cancel
Cancel a subscription. By default, cancels at the end of the current billing period. Set immediately to cancel right away.
Request body:
{
"immediately": false
}Response 200 OK
{
"id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3",
"status": "active",
"cancel_at": "2025-02-15T10:30:00Z"
}When immediately is true, the status changes to canceled and canceled_at is set to the current time. The entitlement cache is automatically invalidated.
Errors:
| Status | Code | Condition |
|---|---|---|
400 | subscription_canceled | Already canceled |
404 | subscription_not_found | Subscription does not exist |
POST /ledger/subscriptions/{id}/pause
Pause a subscription. Paused subscriptions retain their period but do not accrue usage or generate invoices.
Request body:
{
"resume_at": "2025-03-01T00:00:00Z"
}Response 200 OK -- Subscription object with status: "paused".
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_state | Subscription is not active |
404 | subscription_not_found | Subscription does not exist |
POST /ledger/subscriptions/{id}/resume
Resume a paused subscription.
Response 200 OK -- Subscription object with status: "active".
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_state | Subscription is not paused |
404 | subscription_not_found | Subscription does not exist |
POST /ledger/subscriptions/{id}/change-plan
Change the plan for an active subscription. Handles proration and entitlement cache invalidation.
Request body:
{
"plan_id": "plan_01hqx3qfz5ekth2h5y6z6r099y",
"prorate": true
}Response 200 OK -- Updated Subscription object with the new plan.
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_upgrade | Invalid plan transition |
400 | invalid_downgrade | Invalid plan downgrade |
404 | subscription_not_found | Subscription does not exist |
404 | plan_not_found | Target plan does not exist |
Metering
Track and query usage events for metered features. See Metering for details.
POST /ledger/meter/events
Ingest a single usage event. The event is buffered and flushed to the store asynchronously.
Request body:
{
"feature_key": "api_calls",
"quantity": 1,
"idempotency_key": "req_abc123",
"metadata": { "endpoint": "/v1/users" }
}Response 202 Accepted
{
"id": "uevt_01hqx3qh4r5kjyhwkrp5j4wsyb",
"status": "buffered"
}The 202 status indicates the event has been accepted into the buffer and will be persisted asynchronously. Tenant and app context are extracted from the request context.
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing feature_key or invalid quantity |
409 | duplicate_event | Duplicate idempotency key |
429 | meter_buffer_full | Buffer is full, try again later |
POST /ledger/meter/events/batch
Ingest multiple usage events in a single request.
Request body:
{
"events": [
{ "feature_key": "api_calls", "quantity": 5, "idempotency_key": "batch_001" },
{ "feature_key": "storage_gb", "quantity": 2, "idempotency_key": "batch_002" }
]
}Response 202 Accepted
{
"accepted": 2,
"rejected": 0,
"results": [
{ "id": "uevt_01hqx3qh4r5kjyhwkrp5j4wsyb", "status": "buffered" },
{ "id": "uevt_01hqx3qh4r5kjyhwkrp5j4wsyc", "status": "buffered" }
]
}GET /ledger/meter/usage/{subscriptionId}/{meter}
Get aggregated usage for a specific meter within the current billing period.
Response 200 OK
{
"feature_key": "api_calls",
"subscription_id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3",
"current_usage": 45230,
"limit": 100000,
"remaining": 54770,
"period_start": "2025-01-15T00:00:00Z",
"period_end": "2025-02-15T00:00:00Z"
}GET /ledger/meter/usage/{subscriptionId}/{meter}/history
Get historical usage for a meter across billing periods.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
start | datetime | Start of date range (ISO 8601) |
end | datetime | End of date range (ISO 8601) |
granularity | string | daily, weekly, monthly |
Response 200 OK
{
"feature_key": "api_calls",
"data": [
{ "period": "2025-01-01", "usage": 32100 },
{ "period": "2025-01-02", "usage": 28500 },
{ "period": "2025-01-03", "usage": 41200 }
]
}GET /ledger/meter/events
Query raw usage events.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
feature_key | string | Filter by feature key |
start | datetime | Start of date range (ISO 8601) |
end | datetime | End of date range (ISO 8601) |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of UsageEvent objects.
Entitlements
Check feature access and quota for the current tenant. See Entitlements for details.
POST /ledger/entitlements/check
Check if the current tenant is entitled to use a feature. Returns sub-millisecond with cache hit.
Request body:
{
"feature_key": "api_calls"
}Response 200 OK
{
"allowed": true,
"feature": "api_calls",
"used": 45230,
"limit": 100000,
"remaining": 54770,
"soft_limit": false
}When access is denied:
{
"allowed": false,
"feature": "api_calls",
"used": 100500,
"limit": 100000,
"remaining": 0,
"soft_limit": false,
"reason": "quota exceeded"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing feature_key |
403 | no_active_subscription | No active subscription for tenant |
POST /ledger/entitlements/check-batch
Check multiple features in a single request.
Request body:
{
"feature_keys": ["api_calls", "seats", "sso"]
}Response 200 OK
{
"results": {
"api_calls": { "allowed": true, "used": 45230, "limit": 100000, "remaining": 54770 },
"seats": { "allowed": true, "used": 3, "limit": 10, "remaining": 7 },
"sso": { "allowed": true, "used": 0, "limit": 1, "remaining": 1 }
}
}GET /ledger/entitlements/{customerId}
Get all entitlements for a customer, including usage across all features in their current plan.
Response 200 OK
{
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"plan": "Pro",
"plan_id": "plan_01hqx3qfz5ekth2h5y6z6r099x",
"entitlements": [
{ "feature": "api_calls", "allowed": true, "used": 45230, "limit": 100000, "remaining": 54770, "type": "metered" },
{ "feature": "seats", "allowed": true, "used": 3, "limit": 10, "remaining": 7, "type": "seat" },
{ "feature": "sso", "allowed": true, "used": 0, "limit": 1, "remaining": 1, "type": "boolean" }
]
}POST /ledger/entitlements/{customerId}/invalidate
Invalidate the entitlement cache for a customer. Use after manual plan changes or data corrections.
Response 204 No Content
Invoices
Generate, manage, and finalize invoices. See Invoicing for details.
POST /ledger/invoices
Generate an invoice for a subscription. Aggregates usage charges, applies base fees, calculates taxes, and creates line items.
Request body:
{
"subscription_id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3"
}Response 201 Created
{
"id": "inv_01hqx3qgf2c8mhwy9s6zwmf3yh",
"tenant_id": "acme-corp",
"subscription_id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3",
"status": "draft",
"currency": "usd",
"subtotal": { "amount": 4999, "currency": "usd", "display": "$49.99" },
"tax_amount": { "amount": 0, "currency": "usd", "display": "$0.00" },
"discount_amount": { "amount": 0, "currency": "usd", "display": "$0.00" },
"total": { "amount": 4999, "currency": "usd", "display": "$49.99" },
"line_items": [
{
"id": "li_01hqx3qgf2c8mhwy9s6zwmf3yi",
"description": "Base subscription fee",
"quantity": 1,
"unit_amount": { "amount": 4999, "currency": "usd" },
"amount": { "amount": 4999, "currency": "usd" },
"type": "base"
}
],
"period_start": "2025-01-15T00:00:00Z",
"period_end": "2025-02-15T00:00:00Z",
"app_id": "myapp",
"created_at": "2025-02-15T10:00:00Z",
"updated_at": "2025-02-15T10:00:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing subscription_id |
404 | subscription_not_found | Subscription does not exist |
404 | plan_not_found | Associated plan does not exist |
GET /ledger/invoices
List invoices for the current tenant.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: draft, pending, paid, past_due, voided |
start | datetime | Filter by period start (ISO 8601) |
end | datetime | Filter by period end (ISO 8601) |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Invoice objects.
GET /ledger/invoices/{id}
Get an invoice by ID.
Response 200 OK -- Invoice object with full line items.
Errors: 404 Not Found if invoice does not exist.
GET /ledger/invoices/{number}
Get an invoice by invoice number. Invoice numbers are human-readable identifiers (e.g., INV-2025-0042).
Response 200 OK -- Invoice object.
Errors: 404 Not Found if invoice does not exist.
POST /ledger/invoices/{id}/finalize
Finalize a draft invoice. Finalized invoices cannot be modified and are ready for payment collection.
Response 200 OK -- Invoice object with status: "pending".
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invoice_finalized | Invoice is already finalized |
400 | invoice_incomplete | Invoice has incomplete data |
404 | invoice_not_found | Invoice does not exist |
POST /ledger/invoices/{id}/void
Void an invoice. Voided invoices cannot be paid or modified.
Request body:
{
"reason": "Customer requested cancellation"
}Response 200 OK -- Invoice object with status: "voided".
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invoice_paid | Cannot void a paid invoice |
400 | invoice_voided | Invoice is already voided |
404 | invoice_not_found | Invoice does not exist |
POST /ledger/invoices/{id}/pay
Record a manual payment for an invoice.
Request body:
{
"payment_ref": "ch_3P2x4N2eZvKYlo2C0r4JqHBX",
"paid_at": "2025-02-15T12:00:00Z"
}Response 200 OK -- Invoice object with status: "paid".
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invoice_paid | Invoice is already paid |
400 | invoice_voided | Cannot pay a voided invoice |
404 | invoice_not_found | Invoice does not exist |
GET /ledger/invoices/{id}/pdf
Download a PDF rendering of an invoice.
Response 200 OK with Content-Type: application/pdf.
Requires an InvoiceFormatter plugin registered with format "pdf".
Errors:
| Status | Code | Condition |
|---|---|---|
404 | invoice_not_found | Invoice does not exist |
501 | not_implemented | No PDF formatter plugin registered |
Payments
Process and manage payments through Stripe, Paddle, or other providers. See Payments for details.
POST /ledger/payments
Create a payment for an invoice. Routes to the configured payment provider.
Request body:
{
"invoice_id": "inv_01hqx3qgf2c8mhwy9s6zwmf3yh",
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"amount": { "amount": 4999, "currency": "usd" },
"payment_method_id": "pm_1234567890",
"idempotency_key": "inv:inv_01hqx3qgf2c8mhwy9s6zwmf3yh"
}Response 201 Created
{
"id": "pay_01hqx3qgw4ekqhxyz6z6r099xa",
"invoice_id": "inv_01hqx3qgf2c8mhwy9s6zwmf3yh",
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"amount": { "amount": 4999, "currency": "usd", "display": "$49.99" },
"status": "pending",
"processor_name": "stripe",
"processor_id": "pi_3P2x4N2eZvKYlo2C0r4JqHBX",
"created_at": "2025-02-15T12:00:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing required fields |
400 | invoice_paid | Invoice is already paid |
402 | payment_failed | Payment processing failed |
404 | invoice_not_found | Invoice does not exist |
500 | provider_not_configured | No payment provider configured |
GET /ledger/payments
List payments for the current tenant.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: pending, succeeded, failed, refunded, canceled |
invoice_id | string | Filter by invoice ID |
customer_id | string | Filter by customer ID |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Payment objects.
GET /ledger/payments/{id}
Get a payment by ID.
Response 200 OK -- Payment object.
Errors: 404 Not Found if payment does not exist.
POST /ledger/payments/{id}/refund
Issue a full or partial refund for a payment.
Request body:
{
"amount": { "amount": 2500, "currency": "usd" },
"reason": "Customer requested partial refund"
}Omit amount for a full refund.
Response 200 OK
{
"id": "pay_01hqx3qgw4ekqhxyz6z6r099xa",
"status": "refunded",
"refunded_amount": { "amount": 2500, "currency": "usd", "display": "$25.00" },
"refund_reason": "Customer requested partial refund"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_state | Payment is not in succeeded state |
400 | invalid_amount | Refund amount exceeds payment amount |
404 | payment_not_found | Payment does not exist |
POST /ledger/payment-methods
Add a payment method for a customer. The token is obtained from the payment provider's client-side SDK (e.g., Stripe.js).
Request body:
{
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"token": "tok_visa_4242",
"set_default": true
}Response 201 Created
{
"id": "pm_01hqx3qgw4ekqhxyz6z6r099xb",
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"type": "card",
"last4": "4242",
"brand": "visa",
"expiry_month": 12,
"expiry_year": 2027,
"is_default": true
}GET /ledger/payment-methods
List payment methods for the current customer.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
customer_id | string | Filter by customer ID (required) |
Response 200 OK -- Array of PaymentMethod objects.
DELETE /ledger/payment-methods/{id}
Remove a payment method.
Response 204 No Content
Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_state | Cannot delete the only payment method if there are pending invoices |
404 | not_found | Payment method does not exist |
POST /webhooks/stripe
Handle Stripe webhook events. Verifies the webhook signature and updates payment/invoice status accordingly.
Headers:
| Header | Description |
|---|---|
Stripe-Signature | Stripe webhook signature for verification |
Request body: Raw Stripe event JSON payload.
Handled events:
| Event type | Action |
|---|---|
payment_intent.succeeded | Mark payment as succeeded, invoice as paid |
payment_intent.payment_failed | Mark payment as failed |
charge.refunded | Record refund |
Response 200 OK on success, 400 Bad Request on invalid payload, 401 Unauthorized on invalid signature.
POST /webhooks/paddle
Handle Paddle webhook events. Verifies the webhook signature and processes payment notifications.
Headers:
| Header | Description |
|---|---|
Paddle-Signature | Paddle webhook signature for verification |
Request body: Raw Paddle event JSON payload.
Response 200 OK on success, 400 Bad Request on invalid payload.
Customers
Manage customer profiles, billing addresses, and payment methods. See Customer Management for details.
POST /ledger/customers
Create a new customer.
Request body:
{
"name": "Alice Johnson",
"email": "alice@example.com",
"currency": "usd",
"billing_address": {
"line1": "123 Main St",
"line2": "Suite 100",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "US"
},
"tax_ids": [
{ "type": "us_ein", "value": "12-3456789" }
],
"metadata": { "source": "signup" }
}Response 201 Created
{
"id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"tenant_id": "acme-corp",
"app_id": "myapp",
"name": "Alice Johnson",
"email": "alice@example.com",
"currency": "usd",
"balance": { "amount": 0, "currency": "usd", "display": "$0.00" },
"billing_address": {...},
"tax_ids": [...],
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing required fields (name, email) |
400 | invalid_email | Invalid email format |
409 | duplicate_customer_email | Customer with this email already exists |
GET /ledger/customers
List customers for the current app.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Customer objects.
GET /ledger/customers/{id}
Get a customer by ID.
Response 200 OK -- Customer object.
Errors: 404 Not Found if customer does not exist.
GET /ledger/customers/email/{email}
Look up a customer by email address.
Response 200 OK -- Customer object.
Errors: 404 Not Found if no customer with that email exists.
PUT /ledger/customers/{id}
Update a customer. Only provided fields are updated.
Request body:
{
"name": "Alice Smith",
"billing_address": {
"line1": "456 Oak Ave",
"city": "New York",
"state": "NY",
"postal_code": "10001",
"country": "US"
}
}Response 200 OK -- Updated Customer object.
Errors: 404 Not Found if customer does not exist.
DELETE /ledger/customers/{id}
Delete a customer. Customers with active subscriptions cannot be deleted.
Response 204 No Content
Errors:
| Status | Code | Condition |
|---|---|---|
400 | has_active_subscriptions | Customer has active subscriptions |
404 | customer_not_found | Customer does not exist |
POST /ledger/customers/{id}/credit
Add account credit to a customer. Credits are applied automatically to future invoices.
Request body:
{
"amount": { "amount": 10000, "currency": "usd" },
"reason": "Loyalty credit"
}Response 200 OK
{
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"balance": { "amount": -10000, "currency": "usd", "display": "-$100.00" },
"credit_applied": { "amount": 10000, "currency": "usd", "display": "$100.00" }
}Negative balance indicates available credit.
GET /ledger/customers/{id}/subscriptions
List all subscriptions for a customer.
Response 200 OK -- Array of Subscription objects.
GET /ledger/customers/{id}/invoices
List all invoices for a customer.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by invoice status |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Invoice objects.
GET /ledger/customers/{id}/usage
Get current period usage for all metered features.
Response 200 OK
{
"customer_id": "cst_01hqx3qgn9v3rywqk8zxr7t9kj",
"period_start": "2025-01-15T00:00:00Z",
"period_end": "2025-02-15T00:00:00Z",
"usage": {
"api_calls": { "used": 45230, "limit": 100000, "remaining": 54770 },
"storage_gb": { "used": 12, "limit": 50, "remaining": 38 }
}
}POST /ledger/customers/{id}/portal
Create a secure billing portal session for a customer. The portal allows customers to view invoices, update payment methods, and manage subscriptions.
Request body:
{
"return_url": "https://myapp.com/billing"
}Response 200 OK
{
"url": "https://billing.example.com/portal/session/abc123",
"expires_at": "2025-02-15T11:30:00Z"
}GET /ledger/customers/{id}/export
Export all customer data for GDPR compliance. Includes customer profile, subscriptions, invoices, payments, usage events, and entitlement checks.
Response 200 OK
{
"customer": {...},
"subscriptions": [...],
"invoices": [...],
"payments": [...],
"usage_events": [...],
"entitlement_checks": [...],
"exported_at": "2025-02-15T10:00:00Z"
}Coupons
Create and manage discount codes. See Coupons for details.
POST /ledger/coupons
Create a new coupon.
Request body:
{
"code": "SUMMER25",
"name": "Summer 2025 Promotion",
"type": "percentage",
"percentage": 25,
"currency": "usd",
"max_redemptions": 100,
"valid_from": "2025-06-01T00:00:00Z",
"valid_until": "2025-09-01T00:00:00Z",
"metadata": { "campaign": "summer_2025" }
}For fixed-amount coupons, use "type": "amount" with an amount field:
{
"code": "FLAT10",
"name": "10 Dollars Off",
"type": "amount",
"amount": { "amount": 1000, "currency": "usd" },
"currency": "usd",
"max_redemptions": 500
}Response 201 Created
{
"id": "cpn_01hqx3qgw4ekqhxyz6z6r099xa",
"code": "SUMMER25",
"name": "Summer 2025 Promotion",
"type": "percentage",
"percentage": 25,
"currency": "usd",
"max_redemptions": 100,
"times_redeemed": 0,
"valid_from": "2025-06-01T00:00:00Z",
"valid_until": "2025-09-01T00:00:00Z",
"app_id": "myapp",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | invalid_input | Missing required fields (code, type) |
409 | already_exists | Coupon code already exists |
GET /ledger/coupons
List coupons for the current app.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
active | bool | Filter to only currently valid coupons |
limit | int | Max results (default: 20) |
offset | int | Pagination offset |
Response 200 OK -- Paginated array of Coupon objects.
GET /ledger/coupons/{code}
Get a coupon by its code.
Response 200 OK -- Coupon object.
Errors: 404 Not Found if coupon code does not exist.
GET /ledger/coupons/id/{id}
Get a coupon by its ID.
Response 200 OK -- Coupon object.
Errors: 404 Not Found if coupon does not exist.
PUT /ledger/coupons/{id}
Update a coupon. Only provided fields are updated.
Request body:
{
"name": "Extended Summer Promotion",
"valid_until": "2025-10-01T00:00:00Z",
"max_redemptions": 200
}Response 200 OK -- Updated Coupon object.
Errors: 404 Not Found if coupon does not exist.
DELETE /ledger/coupons/{id}
Delete a coupon.
Response 204 No Content
Errors: 404 Not Found if coupon does not exist.
POST /ledger/coupons/{code}/redeem
Redeem a coupon for a subscription. Validates the coupon is active, not expired, and has remaining redemptions.
Request body:
{
"subscription_id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3"
}Response 200 OK
{
"coupon_id": "cpn_01hqx3qgw4ekqhxyz6z6r099xa",
"code": "SUMMER25",
"discount_type": "percentage",
"discount_value": 25,
"subscription_id": "sub_01hqx3qg8w5kjyhwkrp5j4wsy3",
"applied_at": "2025-06-15T10:00:00Z"
}Errors:
| Status | Code | Condition |
|---|---|---|
400 | coupon_expired | Coupon has passed its valid_until date |
400 | coupon_not_started | Coupon valid_from date is in the future |
400 | coupon_exhausted | All redemptions have been used |
400 | coupon_invalid | Coupon failed custom validation |
404 | coupon_not_found | Coupon code does not exist |
404 | subscription_not_found | Subscription does not exist |