openapi: 3.0.3 info: title: Hardcount Billing API description: | The HTTP API of Hardcount, the open-source usage-based billing engine. Send an API key as `Authorization: Bearer `. Bodies are JSON (a few routes answer a PDF, CSV or event stream), and amounts are whole numbers in the currency's minor unit (cents for USD). Routes tagged `Dashboard` or `InboundWebhooks` are not for your code. More: [The Hardcount API](https://gethardcount.com/docs/api/overview/). version: "2026-04-07" license: name: MIT url: https://github.com/gethardcount/hardcount/blob/main/LICENSE contact: name: Hardcount url: https://github.com/gethardcount/hardcount servers: - url: "{baseUrl}" description: Your Hardcount address. The default is the address of a local install. variables: baseUrl: default: http://localhost:8080 description: Your Hardcount server's address, with no trailing slash. security: - BearerAuth: [] tags: - name: Customers description: Customers, billing profiles, payment methods and per-customer views. - name: Pricing description: Meters, plans, prices and price overrides. - name: Subscriptions description: Subscriptions, their items, trials, pauses and cancellations. - name: Usage description: Sending usage events and reading usage totals. - name: Invoices description: Invoices, their lines, payments and refunds. - name: CreditNotes description: Credit notes against issued invoices. - name: Credits description: Prepaid customer credit, kept as a ledger of grant, usage, expiry and adjustment entries. - name: Billing description: Billing runs, previews and analytics. - name: Dunning description: Retrying failed payments. Policies and runs. - name: Webhooks description: Webhook endpoints that Hardcount sends events to, the events, and their deliveries. - name: Auth description: API keys, and which account and mode a key belongs to. - name: Settings description: | The Stripe connection for each mode: its API keys and its webhook signing secret. Settings → Payments in the dashboard uses the same routes. - name: ProviderCosts description: What upstream providers charge you, for margin reports. - name: TestClocks description: Simulated time for test-mode subscriptions. - name: Recipes description: Ready-made pricing setups you can preview and apply. - name: Public description: Pages a customer opens with a token, without an API key. - name: System description: Health checks, Prometheus metrics and first-time setup. - name: Dashboard description: | Routes the Hardcount dashboard uses: sign-in, team members, settings, analytics, exports and the audit log. They are not part of the integration API. Your code does not need them, and they can change between releases. - name: InboundWebhooks description: | Webhooks that Stripe and Postmark send to Hardcount. You give these addresses to the provider; your code does not call them. components: securitySchemes: BearerAuth: type: http scheme: bearer description: | An API key, sent as `Authorization: Bearer `. The header `X-API-Key: ` works too. A key's prefix says its type and mode: - `hc_secret_test_…` and `hc_secret_live_…`: a secret key. It has full access to one account's test or live data, except the team-member routes and the test email, which need a dashboard sign-in. Keep it on your server. - `hc_ingest_test_…` and `hc_ingest_live_…`: a usage-only key, for a gateway such as LiteLLM. It can call `POST /v1/usage-events`, `POST /v1/usage-events/batch`, `POST /v1/integrations/litellm/spend` and `GET /v1/whoami`. Every other route that needs a credential answers it 403. A missing, wrong, expired or revoked key gets 401. When the server cannot check the key (its database is down), the answer is 503 with code `authentication_unavailable`, not 401: the key may be fine, so retry. Create keys in the dashboard or with `POST /v1/api-keys`. The full key is shown only once. SessionCookie: type: apiKey in: cookie name: hardcount_session description: | The dashboard's sign-in cookie. `POST /v1/auth/login` sets it, and so does `POST /v1/auth/accept-invite` when it creates a new account. Routes under `/v1` that take an API key accept it in its place. When a request carries a valid cookie and a key, the cookie is used; an expired cookie is skipped and the key is checked. Operations that list only this scheme (members, mode switch, logout) do not accept an API key. A browser request that would change data (POST, PUT, PATCH or DELETE) from a page on another site is refused with 403 (`cross-site request blocked`). MetricsToken: type: http scheme: bearer description: | The value of the `METRICS_TOKEN` setting, sent as `Authorization: Bearer `. Used only by `GET /metrics`. It is not an API key. PostmarkBasicAuth: type: http scheme: basic description: | HTTP Basic auth with `POSTMARK_WEBHOOK_USER` and `POSTMARK_WEBHOOK_PASS`. You put both in the webhook URL you give Postmark. Used only by `POST /v1/webhooks/postmark`. parameters: IdempotencyKey: name: Idempotency-Key in: header required: false description: | Makes the call safe to retry. Send a unique value (a UUID works) and the same value on every retry. Keys are kept per account and mode. For 24 hours, a repeat with the same key, method, path and body gets the first response again, byte for byte, with an `Idempotent-Replayed: true` header, instead of running again. A first answer of 409 or 422 is not kept, so a retry with the same key runs again. A repeat sent while the first request is still running waits up to 5 seconds for its answer; if there is none by then, it gets 409 with code `conflict_idempotency`. The same key with a different method, path or body is a 422 with code `idempotency_error`. The other error cases are in [Idempotency](https://gethardcount.com/docs/api/idempotency/). The usage routes do not take it: each usage event carries its own `idempotency_key`. schema: type: string example: 5f0c8a2e-7b1d-4c3e-9a6f-2d8b4e1c7a90 Limit: name: limit in: query required: false description: | Rows per page, from 1 to 100. Default 50. Any other value, one that is not a whole number, or `limit` sent twice is a 422 with `param: limit`. It is never clamped. schema: type: integer minimum: 1 maximum: 100 default: 50 Offset: name: offset in: query required: false description: | Rows to skip, 0 or more. Default 0. A negative value, one that is not a whole number, or `offset` sent twice is a 422 with `param: offset`. schema: type: integer minimum: 0 default: 0 headers: RetryAfter: description: | Seconds to wait before the next request can pass, rounded up to a whole second and at least 1. schema: type: integer example: 12 RateLimitLimit: description: | How many requests the limit allows in its period (a minute, or a second on the usage routes). schema: type: integer example: 1000 RateLimitRemaining: description: How many requests are left in the current period. schema: type: integer example: 0 RateLimitReset: description: | When the whole limit is available again, as Unix seconds. One request may pass sooner; `Retry-After` says when. schema: type: integer format: int64 example: 1793870460 responses: InvalidPage: description: "`limit` or `offset` is out of range, not a whole number or sent twice, or the request sends the removed `after` cursor (`param` names it)" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: limit must be a whole number from 1 to 100 request_id: req_dbjg652q3gpgqfoq5cd0 param: limit Unauthorized: description: | No valid credential: the API key or session is missing, wrong, expired or revoked. With no API key and no valid session cookie, the message starts with `missing credentials`. A wrong, expired and revoked key all get the same message, `invalid or expired API key`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: invalid or expired API key request_id: req_dbjg652q3gpgqfoq5cd0 RateLimited: description: | Too many requests. Wait for the number of seconds in `Retry-After`, then retry. The answers that pass the limit carry the `X-RateLimit-*` headers too. Each API key has its own limit, by default 1,000 requests a minute (`HARDCOUNT_RATE_LIMIT_GENERAL_PER_MIN`), and 1,000 a second on the usage routes (`HARDCOUNT_RATE_LIMIT_INGEST_PER_SEC`). The dashboard sessions of one account share one limit per mode. The routes under `/v1/auth` and `/v1/bootstrap` (sign-in, password reset, invite accept, setup) count per client address, by default 100 a minute (`HARDCOUNT_RATE_LIMIT_AUTH_PER_MIN`). The public invoice, payment-update and cost-dashboard pages allow 60 a minute per client address. headers: Retry-After: $ref: "#/components/headers/RetryAfter" X-RateLimit-Limit: $ref: "#/components/headers/RateLimitLimit" X-RateLimit-Remaining: $ref: "#/components/headers/RateLimitRemaining" X-RateLimit-Reset: $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: rate_limited message: too many requests — please retry after the rate limit resets request_id: req_dbjg652q3gpgqfoq5cd0 Forbidden: description: | The credential is valid but may not call this route. A usage-only key (`hc_ingest_…`) can only send usage and call `GET /v1/whoami`; use a secret key. The `message` names the permission the key lacks. A browser request that would change data (POST, PUT, PATCH or DELETE) from a page on another site gets this 403 too, with the message `cross-site request blocked`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have customer:read access" request_id: req_dbjg652q3gpgqfoq5cd0 BodyNotAllowed: description: | This route takes no body, and the request sent one with a field. `param` names the field. Send no body, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "at_period_end" request_id: req_dbjg652q3gpgqfoq5cd0 param: at_period_end InvalidBody: description: | The body is not one valid JSON value, it is not a JSON object, it is empty where the operation needs one, or a field has the wrong JSON type (such as a string where a number belongs). A body over 1 MB gets 400 too, unless the operation lists a 413. On a route that takes `Idempotency-Key`, a 400 is kept for the key: send the corrected request with a new key. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 schemas: ListTotal: type: integer description: | How many rows match the request's filters, on every page, not only this one. The audit log and the customer list's search and name or email sort say where they stop counting. Error: type: object description: | The body of an error answer. `error.type` is the broad class, `error.code` is the exact reason for your code to branch on, and `error.message` is a sentence for a person. Quote `error.request_id` when you ask for help. properties: error: type: object description: What went wrong. required: [type, code, message, request_id] properties: type: type: string description: | One of `invalid_request_error`, `authentication_error`, `rate_limit_error` or `api_error`. Every error the API answers has this shape, including an unknown path (404, code `route_not_found`) and a method the path does not take (405, code `method_not_allowed`, with an `Allow` header). The few exceptions are listed in [Errors](https://gethardcount.com/docs/api/errors/). example: invalid_request_error code: type: string description: | The exact reason, such as `validation_error`, `not_found`, `already_exists`, `invalid_state` or a more specific code the operation lists. Branch on this, not on `message`. message: type: string description: What went wrong, written for a person to read. request_id: type: string description: | The id of the request that produced this answer (`req_…`). It is normally the same value as the `Hardcount-Request-Id` response header. On an answer replayed for a repeated `Idempotency-Key`, the body is the first request's, so this is the first request's id, while the `Hardcount-Request-Id` header holds the new request's id. param: type: string description: | The request field or query parameter the error is about. Sent only when the error names one. A request body may hold only the fields its operation lists, spelled and cased as listed. Any other field, or one the server sets (such as `id`), is refused with 422 and `param` naming it. An operation with no request body lists no fields: send no body, `{}` or `null`. The Stripe and Postmark webhooks and the LiteLLM callback take their sender's body as it comes. example: error: type: invalid_request_error code: validation_error message: must start with https:// request_id: req_dbjg652q3gpgqfoq5cd0 param: url MemberInvitation: type: object description: | An invitation for a teammate to join this account's dashboard. `POST /v1/members/invite` returns it, and `GET /v1/members` lists every invitation the account has sent, newest first. required: [id, email, role, status, expires_at, created_at] properties: id: type: string description: The invitation's id (`hc_minv_…`). example: hc_minv_davn542q3gpgqfoq5ccg email: type: string description: The invited address, in lower case. It becomes the new member's sign-in email. role: type: string description: | Always `member` for an invitation. Roles are recorded but not enforced yet: every member can do everything in the dashboard. status: type: string enum: [pending, accepted, revoked, expired] description: | `pending` until the invitation is accepted or revoked, or until `expires_at` passes (`expired`). The server works it out each time it answers. invited_by_email: type: string description: The sign-in email of the member who sent the invitation. Absent when that user no longer exists. expires_at: type: string format: date-time description: When the accept link stops working, 7 days after `created_at`. created_at: type: string format: date-time description: When the invitation was created and its email queued. accepted_at: type: string format: date-time description: When the invitee accepted. Absent until then. revoked_at: type: string format: date-time description: | When the invitation was revoked, by a member or because its email could not be queued. Absent unless it was revoked. example: id: hc_minv_davn542q3gpgqfoq5ccg email: sam@northwind.example role: member status: pending invited_by_email: maya@northwind.example expires_at: "2026-11-04T15:00:00Z" created_at: "2026-10-28T15:00:00Z" Customer: type: object description: | A customer you bill: the person or company that subscriptions, invoices, credits and payment methods belong to. Create, list, get and update customers under `/v1/customers`. The `customer.email_bounced` and `customer.email_complained` webhook events carry this object too. Not every route fills every optional field. Get and update return the whole record. The list and create answers leave out `email_status`, the bounce fields, `dunning_policy_id` and `stripe_customer_id`, and the list also leaves out `additional_emails`. required: [id, external_id, display_name, status, created_at, updated_at] properties: id: type: string description: The customer's ID, `hc_cus_` and 20 characters. example: hc_cus_dakhnk2q3gpgqfoq5c0g external_id: type: string description: | Your own ID for the customer, such as the account ID in your app. Unique in the account and mode, and fixed after create. Create refuses one longer than 255 bytes (UTF-8). display_name: type: string description: | The name shown on invoices, emails and the dashboard. Create refuses one longer than 255 bytes (UTF-8); an update does not check the length. email: type: string description: | Where billing emails go: invoices, receipts, payment reminders and payment-method links. Absent when the customer has none. additional_emails: type: array maxItems: 10 items: type: string description: | Addresses copied (CC) on billing documents and billing emails: invoice, receipt, payment reminder, final notice, failed payment and credit note. Never on the payment setup emails, whose link lets the reader add a card. Lowercase bare addresses, no duplicates, never the primary `email`. At most 10: a create or update with more is refused with 422. Absent when the list is empty. status: type: string enum: [active, archived] description: | `archived` blocks new subscriptions for the customer. Its unpaid invoices stay collectible. created_at: type: string format: date-time description: When the customer was created. test_clock_id: type: string description: | The test clock the customer was created on (test mode only). Its subscriptions and invoices then run on the clock's simulated time. Set at create and fixed after. Absent for a customer with no test clock. tenant_id: type: string description: The account the customer belongs to. email_status: type: string enum: [unknown, ok, bounced, complained] description: | Whether `email` receives mail, learned from bounce and spam reports. `unknown` until a report arrives. `bounced` or `complained` after one, and Hardcount then stops emailing the address. Changing `email` sets it back to `unknown`. Hardcount does not set `ok` today. email_last_bounced_at: type: string format: date-time description: | When the last bounce or spam report was recorded. Absent until one is, and again after `email` changes. email_bounce_reason: type: string description: | The reason the email provider gave with the last bounce or spam report. Absent until one is recorded, when the report gave no reason, and again after `email` changes. dunning_policy_id: type: string description: | The dunning policy (the plan for retrying failed payments) this customer uses. Absent when the customer uses the account's default policy. stripe_customer_id: type: string description: | The Stripe customer linked to this customer. Absent until Hardcount first creates it: when you create a payment-method link (setup session or setup email), or when the customer opens Stripe Checkout from a payment-update link or the hosted invoice page. Set once, never replaced. livemode: type: boolean description: | Whether the customer is in live mode. The customer routes and webhook events leave it out: use the mode of your API key. updated_at: type: string format: date-time description: When the customer was last changed. example: id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example additional_emails: [finance@acme.example] status: active email_status: unknown dunning_policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g stripe_customer_id: cus_RkT9qW2xYz3AbC created_at: "2026-09-30T16:20:00Z" updated_at: "2026-10-15T09:12:44Z" Meter: type: object description: | A meter counts one kind of usage: every usage event that sends its `event_name`. A meter has no price of its own. Plans price it (`/v1/plans/{id}/prices`). Create, list, get and update meters under `/v1/meters`. required: [id, event_name, name, unit, aggregation, created_at, updated_at] properties: id: type: string description: The meter's ID, `hc_mtr_` and 20 characters. tenant_id: type: string description: The account the meter belongs to. event_name: type: string description: | The `event_name` usage events send for this meter. Letters, digits, `-` and `_`, at most 100 characters. Unique in the account and mode, and fixed after create. name: type: string description: The meter's display name, at most 255 bytes (UTF-8). unit: type: string description: | The unit of the quantity, such as `tokens` or `seconds`. `unit` when none was given. aggregation: type: string enum: [sum, count, max, last] description: | How the meter rolls its events into one quantity per billing period: `sum` adds the quantities, `count` counts the events, `max` takes the highest quantity, and `last` takes the latest event's quantity, by timestamp, then by id. Every price of the meter bills under it. Fixed once the meter has a usage event (see `has_usage`). has_usage: type: boolean description: | Whether the meter has usage events. Once it has, its `aggregation` cannot change. Held usage for its `event_name` does not count until it is released. Returned when you create, get or update one meter; the meter list leaves it out. A create or update that succeeded leaves it out if it could not be read. created_at: type: string format: date-time description: When the meter was created. updated_at: type: string format: date-time description: When the meter was last changed. example: id: hc_mtr_dajr122q3gpgqfoq5c10 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 event_name: tokens name: Tokens unit: tokens aggregation: sum has_usage: true created_at: "2026-09-15T09:00:00Z" updated_at: "2026-09-15T09:00:00Z" Plan: type: object description: | A plan is what a subscription buys: a recurring base fee and the usage prices of its meters, in one currency and billing interval. `GET /v1/plans` lists plans in this shape. Get, create and update answer a `PlanDetail`, which adds two fields. required: [id, code, name, currency, billing_interval, status, base_amount_cents, base_bill_timing, prices, created_at, updated_at] properties: id: type: string description: The plan's ID, `hc_pln_` and 20 characters. tenant_id: type: string description: The account the plan belongs to. code: type: string description: | Your short name for the plan. Letters, digits, `-` and `_`, at most 100 characters, unique in the account and mode. Fixed after create. name: type: string description: | The plan's display name. Create refuses one longer than 255 bytes (UTF-8); an update does not check the length. currency: type: string description: | ISO 4217 code in upper case, such as `USD`. Fixed after create. The plan's prices take it. billing_interval: type: string enum: [monthly, yearly] description: How often a subscription to the plan is billed. Fixed after create. status: type: string enum: [active, archived] description: | An archived plan takes no new subscriptions and no plan change onto it. Subscriptions already on it keep billing. base_amount_cents: type: integer format: int64 description: | The recurring base fee per billing period, in the currency's minor unit (cents for USD). 0 for a usage-only plan. base_bill_timing: type: string enum: [in_advance, in_arrears] description: | When the base fee is invoiced, relative to the period it covers. `in_arrears`: the base fee and the usage are billed at the period end. `in_advance`: the base fee is billed at the period start; the first invoice comes when the subscription starts, and each cycle invoice carries the next period's base fee with the past period's usage. Usage is always billed at the period end. A plan with no base fee is `in_arrears`. prices: type: array description: | The plan's usage prices from now on, in the order they were added. A plan's meters are the meters it has a price for. items: $ref: "#/components/schemas/PlanPrice" description: type: string description: Your description of the plan. Absent when empty. tax_code: type: string description: | A Stripe product tax code, such as `txcd_10103001`, read only by the Stripe Tax provider. Today only the base fee line of the invoice billed when an in-advance subscription starts carries it; cycle invoices and usage lines use the account's `default_product_tax_code`. Absent when not set. created_at: type: string format: date-time description: When the plan was created. updated_at: type: string format: date-time description: When the plan was last changed. example: id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" PlanDetail: description: | A plan as get, create and update plan answer it: the plan with its prices, whether a subscription has ever used it, and how many subscriptions that are not canceled are on it. allOf: - $ref: "#/components/schemas/Plan" - type: object required: [prices, used, live_subscription_count] properties: used: type: boolean description: | Whether a subscription has ever used the plan (canceled ones and scheduled plan changes included). On a plan no one has used, a price change applies at once. On a used plan, it applies from each subscriber's next billing period. A subscriber who first joins the plan in a period after the change gets it from the moment they join. live_subscription_count: type: integer description: | Subscriptions on this plan that are not canceled. While it is above 0, `base_amount_cents` cannot change (see Update plan). Always 0 on the answer to a create. example: id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" used: true live_subscription_count: 1 created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" PriceShape: type: object description: | How a price bills a quantity: its mode and the mode's fields. It is the body of `PATCH /v1/plans/{id}/prices/{price_id}` and part of every price you add. Send only the mode's fields: a field the mode does not use must be 0 or left out (`tiers`: `[]` or left out), or the request is refused with 422 and `param` naming it. required: [mode] properties: mode: type: string enum: [per_unit, graduated, package] description: >- `per_unit` bills quantity × `unit_amount_cents`. `graduated` bills each tier's part of the quantity at that tier's rate. `package` bills ceil(quantity ÷ `package_size`) × `package_amount_cents`: a part package bills as a whole one. unit_amount_cents: type: string description: >- Price per unit in cents, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 units). Used when `mode` is `per_unit`. 0 or more, under 10^18, at most 12 decimal places (trailing zeros do not count), and at most 100 characters. A JSON number is also read. A value outside them is refused with 422 and `param` naming the field. Invoice line amounts round to whole cents. tiers: type: array description: >- Used when `mode` is `graduated`. Each `up_to` is a whole number above the one before. The last tier has `up_to: null` (no upper bound), and only the last. Otherwise 422 with `param` `tiers`. A tier's rate takes the limits of `unit_amount_cents`; a refusal names `tiers[i].unit_amount_cents` (i counts from 0). items: $ref: "#/components/schemas/PriceTier" package_size: type: integer format: int64 description: Used when `mode` is `package`. The units in one package, above 0. package_amount_cents: type: string description: >- Price of one package in cents, as a decimal string (e.g. "0.2" with `package_size` 1000 = $0.002 per 1,000 units), with the limits of `unit_amount_cents`. Used when `mode` is `package`. example: mode: graduated tiers: - up_to: 1000000 unit_amount_cents: "0" - up_to: null unit_amount_cents: "0.0003" PlanPrice: type: object description: | One usage price of a plan: a meter, the events it takes (`dimension_match`) and its rate. The price takes the plan's currency. Its id stays the same when its rate changes; its meter and `dimension_match` are its identity and never change. Every shape field is always sent: the ones the mode does not use are `"0"`, `0` or `[]`. required: [id, plan_id, meter_id, dimension_match, mode, created_at, updated_at, unit_amount_cents, tiers, package_size, package_amount_cents] properties: id: type: string description: The price's ID, `hc_prc_` and 20 characters. example: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: type: string description: The account the price belongs to. plan_id: type: string description: The plan the price belongs to. meter_id: type: string description: The meter whose usage the price bills. dimension_match: type: object additionalProperties: type: string description: | The labels an event must carry for this price to take it, for example `{"model": "claude-opus-4.5", "token_type": "input"}`. `{}` takes every event of the meter. Of the prices whose labels an event carries, the one with the most labels takes it. mode: type: string enum: [per_unit, graduated, package] description: How the price bills a quantity; see PriceShape. unit_amount_cents: type: string description: | Price per unit, as a decimal string of cents. `"0"` unless `mode` is `per_unit`. tiers: type: array description: Empty (`[]`) unless `mode` is `graduated`. items: $ref: "#/components/schemas/PriceTier" package_size: type: integer format: int64 description: The units in one package. 0 unless `mode` is `package`. package_amount_cents: type: string description: | Price of one package, as a decimal string of cents. `"0"` unless `mode` is `package`. created_at: type: string format: date-time description: When the price was first added to the plan. updated_at: type: string format: date-time description: When the content shown was saved. example: id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" PriceWrite: description: | The answer to a price write (add, change or remove): the price, and when the write applies. `now`: no subscription has used the plan, so it applies at once. `next_period`: each subscriber pays the new content from their next billing period; the current period keeps the prices it started with. A subscriber who first joins the plan in a period after the write gets it from the join: a subscription that starts after it (after a trial, from the trial's end), an immediate plan change onto the plan, or an item added on it. Each is priced from that moment by the plan's prices then, so a subscription that starts later on the day of the write bills a price added that day and not one removed that day. A subscription that was on the plan earlier in the period and joins it again keeps the prices of its first join until its next period. On a test clock, the write takes effect just after the clock's time: a subscriber who joins at that same clock time gets the earlier prices. allOf: - $ref: "#/components/schemas/PlanPrice" - type: object required: [effective] properties: effective: type: string enum: [now, next_period] description: When the write applies, as explained above. removed: type: boolean description: True on the answer to a removal. Absent otherwise. ended_overrides: type: array description: | On a removal, the customer overrides of the price it ended (`[]` when there were none). Each ends when the removal takes effect for its customer. Absent on an add or a change. items: type: object required: [id, customer_id] properties: id: type: string description: The ended override's ID. customer_id: type: string description: The customer the override was for. example: id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-10-20T14:00:00Z" effective: next_period PriceInput: description: | One price to add to a plan: the body of `POST /v1/plans/{id}/prices`, and one entry of `prices` on `POST /v1/plans`. The meter and labels, then the shape. On `POST /v1/plans`, a refused field of a price is named with `prices[i].` first, such as `prices[0].meter_id`. allOf: - type: object required: [meter_id] properties: meter_id: type: string description: | The meter whose usage the price bills. It must be a meter in this account and mode. dimension_match: type: object maxProperties: 16 additionalProperties: type: string description: | The labels an event must carry for the price to take it. At most 16 labels. Each key and each value is a string of 1 to 100 bytes (UTF-8), and a key may not be only spaces. Omitted or `{}` takes every event of the meter. Two prices of one plan and meter that could both take one event with the same number of labels are refused (409 `price_conflict`). - $ref: "#/components/schemas/PriceShape" example: meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: model: claude-opus-4.5 mode: per_unit unit_amount_cents: "0.0015" PriceTier: type: object description: One tier of a graduated price. required: [up_to, unit_amount_cents] properties: up_to: type: integer format: int64 nullable: true description: >- The tier's upper bound in whole units: the tier prices the quantity above the previous tier's bound, up to this one. `null` on the last tier, which has no upper bound. Only the last tier is `null`, and it must be. unit_amount_cents: type: string description: | Price per unit in cents in this tier, as a decimal string. `"0"` makes the tier free. example: up_to: 1000000 unit_amount_cents: "0" Subscription: type: object description: | A customer's subscription: the plans it bills (its `items`), its status and its current billing period. Every route under `/v1/subscriptions` that returns a subscription returns this object, with its items. A subscription bills in one currency, the currency of its plans. All its items share one billing interval and one base-fee timing. Usage, and a base fee billed in arrears, are invoiced when a period ends. A base fee billed in advance is invoiced when its period starts: the first one in the same transaction that creates or activates the subscription or ends its trial, each later one on the invoice that closes the period before. required: [id, code, display_name, customer_id, status, billing_time, cancel_at_period_end, created_at, updated_at] properties: id: type: string description: The subscription's ID (`hc_sub_…`). tenant_id: type: string description: The account the subscription belongs to. code: type: string description: | Your code for the subscription: letters, digits, hyphens and underscores. Unique in your account and mode (live or test). display_name: type: string description: The subscription's name, as shown in the dashboard. customer_id: type: string description: The customer the subscription bills (`hc_cus_…`). items: type: array description: The subscription's priced lines. Each item names its plan. items: $ref: "#/components/schemas/SubscriptionItem" status: type: string enum: [draft, trialing, active, canceled] description: | Where the subscription is in its life. - `draft`: created with `status: draft`. It has no billing period and bills nothing until you activate it with `POST /v1/subscriptions/{id}/activate`. - `trialing`: in a free trial until `trial_end_at`. Usage in the trial is not billed. When the trial ends, Hardcount makes the subscription `active` and its first paid period starts. If a cancel is scheduled for the trial end, it becomes `canceled` instead, with no charge. - `active`: billing. Usage and base fees billed in arrears are invoiced when each period ends; a base fee billed in advance when its period starts. - `canceled`: ended at `canceled_at`. It bills nothing more, and no route makes it `active` again. A paused collection is not a status: see `pause_collection`. billing_time: type: string enum: [calendar, anniversary] description: | How monthly periods line up. The first period starts on the day the subscription starts to bill: the day it is created or activated, or, with a trial, the day the trial ends. `calendar`: every period ends on the 1st of a month, in your account's billing time zone, so the first period runs from that day to the next 1st. `anniversary`: each period starts on the same day of the month as the first one (the last day of a shorter month). A plan billed yearly always bills a year from that first day, whatever this says. An immediate change to a plan with another billing interval starts a new first period at the change. trial_start_at: type: string format: date-time description: When the trial started. Absent when the subscription had no trial. trial_end_at: type: string format: date-time description: | When the trial ends, or ended. The first paid period starts at the beginning of that day in your account's billing time zone. Ending the trial early (`POST /v1/subscriptions/{id}/end-trial`) sets it to that moment. Absent when the subscription had no trial. started_at: type: string format: date-time description: | When the subscription started: when it was created, or, for a draft, when it was activated. Absent on a draft. activated_at: type: string format: date-time description: | When the subscription became `active`: at creation without a trial, at activation of a draft, or when its trial ended. Absent on a draft and while it trials. canceled_at: type: string format: date-time description: When the subscription was canceled. Absent until then. cancel_at: type: string format: date-time description: | The instant a cancel is scheduled for, when it was scheduled with `cancel_at`. Absent when none is set, and after the subscription is canceled. cancel_at_period_end: type: boolean description: | True when the subscription cancels at the end of the current period, or, while it trials, at the end of the trial. False after the subscription is canceled. cancel_effective_at: type: string format: date-time description: | When a scheduled cancel takes effect: `cancel_at` when set; otherwise, with `cancel_at_period_end`, `trial_end_at` while the subscription trials and `current_billing_period_end` after. Absent when no cancel is scheduled. pause_collection: $ref: "#/components/schemas/SubscriptionPauseCollection" billing_thresholds: $ref: "#/components/schemas/SubscriptionBillingThresholds" current_billing_period_start: type: string format: date-time description: | Start of the current billing period, normally the beginning of a day in your account's billing time zone. While the subscription trials, it is the start of the first paid period: the beginning of the day the trial ends, not `trial_end_at` itself. After an immediate change to a plan with another billing interval, a period can start or end at the instant of the change. Absent on a draft. On a canceled subscription it keeps its last value. current_billing_period_end: type: string format: date-time description: | End of the current period (exclusive): the start of the next one. While the subscription trials, the end of the first paid period. Absent on a draft. On a canceled subscription it keeps its last value. current_billing_period_display: type: string description: | The current period as text, with the last day included, in your account's billing time zone, for example "Oct 1, 2026 – Oct 31, 2026". Absent when the subscription has no current period, when the time zone could not be read, and on the rows of `GET /v1/test-clocks/{id}/subscriptions`. next_billing_at: type: string format: date-time description: | When the current period's invoice is due to be built: the end of the current period. The billing run builds it once the invoice grace after that time has passed. Absent on a draft. A canceled subscription keeps its last value, and nothing is billed at that time. billing_anchor_day: type: integer description: | Day of the month the subscription bills on (1-31): the day its first period started, or the day of its last immediate change to a plan with another billing interval. Absent on a draft and for calendar-monthly subscriptions. test_clock_id: type: string description: | The test clock the subscription runs on (test mode only), taken from its customer when it was created. Absent when the customer is not on a test clock. billing_failure: $ref: "#/components/schemas/BillingFailure" created_at: type: string format: date-time description: When the subscription was created. For a customer on a test clock, the clock's time. updated_at: type: string format: date-time description: When the subscription last changed. example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" BillingFailure: type: object required: [attempts, max_attempts, last_error, failed_at, parked] description: | Present on a subscription while the billing run is failing on the work that subscription has due now (a period close, or a scheduled mid-period cancel). Absent otherwise: a subscription that bills cleanly, or whose due work changed, carries no `billing_failure`. After each failed attempt the billing run waits before the next one: 15 minutes, 1 hour, 4 hours, 12 hours, then 24 hours three times. The eighth failure parks the subscription (`parked: true`, no `next_attempt_at`). A parked subscription is not attempted again until `POST /v1/subscriptions/{id}/retry-billing` is called. Never set on a subscription attached to a test clock. properties: attempts: type: integer description: Failed attempts on the work that is due now. At least 1. max_attempts: type: integer description: The attempt at which the subscription parks (8). last_error: type: string description: | Why the latest attempt failed: a sentence an operator can act on, or a generic line carrying the subscription id as the reference to search the server log for. Never raw error text. failed_at: type: string format: date-time description: When the latest attempt failed. next_attempt_at: type: string format: date-time description: | The billing run does not attempt the subscription before this instant. The attempt happens on the first run at or after it, so on an hourly schedule it can be up to an hour later. Absent when parked. parked: type: boolean description: True when automatic attempts have stopped. example: attempts: 2 max_attempts: 8 last_error: The tax provider in settings cannot be used. Choose a tax provider that is configured, then retry billing. failed_at: "2026-11-01T01:20:00Z" next_attempt_at: "2026-11-01T02:20:00Z" parked: false UsageEvent: type: object description: | One usage event: a quantity of one meter's usage, for one customer, at one time. Returned by `POST /v1/usage-events` and `POST /v1/usage-events/backfill`, and listed by `GET /v1/usage-events`. An event counts toward the billing period its `timestamp` falls in. required: [id, customer_id, meter_id, quantity, timestamp] properties: id: type: string description: The event's ID (`hc_evt_…`). tenant_id: type: string description: The account the event belongs to. customer_id: type: string description: | The customer the event was recorded for (`hc_cus_…`): the customer whose `external_id` you sent as `external_customer_id`. meter_id: type: string description: | The meter the event counts toward (`hc_mtr_…`): the meter whose `event_name` you sent. quantity: type: string description: Exact quantity as a decimal string (e.g. "1.5"). dimensions: type: object additionalProperties: true description: The dimension values sent with the event. Absent when none were sent. idempotency_key: type: string description: | The key the event was sent with. Hardcount keeps one event per key in each account and mode (live or test), with no expiry: an event sent later with the same key is not stored again, however long after. Absent on an event stored without a key. timestamp: type: string format: date-time description: | When the usage happened. It decides which billing period the event counts toward. An event sent without `timestamp` is stamped with the time Hardcount received it, or, for a customer on a test clock, with the clock's time. An event that is not a backfill may be at most 5 minutes ahead of that time. There is no limit on how far back it goes, but an event in a period whose invoice is already built is stored and not billed (see `billing_status`). origin: type: string enum: [api, backfill] description: | How the event arrived. `api`: `POST /v1/usage-events`, the batch route or the LiteLLM integration. `backfill`: `POST /v1/usage-events/backfill`, or released held usage. Absent on the rows of `GET /v1/usage-events`, which does not return it. provider_cost_cents: type: string description: | What serving this event cost you at your provider, in US cents as a decimal string (e.g. "0.45"), set when the event arrived: from your provider cost rates, or the cost LiteLLM reported for the call (see `provider_cost_source`). Absent when no rate matched or the cost was too large to store, and when the source is `not_applicable`. provider_cost_source: type: string enum: [table, not_applicable, observed] description: | Where `provider_cost_cents` came from: `table` (your provider cost rates), `observed` (the cost LiteLLM reported for the call), or `not_applicable`: the event lacks a `provider` or `token_type` dimension, or has neither `model` nor `model_raw`, so no rate can apply. Absent when the event has those dimensions but no rate matched. billing_status: $ref: "#/components/schemas/UsageBillingStatus" example: id: hc_evt_db6eh6qq3gpgqfoq5c3g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "1200" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" origin: api provider_cost_cents: "0.3" provider_cost_source: table UsageBillingStatus: type: string enum: [after_close, no_price, not_on_plan, outside_subscription] description: | What Hardcount found when the event arrived. It checked the event against the customer's subscriptions, the plans and prices in force, and the invoices already built at that moment. Sent only on the answer that wrote the event; the event is stored all the same. A status that is present was true at that moment: - `after_close`: the event's time is already invoiced. A spend-threshold invoice counts only once it is paid. Bill the event with a one-off invoice if it should be charged. - `not_on_plan`: a billing period not invoiced yet holds the event's time, but its plan has no price for this meter. - `outside_subscription`: no active or trialing subscription of the customer bills the event's time. For example, it has none, or the time is before one starts, in a free trial, or after one ends or its scheduled cancel. - `no_price`: the plan that bills the event's time prices this meter, but no price of that plan takes the event's `dimensions`. A price takes an event whose dimensions include all of the price's `dimension_match` labels. A price's labels are text, so a number or a true/false value in the event never matches: the number `1` does not match the string `"1"`. No `billing_status` means Hardcount found no reason then. It is not a promise that the event will be billed. Hardcount checks only the customer's current billing periods: time after a period end whose invoice is not built yet is not checked, nor is time after a trial's end before Hardcount ends the trial. A later change can change the outcome either way: a subscription created, activated or changed, a trial extended, a scheduled cancel undone, or a price added. The invoice is the final answer. Two cases can make a present status wrong: a price change whose save began before a billing period started and finished after the request read the prices, and a subscription change saved while the request is in flight. Absent on a replay and on a read. UsageEventBatchResult: type: object description: | What `POST /v1/usage-events/batch` wrote: how many events were new, how many were already stored, and one result per event in the order sent. required: [ingested, deduplicated, total, after_close, no_price, not_on_plan, outside_subscription, results] properties: ingested: type: integer description: Newly written events. deduplicated: type: integer description: Idempotency replays — already ingested, counted as success. total: type: integer description: Events in the request. after_close: type: integer description: Of `ingested`, the events answered `billing_status` `after_close`. no_price: type: integer description: Of `ingested`, the events answered `billing_status` `no_price`. not_on_plan: type: integer description: Of `ingested`, the events answered `billing_status` `not_on_plan`. outside_subscription: type: integer description: Of `ingested`, the events answered `billing_status` `outside_subscription`. results: type: array description: | One entry per event, in the order sent: `results[i]` is the request's event `i`. items: $ref: "#/components/schemas/UsageEventBatchEventResult" example: ingested: 2 deduplicated: 0 total: 2 after_close: 0 no_price: 0 not_on_plan: 0 outside_subscription: 0 results: - status: ingested - status: ingested UsageEventBatchEventResult: type: object description: What the batch did with one of its events. required: [status] properties: status: type: string enum: [ingested, deduplicated] description: | `ingested`: the batch wrote the event. `deduplicated`: an event with its `idempotency_key` was already stored, earlier or in this batch, so it was not stored again. billing_status: $ref: "#/components/schemas/UsageBillingStatus" example: status: ingested DocumentBillTo: type: object description: | What an issued invoice or credit note prints about its buyer. It is copied from the customer and the billing profile when the document is issued, so a later edit to either does not change it. The printed name is `legal_name` when set, otherwise `display_name`. required: [display_name] properties: display_name: type: string description: The customer's display name when the document was issued. legal_name: type: string description: The billing profile's legal name. Absent when none was set. email: type: string description: The customer's email address. Absent when none was set. address_line1: type: string description: First address line of the billing profile. Absent when none was set. address_line2: type: string description: Second address line of the billing profile. Absent when none was set. city: type: string description: City of the billing profile. Absent when none was set. state: type: string description: State or region of the billing profile. Absent when none was set. postal_code: type: string description: Postal code of the billing profile. Absent when none was set. country: type: string description: Country of the billing profile, as a two-letter ISO 3166 code. Absent when none was set. tax_id: type: string description: | On an invoice, the tax ID its tax was calculated with when set, otherwise the billing profile's tax ID at issue. Absent when there is none. tax_id_type: type: string description: The kind of tax ID, as the billing profile stores it. Absent when none was set. example: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US DocumentSupplier: type: object description: | What an issued invoice or credit note prints about its seller: your company details from the account settings, copied when the document was issued. A later change to the settings does not change it. Every field is absent when the setting was empty. properties: name: type: string description: Your company name. email: type: string description: Your company's contact email. phone: type: string description: Your company's phone number. address_line1: type: string description: First line of your company address. address_line2: type: string description: Second line of your company address. city: type: string description: City of your company address. state: type: string description: State or region of your company address. postal_code: type: string description: Postal code of your company address. country: type: string description: Country of your company address. tax_id: type: string description: Your company's tax ID. example: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US InvoiceStatus: type: string enum: [draft, finalized, paid, voided, uncollectible] description: | Where the invoice is in its life. - `draft`: not issued yet. Lines can still be added, and nothing is charged. - `finalized`: issued and owed. Hardcount collects it. - `paid`: settled, by card, credit, an offline payment or a credit note that left nothing due. - `voided`: cancelled. Nothing is owed. Voiding is final. - `uncollectible`: written off as bad debt, by you (`POST /v1/invoices/{id}/mark-uncollectible`) or by a dunning policy's final action. Hardcount stops collecting it, but it stays on the books and can still be paid. InvoicePaymentStatus: type: string enum: [pending, processing, succeeded, failed, unknown] description: | The state of the invoice's payment. - `pending`: nothing has been charged yet, or a charge is about to be sent. Also the status after Stripe refused the amount as below its minimum charge (see `amount_below_minimum`). - `processing`: Stripe is still working on a charge. - `succeeded`: the invoice is paid. - `failed`: the last charge was declined or refused. Dunning, when a policy is on, retries it. - `unknown`: a Stripe charge got an unclear answer (a 5xx, a timeout or a dropped connection), so Hardcount cannot tell whether Stripe took the money. With a payment intent recorded, Hardcount asks Stripe for it after a short wait. With none (a parked invoice), Hardcount lists the customer's payments at Stripe on every billing tick: it records a payment for the invoice that appears, and if none has appeared 24 hours after the attempt the invoice goes back to `pending` (`failed` after a dunning retry) and is charged again as usual. InvoiceTaxStatus: type: string enum: [ok, pending] description: | Whether tax has been calculated. `ok`: it has (a zero tax counts). `pending`: the calculation failed, and `tax_error_code` says why and whether Hardcount retries it. A pending invoice stays a draft and cannot be finalized, so a wrong tax amount is never sent. AttentionSeverity: type: string enum: [info, warning, critical] description: | How urgent an `Attention` notice is, most urgent first: `critical`, `warning`, `info`. Sort or filter on it; the dashboard shows one colour per level. AttentionReason: type: string enum: - tax_calculation_failed - tax_location_required - payment_failed - payment_unconfirmed - payment_anomaly - payment_processing - payment_scheduled - awaiting_payment - no_payment_method - dunning_exhausted - collection_paused - commit_exposure - dispute_lost - refunded_in_stripe - amount_below_minimum description: | Why an invoice needs your attention, as a closed code. A code is never reused for another meaning; a retired code stays reserved. `Attention.code` carries the open, provider-specific sub-code. - `tax_calculation_failed`: the tax provider could not calculate tax. The cause is in `code`. - `tax_location_required`: the customer's billing address or tax ID is missing or not valid for the tax provider. Edit the billing profile. - `payment_failed`: the last charge failed. `code` is `payment.declined` for a decline, `payment.card_refused` when the bank refused the card for good, or `payment.authentication_required` when the bank wants the customer to confirm the payment. Stripe's own message is in `provider_response`. - `payment_unconfirmed`: a charge got an unclear answer from Stripe. Hardcount asks Stripe what happened. - `payment_anomaly`: money does not reconcile: a double charge, a captured amount that differs from the amount booked, a payment in another currency, or a payment on a voided invoice. Shown even on paid and voided invoices. - `payment_processing`: a payment may be in progress. Stripe is still processing a charge (it settles on its own), the customer has the hosted payment page open (`code` `payment.page_open`), or a charge is in progress on a written-off invoice (`code` `payment.recovery_processing`). - `payment_scheduled`: the invoice is queued, and the automatic charge collects it on its next run. - `awaiting_payment`: the invoice is unpaid and not queued for an automatic charge. Only invoices written by older releases reach this state. - `no_payment_method`: the customer has no card ready to charge. The invoice stays queued, and the automatic charge collects it on its first run after a card is added, unless collection is paused on the subscription. When Stripe is not connected for the invoice's mode, no card can be added online and the invoice is emailed instead. `message` says which applies. - `dunning_exhausted`: the customer still has no card and automatic recovery has ended without collecting; the policy's final action ran. If that action paused collection on the subscription, resume it too: the automatic charge skips a paused subscription even after a card is added. - `collection_paused`: the invoice is queued for an automatic charge, but its subscription has `pause_collection` set, so the automatic charge skips it. A dunning run already open on the invoice keeps retrying it through the pause, and `message` then says so. With no open run it is not charged automatically until collection resumes. - `commit_exposure`: the invoice funded a prepaid commit and is unpaid, and the customer can already spend that credit or has spent it. Prepaid credit is granted when the invoice is issued, not when it is paid. This reason is returned only when no other reason applies. When one does, the exposure is folded into that reason's `message` and `actions` instead, so do not treat this code as the only sign of exposure. - `dispute_lost`: the customer disputed a payment on the invoice with their bank, and the dispute closed as `lost`, so the bank took the money back. Hardcount leaves the invoice paid and moves no money; you record the loss. Shown even on paid invoices, which is where disputes land. On an invoice that bought prepaid credit, the message also says how much of the credit is still spendable and names commit relief, because an ordinary credit note is refused there. When the invoice also has `payment_anomaly`, that reason stays and its message asks you to check Stripe before any refund. The disputes are at `GET /v1/invoices/{id}/disputes`. - `refunded_in_stripe`: a refund of the invoice's payment was made in Stripe outside Hardcount, and Hardcount did not record it as a credit note: the invoice was not paid yet, the currency differs, the invoice bought prepaid credit, Hardcount first saw the refund more than a day after it was made, a credit note on the invoice has a failed card refund this refund may replace, or the refund is more than the invoice had left to refund or credit. `code` is `payment.refunded_in_stripe.` followed by that review reason. Record it with a credit note that names the refund (`stripe_refund_id` on `POST /v1/credit-notes`), attach it to the credit note whose refund failed (`POST /v1/credit-notes/{id}/attach-stripe-refund`), or dismiss it (`POST /v1/invoices/{id}/stripe-refunds/{stripe_refund_id}/dismiss`). Shown even on paid invoices, which is where refunds land. - `amount_below_minimum`: Stripe refused to charge the invoice, or refused a payment page for it, because the amount is below Stripe's minimum charge. The minimum depends on the currency the Stripe account settles in; Stripe's reply, in `provider_response`, names it. Nothing was charged, the invoice stays `finalized` with `payment_status` `pending`, it is not charged again automatically, dunning does not start (a run already open ends), and the customer is not emailed. Void it or record a payment. `POST /v1/invoices/{id}/collect` applies the customer's credit balance and marks the invoice paid if that covers it, and otherwise asks Stripe again. Stripe's answer can change (for a converted charge the minimum is the settlement currency's), and a different answer ends this reason. RecoveryWarning: type: object required: [blocked] properties: blocked: type: boolean description: Always false. This object only warns; it never refuses. code: type: string enum: - tax_reversed_unrecoverable - relief_not_reissued description: | What will not reconcile. `tax_reversed_unrecoverable`: the invoice's Stripe Tax transaction was reversed, and recording the payment does not report it again, so the tax collected will not appear in your tax provider's records. `relief_not_reissued`: a credit the invoice was owed was never applied, so `amount_due_cents` is higher than what is owed and the customer may have overpaid. message: type: string description: | One sentence for the operator naming the consequence and what to do about it. description: | What you should know before you record an offline payment on a written-off invoice (`status` `uncollectible`): a consequence that will not reconcile on its own. It never refuses the payment, because the money already arrived. A written-off invoice is never charged by card. It is settled only by recording a payment: an offline payment, or a card payment Stripe shows succeeded. example: blocked: false code: relief_not_reissued message: This invoice is owed a credit that was never applied, so the amount shown is higher than what the customer actually owed. If they paid this amount they have overpaid — issue the credit so it is returned or offset against their next invoice. AttentionAction: type: string enum: - edit_billing_profile - retry_tax - retry_payment - wait_provider - rotate_api_key - reconcile_payment - review_registration - charge_now - send_reminder - add_payment_method - update_payment_method - connect_tax_provider - void_invoice - mark_uncollectible description: | A recommended next step, as a closed code. Each code maps to an API route or a dashboard page. - `edit_billing_profile`: fix the customer's billing address or tax ID. - `retry_tax`: `POST /v1/invoices/{id}/retry-tax`. - `retry_payment` and `charge_now`: `POST /v1/invoices/{id}/collect`. - `wait_provider`: nothing to do; the provider is expected to answer. - `rotate_api_key`: replace the Stripe key under Settings. - `reconcile_payment`: open the payment in Stripe and refund or correct it there. Offered on `payment_anomaly`. - `review_registration`: check your tax registrations with the tax provider. - `send_reminder`: email the customer. On `no_payment_method` and `dunning_exhausted` that is the setup link (`POST /v1/invoices/{id}/resend-setup-link`); otherwise it is the invoice with its payment link (`POST /v1/invoices/{id}/send`). - `add_payment_method`: add a card for the customer. - `update_payment_method`: send the customer a link to replace a card that was declined. - `connect_tax_provider`: connect the tax provider under Settings. - `void_invoice`: `POST /v1/invoices/{id}/void`. Offered on prepaid-commit exposure while some granted credit is unspent: voiding retires the unspent balance, and credit already spent is not taken back. Also offered on `amount_below_minimum`. - `mark_uncollectible`: `POST /v1/invoices/{id}/mark-uncollectible`. Offered only on a parked invoice (payment status `unknown` with no payment intent recorded), for a customer who paid another way. After the write-off, Hardcount still records a payment that Stripe shows, but it no longer charges the invoice. The dashboard shows it as a secondary action. AttentionActionItem: type: object description: One recommended next step on an `Attention` notice. required: [code] properties: code: $ref: "#/components/schemas/AttentionAction" label: type: string description: | A default button label. You can show your own, translated text instead. Absent when there is none. example: code: update_payment_method label: Update payment method Attention: type: object description: | Why an invoice needs your attention now, and what to do about it. Hardcount works it out each time the invoice is read, from the invoice's tax and payment state and related facts (the customer's card, the dunning run, disputes, refunds made in Stripe); it is never stored. The invoice leaves it out when nothing needs attention. `GET /v1/invoices`, `GET /v1/invoices/{id}` and the finalize, retry-tax and rotate-public-token responses carry it; the other invoice responses and the webhook events do not. required: [severity, reason, message] properties: severity: $ref: "#/components/schemas/AttentionSeverity" reason: $ref: "#/components/schemas/AttentionReason" message: type: string description: | Text that is safe to show as it is: one plain sentence saying what happens and what to do. Facts that must show up front follow on more lines, separated by a newline (`\n`), one sentence each and up to three lines in all: the setup-link email on `no_payment_method` (and on `dunning_exhausted` when no link can be emailed), and the prepaid credit at risk on an unpaid commit invoice. A no-card commit invoice carries all three. The wording can change between releases; switch on `reason` and `code`, not on this text. actions: type: array items: $ref: "#/components/schemas/AttentionActionItem" description: | Recommended next steps. The first is the main one; the rest are secondary. Absent when there is nothing to do but wait. code: type: string description: | Open, dotted code naming the exact cause, for example `tax.customer_data_invalid` or `payment.declined`. New codes can appear in any release, as with Stripe's error codes. doc_url: type: string description: | A link to documentation about this reason. No such pages are published yet, so this release always leaves it out. param: type: string description: | Dotted path to the field to edit to fix the cause. Today only `tax_location_required` sets it, always to `customer.address.postal_code`, even when the tax ID is what is wrong. Absent otherwise. detail: type: string description: | Hardcount's own explanation behind `message`: why the state holds, what Hardcount does on its own, and ids support may ask for (for example the payment intent ids of a payment anomaly). Plain text; may contain blank-line paragraph breaks. Never a provider payload: that is `provider_response`. provider_response: type: string description: | The provider's response as Hardcount recorded it: the Stripe Tax error (cleaned of its JSON envelope and cut to 500 bytes), or the invoice's `last_payment_error`. Absent when Hardcount did not call the provider or recorded no response. since: type: string format: date-time description: | When the condition started, so you can sort by age. For a tax reason it is when tax was deferred, for `amount_below_minimum` when Stripe refused the amount, for `dispute_lost` when the dispute closed, and for `refunded_in_stripe` when the refund was made. Otherwise it is when the invoice last changed; on a test clock, when it was issued or its last charge failed. Absent when it is not known. next_attempt_at: type: string format: date-time description: | When Hardcount will next retry on its own. Today only a tax retry sets it. Absent when no automatic retry is scheduled. due_by: type: string format: date-time description: | The invoice's payment deadline (its `due_at`). Hardcount does not act on this date; it is shown so you know it. Absent when the reason does not show a deadline or the invoice has none. example: severity: critical reason: payment_failed message: The customer's card was declined. actions: - code: update_payment_method label: Update payment method - code: retry_payment label: Retry payment code: payment.declined provider_response: Your card has insufficient funds. since: "2026-11-01T00:05:03Z" InvoiceBillingReason: type: string enum: [subscription_cycle, subscription_create, subscription_cancel, subscription_update, manual, threshold] description: | Why the invoice was made, like Stripe's `invoice.billing_reason`. Set when the invoice is created and never changed. - `subscription_cycle`: the invoice for a subscription period that ended (a renewal). - `subscription_create`: the first invoice when a subscription starts or its trial ends, for the fees its plans bill in advance. A subscription billed only in arrears has none. - `subscription_cancel`: the final invoice when a subscription is canceled at once in the middle of a period. - `subscription_update`: the invoice for a change in the middle of a period, such as a plan upgrade, a quantity increase or an added item. - `manual`: an invoice you created with `POST /v1/invoices`. - `threshold`: an early invoice because a spend threshold was crossed. InvoiceLineType: type: string enum: [base_fee, usage, add_on] description: | What an invoice line charges for. `base_fee`: a plan's flat fee for the period. `usage`: metered usage, priced by a plan price. `add_on`: any other charge, such as a line you add to a draft (the default for those). ItemChangeType: type: string enum: [plan, quantity, add, remove] description: | The kind of subscription item change that produced a proration: `plan` (the item moved to another plan), `quantity` (its quantity changed), `add` (the item was added) or `remove` (the item was removed). On a credit ledger entry (`source_change_type`) it is never sent in this version. Invoice: type: object description: | A bill for one customer, in one currency. The billing engine creates one at the end of each subscription period (and for a subscription's start when a fee is billed in advance, a change, a cancel or a spend threshold); you can also create one-off invoices with `POST /v1/invoices`. The engine creates its invoices already `finalized`, or as a `draft` while their tax is pending or collection is paused on the subscription. An invoice you create starts as a `draft` until you finalize it. A finalized invoice ends `paid`, `voided` or `uncollectible`. Every amount is a whole number in the currency's minor unit (cents for USD). The invoice routes and the `invoice.*` and `payment.*` webhook events return it. required: - id - customer_id - subscription_id - invoice_number - status - payment_status - currency - subtotal_cents - discount_cents - tax_amount_cents - tax_rate - total_amount_cents - amount_due_cents - amount_paid_cents - credits_applied_cents - billing_period_start - billing_period_end - net_payment_term_days - created_at - updated_at - paid_out_of_band - is_simulated properties: id: type: string description: Unique identifier of the invoice, starting `hc_inv_`. example: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: type: string description: | The account the invoice belongs to. Your API key already decides the account, so you rarely need it. customer_id: type: string description: The customer the invoice bills. subscription_id: type: string description: | The subscription the invoice bills. An empty string when the invoice bills no subscription. An invoice you create with `POST /v1/invoices` carries the `subscription_id` you sent. invoice_number: type: string description: | The number printed on the invoice: the account's invoice prefix (default `HC`), a hyphen and a sequence padded to six digits, for example `HC-000042`. Test mode and live mode count separately. Set when the invoice is created, drafts included. status: $ref: "#/components/schemas/InvoiceStatus" payment_status: $ref: "#/components/schemas/InvoicePaymentStatus" currency: type: string description: | The invoice's currency, as an uppercase ISO 4217 code. Every amount on the invoice and its lines is in this currency's minor unit. example: USD subtotal_cents: type: integer format: int64 description: | The sum of the line amounts, before discount and tax, in the currency's minor unit (cents for USD). With tax-inclusive prices, it is the net amount after the tax is taken out. discount_cents: type: integer format: int64 description: Discount taken off the subtotal, in the currency's minor unit (cents for USD). 0 when there is none. tax_amount_cents: type: integer format: int64 description: | Tax on the invoice, in the currency's minor unit (cents for USD). On an invoice you create with `POST /v1/invoices`, it is 0 until finalize calculates the tax. tax_rate: type: number format: double description: Tax rate as a percent, up to four decimal places (18.5 means 18.5%). tax_name: type: string description: The tax's label, such as `VAT` or `Sales Tax`. Absent when there is none. tax_country: type: string description: The country the tax was calculated for. Absent when none was recorded. tax_id: type: string description: The customer's tax ID the tax was calculated with. Absent when there was none. tax_provider: type: string description: | The tax engine that calculated the tax: `stripe_tax`, `manual` (a flat rate from the account settings) or `none`. A draft's tax can be calculated again (retry-tax, or finalize of a one-off draft); once the invoice is finalized it no longer changes. Absent until tax is calculated. tax_calculation_id: type: string description: | The tax provider's calculation reference (Stripe Tax only). A Stripe Tax calculation expires after 24 hours; Hardcount keeps the reference so a finalized invoice can still be traced. Absent for the manual and none providers. tax_transaction_id: type: string description: | The tax provider's committed transaction (Stripe Tax only). On an invoice the billing engine creates, it is recorded when the invoice is built; on one you create, at finalize. When that first commit fails, a background retry records it later. Hardcount reverses it when the invoice is voided or a credit note is issued. Absent until committed, and for providers that keep no record. tax_reverse_charge: type: boolean description: | True when reverse charge applies: no tax is charged and the customer accounts for it. Absent when false. tax_exempt_reason: type: string description: Why the customer is exempt from tax, as recorded on the customer. Absent when they are not exempt. tax_status: $ref: "#/components/schemas/InvoiceTaxStatus" tax_deferred_at: type: string format: date-time description: | When the tax calculation failed and the invoice was held as a draft. A later successful calculation clears it. Absent unless tax is deferred. tax_retry_count: type: integer description: | How many times tax was calculated again while it was pending: each `POST /v1/invoices/{id}/retry-tax` on pending tax, each automatic retry, and each finalize of a one-off draft whose earlier calculation failed. Absent when 0. tax_pending_reason: type: string description: | Why tax could not be calculated, while `tax_status` is `pending`: the provider's error, or Hardcount's own text when the provider was never called (`tax_error_code` `provider_not_configured`). Cut to 500 bytes. Absent otherwise. tax_error_code: type: string enum: - customer_data_invalid - jurisdiction_unsupported - provider_outage - provider_auth - provider_not_configured - unknown description: | Why tax could not be calculated, as a code. Set while `tax_status` is `pending`; `tax_pending_reason` has the detail. tax_next_retry_at: type: string format: date-time description: | The earliest time the background retry may calculate tax again, after a failed attempt. Absent when a retry may run now, and when none is scheduled: an error code Hardcount does not retry, or the automatic retries are used up. tax_reversed_at: type: string format: date-time description: | When the provider's tax transaction was reversed after a void, or found to need no reversal. Absent while the reversal is not confirmed. payment_anomaly_kind: type: string description: | Set when a payment arrived that the invoice did not expect. The webhook event name of the cause: `payment.duplicate_charge`, `payment.amount_mismatch`, `payment.currency_mismatch` or `payment.received_on_voided_invoice`. Only the latest one is kept. Absent when there is none. payment_anomaly_payment_intent_id: type: string description: The Stripe PaymentIntent of that unexpected payment. Absent when there is none. payment_anomaly_captured_cents: type: integer format: int64 description: The amount Stripe took in that unexpected payment, in the currency's minor unit (cents for USD). Absent when there is none. payment_card_brand: type: string description: Brand of the saved card that paid the invoice, as Stripe names it (for example `visa`). Absent when the card is unknown. payment_card_last4: type: string description: Last four digits of the saved card that paid the invoice. Absent when the card is unknown. attention: $ref: "#/components/schemas/Attention" recovery_warning: allOf: - $ref: "#/components/schemas/RecoveryWarning" description: | What you should know before you record an offline payment on a written-off invoice: a consequence that will not reconcile on its own, never a refusal. Sent only on `uncollectible` invoices, by the same responses that carry `attention`. Absent when there is nothing to say. total_amount_cents: type: integer format: int64 description: | The invoice total: `subtotal_cents` minus `discount_cents` plus `tax_amount_cents`, in the currency's minor unit (cents for USD). It is what the invoice charges before credit and payments, and it does not change when the invoice is paid. amount_due_cents: type: integer format: int64 description: | What the customer still owes, in the currency's minor unit (cents for USD): the total, less customer credit applied (`credits_applied_cents`), credit notes issued before payment, and payments. It becomes 0 when the invoice is paid. A voided or written-off invoice keeps the amount that was due. amount_paid_cents: type: integer format: int64 description: | What was paid by card or recorded as an offline payment, in the currency's minor unit (cents for USD). Set when the invoice is paid, to the amount that was due then; 0 before. Credit applied is counted in `credits_applied_cents`, not here. credits_applied_cents: type: integer format: int64 description: | How much of the customer's credit balance paid this invoice, in the currency's minor unit (cents for USD). Credit is applied when the invoice is collected (after finalize, or by `POST /v1/invoices/{id}/collect`). A void gives it back to the customer's balance, and a credit note issued before payment can give part of it back, lowering this figure. billing_period_start: type: string format: date-time description: | Start of the period the invoice bills (included). On a one-off invoice created without a period, the time it was created. billing_period_end: type: string format: date-time description: | End of the period the invoice bills (not included): the next period's start. On a one-off invoice created without a period, the time it was created. billing_period_display: type: string description: >- The period as people read it, with its last day included, in `billing_timezone`: for example "Oct 1, 2026 – Oct 31, 2026". `billing_period_start` and `billing_period_end` stay the exact boundaries. Worked out when the invoice is read, so it comes with the same responses as `attention`. Absent when the period starts and ends at the same instant. billing_timezone: type: string description: >- The IANA time zone the period boundaries are in: the account's billing time zone, copied onto the invoice when it was created. `billing_period_display`, the PDF and the hosted page show dates in it, so an issued invoice's dates stay the same if the account later changes its time zone. Absent only on invoices created before releases recorded it. bill_to_snapshot: allOf: - $ref: "#/components/schemas/DocumentBillTo" description: | The buyer details this invoice was issued with, copied when it was finalized (or created already finalized). The PDF, the hosted page and the dashboard show an issued invoice from this copy, so a later edit to the customer or billing profile does not change it. Absent on drafts and on invoices issued before this field existed; those show the current customer details. supplier_snapshot: allOf: - $ref: "#/components/schemas/DocumentSupplier" description: | Your company details this invoice was issued with, copied when it was finalized (or created already finalized). Absent on drafts and on invoices issued before this field existed. Branding (logo, brand color) is not part of it. issued_at: type: string format: date-time description: | When the invoice was issued. Every invoice gets one when it is created, drafts included. An invoice you create with `POST /v1/invoices` gets a new one when it is finalized. due_at: type: string format: date-time description: | When payment is due: `issued_at` plus `net_payment_term_days` calendar days, counted in `billing_timezone`. Set with `issued_at`. Hardcount does not charge on this date: it collects right after finalize and retries on the dunning schedule. The `overdue` filter of `GET /v1/invoices` uses it. paid_at: type: string format: date-time description: When the invoice was paid. Absent until it is paid. voided_at: type: string format: date-time description: When the invoice was voided. Absent unless it is voided. uncollectible_at: type: string format: date-time description: When the invoice was written off as uncollectible. Absent unless it was. stripe_payment_intent_id: type: string description: | The Stripe PaymentIntent of the card charge that paid the invoice, or of its last charge that is in progress or failed. It can be absent after a charge was sent: for example when the invoice was then paid without a card (by credits, with nothing due, or outside Hardcount: `paid_out_of_band`), or when Stripe refused the last charge as below its minimum. paid_out_of_band: type: boolean description: | True when the payment was recorded as made outside Hardcount, such as a bank transfer or cheque (`POST /v1/invoices/{id}/record-payment`, or a dunning run resolved as `payment_recovered`). Such an invoice has no `stripe_payment_intent_id`, so there is no card to refund. last_payment_error: type: string description: | The error text recorded with the invoice's last charge attempt that did not succeed. Usually Stripe's message, which is written for the cardholder, with card numbers and email addresses masked. It can also be Hardcount's own text, such as `customer has no payment method on file`. Absent when there is none. auto_charge_pending: type: boolean description: | True while the invoice is queued for the automatic charge, which applies credit and charges the saved card. Absent when false. no_pm_notified_at: type: string format: date-time description: | When Hardcount automatically queued an email to the customer with a link to add a payment method for this invoice. It is queued once, when Stripe is connected, the customer has an email address and the server's `PAYMENT_UPDATE_URL` is set; it reaches the customer only when the server can send email. `POST /v1/invoices/{id}/resend-setup-link` sends it again and does not change this time. Absent until the automatic email is queued. invoice_emailed_at: type: string format: date-time description: | When Hardcount itself emailed the customer this invoice, because Stripe is not connected for the invoice's mode. The email is sent once. An invoice you email with `POST /v1/invoices/{id}/send` does not set it. Absent until then. provider_payment_status: type: string description: | Stripe's PaymentIntent status the last time Hardcount checked it, word for word. For information only: `payment_status` is the invoice's payment state. Absent until Hardcount has checked. An invoice checked by the Stripe Search sweep of earlier releases may still show `search_not_found`, `search_terminal_only` or `search_error`. provider_synced_at: type: string format: date-time description: | When Hardcount last asked Stripe about this invoice's payment, whether or not Stripe answered: a read of `provider_payment_status`, or a check of a charge whose result was lost. Absent until then. public_token: type: string description: | The token in the hosted invoice page's link, like Stripe's `hosted_invoice_url`; `GET /v1/public/invoices/{token}` serves the page's data. Anyone with it can view and pay the invoice, so share it only with the customer. Created when the invoice is finalized, or created already finalized; absent on drafts. Replace it with `POST /v1/invoices/{id}/rotate-public-token`. net_payment_term_days: type: integer description: Days from `issued_at` to `due_at`. 0 means due on receipt. memo: type: string description: A note you set when creating the invoice, printed on the PDF and the hosted page. Absent when empty. footer: type: string description: | The account's invoice footer as it was when this invoice was issued. Printed on the PDF and the hosted invoice page. Absent on a draft and when no footer was set. metadata: type: object additionalProperties: true description: Extra key-value data stored with the invoice. No API request sets it; it is absent when empty. created_at: type: string format: date-time description: When the invoice was created. For a customer on a test clock, the clock's time. updated_at: type: string format: date-time description: When the invoice last changed. source_plan_changed_at: type: string format: date-time description: | On an invoice for a change in the middle of a period (`billing_reason` `subscription_update`), when the change took effect. Absent on other invoices. source_subscription_item_id: type: string description: On an invoice for a change in the middle of a period, the subscription item that changed. Absent on other invoices. source_change_type: $ref: "#/components/schemas/ItemChangeType" billing_reason: $ref: "#/components/schemas/InvoiceBillingReason" is_simulated: type: boolean description: | True when the invoice's dates (created, issued, due, paid) are on a test clock's time instead of the real time. Recorded when the invoice is written: for a cycle invoice, its subscription is on a test clock; for one you create, its customer is. Always false in live mode. test_clock_id: type: string description: | The test clock this invoice's dates follow: the clock its customer is pinned to, for cycle and one-off invoices alike. Read from the customer on `GET /v1/invoices` and `GET /v1/invoices/{id}`; other responses may leave it out. Left out for a customer on wall-clock time, and always in live mode. example: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_period_display: Oct 1, 2026 – Oct 31, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false InvoiceLineItem: type: object description: | One line on an invoice: a base fee, a usage charge or another charge. Amounts are whole numbers in the currency's minor unit (cents for USD). `GET /v1/invoices/{id}` returns the lines, and `POST /v1/invoices/{id}/line-items` returns the line it added. required: - id - invoice_id - line_type - description - quantity - unit_amount_decimal - amount_cents - tax_rate - tax_amount_cents - total_amount_cents - currency - created_at properties: id: type: string description: Unique identifier of the line, starting `hc_ili_`. invoice_id: type: string description: The invoice the line is on. tenant_id: type: string description: The account the line belongs to. Absent when empty. line_type: $ref: "#/components/schemas/InvoiceLineType" meter_id: type: string description: On a usage line, the meter that measured the usage. Absent on other lines. meter_unit: type: string description: > On a usage line, the meter's unit ("tokens", "seconds", …), as it was when the line was written, so prices can be shown per unit the way the pricing pages show them (token rates read "$3.00 / 1M tokens"). Absent on other lines, and on usage lines written before releases recorded it. description: type: string description: | The line's text as printed on the invoice. A base fee reads like "Pro - base fee (qty 1)"; a usage line names the meter and its unit, or the labels its price takes, like "Tokens (tokens)". quantity: type: string description: > The line's exact quantity as a decimal string, for example "3" or "1.5". Line amounts stay whole cents. unit_amount_decimal: type: string description: > The unit price, a decimal string of cents at full precision, for example "0.3" for $0.003. On a usage line priced `per_unit` it is that rate; on any other line it is `amount_cents` ÷ `quantity`. Line amounts and totals stay whole cents. amount_cents: type: integer format: int64 description: | The line's amount before tax, in the currency's minor unit (cents for USD). With tax-inclusive prices, it is the net amount after the tax is taken out. tax_rate: type: number format: double description: Tax rate on the line as a percent, up to four decimal places (18.5 means 18.5%). tax_amount_cents: type: integer format: int64 description: Tax on the line, in the currency's minor unit (cents for USD). tax_jurisdiction: type: string description: The tax jurisdiction the provider named for the line. Absent when none was given. tax_code: type: string description: The product tax code the line was taxed under (for Stripe Tax, a `txcd_…` code). Absent when none was used. tax_reason: type: string description: | Why the line was taxed as it was, in Stripe Tax's words (`standard_rated`, `reverse_charge`, `not_collecting`, `customer_exempt`, `product_exempt`, `zero_rated` and others). `prepaid_credit_purchase` is Hardcount's own value, on a prepaid commit purchase line under the manual or Stripe Tax provider: the line is not taxed, and the usage the credit pays for is taxed when invoiced. Otherwise absent for providers other than Stripe Tax. total_amount_cents: type: integer format: int64 description: The line's amount plus its tax, in the currency's minor unit (cents for USD). currency: type: string description: The line's currency, the same as the invoice's. pricing_mode: type: string description: "On a usage line, the shape of its price: `per_unit`, `graduated` or `package`. Absent on other lines." price_id: type: string description: On a usage line, the plan price that billed it. Absent on other lines. dimension_match: type: object additionalProperties: type: string description: On a usage line, the labels its price takes. Absent for a price that takes every event, and on other lines. billing_period_start: type: string format: date-time description: Start of the time the line bills for (included). Absent on lines with no period of their own, such as lines you add to a draft. billing_period_end: type: string format: date-time description: End of the time the line bills for (not included). Absent on lines with no period of their own. metadata: type: object additionalProperties: true description: Extra key-value data stored with the line. No API request sets it; it is absent when empty. created_at: type: string format: date-time description: When the line was written. commit_granted_cents: type: integer format: int64 description: | Marks the line as a prepaid commit purchase (credit the customer pays for up front): when the invoice is finalized, the customer gets a credit grant of this many cents (in the invoice's currency). It may differ from the line's price: a discounted commit sells $10,000 of credit for $9,000. Only on `add_on` lines of invoices you create, at most one per invoice. Absent on other lines. commit_expires_at: type: string format: date-time description: | When the granted credit expires. The credit can be drawn up to and including this instant; a period invoice draws it when its period ends at or before it. The dashboard sends the local midnight after the picked day. Absent when the credit never expires (the default). example: id: hc_ili_dbj84baq3gpgqfoq5c4g invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: usage meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens description: Tokens (tokens) quantity: "12500000" unit_amount_decimal: "0.0003" amount_cents: 3750 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 3750 currency: USD pricing_mode: per_unit price_id: hc_prc_dajr672q3gpgqfoq5c20 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" created_at: "2026-11-01T00:05:00Z" ProviderCostRate: type: object description: | What you pay an upstream AI provider per token, for one provider, model and token type. When a usage event with matching `provider`, `model` and `token_type` labels arrives, Hardcount stamps this cost on it, unless the event already brings a cost the provider reported. The margin report adds the stamps up. Editing or deleting a rate changes only events that arrive later. The `/v1/provider-costs` routes manage rates. required: [id, provider, model, token_type, unit_cost_cents, currency, created_at, updated_at] properties: id: type: string description: The rate's ID, `hc_pcr_` and 24 hex characters. tenant_id: type: string description: The account the rate belongs to. provider: type: string description: | The provider, in lower case, such as `anthropic`. It must equal the `provider` label of the usage events it costs. model: type: string description: | The model, such as `claude-sonnet-4.5`. An event matches on its `model_raw` label first, then on its `model` label. token_type: type: string description: | The kind of token, in lower case, such as `input`, `output` or `cache_read`. It must equal the `token_type` label of the usage events it costs. unit_cost_cents: type: string description: | US cents per token, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 tokens). currency: type: string enum: [USD] description: Always USD. Cost and margin count every rate in US cents. created_at: type: string format: date-time description: When the rate was first set. updated_at: type: string format: date-time description: When the rate was last set. example: id: hc_pcr_9c3e5a7b1d2f4e6a8c0b2d4f tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 provider: anthropic model: claude-sonnet-4.5 token_type: input unit_cost_cents: "0.0002" currency: USD created_at: "2026-09-15T09:10:00Z" updated_at: "2026-09-15T09:10:00Z" MarginReport: type: object description: | One customer's usage revenue against what that usage cost you at your AI providers, for a window: `GET /v1/customers/{id}/margin`. It looks only at usage: base fees, credits and tax are not in it. Revenue is shown per model only where the price that billed the usage names the model in its `dimension_match`; other revenue is reported as unattributed, never split by a guess. required: [customer_id, from, to, revenue_cents, cost_cents, cost_currency, by_model, unattributed_revenue_cents, unresolved_events, costed_events, cost_rates_configured] properties: customer_id: type: string description: The customer the report is about. from: type: string format: date-time description: Start of the window, inclusive. to: type: string format: date-time description: End of the window, exclusive. revenue_cents: type: integer format: int64 description: | Total rated usage revenue in the window, in the minor unit of `revenue_currency` (cents for USD). It is an estimate at each subscription's current prices. Only active and trialing subscriptions are counted. revenue_currency: type: string description: | Currency of `revenue_cents` (the currency of the customer's prices). Absent when the window's revenue spans more than one currency, or the customer has no priced meter. cost_cents: type: string description: Total stamped provider cost in US cents, as a decimal string (e.g. "275.75"). cost_currency: type: string description: Currency of `cost_cents`. Always USD. margin_bps: type: integer format: int64 description: | (revenue − cost) / revenue in basis points (100 = 1%). Absent when revenue is 0, when `revenue_currency` is not `cost_currency`, when `costed_events` is 0 (no provider cost known), or when `unresolved_events` is above 0 (part of the cost unknown, so the margin would read too high). by_model: type: array description: | One row per model that has stamped cost or attributed revenue in the window, largest revenue first, then largest cost. items: type: object required: [model, cost_cents, attributed] properties: model: type: string description: | The model, from the events' `model` label (else their `model_raw` label, else `(unknown)`), or from the price's `dimension_match` for revenue with no stamped cost. cost_cents: type: string description: This model's stamped provider cost in US cents, as a decimal string. revenue_cents: type: integer format: int64 description: | The revenue of the prices that name this model, in the minor unit of `revenue_currency`. Absent when the row is not attributed or the revenue is 0. attributed: type: boolean description: | True only when the price that billed the usage names this model, so revenue is never split by a guess. margin_bps: type: integer format: int64 description: | This model's (revenue − cost) / revenue in basis points, by the same formula as the report's `margin_bps`. Absent whenever the report's `margin_bps` is absent for currency or `unresolved_events`, and when the row is not attributed or has no provider cost of its own. unattributed_revenue_cents: type: integer format: int64 description: | The revenue of prices that name no model, in the minor unit of `revenue_currency` (cents for USD). unresolved_events: type: integer format: int64 description: | Usage events in the window that carry `provider` and `token_type` labels and a `model` or `model_raw` label, but have no cost: no rate matched (add a rate to cost new events), or the cost, quantity × `unit_cost_cents`, was too large to record (about $9.2 trillion or more, either sign). costed_events: type: integer format: int64 description: | Events in the window that carry a stamped provider cost (zero cost included). 0 means the cost side is unknown. cost_rates_configured: type: boolean description: | Whether the account has any provider cost rate in this mode. False with `costed_events` 0 means no rates are set up. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g from: "2026-10-01T00:00:00Z" to: "2026-11-01T00:00:00Z" revenue_cents: 3750 revenue_currency: USD cost_cents: "2500" cost_currency: USD margin_bps: 3333 costed_events: 4210 cost_rates_configured: true by_model: - model: claude-sonnet-4.5 cost_cents: "2500" attributed: false unattributed_revenue_cents: 3750 unresolved_events: 0 InvoiceWithLineItems: type: object description: | What `GET /v1/invoices/{id}` returns: the invoice and its lines, as two keys side by side. required: [invoice, line_items] properties: invoice: $ref: "#/components/schemas/Invoice" line_items: type: array description: The invoice's lines. An empty list when it has none. items: $ref: "#/components/schemas/InvoiceLineItem" example: invoice: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_period_display: Oct 1, 2026 – Oct 31, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false line_items: - id: hc_ili_dbj84baq3gpgqfoq5c51 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: base_fee description: Pro - base fee (qty 1) quantity: "1" unit_amount_decimal: "4900" amount_cents: 4900 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 4900 currency: USD billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" created_at: "2026-11-01T00:05:00Z" - id: hc_ili_dbj84baq3gpgqfoq5c4g invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: usage meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens description: Tokens (tokens) quantity: "12500000" unit_amount_decimal: "0.0003" amount_cents: 3750 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 3750 currency: USD pricing_mode: per_unit price_id: hc_prc_dajr672q3gpgqfoq5c20 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" created_at: "2026-11-01T00:05:00Z" CreditBalance: type: object description: > A customer's credit balance in one currency and where that credit went. Every cent granted is either still in the balance or in one of the totals: total_granted = total_used + total_expired + total_retired + total_deducted + balance_cents. All amounts are in the currency's minor unit (cents for USD). required: [customer_id, balance_cents, total_granted, total_used, total_expired, total_retired, total_deducted, currency] properties: customer_id: type: string description: The customer (`hc_cus_…`). currency: type: string description: > The currency of this balance, uppercase. Each credit ledger entry stores its own currency, and credit pays only invoices in that currency. A customer with credit in two currencies has two balances; they are never added together or converted. balance_cents: type: integer format: int64 description: > The credit left: the sum of the customer's ledger entries in this currency, in its minor unit (cents for USD). A grant past its expiry still counts here until the expiry run writes it off. total_granted: type: integer format: int64 description: > Every credit added, in the currency's minor unit: grants, positive adjustments, and credit a void or credit note gave back as new credit. Credit a void or credit note put back on the grant it was drawn from is not added again. total_used: type: integer format: int64 description: > Credit applied to invoices, less what a void or credit note put back on the grant it was drawn from, in the currency's minor unit. total_expired: type: integer format: int64 description: Credit written off by expiry, in the currency's minor unit. total_retired: type: integer format: int64 description: > Prepaid commit credit taken back because the invoice that funded it was voided or refunded by a credit note, in the currency's minor unit. total_deducted: type: integer format: int64 description: Credit a dashboard user or an API key deducted (a negative adjustment), in the currency's minor unit. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD balance_cents: 5000 total_granted: 5000 total_used: 0 total_expired: 0 total_retired: 0 total_deducted: 0 CreditLiveBalance: type: object description: > A customer's credit position right now, one entry per currency: every currency the customer holds credit in, and every currency an active or trialing subscription will invoice usage in. Amounts in two currencies are never added together. Returned by `GET /v1/credits/balance/{customer_id}/live`; nothing is stored. required: [customer_id, balances, computed_at] properties: customer_id: type: string description: The customer (`hc_cus_…`). balances: type: array description: Ordered by currency. items: $ref: "#/components/schemas/CreditLiveCurrency" computed_at: type: string format: date-time description: When the server computed these figures. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g balances: - currency: USD balance_cents: 5000 unbilled_usage_cents: 2400 estimated_remaining_cents: 2600 computed_at: "2026-10-20T12:00:00Z" CreditLiveCurrency: type: object description: > The live position in one currency, in its minor unit (cents for USD). `balance_cents` is the credit the customer can spend in it: the settled ledger balance, which moves only when an invoice, grant, adjustment or expiry is written, less any grant that is past its expiry date and not yet written off. `unbilled_usage_cents` is the usage of the current period that no invoice has billed yet and that will be invoiced in this currency. `estimated_remaining_cents` is the first minus the second, never below zero. required: [currency, balance_cents, unbilled_usage_cents, estimated_remaining_cents] properties: currency: type: string description: The currency of this position, uppercase (for example `USD`). balance_cents: type: integer format: int64 description: > Settled ledger balance in this currency, less what is left of any grant past its expiry date that the hourly expiry run has not written off yet (an invoice cannot draw on such a grant), in the currency's minor unit. Can be negative. unbilled_usage_cents: type: integer format: int64 description: > Metered usage that the next invoice of each subscription invoiced in this currency will bill, priced as the period close prices it, in the currency's minor unit. Usage that a mid-period threshold invoice already billed is not counted again. After a mid-period plan change each meter is counted for the time a plan carrying it was on the subscription. For a trialing subscription only usage from the trial end on counts, inside its first paid period; trial usage is free. estimated_remaining_cents: type: integer format: int64 description: > `balance_cents` minus `unbilled_usage_cents`, floored at zero, in the currency's minor unit. To see how far past zero a customer is, subtract the two parts yourself. example: currency: USD balance_cents: 5000 unbilled_usage_cents: 2400 estimated_remaining_cents: 2600 RecipeOverride: type: object description: >- One value of a recipe you can set before you install it, such as the currency or the plan's name. The recipe's `overridable` list holds them; preview and install check `overrides` against them. required: [key, type, default] properties: key: type: string description: The name to use in `overrides`, such as `currency`. type: type: string enum: [string, int] description: The JSON type the value must have. `int` takes any JSON number. default: description: Default value applied when the override is not supplied. enum: type: array description: The only values allowed. Absent when any value of the type is allowed. items: type: string max_length: type: integer description: The most bytes a string value may have. Absent when there is no limit. pattern: type: string description: A regular expression a string value must match. Absent when there is none. example: key: currency type: string default: USD enum: - USD - EUR - GBP RecipeMeter: type: object description: | A meter a recipe creates, or reuses when one with the same `event_name` and aggregation exists. required: [event_name, name, unit, aggregation] properties: event_name: type: string description: The `event_name` usage events send for the meter. name: type: string description: The meter's display name. unit: type: string description: The unit of the quantity, such as `seconds`. aggregation: type: string description: | How the meter rolls its events into one quantity per period, one of `sum`, `count`, `max` or `last`. example: event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum RecipePlan: type: object description: | A plan a recipe creates, with its usage prices. An install always creates a new plan. required: [code, name, currency, billing_interval, base_amount_cents, prices] properties: code: type: string description: | The plan's code. When a plan with this code exists, the install gives the new plan the first free code with `_2`, `_3` and so on added. name: type: string description: The plan's name. currency: type: string description: The plan's currency, such as `USD`. billing_interval: type: string enum: [monthly, yearly] description: How often a subscription to the plan is billed. base_amount_cents: type: integer format: int64 description: The plan's recurring base fee, in the currency's minor unit (cents for USD). base_bill_timing: type: string enum: [in_advance, in_arrears] description: | When the base fee is billed; see Plan. Absent when the recipe leaves it out, which it may only with a base fee of 0. The plan is then `in_arrears`. prices: type: array description: The plan's usage prices. Each takes the plan's currency. items: $ref: "#/components/schemas/RecipePrice" example: code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" RecipeCreates: type: object description: >- How many objects a recipe makes when installed, by kind. A recipe makes only meters, plans and prices. required: [meters, plans, prices] properties: meters: type: integer description: | The meters the recipe declares. An install reuses a matching meter instead of making a new one. plans: type: integer description: The plans an install creates. prices: type: integer description: The prices the install puts on its plans. example: meters: 1 plans: 1 prices: 5 RecipeCreatedObjects: type: object description: >- The IDs of the objects an install made, by kind. `meter_ids` also holds a meter the install reused rather than made. Each list is absent when empty. properties: meter_ids: type: array description: The meters the install made or reused. items: type: string plan_ids: type: array description: The plans the install made. items: type: string price_ids: type: array description: The prices the install put on its plans. items: type: string example: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e RecipeInstance: type: object description: >- The record that a recipe was installed in this account and mode, and what the install made. A recipe is installed once per mode; installing it again returns this record. Its fields are `recipe_key` and `recipe_version`, not the recipe's own `key` and `version`. required: [id, recipe_key, recipe_version, overrides, created_objects, created_at] properties: id: type: string description: The install's ID, `hc_rec_` and 24 hex characters. tenant_id: type: string description: The account the recipe was installed in. recipe_key: type: string description: The `key` of the recipe installed. recipe_version: type: string description: The recipe's `version` at the install. overrides: type: object additionalProperties: true description: | The overrides the install was sent, as sent (`{}` when none). Defaults are not filled in. created_objects: $ref: "#/components/schemas/RecipeCreatedObjects" created_at: type: string format: date-time description: When the recipe was installed. created_by: type: string description: | The ID of the API key that installed the recipe. Absent for an install from a dashboard session. example: id: hc_rec_6b1d3f5a7c9e0b2d4f6a8c1e tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 recipe_key: replicate_style recipe_version: 7.0.0 overrides: plan_name: GPU Pro created_objects: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e created_at: "2026-10-05T10:00:00Z" created_by: hc_key_dajqj1iq3gpgqfoq5ca0 Recipe: type: object description: >- A ready-made pricing setup built into Hardcount: its meters, and its plans with their prices, with every overridable value at its default. Installing it creates those objects. A recipe creates nothing else. required: [key, version, name, summary, overridable, meters, plans] properties: key: type: string description: The recipe's key, used in the recipe routes, such as `replicate_style`. version: type: string description: The recipe's version. name: type: string description: The recipe's display name. summary: type: string description: A short description of what the recipe sets up. description: type: string description: A longer description. Absent when the recipe has none. overridable: type: array description: The values you can set before installing. items: $ref: "#/components/schemas/RecipeOverride" meters: type: array description: The meters the recipe creates or reuses. items: $ref: "#/components/schemas/RecipeMeter" plans: type: array description: The plans the recipe creates, with their prices. items: $ref: "#/components/schemas/RecipePlan" example: key: replicate_style version: 7.0.0 name: GPU compute pricing (per-second) summary: | Ready-made pricing for reselling GPU compute. Bills each customer per second of GPU time, at a separate rate for each GPU type (H100, A100, A40, T4, CPU), on a monthly plan. description: | Sets up everything you need to bill customers for GPU compute time: • A usage meter that counts GPU-seconds. Each event you send must carry the seconds consumed since that job's previous report (a per-interval delta), not a cumulative running total — the meter sums deltas, and cumulative totals would under-bill whenever two jobs share a GPU class • A per-second price for each GPU type — H100, A100 (80GB), A40, T4, and CPU • A monthly plan your customers subscribe to Prices start at Replicate's public per-second rates — a starting point you'll edit to your own margins after setup. Before installing, you can set the currency and the plan's name. overridable: - key: currency type: string default: USD enum: - USD - EUR - GBP - key: plan_name type: string default: GPU Compute max_length: 80 - key: plan_code type: string default: gpu_compute_pro pattern: ^[a-z][a-z0-9_]*$ meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" RecipeListItem: description: >- A recipe as `GET /v1/recipes` lists it: the recipe, how many objects it makes (`creates`), and, in `instantiated`, its install record when it is installed in this account and mode. `instantiated` is absent when it is not installed. Installing again changes nothing, and there is no uninstall. allOf: - $ref: "#/components/schemas/Recipe" - type: object required: [creates] properties: creates: $ref: "#/components/schemas/RecipeCreates" instantiated: $ref: "#/components/schemas/RecipeInstance" example: key: replicate_style version: 7.0.0 name: GPU compute pricing (per-second) summary: | Ready-made pricing for reselling GPU compute. Bills each customer per second of GPU time, at a separate rate for each GPU type (H100, A100, A40, T4, CPU), on a monthly plan. description: | Sets up everything you need to bill customers for GPU compute time: • A usage meter that counts GPU-seconds. Each event you send must carry the seconds consumed since that job's previous report (a per-interval delta), not a cumulative running total — the meter sums deltas, and cumulative totals would under-bill whenever two jobs share a GPU class • A per-second price for each GPU type — H100, A100 (80GB), A40, T4, and CPU • A monthly plan your customers subscribe to Prices start at Replicate's public per-second rates — a starting point you'll edit to your own margins after setup. Before installing, you can set the currency and the plan's name. overridable: - key: currency type: string default: USD enum: - USD - EUR - GBP - key: plan_name type: string default: GPU Compute max_length: 80 - key: plan_code type: string default: gpu_compute_pro pattern: ^[a-z][a-z0-9_]*$ meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" creates: meters: 1 plans: 1 prices: 5 instantiated: id: hc_rec_6b1d3f5a7c9e0b2d4f6a8c1e tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 recipe_key: replicate_style recipe_version: 7.0.0 overrides: plan_name: GPU Pro created_objects: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e created_at: "2026-10-05T10:00:00Z" created_by: hc_key_dajqj1iq3gpgqfoq5ca0 RecipeDetail: description: A recipe as `GET /v1/recipes/{key}` returns it, with how many objects it makes. allOf: - $ref: "#/components/schemas/Recipe" - type: object required: [creates] properties: creates: $ref: "#/components/schemas/RecipeCreates" example: key: replicate_style version: 7.0.0 name: GPU compute pricing (per-second) summary: | Ready-made pricing for reselling GPU compute. Bills each customer per second of GPU time, at a separate rate for each GPU type (H100, A100, A40, T4, CPU), on a monthly plan. description: | Sets up everything you need to bill customers for GPU compute time: • A usage meter that counts GPU-seconds. Each event you send must carry the seconds consumed since that job's previous report (a per-interval delta), not a cumulative running total — the meter sums deltas, and cumulative totals would under-bill whenever two jobs share a GPU class • A per-second price for each GPU type — H100, A100 (80GB), A40, T4, and CPU • A monthly plan your customers subscribe to Prices start at Replicate's public per-second rates — a starting point you'll edit to your own margins after setup. Before installing, you can set the currency and the plan's name. overridable: - key: currency type: string default: USD enum: - USD - EUR - GBP - key: plan_name type: string default: GPU Compute max_length: 80 - key: plan_code type: string default: gpu_compute_pro pattern: ^[a-z][a-z0-9_]*$ meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" creates: meters: 1 plans: 1 prices: 5 RecipePreviewObjects: type: object description: >- What an install creates: the meters, and each plan with its prices. Both arrays are always present. An install reuses a meter that already exists with the same `event_name` and aggregation. required: [meters, plans] properties: meters: type: array description: The meters the install creates or reuses. items: $ref: "#/components/schemas/RecipeMeter" plans: type: array description: | The plans the install creates, with the overrides applied. An install that finds a plan's code taken gives the new plan the first free code with `_2`, `_3` and so on added. items: $ref: "#/components/schemas/RecipePlan" example: meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Pro currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" RecipePrice: description: | One usage price of a recipe plan. It takes the plan's currency. Every shape field is sent. required: [meter, dimension_match, mode, unit_amount_cents, tiers, package_size, package_amount_cents] allOf: - type: object required: [meter, dimension_match] properties: meter: type: string description: The `event_name` of the recipe meter the price is on. dimension_match: type: object description: | The labels an event must carry for the price to take it. `{}` takes every event of the meter. additionalProperties: type: string - $ref: "#/components/schemas/PriceShape" example: meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" RecipePreviewResult: type: object description: >- The answer to `POST /v1/recipes/{key}/preview`: what installing the recipe with your overrides would create. Nothing is written. required: [key, version, objects, warnings] properties: key: type: string description: The recipe's key. version: type: string description: The recipe's version. objects: $ref: "#/components/schemas/RecipePreviewObjects" warnings: type: array description: Problems that would not stop the install. Always empty today. items: type: string example: key: replicate_style version: 7.0.0 objects: meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Pro currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" warnings: [] CustomerBillingProfileInput: type: object description: | The billing details printed on invoices and used for tax: the body of `PUT /v1/customers/{id}/billing-profile`. The whole profile is replaced on every write, so a field you leave out is stored empty. properties: legal_name: type: string description: The customer's legal name, printed in the invoice's Bill to block. phone: type: string description: 7 to 20 characters of digits, spaces, `+`, `-`, `(`, `)`. address_line1: type: string description: First line of the billing address. address_line2: type: string description: Second line of the billing address. city: type: string description: City of the billing address. state: type: string description: State, province or region of the billing address, where the country has one. postal_code: type: string description: Postal code of the billing address, where the country has one. country: type: string description: | Two letters, an ISO 3166 alpha-2 code such as `US` or `IN`. Only the format is checked. Stored in upper case. currency: type: string description: | ISO 4217 code, such as `USD`. When set, invoices for this customer use it instead of the plan currency. Stored in upper case. Zero-decimal currencies such as JPY are refused. tax_status: type: string enum: [standard, exempt, reverse_charge] description: | `standard`: tax is worked out as usual. `exempt`: no tax, and the invoice shows `tax_exempt_reason`. `reverse_charge`: no tax, and the invoice tells the buyer to account for it. Defaults to `standard`. tax_exempt_reason: type: string description: | Why the customer pays no tax, such as a reseller certificate. Required when `tax_status` is `exempt`. tax_id: type: string description: | The buyer's tax ID, stored in upper case. Required when `tax_status` is `reverse_charge`. For `eu_vat`, `in_gst` and `au_abn` its format is checked. tax_id_type: type: string description: | The kind of tax ID, as its Stripe code (for example `eu_vat`, `in_gst`, `au_abn`), stored in lower case. `vat`, `gstin`, `in_gstin` and `abn` are stored as those codes. example: legal_name: Acme Incorporated phone: "+1 415 555 0100" address_line1: 548 Market St address_line2: Suite 100 city: San Francisco state: CA postal_code: "94104" country: US tax_status: standard CustomerBillingProfile: description: | A customer's stored billing profile, as the billing-profile routes return it. Empty text fields are left out, except `tax_id` and `tax_id_type`, which are always sent (`""` when unset). allOf: - $ref: "#/components/schemas/CustomerBillingProfileInput" - type: object required: [customer_id, tax_status, tax_id, tax_id_type, profile_status, created_at, updated_at] properties: customer_id: type: string description: The customer this profile belongs to. tenant_id: type: string description: The account the customer belongs to. profile_status: type: string enum: [incomplete, ready] description: | Set by the server. `ready` when legal name, address line 1, city and country are all set, so the invoice can print a full Bill to block. Otherwise `incomplete`. created_at: type: string format: date-time description: When the profile was first saved. updated_at: type: string format: date-time description: When the profile was last saved. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 legal_name: Acme Incorporated phone: "+1 415 555 0100" address_line1: 548 Market St address_line2: Suite 100 city: San Francisco state: CA postal_code: "94104" country: US tax_status: standard tax_id: "" tax_id_type: "" profile_status: ready created_at: "2026-09-30T16:25:00Z" updated_at: "2026-10-15T09:12:44Z" SentEmail: type: object description: | One billing email Hardcount sent, or tried to send, to a customer about one of its invoices. `GET /v1/customers/{id}/sent-emails` lists them. required: [id, email_type, recipient, status, delivery_state, created_at] properties: id: type: string description: The email's ID, `hc_emob_` and 20 characters. email_type: type: string enum: [invoice, payment_receipt, payment_failed, payment_setup_request, dunning_warning, dunning_escalation, credit_note] description: | Which email it is: the invoice, a payment receipt, a failed payment notice, a request to add a payment method, a payment reminder (`dunning_warning`), a final notice (`dunning_escalation`), or a credit note. recipient: type: string description: | The address the email was sent to: its To address. CC copies are not listed. status: type: string enum: [pending, dispatched, failed, skipped] description: | Hardcount's send state. `pending`: queued, or waiting to retry after a failed attempt. `dispatched`: the mail server accepted it. `failed`: it could not be sent, and Hardcount stopped trying. `skipped`: Hardcount did not send it because its invoice was paid, voided or marked uncollectible while it waited. Only the payment requests are skipped (failed payment notice, payment reminder, final notice, payment setup request); a final notice or payment setup request still goes out for an uncollectible invoice. delivery_state: type: string enum: [unknown, delivered, bounced, complained] description: | What the email provider reported back about delivery. `unknown` until it reports. invoice_number: type: string description: The number of the invoice the email is about. last_error: type: string description: | Why the last send attempt failed, or, on a `skipped` email, why it was not sent. Cleared when the email is dispatched. Absent when there is none. created_at: type: string format: date-time description: When the email was queued. dispatched_at: type: string format: date-time description: When the mail server accepted the email. Absent until then. line: description: | The email in the words of the activity line standard, the same as on the invoice's payment timeline: its name and what happened to it ("Receipt email delivered"), why it was not sent, the invoice it was about, and who it went to. Dated when the mail server took it, or when it was queued if it was not sent. For an email caused on a test clock, it is dated at the clock time instead, with the real send time as `recorded_at`. Present for every email type this list returns. allOf: - $ref: "#/components/schemas/ActivityLine" example: id: hc_emob_dbj84cqq3gpgqfoq5cbg email_type: invoice recipient: billing@acme.example status: dispatched delivery_state: delivered invoice_number: HC-000001 created_at: "2026-11-01T00:05:02Z" dispatched_at: "2026-11-01T00:05:09Z" line: id: email:hc_emob_dbj84cqq3gpgqfoq5cbg kind: email.invoice title: Invoice email delivered detail: - - link: kind: invoice id: hc_inv_dbj84b2q3gpgqfoq5c40 label: HC-000001 - - text: To billing@acme.example tone: neutral occurred_at: "2026-11-01T00:05:09Z" CustomerUsage: type: object description: | A customer's usage and its cost over a window, priced the way the invoice prices it: `GET /v1/customers/{id}/usage`. Only active and trialing subscriptions are counted. Quantities are decimal strings. required: [customer_id, period, subscriptions, meters, totals, warnings, buckets] properties: customer_id: type: string description: The customer the usage belongs to. period: type: object description: | The window the response covers. With no `from` and `to` (`current_billing_cycle`), each subscription is rated over its own current billing period, and the period runs from the earliest start to the latest end of those periods. A meter's totals and daily `buckets` cover its own subscription's whole period, including usage a threshold invoice already billed. required: [from, to, source] properties: from: type: string format: date-time description: Start of the window, inclusive. to: type: string format: date-time description: End of the window, exclusive. source: type: string enum: [current_billing_cycle, explicit] description: | `current_billing_cycle`: no `from` and `to` were sent, and the amounts are what the period's invoices bill. `explicit`: the window you sent. Its amounts are an estimate, so a past window can differ from its invoices. Only active and trialing subscriptions are counted: the usage of a canceled subscription is left out. Each subscription's usage in the window is priced at the prices of its current billing period. subscriptions: type: array description: | One entry per item of each active or trialing subscription. A subscription with several plans appears once per plan, with the same `id`. items: type: object required: [id, plan_id, plan_name, currency, current_period_start, current_period_end] properties: id: type: string description: The subscription's ID. plan_id: type: string description: The plan of this item. plan_name: type: string description: The plan's name. currency: type: string description: The plan's currency, such as `USD`. current_period_start: type: string format: date-time description: Start of the subscription's current billing period. current_period_end: type: string format: date-time description: End of the subscription's current billing period, exclusive. meters: type: array description: One entry per meter the customer's plans price, with or without usage. items: $ref: "#/components/schemas/CustomerUsageMeter" totals: type: array description: One entry per currency. items: type: object required: [currency, amount_cents] properties: currency: type: string description: The currency, such as `USD`. amount_cents: type: integer format: int64 description: | The usage cost of every meter in this currency, in the currency's minor unit (cents for USD). warnings: type: array description: | Problems found working out this usage, in words, such as usage that no price takes or a meter whose usage went below zero. items: type: string buckets: type: array description: | One entry per UTC day in the period, including days with no usage. Empty when the customer's plans price no meter, or when the daily series failed to load (`warnings` then says so). items: type: object required: [bucket_start, per_meter] properties: bucket_start: type: string format: date-time description: The start of the UTC day. per_meter: type: object description: | Quantity per meter ID for that day, as a decimal string: the sum of the day's event quantities, including usage no price takes. So the days need not add up to a meter's `total_quantity`, which uses the meter's aggregation. Empty on a day with no usage. additionalProperties: type: string example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g period: from: "2026-10-12T00:00:00Z" to: "2026-10-14T00:00:00Z" source: explicit subscriptions: - id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g plan_name: Pro currency: USD current_period_start: "2026-10-01T00:00:00Z" current_period_end: "2026-11-01T00:00:00Z" meters: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 event_name: tokens meter_name: Tokens unit: tokens currency: USD total_quantity: "2000000" total_amount_cents: 600 prices: - price_id: hc_prc_dajr672q3gpgqfoq5c20 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: "2000000" amount_cents: 600 unit_amount_decimal: "0.0003" totals: - currency: USD amount_cents: 600 warnings: [] buckets: - bucket_start: "2026-10-12T00:00:00Z" per_meter: hc_mtr_dajr122q3gpgqfoq5c10: "800000" - bucket_start: "2026-10-13T00:00:00Z" per_meter: hc_mtr_dajr122q3gpgqfoq5c10: "1200000" CustomerUsageMeter: type: object description: | One meter in a customer's usage report: its usage over the window and what that usage costs, split by the price that took it. required: [meter_id, event_name, meter_name, unit, currency, total_quantity, total_amount_cents, prices] properties: meter_id: type: string description: The meter's ID. event_name: type: string description: The `event_name` usage events send for this meter. meter_name: type: string description: The meter's display name. unit: type: string description: The meter's unit, such as `tokens`. currency: type: string description: | The currency of the plan whose rows `prices` shows. Empty (`""`) when `prices` is empty. total_quantity: type: string description: | The usage the meter's prices took, as a decimal string, under the meter's aggregation. Usage no price took is left out. total_amount_cents: type: integer format: int64 description: | What the usage costs, in the currency's minor unit (cents for USD). Usage no price took is left out. prices: type: array description: | One row per (plan, price) that took usage, and a `no_price` row per plan for the usage no price of that plan took. Two plans can price one meter differently. items: type: object required: [plan_id, quantity, amount_cents] properties: price_id: type: string description: The plan price that took the usage. Absent on a `no_price` row. plan_id: type: string description: The plan the price belongs to. dimension_match: type: object additionalProperties: type: string description: The labels the price takes. Absent for a price that takes every event. quantity: type: string description: The usage this price took, as a decimal string. amount_cents: type: integer format: int64 description: | What this price bills for the usage, in the currency's minor unit (cents for USD). unit_amount_decimal: type: string description: | Price per unit, as a decimal string of cents; the same figure the invoice line shows. Absent on a `no_price` row. no_price: type: boolean description: | True for usage no price of the plan took. Hardcount bills nothing for it, and it is left out of the meter's totals. Absent on other rows. example: meter_id: hc_mtr_dajr122q3gpgqfoq5c10 event_name: tokens meter_name: Tokens unit: tokens currency: USD total_quantity: "12500000" total_amount_cents: 3750 prices: - price_id: hc_prc_dajr672q3gpgqfoq5c20 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: "12500000" amount_cents: 3750 unit_amount_decimal: "0.0003" CustomerOverview: type: object description: | A one-call summary of a customer for a customer page: its active subscriptions, its 5 newest invoices and what it still owes. `GET /v1/customers/{customer_id}/overview` returns it. required: [customer_id, active_subscriptions, recent_invoices, outstanding_balance] properties: customer_id: type: string description: The customer the overview is about. active_subscriptions: type: array description: | The customer's subscriptions with status `active`, up to 50, with their items. Trialing subscriptions are not listed. items: $ref: "#/components/schemas/Subscription" recent_invoices: type: array description: The 5 newest invoices. items: $ref: "#/components/schemas/Invoice" outstanding_balance: type: object required: [total_cents, unpaid_count, by_currency] description: | Invoices that are not draft, voided or uncollectible and whose payment is pending, failed or unknown. properties: total_cents: type: integer format: int64 description: | Sum of `amount_due_cents`, in minor units (cents for USD). Amounts in different currencies are added together, so use `by_currency` to show an amount. unpaid_count: type: integer description: How many invoices are counted. by_currency: type: array description: | Amount due on the same invoices, one entry per currency, largest first. Empty when nothing is owed. items: type: object required: [currency, amount_cents] properties: currency: type: string description: The currency, such as `USD`. amount_cents: type: integer format: int64 description: Amount due in this currency, in its minor unit (cents for USD). example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g active_subscriptions: - id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T00:00:00Z" updated_at: "2026-10-01T00:00:00Z" status: active billing_time: calendar started_at: "2026-10-01T00:00:00Z" activated_at: "2026-10-01T00:00:00Z" cancel_at_period_end: false current_billing_period_start: "2026-11-01T00:00:00Z" current_billing_period_end: "2026-12-01T00:00:00Z" next_billing_at: "2026-12-01T00:00:00Z" created_at: "2026-10-01T00:00:00Z" updated_at: "2026-11-01T00:05:00Z" recent_invoices: - id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000001 status: finalized payment_status: pending currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_status: ok total_amount_cents: 8650 amount_due_cents: 3650 amount_paid_cents: 0 credits_applied_cents: 5000 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_timezone: UTC issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_out_of_band: false net_payment_term_days: 30 billing_reason: subscription_cycle is_simulated: false created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:00Z" outstanding_balance: total_cents: 3650 unpaid_count: 1 by_currency: - currency: USD amount_cents: 3650 PaymentMethod: type: object description: | A card saved for a customer in Stripe, which Hardcount can charge. The payment-method routes under `/v1/customers/{customer_id}/payment-methods` return it, and the `payment_method.*` webhook events carry it. required: [id, customer_id, type, is_default, created_at] properties: id: type: string description: The payment method's ID in Hardcount, `hc_pm_` and 24 hex characters. customer_id: type: string description: The customer the card belongs to. type: type: string example: card description: The kind of payment method. Always `card` today. card_brand: type: string description: | The card network as Stripe names it, such as `visa` or `mastercard`. Absent when Stripe sent none. card_last4: type: string description: The last 4 digits of the card number. Absent when Stripe sent none. card_exp_month: type: integer description: The card's expiry month, 1 to 12. Absent when Stripe sent none. card_exp_year: type: integer description: The card's expiry year, such as `2029`. Absent when Stripe sent none. is_default: type: boolean description: | Whether this is the card Hardcount charges. A customer with saved cards has one default. created_at: type: string format: date-time description: When the card was saved. example: id: hc_pm_4f8a2c9e1b7d3a6f0c5e8b21 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g type: card card_brand: visa card_last4: "4242" card_exp_month: 12 card_exp_year: 2029 is_default: true created_at: "2026-10-02T11:48:30Z" CreditLedgerEntry: type: object description: | One entry in a customer's credit ledger: credit added (`grant`, or a positive `adjustment`), drawn by an invoice (`usage`), removed (a negative `adjustment`) or written off (`expiry`). Returned by `POST /v1/credits/grant` and `POST /v1/credits/adjust`, and listed by `GET /v1/credits/ledger/{customer_id}`. required: [id, customer_id, entry_type, currency, amount_cents, balance_after, description, created_at] properties: id: type: string description: The entry's ID (`hc_ccl_…`). tenant_id: type: string description: The account the entry belongs to. customer_id: type: string description: The customer whose credit the entry changes (`hc_cus_…`). entry_type: type: string enum: [grant, usage, expiry, adjustment] description: | - `grant`: credit added, by you with `POST /v1/credits/grant` or by Hardcount (a prepaid-commit invoice when it is finalized, a credit note, or a void or credit note that gave drawn credit back). - `usage`: credit an invoice drew. - `expiry`: the unused rest of a grant, written off once its `expires_at` passed. - `adjustment`: credit added or removed with `POST /v1/credits/adjust`, or commit credit taken back when its invoice was voided or refunded. currency: type: string description: > The entry's currency, uppercase. Credit pays only invoices in its own currency. amount_cents: type: integer format: int64 description: | Positive adds credit, negative removes it, in the currency's minor unit (cents for USD). balance_after: type: integer format: int64 description: | The customer's credit balance in this entry's currency after it, in the currency's minor unit (cents for USD). description: type: string description: | What the entry is for: the description you gave a grant or adjustment, "Applied to invoice" and the invoice number on a `usage` entry, or "Expired grant" and the grant's ID on an `expiry` entry. invoice_id: type: string description: | The invoice the entry comes from: the invoice a `usage` entry was drawn by, the voided or credited invoice that gave credit back, or the commit invoice that funded a grant. Absent when no invoice caused the entry, and on the `adjustment` that takes commit credit back after its invoice was voided or refunded: that invoice is in `metadata.funding_invoice_id`. expires_at: type: string format: date-time description: | On a grant, its expiry. The credit can be drawn up to and including this instant; a period invoice draws it when its period ends at or before it. The dashboard sends the local midnight after the picked day. Absent when the credit does not expire. metadata: type: object additionalProperties: true description: | Facts Hardcount records on the entry, such as which grants a draw or a deduction took its credit from (`drained_blocks`). Absent when empty, and on the answer of `POST /v1/credits/adjust`, which does not return it; the ledger list does. created_at: type: string format: date-time description: When the entry was written. For a customer on a test clock, the clock's time. consumed_cents: type: integer format: int64 description: | For entries that add credit, how much of it is gone, in the currency's minor unit: drawn by invoices, expired, removed by a negative adjustment, or, for commit credit, taken back when its invoice was voided or refunded by a credit note. Absent when nothing is. grant_kind: type: string enum: [commit, promotional] description: | On a grant, what the credit cost the customer. `commit`: prepaid credit granted when a commit invoice was finalized; that invoice is `source_invoice_id`. `promotional`: free credit you marked so; invoices draw it before any other credit. Absent for other credit. source_invoice_id: type: string description: | For a `commit` grant, the commit invoice whose finalizing made the grant. Absent on other entries. source_subscription_id: type: string description: | Not sent by any route in this version: no ledger entry is written with it, so it is always absent. source_subscription_item_id: type: string description: | Not sent by any route in this version: no ledger entry is written with it, so it is always absent. source_plan_changed_at: type: string format: date-time description: | Not sent by any route in this version: no ledger entry is written with it, so it is always absent. source_change_type: $ref: "#/components/schemas/ItemChangeType" source_credit_note_id: type: string description: | Recorded on credit that a credit note gave back, but not sent by any route in this version: the ledger list does not return it, and the grant and adjust routes never write it. Such an entry names the invoice in `invoice_id`. source_invoice_reversal_id: type: string description: | Recorded on credit that a voided invoice gave back, but not sent by any route in this version: the ledger list does not return it, and the grant and adjust routes never write it. Such an entry names the invoice in `invoice_id`. example: id: hc_ccl_dav2242q3gpgqfoq5c50 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g entry_type: grant currency: USD amount_cents: 5000 balance_after: 5000 description: Welcome credit expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" grant_kind: promotional SubscriptionItem: type: object required: [id, subscription_id, plan_id, quantity, created_at, updated_at] description: | One priced line of a subscription. It pairs a plan with a quantity. Returned inside a subscription's `items`, and by the item routes. properties: id: type: string description: The item's ID (`hc_si_…`). tenant_id: type: string description: The account the item belongs to. subscription_id: type: string description: The subscription the item is on. plan_id: type: string description: | The plan the item bills now. A scheduled plan change shows in `pending_plan_id` until it takes effect. quantity: type: integer format: int64 description: | How many of the plan the item bills: the plan's base fee is charged this many times. At least 1. metadata: type: object additionalProperties: true description: The item's stored metadata. Absent when empty. pending_plan_id: type: string description: Plan the item moves to at `pending_plan_effective_at`. Absent when no change is scheduled. pending_plan_effective_at: type: string format: date-time description: | When the scheduled plan change takes effect: the end of the period in which it was scheduled. Absent when no change is scheduled. plan_changed_at: type: string format: date-time description: When the plan was last changed immediately. Absent until the first such change. created_at: type: string format: date-time description: When the item was added. updated_at: type: string format: date-time description: When the item last changed. example: id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" SubscriptionPauseCollection: type: object required: [behavior, set_by] description: | Present on a subscription while collection is paused. New invoices are still created, but stay drafts and are not charged. Absent when collection runs normally. A cancel leaves it in place: it still holds the subscription's unpaid invoices, and `DELETE /v1/subscriptions/{id}/pause-collection` releases them. properties: behavior: type: string enum: [keep_as_draft] description: | What happens to the invoices built during the pause. Only `keep_as_draft`: they stay drafts, so they are not finalized, charged or dunned. resumes_at: type: string format: date-time description: | When collection resumes. Absent when the pause lasts until it is cleared. On a pause set by dunning, it is when Hardcount recorded the payment of the invoice the pause was set for. set_by: type: string enum: [operator, dunning] readOnly: true description: | Who paused collection. `operator`: you, through the API or the dashboard; the pause lasts until you resume it or until `resumes_at`. `dunning`: a dunning policy's `pause` final action. A dunning pause lifts by itself once the invoice whose dunning set it is paid, even if another invoice is still owed. Periods that ended during the pause keep their draft invoices. example: behavior: keep_as_draft resumes_at: "2026-12-01T00:00:00Z" set_by: operator SubscriptionBillingThresholds: type: object required: [item_thresholds] description: | Limits that make Hardcount invoice before the period ends. A threshold invoice bills the period's usage so far, and no base fee. The period keeps its dates, the base fee is billed as it would be with no threshold, and the period's next invoice bills the rest of the usage. Absent on a subscription with no thresholds. Set them with `PUT /v1/subscriptions/{id}/billing-thresholds`. properties: amount_gte: type: integer format: int64 description: | Usage threshold in the subscription currency's minor unit (cents for USD), compared with the period's usage so far (the base fee is not counted). Absent when not set. item_thresholds: type: array description: Per-item usage quantity thresholds. Empty when only `amount_gte` is set. items: $ref: "#/components/schemas/SubscriptionItemThreshold" example: amount_gte: 50000 item_thresholds: - subscription_item_id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c usage_gte: "100000000" SubscriptionItemThreshold: type: object required: [subscription_item_id, usage_gte] description: | A usage threshold on one item: when the item's usage in the period reaches `usage_gte`, Hardcount invoices the usage so far. properties: subscription_item_id: type: string description: The item of this subscription the threshold watches. usage_gte: type: string description: Usage quantity threshold, as a decimal string. example: subscription_item_id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c usage_gte: "100000000" SubscriptionItemProration: type: object required: [old_plan_id, new_plan_id, proration_factor, amount_cents, type, credited_to_balance_cents, reduced_open_invoices_cents, held_on_draft_invoices_cents] description: What Hardcount did to settle the rest of the period for an item change. properties: old_plan_id: type: string description: The plan the item was on before the change. The same as `new_plan_id` for a quantity change. new_plan_id: type: string description: The plan the item is on after the change. proration_factor: type: number format: double description: Share of the full billing cycle that was left, from 0 to 1. amount_cents: type: integer format: int64 description: | Gross amount, tax included, in the currency's minor unit (cents for USD): the invoice total (`invoice`), or the whole credit for unused time (`credit` and `adjustment`). `credited_to_balance_cents`, `reduced_open_invoices_cents` and `held_on_draft_invoices_cents` say where the credit went. type: type: string enum: [invoice, credit, adjustment] description: | `invoice`: a proration invoice was created. `credit`: some of the credit went to the customer's balance, or no invoice had room for any of it (`amount_cents` is then 0). `adjustment`: none of it went to the balance. invoice_id: type: string description: The proration invoice. Present when `type` is `invoice`. credited_to_balance_cents: type: integer format: int64 description: | The part of the credit added to the customer's balance, tax included, in the currency's minor unit. On a paid invoice, all of its part. On an open invoice paid partly with credits, the part above what it still asks for: those credits go back to the balance. A part on an invoice whose payment is still processing counts here; if that payment fails, it reduces the invoice instead. 0 when `type` is `invoice`. reduced_open_invoices_cents: type: integer format: int64 description: | The part of the credit taken off the amount due on open invoices, tax included, in the currency's minor unit. 0 when `type` is `invoice`. held_on_draft_invoices_cents: type: integer format: int64 description: | The part of the credit on draft invoices, tax included, in the currency's minor unit. It is held until the draft is finalized, then applied by how the invoice stands: if paid, to the customer's balance; if still open, off what it asks for first, as above. 0 when `type` is `invoice`. example: old_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g new_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g proration_factor: 0.5161290322580645 amount_cents: 2529 type: invoice invoice_id: hc_inv_dbj84b2q3gpgqfoq5c4k credited_to_balance_cents: 0 reduced_open_invoices_cents: 0 held_on_draft_invoices_cents: 0 SubscriptionNewPeriodInvoice: type: object required: [id, invoice_number, status, total_amount_cents, currency] description: | The invoice an immediate change to a plan with another billing interval created for the new period it starts. properties: id: type: string description: The invoice's ID (`hc_inv_…`). invoice_number: type: string description: The invoice's number, as printed on it. status: type: string enum: [draft, finalized] description: | `draft` when the invoice was not issued yet: collection is paused, or its tax could not be calculated yet. Its total may then change before it is finalized. total_amount_cents: type: integer format: int64 description: | The invoice total when the change created it, in the currency's minor unit (cents for USD). On a `draft` whose tax could not be calculated yet, it has no tax in it. currency: type: string description: The invoice's currency, uppercase (for example `USD`). example: id: hc_inv_dbj84b2q3gpgqfoq5c4k invoice_number: HC-000043 status: finalized total_amount_cents: 49000 currency: USD SubscriptionItemChangeResult: type: object required: [item, effective_at] description: | What `PATCH /v1/subscriptions/{id}/items/{itemID}` did: the item after the change, when the change takes effect, and how the rest of the period was settled. properties: item: $ref: "#/components/schemas/SubscriptionItem" effective_at: type: string format: date-time description: When the change takes effect. For a scheduled plan change, the end of the current period. proration: $ref: "#/components/schemas/SubscriptionItemProration" new_period_invoice: $ref: "#/components/schemas/SubscriptionNewPeriodInvoice" example: item: id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 2 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-16T10:30:00Z" effective_at: "2026-10-16T10:30:00Z" proration: old_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g new_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g proration_factor: 0.5161290322580645 amount_cents: 2529 type: invoice invoice_id: hc_inv_dbj84b2q3gpgqfoq5c4k credited_to_balance_cents: 0 reduced_open_invoices_cents: 0 held_on_draft_invoices_cents: 0 SubscriptionTimelineEvent: type: object required: [timestamp, source, event_type, status, description, line] description: | One row of a subscription's activity timeline (`GET /v1/subscriptions/{id}/timeline`), built from one audit log entry. `line` carries the row in its structured form; `description`, `detail`, `detail_timestamp`, `error` and `status` repeat its words for older clients. properties: timestamp: type: string format: date-time description: When the action was recorded (wall-clock time). source: type: string enum: [audit] description: Where the row comes from. Always `audit`, the audit log. event_type: type: string description: The audit action, e.g. `create`, `cancel`, `subscription.item_updated`. status: type: string description: The line's tone in older words, read by the dashboard's dot. enum: [neutral, succeeded, warning, failed] description: type: string description: The line's title, for example "Subscription canceled". detail: type: string description: | The line's meta line as text, without the actor (see `actor_name`) and without a date at its end, which is in `detail_timestamp`. For example "Starter → Pro". detail_timestamp: type: string format: date-time description: The date the end of `detail` refers to, for the client to format, as in "Trial now ends" followed by this date. error: type: string description: The line's problem. actor_type: type: string description: | Who acted, as the audit log records it: `user` (a dashboard user), `api_key`, `customer`, or `system` (Hardcount itself). actor_name: type: string description: | The actor's name: a user's email, an API key's name or a customer's display name. Absent when it is not known. actor_id: type: string description: The actor's ID (a user's, an API key's `hc_key_…` or a customer's), or `system`. is_simulated: type: boolean description: True when the action affected a subscription on a test clock. sim_effective_at: type: string format: date-time description: The test clock's simulated time when the action took effect. test_clock_id: type: string description: The test clock `sim_effective_at` belongs to. Absent when the action was not on a test clock. line: $ref: "#/components/schemas/ActivityLine" example: timestamp: "2026-10-01T09:12:44Z" source: audit event_type: create status: neutral description: Subscription created actor_type: api_key actor_name: Production backend actor_id: hc_key_dajqj1iq3gpgqfoq5ca0 line: id: hc_aud_dbj84c2q3gpgqfoq5cb0 kind: subscription.created title: Subscription created detail: [] actor: kind: api_key name: Production backend tone: neutral occurred_at: "2026-10-01T09:12:44Z" AnalyticsPeriod: type: string description: | The window an analytics call covers, ending now: the last 7, 30 or 90 days, or `12m` for the last 365 days in monthly buckets. The other periods use daily buckets. enum: ["7d", "30d", "90d", "12m"] default: "30d" AnalyticsMrrMovementTotals: type: object required: [new, expansion, contraction, churned, net] description: | How MRR (monthly recurring revenue from plan base fees) changed over the whole window, in the minor unit of the account's default currency (cents for USD). `churned` and `contraction` are positive amounts. `net` = `new` + `expansion` - `contraction` - `churned`. Expansion and contraction count only subscriptions that were active for the whole window: a subscription activated or canceled inside it counts only in `new` or `churned`. Test-clock subscriptions are left out. properties: new: type: integer format: int64 description: MRR of subscriptions activated in the window, in the currency's minor unit. expansion: type: integer format: int64 description: MRR added by item adds and plan or quantity increases, in the currency's minor unit. contraction: type: integer format: int64 description: MRR removed by item removals and plan or quantity decreases, as a positive amount in the currency's minor unit. churned: type: integer format: int64 description: MRR of subscriptions canceled in the window, as a positive amount in the currency's minor unit. net: type: integer format: int64 description: The overall change, `new` + `expansion` - `contraction` - `churned`, in the currency's minor unit. Can be negative. example: new: 4900 expansion: 2000 contraction: 0 churned: 1900 net: 5000 AnalyticsMrrMovementPoint: type: object description: | MRR movement in one bucket of `GET /v1/analytics/mrr-movement`: a UTC day, or a UTC month for `12m`. Amounts are in the minor unit of the account's default currency (cents for USD). Expansion and contraction count only subscriptions that were active for the whole requested window. required: [date, new, expansion, contraction, churned, net] properties: date: type: string description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC. new: type: integer format: int64 description: MRR of subscriptions activated in the bucket, in the currency's minor unit. expansion: type: integer format: int64 description: MRR added in the bucket by item adds and plan or quantity increases, in the currency's minor unit. contraction: type: integer format: int64 description: MRR removed in the bucket by item removals and plan or quantity decreases, as a positive amount in the currency's minor unit. churned: type: integer format: int64 description: MRR of subscriptions canceled in the bucket, as a positive amount in the currency's minor unit. net: type: integer format: int64 description: "`new` + `expansion` - `contraction` - `churned` for the bucket, in the currency's minor unit." example: date: "2026-10-01" new: 4900 expansion: 0 contraction: 0 churned: 0 net: 4900 AnalyticsRevenuePoint: type: object description: | Paid-invoice revenue in one bucket of `GET /v1/analytics/revenue-chart`: a UTC day, or a UTC month for `12m`. Only invoices in the account's default currency are counted. required: [date, revenue_cents, invoice_count] properties: date: type: string description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC. revenue_cents: type: integer format: int64 description: | What paid the invoices paid in the bucket (payment plus customer credit applied), in the minor unit of the default currency (cents for USD). A credit note that lowered an invoice before it was paid is not counted. invoice_count: type: integer description: Invoices paid in the bucket. example: date: "2026-11-01" revenue_cents: 8650 invoice_count: 1 AnalyticsUsagePoint: type: object description: | Usage in one bucket of `GET /v1/analytics/usage`: a UTC day, or a UTC month for `12m`. Events of customers on a test clock are left out. required: [date, events, quantity] properties: date: type: string description: Bucket start, `YYYY-MM-DD` (or `YYYY-MM` for `12m`), in UTC. events: type: integer format: int64 description: Usage events with a timestamp in the bucket, over all meters. quantity: type: string description: Summed quantity, as a decimal string. example: date: "2026-10-12" events: 1840 quantity: "412500" AnalyticsTopMeter: type: object description: | One of the meters with the most usage events in the window, in `GET /v1/analytics/usage`. required: [meter_id, meter_name, event_name, events, quantity] properties: meter_id: type: string description: The meter's id (`hc_mtr_…`). meter_name: type: string description: The meter's display name. event_name: type: string description: The event name usage events send to reach this meter. events: type: integer format: int64 description: Usage events on this meter with a timestamp in the window. quantity: type: string description: Summed quantity, as a decimal string. example: meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_name: Tokens event_name: tokens events: 48210 quantity: "12500000" AnalyticsOverview: type: object required: - period - currency - mrr - mrr_prev - arr - arr_prev - revenue - revenue_prev - outstanding_ar - outstanding_by_currency - avg_invoice_value - credit_balance_by_currency - active_customers - new_customers - live_customers - live_customers_prev - active_subscriptions - trialing_subscriptions - paid_invoices - failed_payments - failed_payments_prev - open_invoices - dunning_active - refunds_needing_attention - usage_events - logo_churn_rate - revenue_churn_rate - nrr - dunning_recovery_rate - mrr_movement description: | The dashboard's headline metrics, from `GET /v1/analytics/overview`. "The window" is the requested period, ending now. "The previous window" is the one of equal length before it. Money is in the minor unit of `currency` (cents for USD). Rates are fractions, so 0.05 means 5%. Customers, subscriptions and invoices on test clocks are left out of every figure. properties: period: $ref: "#/components/schemas/AnalyticsPeriod" currency: type: string description: The account's default currency (USD when none is set). Every money field uses it, except `credit_balance_by_currency` and `outstanding_by_currency`, which carry their own. mrr: type: integer format: int64 description: | MRR now, from plan base fees only (base amount times quantity, yearly plans divided by 12) on `active` subscriptions with plans in `currency`. Trialing subscriptions and usage charges are not counted. mrr_prev: type: integer format: int64 description: MRR at the start of the window, rebuilt from item change history. arr: type: integer format: int64 description: "`mrr` times 12." arr_prev: type: integer format: int64 description: "`mrr_prev` times 12." revenue: type: integer format: int64 description: What paid the invoices paid in the window (payment plus customer credit applied). A credit note that lowered an invoice before it was paid is not counted. revenue_prev: type: integer format: int64 description: "`revenue` for the previous window." outstanding_ar: type: integer format: int64 description: Amount due on finalized invoices whose payment has not succeeded, in `currency` only. outstanding_by_currency: type: array description: | Amount due on the invoices `open_invoices` counts, one entry per currency. `currency` comes first, then the largest amount. Empty when nothing is owed. items: type: object required: - currency - amount_cents properties: currency: type: string description: Three-letter currency code, in upper case. amount_cents: type: integer format: int64 description: Amount due in this currency, in its minor unit (cents for USD). avg_invoice_value: type: integer format: int64 description: Average total of invoices paid in the window. credit_balance_by_currency: type: array description: > Customers' credit balances added up per currency, `currency` first, then largest. A currency whose balance is zero is left out. Customers on a test clock are not counted. items: type: object required: [currency, amount_cents] properties: currency: type: string description: Three-letter currency code, in upper case. amount_cents: type: integer format: int64 description: Credit customers hold in this currency, in its minor unit (cents for USD). active_customers: type: integer description: | Customer records with status `active`. Includes customers with no subscription or only canceled ones; `live_customers` counts the customers you bill. new_customers: type: integer description: Customers created in the window. live_customers: type: integer description: Customers with at least one `active` or `trialing` subscription now. live_customers_prev: type: integer description: | `live_customers` at the start of the window, rebuilt from subscription start and cancel times. active_subscriptions: type: integer description: Subscriptions with status `active`. trialing_subscriptions: type: integer description: Subscriptions with status `trialing`. paid_invoices: type: integer description: Invoices in `currency` paid in the window. failed_payments: type: integer description: Open invoices created in the window whose payment failed. A voided or written-off invoice is not counted. All currencies. failed_payments_prev: type: integer description: "`failed_payments` for the previous window." open_invoices: type: integer description: Finalized invoices whose payment has not succeeded. All currencies. dunning_active: type: integer description: Dunning runs (automatic payment retries) still in progress. refunds_needing_attention: type: integer description: | Issued credit notes whose refund failed or has an unknown result, has stayed pending for 72 hours since the note last changed, or owes a refund that has not started an hour after the note last changed. Test-clock credit notes are not counted. usage_events: type: integer format: int64 description: Usage events with a timestamp in the window. logo_churn_rate: type: number format: double description: Subscriptions canceled in the window, divided by subscriptions active at its start. 0 when none were active. revenue_churn_rate: type: number format: double description: Churned MRR divided by `mrr_prev`. 0 when `mrr_prev` is 0. nrr: type: number format: double description: | Net revenue retention: (`mrr_prev` + expansion - contraction - churned) divided by `mrr_prev`. Can be above 1. 0 when `mrr_prev` is 0. dunning_recovery_rate: type: number format: double description: Dunning runs opened in the window that a payment closed (`resolution` is `payment_recovered` and the invoice has `amount_paid_cents` above 0), divided by all runs opened in the window. A run closed by a void, a write-off, or a credit note or customer credit that left nothing due is not recovered. 0 when none were opened. mrr_movement: $ref: "#/components/schemas/AnalyticsMrrMovementTotals" example: period: "30d" currency: USD mrr: 24500 mrr_prev: 19600 arr: 294000 arr_prev: 235200 revenue: 61850 revenue_prev: 48200 outstanding_ar: 8650 outstanding_by_currency: - currency: USD amount_cents: 8650 avg_invoice_value: 8836 credit_balance_by_currency: - currency: USD amount_cents: 12000 active_customers: 6 new_customers: 1 live_customers: 5 live_customers_prev: 4 active_subscriptions: 5 trialing_subscriptions: 0 paid_invoices: 7 failed_payments: 1 failed_payments_prev: 0 open_invoices: 1 dunning_active: 1 refunds_needing_attention: 0 usage_events: 48210 logo_churn_rate: 0.0 revenue_churn_rate: 0.0 nrr: 1.0 dunning_recovery_rate: 0.5 mrr_movement: new: 4900 expansion: 0 contraction: 0 churned: 0 net: 4900 InvoiceLineItemInput: type: object description: | A line to add to a draft invoice, with `POST /v1/invoices` or `POST /v1/invoices/{id}/line-items`. Its amount is `quantity` × `unit_amount_cents`. required: [description, quantity, unit_amount_cents] properties: description: type: string description: The text printed on the line. Must not be blank. line_type: type: string enum: [base_fee, usage, add_on] description: What the line charges for. Defaults to `add_on`. quantity: type: integer format: int64 minimum: 1 description: How many units, a whole number of 1 or more. unit_amount_cents: type: integer format: int64 minimum: 1 description: | Price of one unit in the currency's minor unit (cents for USD), a whole number of 1 or more. The line's amount is `quantity` × `unit_amount_cents`; a product too large for 64-bit cents is refused. commit_granted_cents: type: integer format: int64 minimum: 1 maximum: 100000000 description: | Makes this line a prepaid commit purchase: when the invoice is finalized, the customer gets this many cents of credit. Only on `add_on` lines of invoices you create, at most one per invoice. At most 100,000,000 ($1,000,000). commit_expires_at: type: string format: date-time description: | When the granted credit expires. The credit can be drawn up to and including this instant; a period invoice draws it when its period ends at or before it. The dashboard sends the local midnight after the picked day. Must be in the future. Needs `commit_granted_cents`. Absent means it never expires. example: description: Onboarding and setup line_type: add_on quantity: 1 unit_amount_cents: 25000 InvoiceCreateRequest: type: object description: The body of `POST /v1/invoices`, which creates a one-off draft invoice. required: [customer_id] properties: customer_id: type: string description: The customer to bill. Must exist in this account and mode. subscription_id: type: string description: Optional. Must belong to `customer_id`. Leave out for a one-off invoice. The subscription's own cycle invoices are still billed, also for the same period. currency: type: string description: | Uppercase or lowercase ISO 4217 code; it is stored uppercase. It must be the customer's currency (see the 409). Defaults to the billing profile's currency, then the plan currency of the customer's draft, trialing and active subscriptions, then the account's default currency. Currencies with no minor unit (such as JPY) are refused. example: USD billing_period_start: type: string format: date-time description: Start of the period the invoice covers. Defaults to now. billing_period_end: type: string format: date-time description: End of the period the invoice covers. Defaults to now. net_payment_term_days: type: integer description: Days until the invoice is due. 0 means due on receipt. Defaults to the account's net payment terms. A negative value is stored as 0. memo: type: string description: A note printed on the invoice. Leading and trailing spaces are removed. line_items: type: array description: Lines created in the same transaction as the invoice. Leave out to create an invoice with no lines and add them later. items: $ref: "#/components/schemas/InvoiceLineItemInput" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD net_payment_term_days: 30 memo: Onboarding for the Acme rollout line_items: - description: Onboarding and setup line_type: add_on quantity: 1 unit_amount_cents: 25000 CreditNoteStats: type: object description: | What `GET /v1/credit-notes/stats` returns: how many credit notes there are in each status, and the money on the issued ones, per currency. It covers every note in this mode. required: [draft_count, issued_count, voided_count, by_currency] properties: draft_count: type: integer description: Credit notes in `draft`. issued_count: type: integer description: Credit notes in `issued`. voided_count: type: integer description: Credit notes in `voided`. by_currency: type: array description: | Money on issued notes, one entry per currency, the account's default currency first. Empty when no note is issued. Amounts are in the currency's minor unit (cents for USD) and never added across currencies. items: type: object required: [currency, total_cents, refunded_cents, refund_pending_cents, refund_failed_cents, credited_cents, out_of_band_cents] properties: currency: type: string description: The currency of this entry, uppercase. total_cents: type: integer format: int64 description: Total of the issued notes. refunded_cents: type: integer format: int64 description: Refunds that succeeded. A refund still pending or one that failed is not counted here. refund_pending_cents: type: integer format: int64 description: Refunds not confirmed yet. Neither succeeded nor failed, the refund is waiting on Stripe, Stripe's answer was unclear, or its result was never recorded. refund_failed_cents: type: integer format: int64 description: Refunds that failed. No money went back to the card. credited_cents: type: integer format: int64 description: Added to customers' credit balances. out_of_band_cents: type: integer format: int64 description: Recorded as paid back outside Stripe. example: draft_count: 1 issued_count: 3 voided_count: 0 by_currency: - currency: USD total_cents: 12500 refunded_cents: 3750 refund_pending_cents: 0 refund_failed_cents: 0 credited_cents: 8750 out_of_band_cents: 0 CreditNote: type: object description: | A credit note reduces what a customer owes on an invoice, or gives money back after it was paid. On an unpaid invoice it lowers `amount_due_cents`. On a paid invoice its total is split three ways: refund to the card (`refund_amount_cents`), credit to the customer's balance (`credit_amount_cents`), and money returned outside Hardcount (`out_of_band_amount_cents`). Amounts are whole numbers in the currency's minor unit (cents for USD). The credit note routes and `POST /v1/invoices/{id}/refund` return it. required: - id - invoice_id - customer_id - credit_note_number - status - reason - subtotal_cents - tax_amount_cents - total_cents - refund_amount_cents - credit_amount_cents - out_of_band_amount_cents - currency - refund_status - is_simulated - issue_pending - tax_reversal_pending - recorded_from_stripe - created_at - updated_at properties: id: type: string description: Unique identifier of the credit note, starting `hc_cn_`. tenant_id: type: string description: The account the credit note belongs to. Absent when empty. invoice_id: type: string description: The invoice the note credits. customer_id: type: string description: The invoice's customer. credit_note_number: type: string description: | The number printed on the note: the account's credit note prefix (default `CN`), a hyphen and a sequence padded to six digits, for example `CN-000003`. Test mode and live mode count separately. Set when the note is created, drafts included. status: type: string enum: [draft, issued, voided] description: | `draft`: created but not applied; it can be issued or voided. `issued`: applied to the invoice and final. `voided`: cancelled before it was issued. reason: type: string description: | Why the note was made, as given when it was created. A refund made with `POST /v1/invoices/{id}/refund` carries its refund reason (`duplicate`, `fraudulent`, `requested_by_customer` or `other`). subtotal_cents: type: integer format: int64 description: The credited amount without its tax, in the currency's minor unit (cents for USD). tax_amount_cents: type: integer format: int64 description: | The tax part of the credited amount, in the currency's minor unit (cents for USD). It is the invoice's own tax share of the note, so crediting half of an invoice reverses half of its tax. total_cents: type: integer format: int64 description: | The credited amount with tax, in the currency's minor unit (cents for USD): the sum of the note's lines. refund_amount_cents: type: integer format: int64 description: | Part of the total refunded to the card that paid the invoice, in the currency's minor unit (cents for USD). 0 on a note against an unpaid invoice. credit_amount_cents: type: integer format: int64 description: | Part of the total added to the customer's credit balance, in the currency's minor unit (cents for USD). 0 on a note against an unpaid invoice. out_of_band_amount_cents: type: integer format: int64 description: | Part of the total paid back outside Hardcount (a bank transfer, cash), recorded for the books, in the currency's minor unit (cents for USD). 0 on a note against an unpaid invoice. commit_retired_cents: type: integer format: int64 description: Prepaid commit credits this note retired, in the currency's minor unit (cents for USD). Absent on other notes. currency: type: string description: The invoice's currency, uppercase. issued_at: type: string format: date-time description: When the note was issued. Absent until it is issued. voided_at: type: string format: date-time description: When the note was voided. Absent unless it is voided. refund_status: type: string enum: [none, pending, succeeded, failed, unknown] description: | The refund part's state at Stripe. `none`: no refund was sent (no refund part, or the note is not issued yet); an issued note with a refund part stays `none` only when its refund's result was not recorded. `pending`: Stripe has not finished it. `succeeded` and `failed`: Stripe's answer. `unknown`: Stripe gave no clear answer (a timeout, a network error, a Stripe 5xx, a 409 or a 429), so the customer may already be refunded. Don't refund it again in the Stripe Dashboard. Retry it with `POST /v1/credit-notes/{id}/retry-refund`, which looks for the refund at Stripe first and then resends the same request with the same idempotency key. stripe_refund_id: type: string description: The Stripe refund (`re_…`) of the refund part. Absent until Stripe made one. recorded_from_stripe: type: boolean description: | True when the card refund is a refund made in Stripe outside Hardcount: recorded from Stripe's refund webhook, linked with `stripe_refund_id` at create, or attached with `POST /v1/credit-notes/{id}/attach-stripe-refund`. Hardcount made no refund call for it; `stripe_refund_id` names the refund. bill_to_snapshot: $ref: "#/components/schemas/DocumentBillTo" supplier_snapshot: $ref: "#/components/schemas/DocumentSupplier" tax_transaction_id: type: string description: | The tax provider's reversal transaction, set when the note is issued against an invoice whose tax the provider recorded. Absent otherwise. is_simulated: type: boolean description: True when the note's dates are test-clock time (its invoice's customer is on a test clock). issue_pending: type: boolean description: True on a draft that Hardcount will issue automatically, such as the credit for unused time after a plan downgrade. tax_reversal_pending: type: boolean description: True when the tax reversal failed and will be retried. metadata: type: object additionalProperties: true description: Extra key-value data stored with the note. No API request sets it; it is absent when empty. created_at: type: string format: date-time description: When the note was created. For an invoice on a test clock, the clock's time. recorded_at: type: string format: date-time description: | The real-world time the note was created. It differs from `created_at` only when the invoice is on a test clock. Absent on notes created before releases recorded it. updated_at: type: string format: date-time description: When the note last changed. example: id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk2fLkdIwHu7ix1b2c3d4e recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-03T10:15:02Z" ActivityLine: type: object description: | One activity row, with every word written by the server: a title with no values, the amount when the event moves money, asks for it or cancels money owed, a problem line on failure and warning rows, the meta line's parts and the actor, a tone, and the event's time on the entity's calendar. `recorded_at` is the real-world time, set only for an entity on a test clock. The invoice payment timeline and dunning run events carry it. required: [id, kind, title, detail, tone, occurred_at] properties: id: type: string description: | The row's stable key: the event's `id` on an invoice timeline row, the audit entry's id on a subscription timeline row, `dunning:` and the event's id on a dunning run event (the same key the invoice timeline gives that event), and `email:` and the email's id on a sent email (likewise). kind: type: string description: Machine name of the event. example: invoice.voided title: type: string description: What happened, in plain words with no values, for example "Payment failed". example: Payment failed amount: $ref: "#/components/schemas/ActivityMoney" problem: type: string description: What went wrong or what it means, on failure and warning rows whose title does not say it all. Absent otherwise. example: "Card declined: insufficient funds" detail: type: array description: | The meta line before the actor. Each item is one segment, shown joined by " · "; a segment's parts read as one phrase, for example a money part followed by the text " refunded to card". An empty list when there is nothing to add. items: type: array items: $ref: "#/components/schemas/ActivityPart" actor: $ref: "#/components/schemas/ActivityActor" tone: type: string enum: [neutral, success, warning, failure] description: | neutral: something happened. success: money arrived, or was returned as intended. warning: needs a look, or money will not be collected. failure: an attempt failed. occurred_at: type: string format: date-time description: When the event happened, on the entity's calendar (the test clock's time for an entity on a test clock). recorded_at: type: string format: date-time description: The real-world time of the event. Set only for an entity on a test clock, when it is known. example: id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.paid title: Invoice paid amount: cents: 8650 currency: USD detail: - - text: Visa •••• 4242 tone: success occurred_at: "2026-11-01T00:05:03Z" ActivityMoney: type: object description: An amount on an activity row. The dashboard formats it, for example as `$86.50`. required: [cents, currency] properties: cents: type: integer format: int64 description: The amount in the currency's minor unit (cents for USD). currency: type: string description: The currency, as an uppercase ISO 4217 code. example: USD example: cents: 8650 currency: USD ActivityPart: type: object description: One piece of a meta segment. Exactly one field is set. properties: text: type: string description: Plain words to show as they are. money: $ref: "#/components/schemas/ActivityMoney" time: type: string format: date-time description: A time to format for the reader, such as a retry date. link: $ref: "#/components/schemas/ActivityLink" example: text: After 2 retries ActivityLink: type: object description: An object the row's own data names, which still exists. required: [kind, id, label] properties: kind: type: string enum: [invoice, credit_note, subscription] description: The kind of object. id: type: string description: The object's id, to fetch or link to it. label: type: string description: The text to show for the link, such as a credit note number or a subscription's name. example: kind: credit_note id: hc_cn_dbkrtc2q3gpgqfoq5c5g label: CN-000003 ActivityActor: type: object description: | Who did it. `person` and `api_key` carry the name (a person's email when they have no name), `customer` says where through `via`, and `automatic` is anything Hardcount did on its own. Absent when no fact written with the row names one. required: [kind] properties: kind: type: string enum: [person, api_key, customer, automatic] description: The kind of actor. name: type: string description: The person's name or email, the API key's name, or the customer's display name. Absent for `automatic` and when no name is known. via: type: string enum: [payment_page, portal] description: Where the customer acted. Set only for `customer`. example: kind: person name: ops@examplelabs.example InvoiceTimelineEvent: type: object description: | One row of an invoice's activity timeline (`GET /v1/invoices/{id}/payment-timeline`). `line` is the row in the activity line's words; `description`, `detail`, `error`, `status`, `amount_cents` and `currency` repeat it for clients that read the older fields. required: [id, timestamp, source, event_type, status, description, line] properties: id: type: string description: Stable key for the row. timestamp: type: string format: date-time description: | When the event happened, to the second: on the invoice's calendar (the test clock's time for an invoice on a test clock), and at Stripe's own time for disputes and refunds made in Stripe. source: type: string enum: [lifecycle, payment, dunning, email, credit_note, dispute, stripe_refund] description: | Where the row comes from: `lifecycle` (created, finalized, paid, voided, written off), `payment` (a charge attempt), `dunning` (a payment retry run), `email` (an email to the customer), `credit_note`, `dispute` or `stripe_refund` (a refund made in Stripe outside Hardcount). event_type: type: string description: | Machine name of the event, for example `invoice.finalized`, `charge_attempt.failed`, `retry_attempted`, `credit_note.issued` or `payment.dispute_opened`. example: invoice.finalized status: type: string description: The line's tone in older words, read by the dashboard's dot. enum: [neutral, succeeded, warning, failed] description: type: string description: The line's title. detail: type: string description: The line's meta line as text, its actor last, for example "Visa •••• 4242 · After 2 retries". Absent when empty. error: type: string description: The line's problem. Absent when there is none. amount_cents: type: integer format: int64 description: The line's amount, in the currency's minor unit (cents for USD). Absent when the row has no amount. currency: type: string description: The currency of `amount_cents`. Absent when the row has no amount. payment_intent_id: type: string description: The Stripe PaymentIntent the row is about, on payment, dunning, dispute and refund rows that name one. Absent otherwise. attempt_count: type: integer description: On dunning rows, the run's retry count at the event; on the paid row, the retries that ran before payment. Absent when 0. is_simulated: type: boolean description: True when `timestamp` is test-clock time. Absent when false. recorded_at: type: string format: date-time description: Real-world time of a row whose `timestamp` is test-clock time. Absent otherwise. line: $ref: "#/components/schemas/ActivityLine" example: id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:03Z" source: lifecycle event_type: invoice.paid status: succeeded description: Invoice paid detail: Visa •••• 4242 amount_cents: 8650 currency: USD line: id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.paid title: Invoice paid amount: cents: 8650 currency: USD detail: - - text: Visa •••• 4242 tone: success occurred_at: "2026-11-01T00:05:03Z" InvoiceDispute: type: object description: | A card dispute (chargeback) Stripe reported against one of the invoice's payments, recorded from Stripe's `charge.dispute.created`, `.updated` and `.closed` webhook events. It holds the dispute as the newest of those events showed it, whatever order they arrived in. Hardcount records and reports a dispute, and nothing else: the invoice stays paid and no money moves. required: - id - invoice_id - stripe_dispute_id - stripe_payment_intent_id - status - amount_cents - currency - opened_at - livemode - created_at - updated_at properties: id: type: string description: Unique identifier of the dispute record, `hc_dsp_` and 24 hex characters. example: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: type: string description: The invoice whose payment is disputed. stripe_dispute_id: type: string description: Stripe's dispute id (`du_…`). example: du_1MtJUT2eZvKYlo2CNaw2HvEv stripe_charge_id: type: string description: The disputed Stripe charge (`ch_…`). Absent when Stripe did not send it. stripe_payment_intent_id: type: string description: The disputed payment. status: type: string enum: - warning_needs_response - warning_under_review - warning_closed - needs_response - under_review - won - lost - prevented description: | Stripe's dispute status, as Stripe sends it. The `warning_` statuses are an inquiry, which comes before a chargeback or instead of one. `won`, `lost`, `warning_closed` and `prevented` are closed. reason: type: string description: The reason the cardholder gave, for example `fraudulent`. Absent when Stripe gave none. amount_cents: type: integer format: int64 description: The disputed amount, in the currency's minor unit (cents for USD). It can differ from the payment. currency: type: string description: The dispute's currency, uppercase. example: USD evidence_due_by: type: string format: date-time description: The deadline to respond in Stripe. Absent when Stripe gave none. opened_at: type: string format: date-time description: When Stripe opened the dispute. closed_at: type: string format: date-time description: When Stripe closed it. Absent while it is open. livemode: type: boolean description: True for a live-mode payment, false for test mode. created_at: type: string format: date-time description: When Hardcount first recorded the dispute. updated_at: type: string format: date-time description: When Hardcount last updated the record from a Stripe event. example: id: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_dispute_id: du_1QHk2fLkdIwHu7ix2c3d4e5f stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d status: needs_response reason: fraudulent amount_cents: 8650 currency: USD evidence_due_by: "2026-11-25T23:59:59Z" opened_at: "2026-11-10T08:30:00Z" livemode: false created_at: "2026-11-10T08:30:04Z" updated_at: "2026-11-10T08:30:04Z" InvoiceDisputeList: type: object description: What `GET /v1/invoices/{id}/disputes` returns, one page of the invoice's disputes, oldest first. required: [data, total] properties: data: type: array description: The disputes on this page. An empty list when the invoice has none. items: $ref: "#/components/schemas/InvoiceDispute" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_dispute_id: du_1QHk2fLkdIwHu7ix2c3d4e5f stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d status: needs_response reason: fraudulent amount_cents: 8650 currency: USD evidence_due_by: "2026-11-25T23:59:59Z" opened_at: "2026-11-10T08:30:00Z" livemode: false created_at: "2026-11-10T08:30:04Z" updated_at: "2026-11-10T08:30:04Z" total: 1 InvoiceStripeRefund: type: object description: | A refund made in Stripe outside Hardcount (the Stripe dashboard, or Stripe's API called directly) on one of the invoice's payments, recorded from Stripe's `refund.created`, `refund.updated`, `refund.failed` and `charge.refund.updated` webhook events. It holds the refund as the newest of those events showed it. Hardcount's own refunds are never listed here. `outcome` is what Hardcount did with the refund: decided when Hardcount first sees it, or, for a refund first seen in flight (`awaiting`), when a later event shows how it ended. After that only you change it, by recording a flagged refund with a credit note or dismissing it. required: - id - invoice_id - stripe_refund_id - stripe_payment_intent_id - amount_cents - currency - status - outcome - refund_created_at - livemode - created_at - updated_at properties: id: type: string description: Unique identifier of the refund record, `hc_srf_` and 24 hex characters. example: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: type: string description: The invoice whose payment was refunded. stripe_refund_id: type: string description: Stripe's refund id (`re_…`). example: re_3MtJUT2eZvKYlo2C1Fq6Lk8M stripe_payment_intent_id: type: string description: The refunded payment. stripe_charge_id: type: string description: The refunded Stripe charge (`ch_…`). Absent when Stripe did not send it. amount_cents: type: integer format: int64 description: The refunded amount, in the currency's minor unit (cents for USD). currency: type: string description: The refund's currency, uppercase. example: USD status: type: string enum: [pending, requires_action, succeeded, failed, canceled] description: Stripe's refund status, as Stripe sends it. reason: type: string description: Stripe's refund reason (`duplicate`, `fraudulent` or `requested_by_customer`). Absent when none was given. failure_reason: type: string description: Why Stripe could not make the refund, once it failed. outcome: type: string enum: [awaiting, credit_note, other_payment, needs_review, not_refunded, dismissed] description: | `awaiting`: first seen pending or requiring action. Nothing is recorded or sent until a later event shows how it ended: once it succeeded it is decided as below (the 24-hour rule counts from Hardcount's first sighting); failed or canceled, it becomes `not_refunded`. `credit_note`: recorded as an issued credit note whose card refund is this refund (`credit_note_id`); Hardcount called nothing at Stripe for it. `other_payment`: a refund of a payment that did not pay the invoice (a second charge, or money on a voided invoice), so no credit note. `needs_review`: a refund of the invoice's own payment that Hardcount did not record (`review_reason` says why); the invoice shows the `refunded_in_stripe` notice until a dashboard user or an API key records it with a credit note or dismisses it. `not_refunded`: first seen already failed or canceled, so no money moved. `dismissed`: a dashboard user or an API key marked it as handled outside Hardcount. review_reason: type: string enum: [invoice_not_paid, currency_mismatch, prepaid_commit, seen_late, credit_note_refund_failed, exceeds_card_refundable, exceeds_creditable] description: | Why a refund of the invoice's own payment was flagged instead of recorded. Set for `needs_review` and `dismissed`, and kept when a dashboard user or an API key records one with a credit note. credit_note_id: type: string description: The credit note that records the refund, when `outcome` is `credit_note`. dismissed_note: type: string description: The note given when the refund was dismissed. Absent unless `outcome` is `dismissed`. dismissed_at: type: string format: date-time description: When the refund was dismissed. Absent unless `outcome` is `dismissed`. refund_created_at: type: string format: date-time description: When Stripe made the refund. livemode: type: boolean description: True for a live-mode payment, false for test mode. created_at: type: string format: date-time description: When Hardcount first recorded the refund. updated_at: type: string format: date-time description: When Hardcount last updated the record. example: id: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_refund_id: re_3QHk2fLkdIwHu7ix3d4e5f6a stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d amount_cents: 2000 currency: USD status: succeeded reason: requested_by_customer outcome: needs_review review_reason: seen_late refund_created_at: "2026-11-05T16:20:00Z" livemode: false created_at: "2026-11-07T09:00:12Z" updated_at: "2026-11-07T09:00:12Z" InvoiceStripeRefundList: type: object description: What `GET /v1/invoices/{id}/stripe-refunds` returns, one page of the refunds made in Stripe on the invoice's payments, oldest first. required: [data, total] properties: data: type: array description: The refunds on this page. An empty list when the invoice has none. items: $ref: "#/components/schemas/InvoiceStripeRefund" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_refund_id: re_3QHk2fLkdIwHu7ix3d4e5f6a stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d amount_cents: 2000 currency: USD status: succeeded reason: requested_by_customer outcome: needs_review review_reason: seen_late refund_created_at: "2026-11-05T16:20:00Z" livemode: false created_at: "2026-11-07T09:00:12Z" updated_at: "2026-11-07T09:00:12Z" total: 1 StripeRefundWebhookEventData: type: object description: | The `data` of the outbound webhook event `payment.refunded_in_stripe` ([Webhook events](https://gethardcount.com/docs/api/webhook-events/)): the invoice the refunded payment belongs to, as it stood (a refund never changes it), and the refund as the API returns it. The invoice leaves out `attention`, `recovery_warning`, `billing_period_display` and `test_clock_id`, which are worked out per read. Sent once per refund, when its outcome is decided: when Hardcount first sees it, or, for a refund first seen in flight, when it succeeds, fails or is canceled. Saved in the same transaction that records it. A later change to the refund sends nothing more. required: [invoice, refund] properties: invoice: $ref: "#/components/schemas/Invoice" refund: $ref: "#/components/schemas/InvoiceStripeRefund" example: invoice: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false refund: id: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_refund_id: re_3QHk2fLkdIwHu7ix3d4e5f6a stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d amount_cents: 2000 currency: USD status: succeeded reason: requested_by_customer outcome: needs_review review_reason: seen_late refund_created_at: "2026-11-05T16:20:00Z" livemode: false created_at: "2026-11-07T09:00:12Z" updated_at: "2026-11-07T09:00:12Z" DisputeWebhookEventData: type: object description: | The `data` of the outbound webhook events `payment.dispute_opened` and `payment.dispute_closed` ([Webhook events](https://gethardcount.com/docs/api/webhook-events/)): the invoice the disputed payment paid and the dispute, each as the API returns it. The invoice leaves out `attention`, `recovery_warning`, `billing_period_display` and `test_clock_id`, which are worked out per read. Both events are saved in the same transaction that records the dispute, so a crash cannot drop them. `payment.dispute_opened` is sent once per dispute, when Hardcount first records it; if the first event Hardcount receives is already the close, both are sent, and both carry the closed status and `closed_at`. Their delivery order is not guaranteed. `payment.dispute_closed` is sent when the dispute reaches a closed status it did not have: `won`, `lost`, `warning_closed` or `prevented`. A lost dispute that Stripe later marks won sends it again. required: [invoice, dispute] properties: invoice: $ref: "#/components/schemas/Invoice" dispute: $ref: "#/components/schemas/InvoiceDispute" example: invoice: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false dispute: id: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_dispute_id: du_1QHk2fLkdIwHu7ix2c3d4e5f stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d status: needs_response reason: fraudulent amount_cents: 8650 currency: USD evidence_due_by: "2026-11-25T23:59:59Z" opened_at: "2026-11-10T08:30:00Z" livemode: false created_at: "2026-11-10T08:30:04Z" updated_at: "2026-11-10T08:30:04Z" InvoicePaymentTimeline: type: object description: | What `GET /v1/invoices/{id}/payment-timeline` returns: the invoice's activity, oldest first, and which sources could not be read. required: [events, degraded, truncated] properties: events: type: array description: The rows, oldest first. An empty list for a draft. items: $ref: "#/components/schemas/InvoiceTimelineEvent" degraded: type: array description: Sources that could not be read, so their rows are missing. items: type: string enum: [audit, credit_notes, emails, dunning, charge_attempts, disputes, stripe_refunds] truncated: type: boolean description: True when the credit-note source hit its limit of 100 rows. example: events: - id: lifecycle:created:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:00Z" source: lifecycle event_type: invoice.created status: neutral description: Invoice created detail: Renewal for Oct 1, 2026 – Oct 31, 2026 · Acme Pro line: id: lifecycle:created:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.created title: Invoice created detail: - - text: Renewal for Oct 1, 2026 – Oct 31, 2026 - - link: kind: subscription id: hc_sub_dauq502q3gpgqfoq5c2g label: Acme Pro tone: neutral occurred_at: "2026-11-01T00:05:00Z" - id: lifecycle:finalized:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:00Z" source: lifecycle event_type: invoice.finalized status: neutral description: Invoice finalized detail: Automatic amount_cents: 8650 currency: USD line: id: lifecycle:finalized:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.finalized title: Invoice finalized amount: cents: 8650 currency: USD detail: [] actor: kind: automatic tone: neutral occurred_at: "2026-11-01T00:05:00Z" - id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:03Z" source: lifecycle event_type: invoice.paid status: succeeded description: Invoice paid detail: Visa •••• 4242 amount_cents: 8650 currency: USD line: id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.paid title: Invoice paid amount: cents: 8650 currency: USD detail: - - text: Visa •••• 4242 tone: success occurred_at: "2026-11-01T00:05:03Z" degraded: [] truncated: false DunningRun: type: object description: | A dunning run: the payment retries for one unpaid invoice. Dunning is retrying a failed or missing payment on a schedule (the invoice's dunning policy), and running the policy's final action when the retries run out. Each invoice has at most one run. required: [id, invoice_id, policy_id, state, attempt_count, paused, created_at, updated_at] properties: id: type: string description: Unique identifier of the run, starting `hc_drun_`. tenant_id: type: string description: The account the run belongs to. Absent when empty. invoice_id: type: string description: The invoice the run collects. customer_id: type: string description: The invoice's customer. Absent when not recorded. policy_id: type: string description: The dunning policy whose schedule and final action the run follows. state: type: string enum: [active, escalated, resolved] description: | `active`: retries are running. `escalated`: retries ran out (`resolution` is `retries_exhausted`). `resolved`: the run ended another way; see `resolution`. reason: type: string description: | Why the run started: `payment_failed` (a charge was declined or refused) or `no_payment_method` (the customer had no card, so nothing was charged). Absent when not recorded. attempt_count: type: integer description: How many retries have run so far. last_attempt_at: type: string format: date-time description: When the last retry ran. Absent before the first retry. next_action_at: type: string format: date-time description: | When the run acts next: the next retry, or the final action once the retries are used. Absent when the run has ended. paused: type: boolean description: True while the run is paused, so its retries are skipped. No API route pauses a run. resolved_at: type: string format: date-time description: When the run ended, resolved or escalated. Absent while it is active. resolution: type: string enum: - payment_recovered - invoice_voided - invoice_not_collectible - retries_exhausted - action_failed - amount_below_minimum description: | How the run ended, set when it is resolved. Exception: `action_failed` is set on a run that is still `active`. The final action (pause, cancel or mark uncollectible) failed and will be retried at `next_action_at`. `amount_below_minimum`: a charge on the invoice was refused by Stripe because the amount is below its minimum charge, so the run ended without its final action. Absent while the run is active. created_at: type: string format: date-time description: | When the run started: when the payment failed or the collection found no card. For an invoice on a test clock, the clock's time. updated_at: type: string format: date-time description: When the run last changed. invoice_number: type: string description: The invoice's number. Filled in on list responses. invoice_amount_due_cents: type: integer format: int64 description: What the invoice still owes, in the currency's minor unit (cents for USD). Filled in on list responses; absent when 0. invoice_currency: type: string description: The invoice's currency. Filled in on list responses. effective_now: type: string format: date-time description: The test clock's current time, when the customer is on a test clock. Filled in on list responses. test_clock_id: type: string description: The customer's test clock, when they are on one. Filled in on list responses. max_retry_attempts: type: integer description: | How many retries the run's policy makes: the length of its `retry_days`. Filled in on list responses. When `attempt_count` has reached it, no charge is retried: `next_action_at` is when the final action (pause, cancel or mark uncollectible) runs. example: id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g state: active reason: payment_failed attempt_count: 1 last_attempt_at: "2026-11-04T00:05:03Z" next_action_at: "2026-11-08T00:05:03Z" paused: false created_at: "2026-11-01T00:05:03Z" updated_at: "2026-11-04T00:05:04Z" invoice_number: HC-000042 invoice_amount_due_cents: 8650 invoice_currency: USD max_retry_attempts: 3 DunningRunEvent: type: object description: One step in a dunning run's history, as `GET /v1/dunning/runs/{id}` returns it, oldest first. required: [id, run_id, invoice_id, event_type, state, attempt_count, created_at] properties: id: type: string description: Unique identifier of the event, starting `hc_devt_`. run_id: type: string description: The run the event belongs to. tenant_id: type: string description: The account the event belongs to. Absent when empty. invoice_id: type: string description: The run's invoice. event_type: type: string enum: - dunning_started - retry_attempted - escalated - resolved description: | `dunning_started`: the run began. `retry_attempted`: a retry ran (or was skipped; see `reason`). `escalated`: the retries ran out and the final action ran. `resolved`: the run ended. state: type: string enum: [active, escalated, resolved] description: The run's state after this event. reason: type: string description: | Detail that depends on the event. On `dunning_started`, why the run started (`payment_failed` or `no_payment_method`). On `retry_attempted`, the outcome: `succeeded`, the decline text (for example `payment failed: Your card has insufficient funds.`), or a reason nothing was charged (`no_payment_method`, `card_refused`, `needs_confirmation`, `amount_below_minimum`, `payment_processing`). On `escalated`, what the final action did, joined with `+` (`subscription_paused`, `subscription_canceled`, `invoice_marked_uncollectible`), empty when it changed nothing. On `resolved`, what ended the run, such as `payment_recovered` or `invoice_voided`. Absent when empty. attempt_count: type: integer description: The run's retry count at this event. metadata: type: object additionalProperties: true description: Extra facts about the event, such as `payment_intent_id` on a retry that charged. Absent when empty. created_at: type: string format: date-time description: When the event happened. For an invoice on a test clock, the clock's time. recorded_at: type: string format: date-time description: The real-world time the event was written. Absent on events written before releases recorded it. line: description: | The event in the words of the activity line: the line the invoice's payment timeline shows for the same event, without that timeline's folds. Absent when the run's invoice could not be read (`degraded` names `invoice`). allOf: - $ref: "#/components/schemas/ActivityLine" example: id: hc_devt_dc7m3maq3gpgqfoq5c7g run_id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 event_type: retry_attempted state: active reason: "payment failed: Your card has insufficient funds." attempt_count: 1 metadata: payment_intent_id: pi_3QHk2fLkdIwHu7ix4e5f6a7b created_at: "2026-11-04T00:05:03Z" recorded_at: "2026-11-04T00:05:03Z" line: id: dunning:hc_devt_dc7m3maq3gpgqfoq5c7g kind: payment_recovery.retry_failed title: Payment retry 1 failed amount: cents: 8650 currency: USD problem: "Card declined: insufficient funds" detail: [] actor: kind: automatic tone: failure occurred_at: "2026-11-04T00:05:03Z" DunningRunDetail: type: object description: What `GET /v1/dunning/runs/{id}` returns, the run and its event history. required: [run, events, degraded] properties: run: $ref: "#/components/schemas/DunningRun" events: type: array description: The run's events, oldest first. Empty when the history could not be read (`degraded` names `events`). items: $ref: "#/components/schemas/DunningRunEvent" degraded: type: array description: | Sources that could not be read. `events`: the event history, so `events` is empty. `invoice`: the run's invoice, so no event has a `line`. The others leave the lines without what they would add: `emails` the reminder and final-notice clauses, `charge_attempts` the decline reason and amount, `audit` who resolved the run. items: type: string enum: [events, invoice, emails, charge_attempts, audit] example: run: id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g state: active reason: payment_failed attempt_count: 1 last_attempt_at: "2026-11-04T00:05:03Z" next_action_at: "2026-11-08T00:05:03Z" paused: false created_at: "2026-11-01T00:05:03Z" updated_at: "2026-11-04T00:05:04Z" events: - id: hc_devt_dc7m3maq3gpgqfoq5c6v run_id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 event_type: dunning_started state: active reason: payment_failed attempt_count: 0 created_at: "2026-11-01T00:05:03Z" recorded_at: "2026-11-01T00:05:03Z" line: id: dunning:hc_devt_dc7m3maq3gpgqfoq5c6v kind: payment_recovery.started title: Payment recovery started detail: [] actor: kind: automatic tone: neutral occurred_at: "2026-11-01T00:05:03Z" - id: hc_devt_dc7m3maq3gpgqfoq5c7g run_id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 event_type: retry_attempted state: active reason: "payment failed: Your card has insufficient funds." attempt_count: 1 metadata: payment_intent_id: pi_3QHk2fLkdIwHu7ix4e5f6a7b created_at: "2026-11-04T00:05:03Z" recorded_at: "2026-11-04T00:05:03Z" line: id: dunning:hc_devt_dc7m3maq3gpgqfoq5c7g kind: payment_recovery.retry_failed title: Payment retry 1 failed amount: cents: 8650 currency: USD problem: "Card declined: insufficient funds" detail: [] actor: kind: automatic tone: failure occurred_at: "2026-11-04T00:05:03Z" degraded: [] DunningStats: type: object description: | What `GET /v1/dunning/stats` returns: dunning runs counted by state, and the amount still due on the open ones. It covers every run in this mode. Amounts are in the currency's minor unit (cents for USD). required: [active_count, escalated_count, resolved_count, recovered_count, at_risk_cents, currency, at_risk_by_currency] properties: active_count: type: integer description: Runs in `active`, still retrying. escalated_count: type: integer description: Runs in `escalated`, whose retries ran out. resolved_count: type: integer description: Resolved runs, whatever closed them (a payment, a void or a write-off). recovered_count: type: integer description: Resolved runs that a payment closed (`resolution` is `payment_recovered` and the invoice has `amount_paid_cents` above 0). A run closed by a credit note or customer credit that left nothing due is resolved, not recovered. at_risk_cents: type: integer format: int64 description: Amount due on active and escalated runs whose invoice is still open, in `currency` only, in its minor unit (cents for USD). A voided or written-off invoice is not at risk. currency: type: string description: The account's default currency, the one `at_risk_cents` counts. at_risk_by_currency: type: array description: | Amount due on active and escalated runs whose invoice is still open, one entry per currency. `currency` comes first, then the largest amount. Empty when no run is at risk. items: type: object required: [currency, amount_cents] properties: currency: type: string description: The currency of this entry, uppercase. amount_cents: type: integer format: int64 description: The amount at risk in this currency, in its minor unit (cents for USD). example: active_count: 2 escalated_count: 1 resolved_count: 14 recovered_count: 11 at_risk_cents: 25950 currency: USD at_risk_by_currency: - currency: USD amount_cents: 25950 CustomerPriceOverride: type: object description: | A customer's negotiated rate for one plan price. It keeps applying when the price's rate changes, and ends when the price is removed. It takes the plan's currency. The `/v1/price-overrides` routes create, list and end overrides. Every shape field is always sent: the ones the mode does not use are `"0"`, `0` or `[]`. required: [id, customer_id, price_id, mode, active, created_at, updated_at, unit_amount_cents, tiers, package_size, package_amount_cents] properties: id: type: string description: The override's ID, `hc_cpo_` and 20 characters. example: hc_cpo_daof442q3gpgqfoq5cag tenant_id: type: string description: The account the override belongs to. customer_id: type: string description: The customer who pays this rate. price_id: type: string description: The plan price this override replaces the rate of, for this customer. mode: type: string enum: [per_unit, graduated, package] description: How the override bills a quantity; see PriceShape. unit_amount_cents: type: string description: | Price per unit, as a decimal string of cents. `"0"` unless `mode` is `per_unit`. tiers: type: array description: Empty (`[]`) unless `mode` is `graduated`. items: $ref: "#/components/schemas/PriceTier" package_size: type: integer format: int64 description: The units in one package. 0 unless `mode` is `package`. package_amount_cents: type: string description: | Price of one package, as a decimal string of cents. `"0"` unless `mode` is `package`. reason: type: string description: Your note on why the customer has this rate. Absent when none was given. active: type: boolean description: | True until the override is ended: by `DELETE /v1/price-overrides/{id}`, by a new override for the same customer and price, or by the removal of the price. created_at: type: string format: date-time description: | When the override starts, in the customer's time: wall time, or its test clock's time. A subscription already on the plan gets it from its next period; one that first joins the plan in a period after this instant gets it from the join. deactivated_at: type: string format: date-time description: When the override ended, in the customer's time. Absent while it is active. updated_at: type: string format: date-time description: When the override was last written. example: id: hc_cpo_daof442q3gpgqfoq5cag tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g price_id: hc_prc_dajr672q3gpgqfoq5c20 mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" reason: Annual commitment discount active: true created_at: "2026-10-15T12:30:00Z" updated_at: "2026-10-15T12:30:00Z" DunningPolicyInput: type: object description: | The body of `POST /v1/dunning/policies` and `PATCH /v1/dunning/policies/{id}`. A PATCH replaces every setting, so send the full policy. `id`, `tenant_id`, `is_default`, `seeded`, `created_at` and `updated_at` are set by the server; sending one is refused with 422. required: [retry_days] properties: name: type: string maxLength: 255 description: A name for the policy, up to 255 bytes (a character outside ASCII counts as more than one). Defaults to empty. enabled: type: boolean default: false description: A disabled policy starts no dunning. retry_days: type: array minItems: 1 maxItems: 15 items: type: integer minimum: 1 maximum: 30 example: [3, 4, 7] description: | Days to wait before each retry. The first counts from the failed payment or the collection that found no card. Each later one counts from the retry before it: `[3, 4, 7]` retries on day 3, 7 and 14. The number of retries is the length of the list, 1 to 15, and each wait is a whole number of days from 1 to 30. A day is 24 hours. `retry_schedule`, `max_retry_attempts`, `grace_period_days` and `final_action` are refused. final_subscription_action: type: string enum: [none, pause, cancel] default: pause description: | What happens to the subscription when retries run out. `pause` pauses collection and sends `subscription.collection_paused` with `triggered_by: dunning`. It does nothing if collection is already paused, the subscription is canceled, or the invoice was paid first. `cancel` cancels the subscription and sends `subscription.canceled` with `canceled_by: dunning`. It does nothing if the subscription is already canceled. If a scheduled cancel was already due, that cancel happens instead, and its event says `canceled_by: schedule`. `none` leaves it alone. An invoice with no subscription is not affected. final_invoice_action: type: string enum: [none, mark_uncollectible] default: none description: | What happens to the invoice when retries run out. `mark_uncollectible` writes it off as bad debt; `none` leaves it open and due. example: name: Strict retries enabled: true retry_days: [2, 3, 5] final_subscription_action: cancel final_invoice_action: mark_uncollectible DunningPolicy: type: object description: | A dunning policy: how failed or missing payments are retried, and what happens when the retries run out. Every account starts with one policy per mode, "Default retries" (retries on day 3, 7 and 14, then no final action), as the default. Customers without an assigned policy use the default. required: [id, name, enabled, is_default, seeded, retry_days, final_subscription_action, final_invoice_action, created_at, updated_at] properties: id: type: string description: Unique identifier of the policy, starting `hc_dpol_`. example: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: type: string description: The account the policy belongs to. Absent when empty. name: type: string description: The policy's name. Can be empty. enabled: type: boolean description: Whether the policy starts dunning. A disabled policy starts no runs. is_default: type: boolean description: Customers without an assigned policy use the default. seeded: type: boolean description: | True for the "Default retries" policy the account was created with, until you edit it or make it the default. While it is true, the next enabled policy you create becomes the default instead. Set by the server: a request body that sends it is refused with 422. retry_days: type: array items: type: integer example: [3, 4, 7] description: Days to wait before each retry; the number of retries is its length. final_subscription_action: type: string enum: [none, pause, cancel] description: What happens to the subscription when retries run out. final_invoice_action: type: string enum: [none, mark_uncollectible] description: What happens to the invoice when retries run out. created_at: type: string format: date-time description: When the policy was created. updated_at: type: string format: date-time description: When the policy last changed. example: id: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Default retries enabled: true is_default: true seeded: true retry_days: [3, 4, 7] final_subscription_action: none final_invoice_action: none created_at: "2026-09-15T12:00:00Z" updated_at: "2026-09-15T12:00:00Z" DunningPolicyListItem: description: A dunning policy as `GET /v1/dunning/policies` lists it, with how many customers are assigned to it. allOf: - $ref: "#/components/schemas/DunningPolicy" - type: object required: [assigned_customers] properties: assigned_customers: type: integer description: Customers explicitly assigned to this policy. Customers using it only as the default are not counted. example: id: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Default retries enabled: true is_default: true seeded: true retry_days: [3, 4, 7] final_subscription_action: none final_invoice_action: none created_at: "2026-09-15T12:00:00Z" updated_at: "2026-09-15T12:00:00Z" assigned_customers: 0 UsageAggregate: type: object description: | Totals over every usage event that matches the filters of `GET /v1/usage-events/aggregate`, with one row per meter. required: [total_events, total_units, active_meters, active_customers, by_meter] properties: total_events: type: integer description: Number of matching events. total_units: type: string description: | Sum of all matching quantities across meters, as a decimal string. It adds different units and ignores each meter's aggregation; use `by_meter[].value` instead. active_meters: type: integer description: Distinct meters among the matching events. active_customers: type: integer description: Distinct customers among the matching events. by_meter: type: array description: One row per meter, largest total first. items: type: object required: [meter_id, total, aggregation, value, events, customers, peak, daily] properties: meter_id: type: string description: The meter (`hc_mtr_…`). total: type: string description: Plain sum of reported quantities, as a decimal string. aggregation: type: string description: | The meter's aggregation (`sum`, `count`, `max`, `last`), which every price of the meter bills under. Empty when unknown. value: type: string description: | Usage under the meter's aggregation, as a decimal string. `max` and `last` take each customer's peak or latest reading in the window and add them across customers. Billing applies them per billing period. events: type: integer description: Number of matching events on this meter. customers: type: integer description: Distinct customers with events on this meter. peak: type: string description: Highest single reported quantity, as a decimal string. daily: type: array description: One entry per calendar day (in `tz`) with events, oldest first. items: type: object required: [date, value] properties: date: type: string format: date description: The calendar day, `YYYY-MM-DD`, in `tz`. value: type: string description: The day's usage under the meter's aggregation. example: total_events: 268 total_units: "805500" active_meters: 1 active_customers: 1 by_meter: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 total: "805500" aggregation: sum value: "805500" events: 268 customers: 1 peak: "48000" daily: - date: "2026-10-12" value: "410000" - date: "2026-10-13" value: "395500" LitellmSpendLog: type: object description: | The fields of a LiteLLM `StandardLoggingPayload` that Hardcount reads. Other fields are accepted and ignored. properties: id: type: string description: LiteLLM's call id. Used to build the events' idempotency keys. status: type: string description: A `failure` record (a call the provider refused or that timed out) is skipped, whatever token counts it carries. cache_hit: type: boolean nullable: true description: | `true` when LiteLLM answered from its own response cache. The record is skipped and counted in `cache_hits`: the provider charged nothing. call_type: type: string description: | A record bills on its token counts unless its call type is one Hardcount does not bill (images, audio, realtime, moderation, batches, files, A2A, response retrieval, ...); those are skipped, and a skipped record that carries tokens is logged. model: type: string description: | The model the call used, as LiteLLM names it. Each event carries it as the `model_raw` dimension, and as `model` the model family name when Hardcount recognises it (the name as sent otherwise). custom_llm_provider: type: string description: The provider LiteLLM sent the call to, such as `openai`. Each event carries it as the `provider` dimension. user: type: string description: The customer's external_id. end_user: type: string description: Where LiteLLM Proxy puts the caller's `user`. Used when `user` is empty. usage: type: object description: | The call's token counts. When it is absent, Hardcount reads `metadata.usage_object`, then the top-level `prompt_tokens` and `completion_tokens`. properties: prompt_tokens: type: integer format: int64 description: Includes cache-read tokens. completion_tokens: type: integer format: int64 description: Output tokens. They become the `output` event. total_tokens: type: integer format: int64 description: Accepted but not used. prompt_tokens_details: type: object description: The breakdown of `prompt_tokens`, as LiteLLM reports it. properties: cached_tokens: type: integer format: int64 description: | Prompt tokens read from the provider's cache. They become the `cache_read` event, taken out of `input`. cache_creation_tokens: type: integer format: int64 description: | Prompt tokens written to the provider's cache. Hardcount takes the larger of this and `cache_creation_input_tokens`. cache_creation_token_details: type: object description: | Anthropic's cache writes by cache lifetime. They become `cache_write_5m` and `cache_write_1h` events, taken out of `input`. Without this split, when it does not add up to the cache-write count, or when the writes do not fit in `prompt_tokens` less the cache reads, the writes stay in `input`. properties: ephemeral_5m_input_tokens: type: integer format: int64 nullable: true description: Cache writes with a 5-minute lifetime. They become the `cache_write_5m` event. ephemeral_1h_input_tokens: type: integer format: int64 nullable: true description: Cache writes with a 1-hour lifetime. They become the `cache_write_1h` event. cache_read_input_tokens: type: integer format: int64 description: | Anthropic's cache-read tokens, included in prompt_tokens. Hardcount takes the larger of this and `prompt_tokens_details.cached_tokens` as the `cache_read` event. cache_creation_input_tokens: type: integer format: int64 description: Cache-write tokens, included in prompt_tokens. Split into `cache_write_5m` and `cache_write_1h` by `prompt_tokens_details.cache_creation_token_details`. prompt_tokens: type: integer format: int64 description: Used when there is no `usage` object. completion_tokens: type: integer format: int64 description: Output tokens. Used when there is no `usage` object. total_tokens: type: integer format: int64 description: Accepted but not used. response_cost: type: number description: Accepted but not stored. cost_breakdown: type: object description: | The provider's own cost, in dollars. When present, each event's provider cost comes from its part (input, output, cache read or cache write) instead of the provider-costs table. A negative part falls back to the table. A missing `input_cost` or `output_cost` reads as 0 and is recorded as a cost of 0; a missing `cache_read_cost` or `cache_creation_cost` means unknown. `input_cost` includes cache reads and cache writes, so the input event gets `input_cost` minus `cache_read_cost` and `cache_creation_cost`. If there were cache reads but no `cache_read_cost`, the input and cache-read events use the provider-costs table; if there were split-off cache writes but no `cache_creation_cost`, the input and cache-write events do. With 1-hour cache writes the input and cache-write events always do: LiteLLM prices `cache_creation_cost` at the 5-minute rate. properties: input_cost: type: number description: The cost of the whole prompt, in dollars, cache reads and writes included. output_cost: type: number description: The cost of the output tokens, in dollars. cache_read_cost: type: number description: The part of `input_cost` spent on cache reads, in dollars. cache_creation_cost: type: number description: LiteLLM's cost of the cache writes, in dollars, priced at the 5-minute rate. tool_usage_cost: type: number description: Accepted but not used. total_cost: type: number description: Accepted but not used. startTime: type: number description: Unix seconds. Event time when `endTime` is absent. endTime: type: number description: Unix seconds. The event time. With neither, the customer's current time is used. metadata: type: object additionalProperties: true description: | `team_id` and `request_tags` become event dimensions (a list of tags becomes one sorted, comma-separated string); `usage_object` is read for token counts; `user_api_key_end_user_id` names the customer when `user` and `end_user` are empty; a `user_api_key_request_route` of `/vertex_ai/live` on a `pass_through_endpoint` record marks Vertex AI Live audio, which is skipped. Other keys do not change billing. example: id: chatcmpl-AZx81kq4ZtR2 status: success call_type: acompletion model: gpt-4o-2024-08-06 custom_llm_provider: openai user: acme-42 usage: prompt_tokens: 1200 completion_tokens: 350 total_tokens: 1550 prompt_tokens_details: cached_tokens: 200 cost_breakdown: input_cost: 0.00275 output_cost: 0.0035 cache_read_cost: 0.00025 total_cost: 0.00625 startTime: 1791813785.8 endTime: 1791813787.0 metadata: team_id: team-research HeldUsageGroup: type: object description: The usage rows still held for one external customer ID, event name and reason. required: [external_customer_id, event_name, reason, rows, total_quantity, first_event_at, last_event_at, last_received_at, customer_id, meter_exists] properties: external_customer_id: type: string description: The LiteLLM `user` value the rows carried. event_name: type: string description: The `event_name` the rows carried. reason: type: string enum: [customer_not_found, meter_not_found] description: What was missing when the rows arrived. rows: type: integer description: Number of rows held in the group. total_quantity: type: string description: Sum of the held quantities, as a decimal string. first_event_at: type: string format: date-time description: The earliest usage time among the rows. last_event_at: type: string format: date-time description: The latest usage time among the rows. last_received_at: type: string format: date-time description: When Hardcount last received a row for this group. customer_id: type: string description: The customer whose external ID now matches, or empty when none does yet. meter_exists: type: boolean description: Whether a meter with this `event_name` exists now. example: external_customer_id: globex-7 event_name: tokens reason: customer_not_found rows: 12 total_quantity: "18400" first_event_at: "2026-10-14T08:00:12Z" last_event_at: "2026-10-14T09:41:55Z" last_received_at: "2026-10-14T09:41:56Z" customer_id: "" meter_exists: true HeldUsageReleaseResult: type: object description: | What `POST /v1/usage-events/held/release` did. The counts are what Hardcount found when the release wrote the rows, as for `billing_status` (see the `billing_status` field of a usage event). The invoice is the final answer. required: [customer_id, released, deduplicated, still_held, more_to_release, in_open_period, no_price, not_on_plan, pending_threshold_invoice, outside_open_period] properties: customer_id: type: string description: The customer the rows were ingested for. released: type: integer description: Rows newly written as usage events. deduplicated: type: integer description: | Rows that were already usage events under the same idempotency key (LiteLLM resent the call after the customer existed), and LiteLLM cache-write rows whose call is already billed with the writes inside its input row. Marked released, not written again. still_held: type: integer description: | Rows for this external ID still held that a release cannot take yet: their meter does not exist yet, or their time is after the customer's current time (a customer on a test clock). more_to_release: type: integer description: | Rows for this external ID still held only because one release takes at most 5,000 rows. Release again to take them. in_open_period: type: integer description: | Released rows in a billing period not invoiced yet that a price of its plan takes. Rows Hardcount does not check count here too: rows after the current period's end, while the subscription continues, and rows after a trial's end while the subscription still trials. Rows of a sum or count meter stamped before the end of this period's spend-threshold invoice are not counted here: see `pending_threshold_invoice` and `outside_open_period`. no_price: type: integer description: | Released rows in a billing period not invoiced yet whose plan prices the meter, but no price of that plan takes them (their dimensions do not include any price's `dimension_match`). not_on_plan: type: integer description: | Released rows in a billing period not invoiced yet whose plan does not price the meter. pending_threshold_invoice: type: integer description: | Released rows of a sum or count meter stamped before the end of this period's spend-threshold invoice, while that invoice is not paid. The period's invoice counts them only if that invoice is voided before the period closes. A written-off invoice can still be voided. outside_open_period: type: integer description: | Released rows outside every subscription's time (before it started, in a free trial, after it ended or after its scheduled cancel, or in another, ended subscription's time), and rows in a period already invoiced. Also rows of a sum or count meter stamped before the end of this period's paid spend-threshold invoice. Bill them with a one-off invoice if they should be charged. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0k released: 12 deduplicated: 0 still_held: 0 more_to_release: 0 in_open_period: 12 no_price: 0 not_on_plan: 0 pending_threshold_invoice: 0 outside_open_period: 0 LitellmSpendResult: type: object description: | What `POST /v1/integrations/litellm/spend` did with the records it received. Each record becomes up to five usage events, one per token type; the counts below count events, except `skipped` and `cache_hits`, which count records. required: [accepted, deduplicated, held, skipped, cache_hits, after_close, no_price, not_on_plan, outside_subscription] properties: accepted: type: integer description: Usage events newly recorded. deduplicated: type: integer description: Usage events already recorded, or already held, by an earlier delivery. held: type: integer description: | Usage events kept because their customer or meter does not exist yet. Not billed until released with `POST /v1/usage-events/held/release`. skipped: type: integer description: Records that bill nothing - a call type Hardcount does not bill (even when it carries tokens), no token counts, a failed call (`status` `failure`) or a LiteLLM response-cache hit. cache_hits: type: integer description: The LiteLLM response-cache hits among `skipped`. LiteLLM's own spend counts them as $0. after_close: type: integer description: | Of `accepted`, the events answered `billing_status: after_close`: their time was already invoiced. A replay never counts here. no_price: type: integer description: | Of `accepted`, the events no price of the subscription's plan takes (`billing_status: no_price`), such as a model no price names. A replay never counts here. not_on_plan: type: integer description: | Of `accepted`, the events whose meter the subscription's plan does not price (`billing_status: not_on_plan`). A replay never counts here. outside_subscription: type: integer description: | Of `accepted`, the events whose time no subscription of the customer bills (`billing_status: outside_subscription`). A replay never counts here. errors: type: array description: | One entry per record that was not recorded. A record's events are recorded together or not at all. Absent when every record was recorded, held or skipped. items: type: object required: [id, error] properties: id: type: string description: The LiteLLM record id. error: type: string description: Why the record was not recorded, such as a missing `user`. example: accepted: 3 deduplicated: 0 held: 0 skipped: 0 cache_hits: 0 after_close: 0 no_price: 0 not_on_plan: 0 outside_subscription: 0 APIKey: type: object description: | An API key's details. The key's value is never returned here: only `POST /v1/api-keys` and `POST /v1/api-keys/{id}/rotate` show it, once, as `raw_key`. required: [id, key_prefix, key_type, livemode, name, tenant_id, created_at] properties: id: type: string description: The key's id (`hc_key_…`). Use it to revoke or rotate the key. example: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: type: string description: | The key's type and mode prefix plus the first 12 characters of its secret part. Enough to tell keys apart; never the full key. example: hc_secret_test_4f9c2a7e1b3d key_type: type: string enum: [secret, ingest] description: | `secret` can read and change everything in its mode of the account, except team members and the test email, which need a dashboard sign-in. `ingest` can only send usage events and call `GET /v1/whoami`, for LiteLLM or another gateway. livemode: type: boolean description: True for a live-mode key (`…_live_…`), false for a test-mode key. A key reaches only its own mode's data. name: type: string description: The label shown in the key list. tenant_id: type: string description: The account the key belongs to. created_at: type: string format: date-time description: When the key was created. expires_at: type: string format: date-time description: | When the key stops working. Also set on the old key during a rotation grace period, and copied to the new key when the old one had it. Absent for a key that does not expire. revoked_at: type: string format: date-time description: When the key was revoked. Absent unless it was revoked. A revoked key gets 401. last_used_at: type: string format: date-time description: | When the key last authenticated a request. Each server updates it at most once a minute, so it can lag a little. Absent until the key is first used. example: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" last_used_at: "2026-11-01T00:04:12Z" APIKeyCreated: type: object description: A new API key and its full value. `raw_key` appears only in this answer. required: [key, raw_key] properties: key: $ref: "#/components/schemas/APIKey" raw_key: type: string description: | The full key: its prefix (`hc_secret_test_`, `hc_ingest_live_` and so on) and 64 hex characters. Shown only once; store it now. Hardcount keeps only a salted hash. example: hc_secret_test_4f9c2a7e1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8 example: key: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" raw_key: hc_secret_test_4f9c2a7e1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8 APIKeyRotation: type: object description: | The result of rotating a key: the old key as it now stands (revoked, or expiring at the end of the grace period), the new key, and the new key's full value. required: [old_key, new_key, raw_key] properties: old_key: $ref: "#/components/schemas/APIKey" new_key: $ref: "#/components/schemas/APIKey" raw_key: type: string description: The new key's full value. Shown only once; store it now. example: old_key: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" expires_at: "2026-11-02T10:00:00Z" last_used_at: "2026-11-01T09:58:40Z" new_key: id: hc_key_dajqj1iq3gpgqfoq5cak key_prefix: hc_secret_test_a81c4e7f02d9 key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-11-01T10:00:00Z" raw_key: hc_secret_test_a81c4e7f02d95b3e6c1f8a04d7b2e9c3516f0a8d2c4b7e91f3a6d05c8b2e4f71 TenantSettingsInput: type: object description: | The account's billing settings, as `PUT /v1/settings` takes them. Every field is optional. Fields you leave out keep their stored value. Settings are shared by test and live mode. The length limits count bytes, not characters: an accented letter counts as two. properties: default_currency: type: string description: ISO 4217 code, stored in upper case. Zero-decimal currencies (such as JPY) are not supported yet. example: USD timezone: type: string description: IANA zone name, such as `America/New_York`. `Local` is rejected. All billing date math uses this zone. example: UTC invoice_prefix: type: string pattern: "^[A-Za-z0-9-]{1,20}$" description: Starts every invoice number, such as `HC-000001`. Stored in upper case. credit_note_prefix: type: string pattern: "^[A-Za-z0-9-]{1,20}$" description: | Starts every credit note number, such as `CN-000001`. Stored in upper case. A change applies to new credit notes only. Issued notes keep their numbers. It must differ from `invoice_prefix`, or an invoice and a credit note would get the same number. net_payment_terms: type: integer minimum: 0 maximum: 365 description: Days until an invoice is due. 0 means due on issue. credit_balance_low_threshold_cents: type: integer format: int64 nullable: true minimum: 1 description: | When a customer's credit in one currency drops below this amount, in that currency's minor units, the `credit.balance_low` webhook fires: 1000 means $10.00 of USD credit and €10.00 of EUR credit. Send null to turn it off. Answers leave it out while it is off. tax_provider: type: string enum: [none, manual, stripe_tax] description: "`none` = no tax, `manual` = the flat `tax_rate`, `stripe_tax` = Stripe Tax." tax_rate: type: number minimum: 0 maximum: 100 description: | Percent, from 0 to 100 (18.5 = 18.5%), stored to 4 decimal places. Used by the `manual` provider. tax_name: type: string description: Label for the tax line, such as `VAT`. tax_inclusive: type: boolean description: Whether prices already include tax. tax_on_failure: type: string enum: [block] description: What to do when the tax provider fails. `block`, the only value, holds the invoice until tax succeeds. default_product_tax_code: type: string pattern: "^(txcd_[0-9]{8})?$" description: | Stripe product tax code used when a plan has none, such as `txcd_10103001`. Empty is allowed. Surrounding whitespace is trimmed before the check. A save with `tax_provider: stripe_tax` and no code stores `txcd_10103001` (SaaS, business use). company_name: type: string maxLength: 255 description: Shown on invoices and as the sender name on emails. No line breaks. company_address_line1: type: string maxLength: 200 description: First line of your business address, printed as the seller on invoices and credit notes. company_address_line2: type: string maxLength: 200 description: Second line of your business address, such as a suite number. company_city: type: string maxLength: 100 description: City of your business address. company_state: type: string maxLength: 100 description: State, province or region of your business address. company_postal_code: type: string maxLength: 20 description: Postal code of your business address. company_country: type: string maxLength: 2 pattern: "^([A-Za-z]{2})?$" description: | ISO 3166 alpha-2 code, such as `US`. Empty is allowed. Whitespace is trimmed and the code is upper-cased before it is checked and stored. company_email: type: string description: | Your billing contact address, printed as the seller on invoices and credit notes and shown on the hosted pages. One plain address, such as `billing@example.com`, with no display name. company_phone: type: string description: 7 to 20 characters of digits, spaces, `+`, `-`, `(` and `)`. logo_url: type: string description: Shown in emails and on the hosted invoice page. PDFs do not print it. brand_color: type: string pattern: "^#[0-9a-fA-F]{6}$" description: Accent color on invoice PDFs, as `#rrggbb`. Stored in lower case. tax_id: type: string maxLength: 50 description: Your own tax number (VAT, EIN, GSTIN), printed on invoices. support_url: type: string maxLength: 500 description: | Must start with `http://` or `https://`. Linked in emails and on the hosted invoice page, and printed at the foot of invoice and credit-note PDFs. invoice_footer: type: string maxLength: 1000 description: | Printed under the totals on invoice PDFs and on the hosted invoice page. Each invoice keeps the footer it was issued with, so a change applies to invoices issued after it. A draft shows the current text. example: default_currency: USD timezone: America/Los_Angeles invoice_prefix: NW credit_note_prefix: NWCN net_payment_terms: 30 credit_balance_low_threshold_cents: 1000 tax_provider: manual tax_rate: 8.625 tax_name: Sales tax tax_inclusive: false tax_on_failure: block company_name: Northwind AI, Inc. company_address_line1: 500 Market Street company_address_line2: Suite 400 company_city: San Francisco company_state: CA company_postal_code: "94105" company_country: US company_email: billing@northwind.example company_phone: "+1 415 555 0132" logo_url: https://northwind.example/logo.png brand_color: "#1f6feb" tax_id: "12-3456789" support_url: https://northwind.example/support invoice_footer: Thank you for your business. TenantSettings: description: | The account's billing settings, as `GET /v1/settings` and `PUT /v1/settings` return them: every stored setting, plus the number the next invoice in the caller's mode gets. allOf: - $ref: "#/components/schemas/TenantSettingsInput" - type: object required: [tenant_id, default_currency, timezone, invoice_prefix, credit_note_prefix, net_payment_terms, tax_provider, tax_rate, tax_inclusive, next_invoice_sequence, created_at, updated_at] properties: tenant_id: type: string description: The account these settings belong to. `PUT` refuses it (422). next_invoice_sequence: type: integer description: | The number the next invoice in this mode (test or live) gets, before the prefix: 43 means the next invoice is `-000043`. Test and live count separately, and changing `invoice_prefix` keeps the count. Not a setting: `PUT` refuses it as an unknown field (422). created_at: type: string format: date-time description: When the account's settings were first stored. Zero time (`0001-01-01T00:00:00Z`) when none are stored yet, and the answer shows the defaults. updated_at: type: string format: date-time description: When the settings were last saved. Zero time (`0001-01-01T00:00:00Z`) when none are stored yet. example: tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 default_currency: USD timezone: America/Los_Angeles invoice_prefix: NW credit_note_prefix: NWCN net_payment_terms: 30 credit_balance_low_threshold_cents: 1000 tax_provider: manual tax_rate: 8.625 tax_name: Sales tax tax_inclusive: false tax_on_failure: block company_name: Northwind AI, Inc. company_address_line1: 500 Market Street company_address_line2: Suite 400 company_city: San Francisco company_state: CA company_postal_code: "94105" company_country: US company_email: billing@northwind.example company_phone: "+1 415 555 0132" logo_url: https://northwind.example/logo.png brand_color: "#1f6feb" tax_id: "12-3456789" support_url: https://northwind.example/support invoice_footer: Thank you for your business. next_invoice_sequence: 43 created_at: "2026-10-01T08:00:00Z" updated_at: "2026-10-20T16:45:10Z" StripeConnection: type: object description: | The account's own Stripe keys for one mode, without the secrets. Hardcount uses them to charge customers and to read Stripe's answers. `GET /v1/settings/stripe` lists one per connected mode. required: [id, tenant_id, livemode, secret_key_last4, publishable_key, has_webhook_secret, created_at, updated_at] properties: id: type: string description: | The connection's id (`hc_spc_…`). It is also the `{endpoint_id}` in the Stripe webhook URL `/v1/webhooks/stripe/{endpoint_id}`. tenant_id: type: string description: The account the keys belong to. livemode: type: boolean description: True for live-mode keys (`sk_live_…`), false for test-mode keys. stripe_account_id: type: string description: The Stripe account the key belongs to (`acct_…`). Absent until Stripe first accepts a key for this mode. stripe_account_name: type: string description: | The Stripe account's business name, or its dashboard name or email when it has none. Absent until Stripe accepts a key. secret_key_prefix: type: string description: First 12 characters of the secret key. secret_key_last4: type: string description: Last 4 characters of the secret key. publishable_key: type: string description: The publishable key, in full. It is not a secret. webhook_secret_last4: type: string description: Last 4 characters of the webhook signing secret. Absent until one is saved. has_webhook_secret: type: boolean description: | Whether a webhook signing secret is saved. Until one is, Stripe webhooks sent to this connection are refused with 404. verified_at: type: string format: date-time description: | When Stripe last accepted the key. Absent until it does. Saving new keys clears it until the next check passes. last_verified_error: type: string description: | Why the last Stripe check failed, in Stripe's own words (or the network error when Stripe was not reached). Also set when Stripe refuses the key on a charge (401, or 403 for a restricted key); the next charge that goes through clears that. Absent when the check passed. last_verified_error_kind: type: string enum: [rejected, unavailable] description: | What kind of failure `last_verified_error` is. `rejected`: Stripe answered and refused the key (invalid, revoked, or without access). `unavailable`: Stripe was not reached, or answered 429/5xx, so the key was not checked; connecting again may work. Absent when the check passed, and on keys that failed before this field existed. created_at: type: string format: date-time description: When keys were first saved for this mode. updated_at: type: string format: date-time description: When the connection last changed. retries_queued: type: integer description: | Only in the answer to `POST /v1/settings/stripe`, and only when above 0: how many invoices that were stuck waiting for Stripe keys are now being retried in the background. example: id: hc_spc_5e2a9c7f1b4d8e3a6c0f2b9d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false stripe_account_id: acct_1Q2w3E4r5T6y7U8i stripe_account_name: Northwind AI secret_key_prefix: sk_test_51Q2 secret_key_last4: 7fQa publishable_key: pk_test_51Q2w3E4r5T6y7U8iN0rthw1ndAIpub11shab1eK3yExamp1e00aBcDeF webhook_secret_last4: c41d has_webhook_secret: true verified_at: "2026-10-01T08:30:02Z" created_at: "2026-10-01T08:30:00Z" updated_at: "2026-10-01T08:30:02Z" Tenant: type: object description: | An account: one business that bills its customers with Hardcount. It has separate test and live data. `POST /v1/bootstrap` creates the first one. required: [id, name, status, created_at, updated_at] properties: id: type: string description: The account's id (`hc_ten_…`). Other objects carry it as `tenant_id`. example: hc_ten_dajqj02q3gpgqfoq5c00 name: type: string description: The account's name, given at setup. status: type: string enum: [active, suspended, deleted] description: | The account's state. Setup creates it `active`. Nothing in this version sets `suspended` or `deleted`. created_at: type: string format: date-time description: When the account was created. updated_at: type: string format: date-time description: When the account record last changed. example: id: hc_ten_dajqj02q3gpgqfoq5c00 name: Northwind AI status: active created_at: "2026-10-01T08:00:00Z" updated_at: "2026-10-01T08:00:00Z" BootstrapResult: type: object description: | What `POST /v1/bootstrap` returns: the new account, its owner's dashboard sign-in, and one test and one live secret key. This answer is the only place the keys appear. required: [tenant, owner_email, owner_password, password_generated, secret_key_test, secret_key_live, message] properties: tenant: $ref: "#/components/schemas/Tenant" owner_email: type: string description: The owner's dashboard sign-in email. owner_password: type: string description: | The owner's dashboard password: the one you sent, or the generated one (24 hex characters). password_generated: type: boolean description: True when Hardcount generated `owner_password` because the request sent none. secret_key_test: type: string description: Test-mode secret key (`hc_secret_test_...`). secret_key_live: type: string description: Live-mode secret key (`hc_secret_live_...`). message: type: string description: A reminder to save the credentials now. example: tenant: id: hc_ten_dajqj02q3gpgqfoq5c00 name: Northwind AI status: active created_at: "2026-10-01T08:00:00Z" updated_at: "2026-10-01T08:00:00Z" owner_email: maya@northwind.example owner_password: 7f3a9c1e5b2d8f4a6c0e3b1d password_generated: true secret_key_test: hc_secret_test_4f9c2a7e1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8 secret_key_live: hc_secret_live_6d2b8f4a1c9e3d7b5f0a2c8e4b6d1f9a3c7e5b0d2f8a4c6e1b9d3f7a5c0e2b84 message: Bootstrap complete. Save these credentials — they will not be shown again. AuditLogEntry: type: object description: | One audit log row: who did what to which resource, and when. `GET /v1/audit-log` lists them. required: [id, actor_type, actor_id, action, resource_type, resource_id, created_at] properties: id: type: string description: The entry's id (`hc_aud_…`). tenant_id: type: string description: The account the entry belongs to. actor_type: type: string enum: [user, api_key, customer, system] description: | Who did it. `user`: a dashboard member. `api_key`: a request made with an API key. `customer`: a customer acting on a hosted page. `system`: Hardcount itself, such as a background job or a Stripe webhook. actor_id: type: string description: The user id, API key id or customer id, or `system`. actor_name: type: string description: | A readable name for the actor: the API key's name, the user's email or the customer's display name. Absent when none is known. action: type: string description: | What was done, such as `create`, `update`, `revoke`, `finalize` or `export`. `GET /v1/audit-log/filters` lists the values in use. resource_type: type: string description: The kind of resource, such as `invoice`, `api_key`, `setting` or `user`. resource_id: type: string description: The resource's id. Empty for account-wide resources such as settings, and for exports. resource_label: type: string description: | A readable label for the resource, such as an invoice number or a key's name. For a `user` resource, the user's or invitee's email. Absent when there is none. metadata: type: object additionalProperties: true description: | Action details. A settings change has `changed`, which lists each changed field with `from` and `to`. Absent when the action has none. ip_address: type: string description: The client address of the request that made the change. Absent for work with no request. request_id: type: string description: Matches the `Hardcount-Request-Id` header of the request that made the change. Absent for work with no request. created_at: type: string format: date-time description: When the entry was written, in real time. sim_effective_at: type: string format: date-time description: Simulated time, for entries made inside a test clock. Absent otherwise. test_clock_id: type: string description: The test clock whose simulation the entry was made in. Absent otherwise. subject_deleted: type: boolean description: | True when the resource no longer exists because its test clock was deleted. The entry is kept. Absent otherwise. example: id: hc_aud_dbj84c2q3gpgqfoq5cb0 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 actor_type: user actor_id: hc_usr_8d3f1a6c2e9b4d7f0a5c3e1b actor_name: maya@northwind.example action: revoke resource_type: api_key resource_id: hc_key_dajqj1iq3gpgqfoq5ca0 resource_label: Backend (test) metadata: key_type: secret ip_address: 203.0.113.24 request_id: req_dbjg652q3gpgqfoq5cd0 created_at: "2026-11-01T09:12:30Z" AuditLogFilters: type: object description: | The values `GET /v1/audit-log` can filter on, from `GET /v1/audit-log/filters`, for filter menus. Only entries in the caller's mode count. required: [actions, resource_types, test_clocks] properties: actions: type: array description: Every `action` value recorded in this mode, sorted A to Z, at most 500. items: type: string resource_types: type: array description: Every `resource_type` value recorded in this mode, sorted A to Z, at most 500. items: type: string test_clocks: type: array description: Test clocks seen in the log, most recent activity first, at most 100. Deleted clocks are included. items: type: object required: [id, name] properties: id: type: string description: The test clock's id (`hc_tclk_…`). name: type: string description: The clock's name, taken from its own audit entries. Empty when the log has none. example: actions: [create, export, finalize, revoke, rotate, update] resource_types: [api_key, customer, invoice, setting, subscription, test_clock] test_clocks: - id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d name: October renewal test WebhookEndpoint: type: object description: | A URL that Hardcount sends webhook events to, for one mode. Its signing secret is never returned here: only creating the endpoint and rotating its secret show it, once. required: [id, livemode, url, secret_last4, events, active, created_at, updated_at] properties: id: type: string description: The endpoint's id (`hc_whe_…`). tenant_id: type: string description: The account the endpoint belongs to. livemode: type: boolean description: The mode the endpoint gets events from, true for live. It is the caller's mode when the endpoint is created. url: type: string description: | Where Hardcount POSTs each event. It must be `https://` on a public address. Outside production (`APP_ENV` is not `production`), a `localhost` or loopback URL works too, over `http://` or `https://`. Each delivery checks the address again, so a host that later points to a private address is not called. description: type: string description: Your note about the endpoint. Absent when empty. secret_last4: type: string description: Last 4 characters of the current signing secret. events: type: array items: type: string description: | The event types the endpoint gets. `*` means every event, and a prefix such as `invoice.*` means every event whose type starts with `invoice.`. active: type: boolean description: | False while the endpoint is paused. A paused endpoint gets no new events, its pending retries end as `failed`, and its deliveries cannot be resent. secondary_secret_last4: type: string description: | Last 4 characters of the previous signing secret, after a rotation. Absent if the secret was never rotated. secondary_secret_expires_at: type: string format: date-time description: | When the previous secret stops signing deliveries, 72 hours after the rotation. It stays in the answer after that time. Absent if the secret was never rotated. created_at: type: string format: date-time description: When the endpoint was created. updated_at: type: string format: date-time description: When the endpoint last changed. example: id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync secret_last4: "4e9f" events: ["invoice.*", "payment.failed", "credit.balance_depleted"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-10-02T10:00:00Z" WebhookEndpointStats: type: object description: | All-time delivery counts for one endpoint in the caller's mode, from `GET /v1/webhook-endpoints/endpoints/stats`. required: [endpoint_id, total_deliveries, succeeded, failed, success_rate] properties: endpoint_id: type: string description: The endpoint's id. It can name a deleted endpoint. total_deliveries: type: integer description: All deliveries, including pending ones. succeeded: type: integer description: Deliveries that got a 2xx answer. failed: type: integer description: Deliveries that failed for good. success_rate: type: number description: Percent (0-100) of finished deliveries that succeeded. Pending deliveries are not counted. 0 when none finished. example: endpoint_id: hc_whe_danslg2q3gpgqfoq5c80 total_deliveries: 120 succeeded: 117 failed: 1 success_rate: 99.15254237288136 WebhookEndpointDelivery: type: object description: | One delivery of one event to one endpoint, with the result of its latest attempt. A failed attempt is tried again after about 1 minute, 5 minutes, 30 minutes, 2 hours and 24 hours. After the sixth failed attempt the delivery is `failed`. A redirect counts as a failure. required: [id, event_id, event_type, is_replay, status, status_code, attempt_count, response_body, error, created_at, completed_at, next_retry_at] properties: id: type: string description: The delivery's id (`hc_whd_…`). event_id: type: string description: The event this delivery sent. For a replay, the replay's own id; the message itself carried the original event's `id`. event_type: type: string description: The event's type, such as `invoice.finalized`. is_replay: type: boolean description: The delivery came from a replay. replay_of_event_id: type: string description: The original event, when `is_replay` is true. Absent otherwise. status: type: string enum: [pending, succeeded, failed] description: | `pending` while an attempt is running or due, `succeeded` after a 2xx answer, `failed` once no attempt is left or the endpoint was paused or deleted. status_code: type: integer description: HTTP status from the receiver's latest answer. 0 when no attempt got an answer. attempt_count: type: integer description: Attempts made so far, at most 6. response_body: type: string description: Up to the first 1 KB of the receiver's latest answer. error: type: string description: | Why an attempt failed, such as `HTTP 500`, a connection error, `endpoint disabled` or `endpoint deleted`. Empty when no attempt failed. A delivery that succeeded on a retry keeps the earlier failure's text. created_at: type: string format: date-time description: When the delivery was queued. completed_at: type: string format: date-time nullable: true description: When the delivery succeeded or failed for good. null while pending. next_retry_at: type: string format: date-time nullable: true description: | While pending, when the next attempt is due. While an attempt is running, it is when a retry takes over if that attempt never finishes. null once the delivery is finished. example: id: hc_whd_dbj84bqq3gpgqfoq5c90 event_id: hc_whevt_dbj84biq3gpgqfoq5c8g event_type: credit.balance_depleted is_replay: false status: succeeded status_code: 200 attempt_count: 1 response_body: '{"received":true}' error: "" created_at: "2026-11-01T00:05:00Z" completed_at: "2026-11-01T00:05:01Z" next_retry_at: null WebhookEventDeliveryAttempt: type: object description: | One row of an event's delivery timeline, from `GET /v1/webhook_events/{id}/deliveries`. Each row is one endpoint's delivery of the event or of one of its replays, with the result of its latest attempt. required: [id, event_id, endpoint_id, endpoint_url, endpoint_description, attempt_no, status, status_code, response_body, error, request_payload_sha256, attempted_at, completed_at, next_retry_at, is_replay, replay_event_id] properties: id: type: string description: The delivery's id (`hc_whd_…`). event_id: type: string description: The event this delivery belongs to. When `is_replay` is true, the replay's own id; the message it sent carries the original event's `id` (`root_event_id`). endpoint_id: type: string description: The endpoint the delivery went to. endpoint_url: type: string description: The endpoint's URL. It is kept after the endpoint is deleted. endpoint_description: type: string description: The endpoint's description, or empty. attempt_no: type: integer description: Position in the timeline, starting at 1. It is not the number of attempts. status: type: string enum: [pending, succeeded, failed] description: "`pending` while an attempt is running or due, `succeeded` after a 2xx answer, `failed` once the delivery failed for good." status_code: type: integer description: HTTP status from the receiver's latest answer. 0 when no attempt got an answer. response_body: type: string description: Up to the first 1 KB of the receiver's latest answer. error: type: string description: Why an attempt failed, such as `HTTP 500` or a connection error. Empty when no attempt failed. request_payload_sha256: type: string description: | SHA-256, in hex, of the event's data. Two rows with the same value sent the same data. attempted_at: type: string format: date-time description: When the delivery was queued. completed_at: type: string format: date-time nullable: true description: When the delivery succeeded or failed for good. null while pending. next_retry_at: type: string format: date-time nullable: true description: | While pending, when the next attempt is due. While an attempt is running, it is when a retry takes over if that attempt never finishes. null once the delivery is finished. is_replay: type: boolean description: True when the row belongs to a replay of the event. replay_event_id: type: string description: The replay's event id when `is_replay` is true; otherwise empty. example: id: hc_whd_dbj84bqq3gpgqfoq5c90 event_id: hc_whevt_dbj84biq3gpgqfoq5c8g endpoint_id: hc_whe_danslg2q3gpgqfoq5c80 endpoint_url: https://example.com/hardcount/webhooks endpoint_description: Billing sync attempt_no: 1 status: succeeded status_code: 200 response_body: '{"received":true}' error: "" request_payload_sha256: 2729692ed25262f63668409fe4ef3b8a010360397231ebf671f77bac8956322e attempted_at: "2026-11-01T00:05:00Z" completed_at: "2026-11-01T00:05:01Z" next_retry_at: null is_replay: false replay_event_id: "" WebhookReplayResult: type: object description: What `POST /v1/webhook_events/{id}/replay` returns. required: [event_id, replay_of, status] properties: event_id: type: string description: The new event created by the replay. replay_of: type: string description: The original event. status: type: string enum: [queued, no_matching_endpoints] description: | `queued` when at least one endpoint will get the replay. `no_matching_endpoints` when no active endpoint in this mode subscribes to the event's type; the replay event is still created. example: event_id: hc_whevt_dbj84biq3gpgqfoq5c9r replay_of: hc_whevt_dbj84biq3gpgqfoq5c8g status: queued WebhookDeliveryReplayResult: type: object description: What `POST /v1/webhook-endpoints/endpoints/{id}/deliveries/{deliveryID}/replay` returns. required: [event_id, replay_of, delivery_id, status] properties: event_id: type: string description: The new event created by the replay. replay_of: type: string description: The original event. delivery_id: type: string description: The new delivery to this endpoint. status: type: string enum: [queued] description: Always `queued`. The send happens in the background. example: event_id: hc_whevt_dbj84biq3gpgqfoq5c9r replay_of: hc_whevt_dbj84biq3gpgqfoq5c8g delivery_id: hc_whd_dbj84bqq3gpgqfoq5c9k status: queued WebhookStreamFrame: type: object description: The JSON in each `data:` line of `/v1/webhook_events/stream`. required: [event_id, event_type, customer_id, status, last_attempt_at, created_at, livemode, replay_of_event_id] properties: event_id: type: string description: The event's id. For a replay, the replay's own id. event_type: type: string description: The event's type, such as `invoice.finalized`. customer_id: type: string description: | The customer the event is about: the `customer_id` of the resource in `data` (its `id` for a `customer.*` event), or empty. For an event queued before `data` held the resource, its `data.customer_id`. status: type: string description: | Delivery state. Frames sent after the first batch send `pending` (a new event or replay), `succeeded` (one delivery got a 2xx answer) or `failed` (one delivery failed for good). Frames in the first batch send the event's roll-up over all its deliveries: `pending`, `failed`, `delivered` (every delivery succeeded) or `no_endpoints`. last_attempt_at: type: string format: date-time nullable: true description: When the delivery result this frame reports happened. null in the first batch and in a new event's first frame. created_at: type: string format: date-time description: When the event was created. livemode: type: boolean description: The event's mode. Frames sent after the first batch are not filtered by mode, so check it. replay_of_event_id: type: string nullable: true description: The original event, when this frame is about a replay. null otherwise. example: event_id: hc_whevt_dbj84biq3gpgqfoq5c8g event_type: credit.balance_depleted customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g status: succeeded last_attempt_at: "2026-11-01T00:05:01Z" created_at: "2026-11-01T00:05:00Z" livemode: false replay_of_event_id: null HostedInvoice: type: object description: | What the hosted invoice page shows, from `GET /v1/public/invoices/{token}`. It leaves out internal ids and Stripe details. `bill_to` and the company details in `branding` come from the same place as the invoice PDF's: an issued invoice shows the details it was issued with. Amounts are whole numbers in the currency's minor unit (cents for USD). required: [invoice, line_items, bill_to, branding, credit_notes, pay_enabled, livemode] properties: invoice: type: object description: The invoice's number, status, totals and dates. required: [invoice_number, status, payment_status, currency, subtotal_cents, discount_cents, tax_amount_cents, tax_rate, total_amount_cents, amount_due_cents, amount_paid_cents, credits_applied_cents] properties: invoice_number: type: string description: The number printed on the invoice, for example `HC-000042`. status: $ref: "#/components/schemas/InvoiceStatus" payment_status: $ref: "#/components/schemas/InvoicePaymentStatus" currency: type: string description: The invoice's currency, as an uppercase ISO 4217 code. subtotal_cents: type: integer format: int64 description: The sum of the line amounts before discount and tax, in the currency's minor unit (cents for USD). discount_cents: type: integer format: int64 description: Discount taken off the subtotal, in the currency's minor unit (cents for USD). tax_amount_cents: type: integer format: int64 description: Tax on the invoice, in the currency's minor unit (cents for USD). tax_rate: type: number description: Tax rate in percent, for example `18` for 18%. tax_name: type: string description: The tax's label, such as `VAT`. Absent when there is none. tax_reverse_charge: type: boolean description: True when reverse charge applies and the customer accounts for the tax. Absent when false. tax_exempt_reason: type: string description: Why the customer is exempt from tax. Absent when they are not. total_amount_cents: type: integer format: int64 description: The invoice total with tax, in the currency's minor unit (cents for USD). amount_due_cents: type: integer format: int64 description: What is still owed, in the currency's minor unit (cents for USD). 0 once paid. amount_paid_cents: type: integer format: int64 description: What was paid, in the currency's minor unit (cents for USD). credits_applied_cents: type: integer format: int64 description: Customer credit used to pay the invoice, in the currency's minor unit (cents for USD). issued_at: type: string format: date-time description: When the invoice was issued. due_at: type: string format: date-time description: When payment is due. paid_at: type: string format: date-time description: When the invoice was paid. Absent until it is paid. voided_at: type: string format: date-time description: When the invoice was voided. Absent unless it is voided. billing_timezone: type: string description: The time zone to show the dates in, so they match the PDF. example: Asia/Kolkata memo: type: string description: The invoice's note. Absent when empty. footer: type: string description: The footer this invoice was issued with. Absent when there is none. line_items: type: array description: The invoice's lines. items: type: object required: [description, quantity, unit_amount_decimal, amount_cents, total_amount_cents, currency] properties: description: type: string description: The line's text. quantity: type: string description: The exact quantity as a decimal string, for example `3` or `1.5`. unit_amount_decimal: type: string description: The unit price, a decimal string of cents at full precision, for example `0.3` for $0.003. meter_unit: type: string description: On a usage line, the meter's unit, such as `tokens`. Absent on other lines. amount_cents: type: integer format: int64 description: The line's amount before tax, in the currency's minor unit (cents for USD). tax_amount_cents: type: integer format: int64 description: Tax on the line, in the currency's minor unit (cents for USD). Absent when 0. total_amount_cents: type: integer format: int64 description: The line's amount plus its tax, in the currency's minor unit (cents for USD). currency: type: string description: The line's currency. bill_to: type: object description: The buyer as the PDF prints it. Each field is absent when empty. properties: name: type: string description: The billing profile's legal name, or the customer's display name when it has none. email: type: string description: The customer's email address. address_line1: type: string description: First address line. address_line2: type: string description: Second address line. city: type: string description: City. state: type: string description: State or region. postal_code: type: string description: Postal code. country: type: string description: Country, as a two-letter code. branding: type: object description: The seller's company details and branding. Each field is absent when empty. properties: company_name: type: string description: The seller's company name. company_email: type: string description: The seller's contact email. company_phone: type: string description: The seller's phone number. company_address_line1: type: string description: First line of the seller's address. company_address_line2: type: string description: Second line of the seller's address. company_city: type: string description: City of the seller's address. company_state: type: string description: State or region of the seller's address. company_postal_code: type: string description: Postal code of the seller's address. company_country: type: string description: Country of the seller's address. logo_url: type: string description: The seller's logo, from the current account settings. brand_color: type: string description: The seller's brand color, as the account settings store it (for example `#4F46E5`). support_url: type: string description: Where the customer can get help, from the current account settings. credit_notes: type: array description: | The invoice's issued credit notes, as its PDF prints them, so the total minus credit notes minus what was paid is the amount due. One issued before payment lowered what is owed (`before_payment`). One issued after payment is listed once a part of it has settled, with what came back: a card refund once Stripe confirmed it, credit added to the customer's balance, or an amount settled another way. Empty when there are none. items: type: object required: [number, amount_cents, before_payment] properties: number: type: string description: The credit note's number, for example `CN-000003`. amount_cents: type: integer format: int64 description: The credit note's total, in the currency's minor unit (cents for USD). before_payment: type: boolean description: True when the note was issued before payment and lowered what is owed. refunded_cents: type: integer format: int64 description: For a note after payment, the card refund once Stripe confirmed it. Absent when 0. credited_cents: type: integer format: int64 description: For a note after payment, the credit added to the customer's balance. Absent when 0. settled_outside_cents: type: integer format: int64 description: For a note after payment, the amount paid back another way. Absent when 0. pay_enabled: type: boolean description: | True when the customer can pay online now: the invoice is finalized, something is owed, no payment is in progress, the seller has Stripe connected for the invoice's mode, and Stripe's latest answer for the amount was not that it is below its minimum charge. pay_unavailable_reason: type: string enum: [online_payment_unavailable, amount_below_minimum] description: | Sent only when money is owed but can't be paid online: `online_payment_unavailable` when the seller has no payment provider connected for this mode, `amount_below_minimum` when Stripe's latest answer refused the amount as below its minimum charge. livemode: type: boolean description: False for a test-mode invoice. The page shows a test-mode banner. example: invoice: invoice_number: HC-000042 status: finalized payment_status: pending currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 total_amount_cents: 8650 amount_due_cents: 8650 amount_paid_cents: 0 credits_applied_cents: 0 issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" billing_timezone: UTC line_items: - description: Pro - base fee (qty 1) quantity: "1" unit_amount_decimal: "4900" amount_cents: 4900 total_amount_cents: 4900 currency: USD - description: Tokens (tokens) quantity: "12500000" unit_amount_decimal: "0.0003" meter_unit: tokens amount_cents: 3750 total_amount_cents: 3750 currency: USD bill_to: name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US branding: company_name: Example Labs Inc company_email: billing@examplelabs.example company_address_line1: 1 Congress Avenue company_city: Austin company_state: TX company_postal_code: "78701" company_country: US logo_url: https://examplelabs.example/logo.png brand_color: "#4F46E5" support_url: https://examplelabs.example/support credit_notes: [] pay_enabled: true livemode: false PublicPaymentUpdate: type: object description: | What the payment-update page shows before the customer adds a card: `GET /v1/public/payment-updates/{token}`. The page opens from the link in a payment setup email. Hardcount queues one when an invoice has no payment method to charge, the customer has an email address and the server's `PAYMENT_UPDATE_URL` is set, and again on each `POST /v1/invoices/{id}/resend-setup-link`. required: [customer_name, invoice_number, invoice_status, amount_due_cents, currency, branding] properties: customer_name: type: string description: The customer's display name. invoice_number: type: string description: The number of the invoice the link was sent for. invoice_status: $ref: "#/components/schemas/InvoiceStatus" amount_due_cents: type: integer format: int64 description: | What is due on the invoice, in the currency's minor unit (cents for USD). Not cleared when the invoice is voided: check `invoice_status` before asking for payment. currency: type: string description: The invoice's currency, such as `USD`. branding: type: object description: | The seller's branding from its account settings. A field the seller has not set is absent. properties: company_name: type: string description: The seller's company name. company_email: type: string description: The seller's contact email. logo_url: type: string description: The address of the seller's logo. brand_color: type: string description: The seller's brand color, such as `#4F46E5`. support_url: type: string description: Where the customer can get help. example: customer_name: Acme Inc invoice_number: HC-000001 invoice_status: finalized amount_due_cents: 3650 currency: USD branding: company_name: Example Labs company_email: billing@examplelabs.example logo_url: https://examplelabs.example/logo.png brand_color: "#4F46E5" support_url: https://examplelabs.example/support ReadinessReport: type: object description: The body of `GET /health/ready`, on both 200 and 503. required: [status, checks] properties: status: type: string enum: [ok, degraded, draining] description: | `ok` when every check passed. `degraded` when the database cannot be reached (503) or the billing scheduler is late (still 200). `draining` while the server shuts down (503). checks: type: object description: | One entry per check. `api` is `ok` or `draining`. `database` is `ok` or `error`. `scheduler` starts with `ok` or `degraded`, followed by detail such as `ok: last run 12s ago`. While draining, only `api` is sent. additionalProperties: type: string example: status: ok checks: api: ok database: ok scheduler: "ok: last run 12s ago" CreditGrantBlock: type: object description: | One grant on a customer's credit balance (made by a grant or a positive adjustment) and how much of it is left. Amounts are in the currency's minor unit (cents for USD). required: [id, entry_type, currency, description, amount_cents, consumed_cents, remaining_cents, expired_cents, retired_cents, deducted_cents, created_at] properties: id: type: string description: The ledger entry that added the credit. entry_type: type: string description: "`grant` or `adjustment`." currency: type: string description: The block's currency, uppercase. grant_kind: type: string enum: [commit, promotional] description: Absent for unclassified credit. description: type: string description: The description of the ledger entry that added the credit. amount_cents: type: integer format: int64 description: The credit the grant started with, in the currency's minor unit. consumed_cents: type: integer format: int64 description: | Everything drawn from the grant, in the currency's minor unit: used by invoices, expired, retired and deducted. remaining_cents: type: integer format: int64 description: Credit left in the grant, `amount_cents` minus `consumed_cents`, in the currency's minor unit. expired_cents: type: integer format: int64 description: The part that expired unused, in the currency's minor unit. retired_cents: type: integer format: int64 description: | Commit credit taken back when the invoice that funded it was voided, or refunded by a credit note, in the currency's minor unit. deducted_cents: type: integer format: int64 description: The part a dashboard user or an API key removed with a negative adjustment, in the currency's minor unit. expires_at: type: string format: date-time description: | The credit can be drawn up to and including this instant; a period invoice draws it when its period ends at or before it. The dashboard sends the local midnight after the picked day. Absent when the credit does not expire. source_invoice_id: type: string description: For a `commit` grant, the commit invoice whose finalizing made the grant. Absent for other grants. created_at: type: string format: date-time description: When the grant was added. example: id: hc_ccl_dav2242q3gpgqfoq5c50 entry_type: grant currency: USD grant_kind: promotional description: Welcome credit amount_cents: 5000 consumed_cents: 0 remaining_cents: 5000 expired_cents: 0 retired_cents: 0 deducted_cents: 0 expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" CreditGrants: type: object description: | A customer's credit grants, newest first, with the remaining credit added up by currency and kind. Amounts are in the currency's minor unit (cents for USD). required: [grants, remaining_by_currency] properties: grants: type: array description: | The customer's credit grants, newest first. A grant with nothing left is listed only with `include_exhausted=true`. items: $ref: "#/components/schemas/CreditGrantBlock" remaining_by_currency: type: array description: One entry per currency with credit left, ordered by currency. items: type: object required: [currency, commit_remaining_cents, promotional_remaining_cents, other_remaining_cents] properties: currency: type: string description: The currency, uppercase. commit_remaining_cents: type: integer format: int64 description: Remaining prepaid commit credit (`commit`), granted by finalized commit invoices, in the currency's minor unit. promotional_remaining_cents: type: integer format: int64 description: Remaining free credit (`promotional`), in the currency's minor unit. other_remaining_cents: type: integer format: int64 description: Remaining credit with no kind, such as adjustments and credit-note credit, in the currency's minor unit. example: grants: - id: hc_ccl_dav2242q3gpgqfoq5c50 entry_type: grant currency: USD grant_kind: promotional description: Welcome credit amount_cents: 5000 consumed_cents: 0 remaining_cents: 5000 expired_cents: 0 retired_cents: 0 deducted_cents: 0 expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" remaining_by_currency: - currency: USD commit_remaining_cents: 0 promotional_remaining_cents: 5000 other_remaining_cents: 0 WebhookEndpointCreated: type: object description: A new webhook endpoint and its signing secret. The secret is shown only here. required: [endpoint, secret] properties: endpoint: $ref: "#/components/schemas/WebhookEndpoint" secret: type: string description: The signing secret (`whsec_…`). Store it now; Hardcount does not show it again. example: endpoint: id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync secret_last4: "4e9f" events: ["invoice.*", "payment.failed", "credit.balance_depleted"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-10-02T10:00:00Z" secret: whsec_9b4e2c7a1f5d8e3b6c0a4f2d7e1b9c5a3f8d6e2b1a7c4e9f WebhookSecretRotation: type: object description: The endpoint's new signing secret. It is shown only here. required: [secret] properties: secret: type: string description: The new signing secret (`whsec_…`). Store it now; Hardcount does not show it again. secondary_valid_until: type: string format: date-time description: | Until this time, 72 hours after the rotation, each delivery is signed with the old secret too, so your receiver can switch without rejecting events. example: secret: whsec_1f8a3c6e9b2d5f7a0c4e8b1d3f6a9c2e5b7d0f4a8c1e3b6d secondary_valid_until: "2026-11-04T10:00:00Z" WebhookEvent: type: object description: | One outbound webhook event, as Hardcount created it. `GET /v1/webhook-endpoints/events` lists them. Each delivery POSTs `{"id", "event_type", "created_at", "data"}` to the endpoint, where `data` is this event's `payload`. required: [id, livemode, event_type, payload, created_at] properties: id: type: string description: The event's id (`hc_whevt_…`). A replay has its own id but is sent with the original event's `id` and `created_at`. tenant_id: type: string description: The account the event belongs to. livemode: type: boolean description: The mode the event happened in. Only endpoints of the same mode get it. event_type: type: string description: "What happened, such as `invoice.finalized`. [Webhook events](https://gethardcount.com/docs/api/webhook-events/) lists every type." example: invoice.finalized payload: type: object additionalProperties: true description: | The event's data: the resource the event is about, under the resource's name (`invoice`, `subscription`, `customer`, `payment_method`, `dunning_run` or `credit_balance`), as the API returns it, and the few keys the event adds beside it. [Webhook events](https://gethardcount.com/docs/api/webhook-events/) lists what each event carries; `payment.dispute_opened` and `payment.dispute_closed` carry a `DisputeWebhookEventData`; `payment.refunded_in_stripe` carries a `StripeRefundWebhookEventData`. created_at: type: string format: date-time description: When the event was created. replay_of_event_id: type: string description: | Set on a replay. The original event it copies; a replay of a replay points to that same original. Absent on an original event. example: id: hc_whevt_dbj84biq3gpgqfoq5c8g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false event_type: credit.balance_depleted payload: credit_balance: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD balance_cents: 0 total_granted: 5000 total_used: 5000 total_expired: 0 total_retired: 0 total_deducted: 0 created_at: "2026-11-01T00:05:00Z" TestClock: type: object description: | A clock for test mode. Customers and subscriptions attached to it are billed on its time, not the real time. Returned by the `/v1/test-clocks` routes. required: [id, name, frozen_time, status, created_at, updated_at] properties: id: type: string description: The clock's ID (`hc_tclk_…`). tenant_id: type: string description: The account the clock belongs to. name: type: string description: Your name for the clock. Empty when you gave none. frozen_time: type: string format: date-time description: The clock's current time. status: type: string enum: [ready, advancing, internal_failure] description: | `ready`: it can be advanced. `advancing`: billing is catching up to the new time. `internal_failure`: the last advance failed partway; resume it with `POST /v1/test-clocks/{id}/retry-advance`. created_at: type: string format: date-time description: When the clock was created, in real time. updated_at: type: string format: date-time description: When the clock last changed, in real time. last_failure_reason: type: string description: | Why the last advance failed, for a clock in `internal_failure`. Cleared when you retry the advance, and by the next advance that succeeds. Absent when there is none. last_advance_summary: $ref: "#/components/schemas/TestClockAdvanceSummary" example: id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-11-02T00:00:00Z" status: ready created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:04:12Z" last_advance_summary: advanced_from: "2026-10-01T00:00:00Z" advanced_to: "2026-11-02T00:00:00Z" invoices_generated: 1 trials_activated: 0 pauses_resumed: 0 thresholds_fired: 0 tax_retried: 0 charges_retried: 1 clawbacks_issued: 0 credits_expired: 0 dunning_advanced: 0 had_errors: false TestClockAdvanceSummary: type: object description: | What the latest advance did: the time span it covered and how many of each billing step it ran. Absent until the clock is first advanced. required: [advanced_from, advanced_to, invoices_generated, trials_activated, pauses_resumed, thresholds_fired, tax_retried, charges_retried, clawbacks_issued, credits_expired, dunning_advanced, had_errors] properties: advanced_from: type: string format: date-time description: | The clock's time before the advance. After a retried advance it equals `advanced_to`. When the server restarted during the advance and resumed it, it is `0001-01-01T00:00:00Z`. advanced_to: type: string format: date-time description: The clock's time after the advance. invoices_generated: type: integer description: Period invoices the advance created. trials_activated: type: integer description: | Subscriptions whose trial ended by the clock's new time: those that became `active`, and those a cancel scheduled for the trial end canceled with no charge. pauses_resumed: type: integer description: Collection pauses whose `resumes_at` passed in the span and that were lifted. thresholds_fired: type: integer description: Billing-threshold invoices issued. tax_retried: type: integer description: Invoices waiting for tax whose tax calculation was tried again. charges_retried: type: integer description: Invoices waiting to be charged that the advance tried to collect. clawbacks_issued: type: integer description: | Credit notes for unused time that were waiting for their invoice to settle, issued now. credits_expired: type: integer description: Credit grants that expired in the span. dunning_advanced: type: integer description: Dunning runs that took a step. had_errors: type: boolean description: | True when a step failed, which leaves the clock in `internal_failure`. The steps after a failed one still ran, so each count is what its step did. example: advanced_from: "2026-10-01T00:00:00Z" advanced_to: "2026-11-02T00:00:00Z" invoices_generated: 1 trials_activated: 0 pauses_resumed: 0 thresholds_fired: 0 tax_retried: 0 charges_retried: 1 clawbacks_issued: 0 credits_expired: 0 dunning_advanced: 0 had_errors: false InvoicePreview: type: object description: | A dry-run invoice built from the usage recorded so far, from `POST /v1/invoices/create_preview`. Nothing is saved. Amounts are whole numbers in the currency's minor unit (cents for USD), and tax is not included. required: [customer_id, subscription_id, plan_name, billing_period_start, billing_period_end, lines, totals, warnings, generated_at] properties: customer_id: type: string description: The customer the preview is for. subscription_id: type: string description: The subscription the preview bills. plan_name: type: string description: The names of the subscription's plans, comma-separated. billing_period_start: type: string format: date-time description: | Start of the period the invoice would bill (included): the previewed window, or, when a plan on the subscription bills its fee in advance, the period after it, as on the real invoice. billing_period_end: type: string format: date-time description: | End of the period the invoice would bill (not included). Shifts with `billing_period_start` when a fee is billed in advance. lines: type: array description: The lines the invoice would have. An empty list when nothing would be billed. items: $ref: "#/components/schemas/InvoicePreviewLine" totals: type: array description: One total per currency on the lines. items: $ref: "#/components/schemas/InvoicePreviewTotal" warnings: type: array description: Ways the real invoice can differ from this preview, in plain words. An empty list when there are none. items: type: string generated_at: type: string format: date-time description: When the preview was built. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_name: Pro billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" lines: - line_type: base_fee description: Pro - base fee (qty 1) currency: USD quantity: "1" unit_amount_cents: 4900 amount_cents: 4900 - line_type: usage description: Tokens (tokens) meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens price_id: hc_prc_dajr672q3gpgqfoq5c20 currency: USD quantity: "12500000" unit_amount_cents: 0 amount_cents: 3750 pricing_mode: per_unit totals: - currency: USD amount_cents: 8650 warnings: [] generated_at: "2026-10-31T18:00:00Z" InvoicePreviewLine: type: object description: One line of an invoice preview. required: [line_type, description, currency, quantity, unit_amount_cents, amount_cents] properties: line_type: type: string enum: [base_fee, usage] description: "`base_fee`: a plan's flat fee. `usage`: metered usage priced by a plan price." description: type: string description: The text the invoice line would carry. meter_id: type: string description: On a usage line, the meter that measured the usage. Absent on base fee lines. meter_unit: type: string description: On a usage line, the meter's unit, such as `tokens`. Absent on base fee lines. price_id: type: string description: On a usage line, the plan price that priced it. dimension_match: type: object additionalProperties: type: string description: On a usage line, the labels its price takes. Absent for a price that takes every event. currency: type: string description: The line's currency, the plan's. quantity: type: string description: Exact quantity as a decimal string. unit_amount_cents: type: integer format: int64 description: | Unit price in whole cents of the currency's minor unit. On a base fee line it is the plan's base fee per unit; on a usage line it is `amount_cents` ÷ `quantity` rounded to a whole cent, so a sub-cent rate shows 0. amount_cents: type: integer format: int64 description: The line's amount, in the currency's minor unit (cents for USD). pricing_mode: type: string enum: [per_unit, graduated, package] description: On a usage line, the shape of its price. Absent on base fee lines. example: line_type: usage description: Tokens (tokens) meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens price_id: hc_prc_dajr672q3gpgqfoq5c20 currency: USD quantity: "12500000" unit_amount_cents: 0 amount_cents: 3750 pricing_mode: per_unit InvoicePreviewTotal: type: object description: The total of an invoice preview's lines in one currency. required: [currency, amount_cents] properties: currency: type: string description: The currency, uppercase. amount_cents: type: integer format: int64 description: The sum of the lines in this currency, in its minor unit (cents for USD), before tax. example: currency: USD amount_cents: 8650 paths: /health: get: tags: [System] summary: Check that the server is up operationId: getHealth security: [] description: | Answers 200 whenever the server process can answer. It checks nothing else. For a load balancer, use `GET /health/ready`, which also checks the database. Not rate limited. responses: "200": description: The server is up. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [ok] description: Always `ok`. example: status: ok /health/ready: get: tags: [System] summary: Check that the server can take requests operationId: getHealthReady security: [] description: | Tells a load balancer whether this server can take requests. It returns 503 when the server is shutting down (`draining`) or cannot reach the database. A late billing scheduler shows as `degraded` in the body but keeps the status at 200: taking the server out of the balancer would not restart the scheduler. Alert on the scheduler metrics instead (see [Monitoring](https://gethardcount.com/docs/self-host/monitoring/)). The database check waits at most 3 seconds. Not rate limited. responses: "200": description: Ready. `status` is `ok`, or `degraded` when only the scheduler is late. content: application/json: schema: $ref: "#/components/schemas/ReadinessReport" example: status: ok checks: api: ok database: ok scheduler: "ok: last run 12s ago" "503": description: Not ready. `status` is `draining`, or `degraded` when the database cannot be reached. content: application/json: schema: $ref: "#/components/schemas/ReadinessReport" example: status: degraded checks: api: ok database: error scheduler: "ok: last run 12s ago" /metrics: get: tags: [System] summary: Get Prometheus metrics operationId: getMetrics security: - MetricsToken: [] - {} description: | Metrics in the Prometheus text format, for a Prometheus scrape. When `METRICS_TOKEN` is set, send it as a Bearer token. When it is not set, the route is open, except with `APP_ENV=production`, where it answers 401. This is not an API key route. Not rate limited. responses: "200": description: Metrics in the Prometheus text format. content: text/plain: schema: type: string "401": description: | The token is missing or wrong, or `METRICS_TOKEN` is not set in production. The answer carries `WWW-Authenticate: Bearer`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: metrics need the METRICS_TOKEN bearer token request_id: req_dbjg652q3gpgqfoq5cd0 /v1/customers: post: tags: [Customers] summary: Create a customer operationId: createCustomer description: | Creates a customer in the mode of your API key. `external_id` must be unique in the account and mode. To run the customer on a test clock (test mode only), send `test_clock_id`; it cannot change later. The create is written to the audit log. `dunning_policy_id` is not a create field: assign a policy with `PATCH /v1/customers/{id}`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [external_id, display_name] properties: external_id: type: string description: | Your own ID for the customer, such as the account ID in your app. Spaces at the ends are trimmed; then at most 255 bytes (UTF-8). Unique in the account and mode. display_name: type: string description: | The name shown on invoices, emails and the dashboard. Spaces at the ends are trimmed; then at most 255 bytes (UTF-8). email: type: string description: | Where billing emails go. One bare address with a dot in its domain, such as `billing@acme.example`: no display name, quotes or angle brackets. additional_emails: type: array maxItems: 10 items: type: string description: | Addresses to copy on billing emails. Each is checked like `email`, lowercased and deduplicated. None may equal `email`. More than 10 after deduplication is refused with 422. test_clock_id: type: string description: | Test mode only. Runs the customer on this test clock, which must exist in the account and mode. Fixed after create. example: external_id: acme-42 display_name: Acme Inc email: billing@acme.example additional_emails: - finance@acme.example responses: "201": description: | The new customer. The create answer leaves out `email_status`, `dunning_policy_id` and `stripe_customer_id`. content: application/json: schema: $ref: "#/components/schemas/Customer" example: id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example additional_emails: - finance@acme.example status: active created_at: "2026-09-30T16:20:00Z" updated_at: "2026-09-30T16:20:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "409": description: | A customer with this `external_id` already exists in this account and mode (`already_exists`, `param: external_id`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: customer with external_id "acme-42" already exists request_id: req_dbjg652q3gpgqfoq5cd0 param: external_id "422": description: | A field failed validation (`validation_error`, `param` names it): a missing or too long `external_id` or `display_name`, an invalid `email` or `additional_emails` entry, an `additional_emails` entry equal to `email`, more than 10 `additional_emails`, or a `test_clock_id` with no clock in this account and mode. An unknown field is refused the same way. `dunning_policy_id` is not a create field: assign a policy with `PATCH /v1/customers/{id}`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "invalid email: domain must contain a dot" request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" get: tags: [Customers] summary: List customers operationId: listCustomers description: | Customers in the mode of your API key, newest first unless you choose a sort. The list answer leaves out `additional_emails`, `email_status`, the bounce fields, `dunning_policy_id` and `stripe_customer_id`: get one customer for those. parameters: - name: status in: query description: | Only customers with this status, `active` or `archived`. Any other value matches no customer. schema: type: string - name: external_id in: query description: Only the customer with this `external_id` (exact match). schema: type: string - name: ids in: query description: | Comma-separated customer IDs. Only those customers are returned; IDs not found are skipped. schema: type: string - name: search in: query description: | Case-insensitive substring match on display name, email, external ID or ID. It looks at the first 5,000 customers, in the list's sort order, that match the other filters; the rows and `total` stop there. schema: type: string - name: sort in: query description: | The order of the list. `created_at` (the default), `display_name` (or `name`), `email`, `external_id` or `status`. Any other value is a 422 with `param: sort`. On a server that encrypts customer data, a sort by `display_name`, `name` or `email` looks at the 5,000 newest customers at most; the rows and `total` stop there. schema: type: string enum: [created_at, display_name, name, email, external_id, status] default: created_at - name: dir in: query description: | Sort direction: `desc` (the default) or `asc`, in lower case. Any other value is a 422 with `param: dir`. schema: type: string enum: [asc, desc] default: desc - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of customers, and how many match. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Customer" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example status: active created_at: "2026-09-30T16:20:00Z" updated_at: "2026-09-30T16:20:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `limit` or `offset` is out of range or not a whole number, the request sends the removed `after` cursor, `sort` or `dir` is not one of its values. `param` names it, and for `sort` and `dir` the message lists the allowed values. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'sort "nope" is not one of created_at, display_name, name, email, external_id, status' request_id: req_dbjg652q3gpgqfoq5cd0 param: sort "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}/rotate-cost-dashboard-token: post: tags: [Customers] summary: Rotate the customer's public cost-dashboard link operationId: rotateCustomerCostDashboardToken description: | Makes a new public cost-dashboard token for the customer and returns it with the page's address. The token is the only credential for `GET /v1/public/cost-dashboard/{token}`, so anyone with the link can read the customer's usage and cost. The previous token stops working at once. Hardcount keeps only a hash of the token, so this answer is the only time you see it: copy it now. The rotation is written to the audit log, without the token. Send no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string responses: "200": description: The new token and the public page address. content: application/json: schema: type: object required: [token, public_url] properties: token: type: string description: | The new token, `hc_pcd_` and 64 hex characters. Shown only in this answer. example: hc_pcd_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a public_url: type: string description: | `/v1/public/cost-dashboard/` and the token, after the server's `HARDCOUNT_API_BASE_URL` when that is set. Without it, the path alone. example: token: hc_pcd_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a public_url: https://api.example.com/v1/public/cost-dashboard/hc_pcd_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "customer_id" request_id: req_dbjg652q3gpgqfoq5cd0 param: customer_id "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}: get: tags: [Customers] summary: Get a customer operationId: getCustomer description: One customer, with every field it has. parameters: - name: id in: path required: true schema: type: string responses: "200": description: The customer. content: application/json: schema: $ref: "#/components/schemas/Customer" example: id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example additional_emails: - finance@acme.example status: active email_status: unknown dunning_policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g stripe_customer_id: cus_RkT9qW2xYz3AbC created_at: "2026-09-30T16:20:00Z" updated_at: "2026-10-15T09:12:44Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" patch: tags: [Customers] summary: Update a customer description: | Changes only the fields you send. An empty `display_name` or `email` is ignored, so you cannot clear the email here. - `status: archived` is refused with 409 `invalid_state` while the customer has an active or trialing subscription, which still bills. Cancel those subscriptions first. Archiving does not stop dunning (the retries of failed payments) on unpaid invoices. - `dunning_policy_id` set to `""` removes the assignment, so the account's default policy applies. A set ID must be a policy in this account and mode, or the call returns 404. The change applies to the next dunning run, not to runs already started. - `additional_emails` replaces the whole CC list; `[]` clears it. Entries are lowercased and deduplicated, and may not equal the primary email. More than 10 after deduplication is refused with 422. - A new `email` that is already on the CC list is refused with 422 unless you send a new `additional_emails` too. Changing the email sets `email_status` back to `unknown`, after the answer is built: the answer still shows the earlier `email_status`. When the customer has a linked Stripe customer, every update then sends Stripe the customer's email and the billing profile's fields: legal name (as the Stripe name), phone, address, tax status and tax ID. `display_name` is not sent to Stripe. A Stripe failure is logged and does not fail the call. The change is written to the audit log (it records that the email changed, not the address). operationId: updateCustomer parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: display_name: type: string description: | The new name. Spaces at the ends are trimmed. Empty or left out keeps the current name. Unlike create, the update does not check its length. email: type: string description: | The new primary email, checked as on create. Empty or left out keeps the current email. status: type: string enum: [active, archived] description: | `archived` blocks new subscriptions for the customer. `active` lifts that. dunning_policy_id: type: string description: Policy ID, or `""` to use the account's default policy. additional_emails: type: array maxItems: 10 items: type: string description: | The new CC list, which replaces the whole list. `[]` clears it. Left out keeps the current list. More than 10 after deduplication is refused with 422. example: dunning_policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g additional_emails: - finance@acme.example responses: "200": description: The updated customer. content: application/json: schema: $ref: "#/components/schemas/Customer" example: id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example additional_emails: - finance@acme.example status: active email_status: unknown dunning_policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g stripe_customer_id: cus_RkT9qW2xYz3AbC created_at: "2026-09-30T16:20:00Z" updated_at: "2026-10-15T09:12:44Z" "400": description: The body is not valid JSON, or a field has the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | No customer with this ID in this account and mode (`customer not found`), or no dunning policy with the `dunning_policy_id` you sent (`dunning policy not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning policy not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Archive refused because an active or trialing subscription still bills (`invalid_state`). The message names the subscriptions. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: This customer has 1 subscription that still bills (Acme Pro). Cancel it first; archiving doesn't stop billing. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A field failed validation (`validation_error`, `param` names it): an invalid `email` or CC entry, a CC entry equal to the email, more than 10 CC entries, a new email that is on the CC list, or a `status` other than `active` or `archived`. An unknown field, such as `external_id`, is refused the same way. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: this address is on the additional-emails list — remove it there first request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}/billing-profile: put: tags: [Customers] summary: Create or replace the customer's billing profile description: | Writes the whole profile. Fields you leave out are stored empty, so send every field you want to keep. `profile_status` is worked out by the server: `ready` when legal name, address line 1, city and country are all set, otherwise `incomplete`. Checks, each answered with 422 and `param` set to the field: - `country` must be two letters (only the format is checked). - `currency` must be a supported ISO 4217 code. Zero-decimal currencies such as JPY are refused. - `phone` is 7 to 20 digits, spaces or `+-()`. - `tax_id` must match `tax_id_type` when the type is `eu_vat`, `in_gst` or `au_abn`. - `tax_status: exempt` needs `tax_exempt_reason`, and `tax_status: reverse_charge` needs `tax_id`. Country, currency and tax ID are stored in upper case. A currency that differs from the plan currency of a draft, trialing or active subscription is refused with 409 `invalid_state`, because invoices would show plan prices in the new currency without conversion. After saving, the profile is copied to the linked Stripe customer (best effort), and draft invoices whose tax failed on bad customer data are retried. The change is written to the audit log. operationId: upsertCustomerBillingProfile parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CustomerBillingProfileInput" example: legal_name: Acme Incorporated phone: +1 415 555 0100 address_line1: 548 Market St address_line2: Suite 100 city: San Francisco state: CA postal_code: "94104" country: US tax_status: standard responses: "200": description: The stored profile. content: application/json: schema: $ref: "#/components/schemas/CustomerBillingProfile" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 legal_name: Acme Incorporated phone: +1 415 555 0100 address_line1: 548 Market St address_line2: Suite 100 city: San Francisco state: CA postal_code: "94104" country: US tax_status: standard tax_id: "" tax_id_type: "" profile_status: ready created_at: "2026-09-30T16:25:00Z" updated_at: "2026-10-15T09:12:44Z" "400": description: The body is not valid JSON, or a field has the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The currency differs from the plan currency of one of the customer's draft, trialing or active subscriptions (`invalid_state`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: billing profile currency EUR conflicts with subscription hc_sub_dauq502q3gpgqfoq5c2g's plan currency USD — invoices would relabel plan prices in EUR without conversion; cancel the subscription or keep the currency request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A field failed validation, or the body has a key the profile does not have, such as `tax_exempt` (set `tax_status` instead), or one the server sets, such as `customer_id` or `created_at`. `param` names the field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be an ISO-3166 alpha-2 country code (e.g. 'IN', 'US') request_id: req_dbjg652q3gpgqfoq5cd0 param: country "429": $ref: "#/components/responses/RateLimited" get: tags: [Customers] summary: Get the customer's billing profile operationId: getCustomerBillingProfile parameters: - name: id in: path required: true schema: type: string responses: "200": description: The billing profile. content: application/json: schema: $ref: "#/components/schemas/CustomerBillingProfile" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 legal_name: Acme Incorporated phone: +1 415 555 0100 address_line1: 548 Market St address_line2: Suite 100 city: San Francisco state: CA postal_code: "94104" country: US tax_status: standard tax_id: "" tax_id_type: "" profile_status: ready created_at: "2026-09-30T16:25:00Z" updated_at: "2026-10-15T09:12:44Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | The customer has no billing profile yet, or the customer does not exist. Both return `billing profile not found`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: billing profile not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}/sent-emails: get: tags: [Customers] summary: List emails sent to the customer description: | Billing emails tied to this customer's invoices from the last 30 days, newest first: invoice, payment receipt, payment failed, payment setup request, payment reminder, final notice, and credit note emails. Emails not tied to an invoice (such as a link sent with `POST /v1/customers/{customer_id}/payment-methods/send-setup-email`) are not listed. operationId: listCustomerSentEmails parameters: - name: id in: path required: true schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of the emails, and how many there are in the 30 days. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/SentEmail" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_emob_dbj84cqq3gpgqfoq5cbg email_type: invoice recipient: billing@acme.example status: dispatched delivery_state: delivered invoice_number: HC-000001 created_at: "2026-11-01T00:05:02Z" dispatched_at: "2026-11-01T00:05:09Z" line: id: email:hc_emob_dbj84cqq3gpgqfoq5cbg kind: email.invoice title: Invoice email delivered detail: - - link: kind: invoice id: hc_inv_dbj84b2q3gpgqfoq5c40 label: HC-000001 - - text: To billing@acme.example tone: neutral occurred_at: "2026-11-01T00:05:09Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}/usage: get: tags: [Customers] summary: Get the customer's usage and cost for a period description: | Usage per meter on the customer's active and trialing subscriptions, priced the same way the invoice prices it. Send both `from` and `to`, or neither. With neither, each subscription is rated over its own current billing period, and `period` runs from the earliest start to the latest end of those periods. A trialing subscription's current period is its first paid period, which starts when the trial ends. With both, the window is `[from, to)`, at most one year long, and the amounts are an estimate at each subscription's current prices. Decimal quantities are returned as strings. If the daily series fails to load, `buckets` is empty and `warnings` says so. operationId: getCustomerUsage parameters: - name: id in: path required: true schema: type: string - name: from in: query description: | Start of the window. An RFC 3339 instant, or `YYYY-MM-DD` for the start of that day in UTC. schema: type: string - name: to in: query description: | End of the window, exclusive. An RFC 3339 instant, or `YYYY-MM-DD` for 23:59:59.999999 UTC that day, so the day is included. schema: type: string responses: "200": description: Usage for the period. content: application/json: schema: $ref: "#/components/schemas/CustomerUsage" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g period: from: "2026-10-12T00:00:00Z" to: "2026-10-14T00:00:00Z" source: explicit subscriptions: - id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g plan_name: Pro currency: USD current_period_start: "2026-10-01T00:00:00Z" current_period_end: "2026-11-01T00:00:00Z" meters: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 event_name: tokens meter_name: Tokens unit: tokens currency: USD total_quantity: "2000000" total_amount_cents: 600 prices: - price_id: hc_prc_dajr672q3gpgqfoq5c20 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: "2000000" amount_cents: 600 unit_amount_decimal: "0.0003" totals: - currency: USD amount_cents: 600 warnings: [] buckets: - bucket_start: "2026-10-12T00:00:00Z" per_meter: hc_mtr_dajr122q3gpgqfoq5c10: "800000" - bucket_start: "2026-10-13T00:00:00Z" per_meter: hc_mtr_dajr122q3gpgqfoq5c10: "1200000" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A bad or missing period. `validation_error` with `param` `from` or `to` when a value is not RFC 3339 or `YYYY-MM-DD`, or `to` is before `from`; with `param` `period` when only one of `from` and `to` is sent, when `from` is not before `to`, or when the window is longer than one year. `customer_has_no_subscription` (`param` `period`) when no period is sent and the customer has no active or trialing subscription with a current billing period. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: customer_has_no_subscription message: customer has no active subscription with a current billing cycle; pass ?from= and ?to= to query usage outside a billing cycle request_id: req_dbjg652q3gpgqfoq5cd0 param: period "429": $ref: "#/components/responses/RateLimited" /v1/customers/{customer_id}/overview: get: tags: [Customers] summary: Get a customer overview description: | The customer's active subscriptions (up to 50), the 5 newest invoices, and the amount still owed on unpaid invoices. A failed read inside the overview returns an empty part or a zero balance rather than an error. operationId: getCustomerOverview parameters: - name: customer_id in: path required: true schema: type: string responses: "200": description: Customer overview. content: application/json: schema: $ref: "#/components/schemas/CustomerOverview" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g active_subscriptions: - id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T00:00:00Z" updated_at: "2026-10-01T00:00:00Z" status: active billing_time: calendar started_at: "2026-10-01T00:00:00Z" activated_at: "2026-10-01T00:00:00Z" cancel_at_period_end: false current_billing_period_start: "2026-11-01T00:00:00Z" current_billing_period_end: "2026-12-01T00:00:00Z" next_billing_at: "2026-12-01T00:00:00Z" created_at: "2026-10-01T00:00:00Z" updated_at: "2026-11-01T00:05:00Z" recent_invoices: - id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000001 status: finalized payment_status: pending currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_status: ok total_amount_cents: 8650 amount_due_cents: 3650 amount_paid_cents: 0 credits_applied_cents: 5000 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_timezone: UTC issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_out_of_band: false net_payment_term_days: 30 billing_reason: subscription_cycle is_simulated: false created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:00Z" outstanding_balance: total_cents: 3650 unpaid_count: 1 by_currency: - currency: USD amount_cents: 3650 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/customers/{customer_id}/payment-methods: get: tags: [Customers] summary: List the customer's payment methods description: | Saved cards that are not detached, default first, then newest. Paged with `limit` and `offset`; `total` counts every saved card. operationId: listCustomerPaymentMethods parameters: - name: customer_id in: path required: true schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of payment methods, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/PaymentMethod" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_pm_4f8a2c9e1b7d3a6f0c5e8b21 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g type: card card_brand: visa card_last4: "4242" card_exp_month: 12 card_exp_year: 2029 is_default: true created_at: "2026-10-02T11:48:30Z" - id: hc_pm_4f8a2c9e1b7d3a6f0c5e8b35 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g type: card card_brand: mastercard card_last4: "4444" card_exp_month: 3 card_exp_year: 2028 is_default: false created_at: "2026-09-30T16:40:00Z" total: 2 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/customers/{customer_id}/payment-methods/send-setup-email: post: tags: [Customers] summary: Email the customer a link to add a payment method description: | Creates a Stripe Checkout setup link (as `setup-session` does) and queues an email with it to the customer's primary email. The link is never sent to the CC list. The card details go from the customer's browser to Stripe; Hardcount does not see them. The card is saved when Stripe reports the setup succeeded. At most one call per customer per minute when the server has Redis configured. The minute starts at any call whose body is accepted, including one that then fails, so a second call within it returns 429 `cooldown_active` with a `Retry-After` header. Without Redis there is no cooldown. On a server with no SMTP set up, the call answers 409 `email_not_configured` and creates no link. The send is written to the audit log. operationId: sendCustomerPaymentSetupEmail parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: customer_id in: path required: true schema: type: string requestBody: required: false content: application/json: schema: type: object properties: note: type: string description: | Optional text shown at the top of the email. At most 2000 bytes (UTF-8) after trimming whitespace. example: note: Your card on file expired. Please add a new one so we can bill October usage. responses: "202": description: Email queued. content: application/json: schema: type: object required: [to, subject] properties: to: type: string description: The customer's email address, which the email was queued for. subject: type: string description: The email's subject. example: to: billing@acme.example subject: Add a payment method "400": description: The body is not valid JSON. Nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing was sent. `email_not_configured`: this server has no SMTP set up, and no setup link was created. `stripe_not_connected`: Stripe is not connected for this mode. Like any other call that gets this far, it starts the one-minute cooldown. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: email_not_configured message: Email isn't set up on this server, so nothing was sent. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `note_too_long` (more than 2000 bytes), `missing_email` (the customer has no email), or an unknown field (`validation_error`, `param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: missing_email message: this customer has no email on file — add one before sending a setup link request_id: req_dbjg652q3gpgqfoq5cd0 "429": description: | `cooldown_active`: this endpoint was called for this customer less than a minute ago. `Retry-After` gives the seconds until the minute ends. The general rate limit answers 429 `rate_limited` too. headers: Retry-After: description: Seconds to wait. schema: type: integer content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: cooldown_active message: a setup link was sent to this customer recently — wait before sending again request_id: req_dbjg652q3gpgqfoq5cd0 "500": description: | The setup session could not be created, for example because Stripe refused it. No email was queued. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 "502": description: | The email could not be queued (`email_enqueue_failed`). The setup link was created. Retry. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: email_enqueue_failed message: failed to queue the setup link email — please retry; if it persists, check the email outbox dashboard request_id: req_dbjg652q3gpgqfoq5cd0 /v1/customers/{customer_id}/payment-methods/setup-session: post: tags: [Customers] summary: Create a link for the customer to add a payment method description: | Returns a Stripe Checkout URL in setup mode (card only, no charge) for you to give to the customer. Creates the Stripe customer on first use. When the customer finishes, Stripe sends them to `return_url` with `?status=success` or `?status=cancel` added. The card is saved when Stripe reports the setup succeeded. The link is written to the audit log; if the audit row cannot be written, no link is returned. A Stripe failure answers 500 `internal_error`. operationId: createCustomerPaymentSetupSession parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: customer_id in: path required: true schema: type: string requestBody: required: false content: application/json: schema: type: object properties: return_url: type: string description: | Where Stripe sends the customer afterwards. Defaults to `/payment-method-added` on the server's `CUSTOMER_PORTAL_URL` (`http://localhost:5173` when that is unset). example: return_url: https://app.example.com/billing/card-added responses: "201": description: Setup link created. content: application/json: schema: type: object required: [url, session_id] properties: url: type: string description: Stripe Checkout URL. Expires on Stripe's schedule (about 24 hours). session_id: type: string description: Stripe Checkout Session ID. example: url: https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXyZ session_id: cs_test_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXyZ "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | No customer with this ID in this account and mode. The message reads `payment_method not found`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: payment_method not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `stripe_not_connected`: Stripe is not connected for this mode. Connect it under Settings. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: stripe_not_connected message: Stripe is not connected for this mode. Connect it under Settings, then try again. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The body has an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "success_url" request_id: req_dbjg652q3gpgqfoq5cd0 param: success_url "429": $ref: "#/components/responses/RateLimited" /v1/customers/{customer_id}/payment-methods/{pmID}: delete: tags: [Customers] summary: Detach a payment method description: | Detaches the card in Stripe and marks it detached in Hardcount. If it was the default, the newest remaining card becomes the default in the same step, and that change is copied to Stripe (best effort). Both changes are written to the audit log. A Stripe error on the detach, including Stripe not being connected for this mode, returns 500 and leaves the card attached. Send no body. operationId: detachCustomerPaymentMethod parameters: - name: customer_id in: path required: true schema: type: string - name: pmID in: path required: true description: | The payment method's Hardcount ID, `hc_pm_` and 24 hex characters (not the Stripe ID). schema: type: string responses: "200": description: The detached payment method, now with `is_default` false. content: application/json: schema: $ref: "#/components/schemas/PaymentMethod" example: id: hc_pm_4f8a2c9e1b7d3a6f0c5e8b21 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g type: card card_brand: visa card_last4: "4242" card_exp_month: 12 card_exp_year: 2029 is_default: false created_at: "2026-10-02T11:48:30Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No payment method with this ID for this customer. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: payment_method not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "is_default" request_id: req_dbjg652q3gpgqfoq5cd0 param: is_default "429": $ref: "#/components/responses/RateLimited" /v1/customers/{customer_id}/payment-methods/{pmID}/default: post: tags: [Customers] summary: Make a payment method the default description: | Makes this card the one Hardcount charges and clears the flag on the others. The change is copied to the Stripe customer (best effort; a Stripe failure is recorded on the audit row and does not fail the call). Send no body. operationId: setCustomerDefaultPaymentMethod parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: customer_id in: path required: true schema: type: string - name: pmID in: path required: true description: | The payment method's Hardcount ID, `hc_pm_` and 24 hex characters (not the Stripe ID). schema: type: string responses: "200": description: The new default payment method. content: application/json: schema: $ref: "#/components/schemas/PaymentMethod" example: id: hc_pm_4f8a2c9e1b7d3a6f0c5e8b21 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g type: card card_brand: visa card_last4: "4242" card_exp_month: 12 card_exp_year: 2029 is_default: true created_at: "2026-10-02T11:48:30Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No such active payment method for this customer. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: payment_method not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "is_default" request_id: req_dbjg652q3gpgqfoq5cd0 param: is_default "429": $ref: "#/components/responses/RateLimited" /v1/public/cost-dashboard/{token}: get: tags: [Public] security: [] summary: Get a customer's public cost dashboard operationId: getPublicCostDashboard description: | Public: no API key. The `hc_pcd_` token in the URL is the only credential, so you can embed the page or fetch it from your own app. Make the token with `POST /v1/customers/{id}/rotate-cost-dashboard-token`. The answer holds the customer's usage and cost for the current billing period and its credit balance. It never holds the customer's name, email, external ID or billing profile, your provider costs, or usage that no price takes. A token with the wrong prefix or no customer gets 401, the same answer for both. A customer with no active or trialing subscription gets 200 with `billing_period.source` `no_subscription`: `subscriptions`, `usage` and `totals` are empty, and `credits` still lists the credit balance. Limited to 60 requests a minute per IP address. The limit is shared with the hosted invoice and payment-update pages. parameters: - name: token in: path required: true description: The cost-dashboard token, `hc_pcd_` and 64 hex characters. schema: type: string responses: "200": description: The customer's usage, cost and credit for the current billing period. content: application/json: schema: type: object required: [customer_id, tenant_id, livemode, billing_period, subscriptions, usage, totals, projected_total_cents, credits] properties: customer_id: type: string description: The customer the token belongs to. tenant_id: type: string description: The account the customer belongs to. livemode: type: boolean description: | False for a test-mode customer: show a test-mode notice. billing_period: type: object description: The period the usage covers. required: [start, end, source] properties: start: type: string format: date-time description: | Start of the period. The zero time `0001-01-01T00:00:00Z` when `source` is `no_subscription`. end: type: string format: date-time description: | End of the period, exclusive. The zero time when `source` is `no_subscription`. source: type: string enum: [subscription, no_subscription] description: | Check this before you show the period. `subscription`: the period runs from the earliest start to the latest end of the current billing periods of the customer's active and trialing subscriptions. `no_subscription`: the customer has no active or trialing subscription; `subscriptions`, `usage` and `totals` are empty (`credits` is not), and `start` and `end` hold the zero time, not a real range. subscriptions: type: array description: One entry per plan of each active or trialing subscription. items: type: object required: [id, plan_name, currency, current_period_start, current_period_end] properties: id: type: string description: The subscription's ID. plan_name: type: string description: The plan's name. currency: type: string description: The plan's currency, such as `USD`. current_period_start: type: string format: date-time description: Start of the subscription's current billing period. current_period_end: type: string format: date-time description: End of the subscription's current billing period, exclusive. usage: type: array description: One entry per meter the customer's plans price. items: type: object required: [event_name, meter_name, unit, currency, total_quantity, total_amount_cents, prices] properties: event_name: type: string description: The `event_name` usage events send for the meter. meter_name: type: string description: The meter's display name. unit: type: string description: The meter's unit, such as `tokens`. currency: type: string description: | The currency of the plan that priced the meter's usage. Empty (`""`) when the meter has no usage this period. total_quantity: type: string description: The usage the prices took this period, as a decimal string. total_amount_cents: type: integer format: int64 description: | What the usage costs, in the currency's minor unit (cents for USD). prices: type: array description: One row per price that took usage this period. items: type: object required: [quantity, amount_cents] properties: dimension_match: type: object additionalProperties: { type: string } description: | The labels the price takes. Absent for a price that takes every event. quantity: type: string description: The usage this price took, as a decimal string. amount_cents: type: integer format: int64 description: | What this price bills, in the currency's minor unit (cents for USD). unit_amount_decimal: type: string description: | Price per unit, as a decimal string of cents, not dollars: "0.0003" is 0.0003 cents per unit ($3.00 per 1M units). Multiplied by `quantity`, it gives cents, like `amount_cents`. totals: type: array description: One entry per currency present in `usage`. items: type: object required: [currency, amount_cents] properties: currency: type: string description: The currency, such as `USD`. amount_cents: type: integer format: int64 description: | The usage cost in this currency, in its minor unit (cents for USD). projected_total_cents: type: integer format: int64 description: | The metered cost of the current billing period so far, in minor units, including usage a threshold invoice already billed. It adds up every entry of `totals`, whatever its currency, so use `totals` when there is more than one. Recurring base fees are not included. credits: type: array description: | The customer's credit position, one entry per currency, ordered by currency. `balance_cents` is the ledger balance in that currency, less any grant past its expiry date that is not yet written off: credit is drawn down when an invoice is issued, so it does not move with usage. `unbilled_usage_cents` is the usage no invoice has billed yet that will be invoiced in that currency, and `estimated_remaining_cents` is the balance minus that, never below zero. These three are the same figures `GET /v1/credits/balance/{customer_id}/live` returns. `unbilled_usage_cents` differs from `projected_total_cents` after a threshold invoice: it leaves out the usage that invoice billed. It is an estimate: tax, base fees billed at period end and unpaid invoices are not subtracted. items: type: object required: [currency, balance_cents, unbilled_usage_cents, commit_remaining_cents, promotional_remaining_cents, estimated_remaining_cents] properties: currency: type: string description: The currency of this entry. balance_cents: type: integer format: int64 description: | The credit balance in this currency, in its minor unit (cents for USD). unbilled_usage_cents: type: integer format: int64 description: Usage no invoice has billed yet, in minor units. commit_remaining_cents: type: integer format: int64 description: | What is left of the customer's prepaid commit, in minor units. promotional_remaining_cents: type: integer format: int64 description: What is left of promotional credit, in minor units. estimated_remaining_cents: type: integer format: int64 description: | The balance minus unbilled usage, never below zero, in minor units. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false billing_period: start: "2026-10-01T00:00:00Z" end: "2026-11-01T00:00:00Z" source: subscription subscriptions: - id: hc_sub_dauq502q3gpgqfoq5c2g plan_name: Pro currency: USD current_period_start: "2026-10-01T00:00:00Z" current_period_end: "2026-11-01T00:00:00Z" usage: - event_name: tokens meter_name: Tokens unit: tokens currency: USD total_quantity: "12500000" total_amount_cents: 3750 prices: - quantity: "12500000" amount_cents: 3750 unit_amount_decimal: "0.0003" totals: - currency: USD amount_cents: 3750 projected_total_cents: 3750 credits: - currency: USD balance_cents: 5000 unbilled_usage_cents: 3750 commit_remaining_cents: 0 promotional_remaining_cents: 5000 estimated_remaining_cents: 1250 "401": description: | The token has the wrong prefix or matches no customer. Rotate the token to get a working link. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: invalid cost-dashboard token request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/public/invoices/{token}: get: tags: [Public] security: [] summary: Data behind the hosted invoice page operationId: getHostedInvoice description: | Unauthenticated. The invoice's public token in the URL is the only credential. Paid and voided invoices stay viewable. A draft is never shown: it returns 404 like an unknown token, and so does a token replaced by `POST /v1/invoices/{id}/rotate-public-token`. Share the page link with customers instead of calling this route. parameters: - name: token in: path required: true description: The invoice's public token (`hc_pinv_…`), from the hosted invoice link. schema: type: string responses: "200": description: The invoice as the page shows it. content: application/json: schema: $ref: "#/components/schemas/HostedInvoice" example: invoice: invoice_number: HC-000042 status: finalized payment_status: pending currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 total_amount_cents: 8650 amount_due_cents: 8650 amount_paid_cents: 0 credits_applied_cents: 0 issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" billing_timezone: UTC line_items: - description: Pro - base fee (qty 1) quantity: "1" unit_amount_decimal: "4900" amount_cents: 4900 total_amount_cents: 4900 currency: USD - description: Tokens (tokens) quantity: "12500000" unit_amount_decimal: "0.0003" meter_unit: tokens amount_cents: 3750 total_amount_cents: 3750 currency: USD bill_to: name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US branding: company_name: Example Labs Inc company_email: billing@examplelabs.example company_address_line1: 1 Congress Avenue company_city: Austin company_state: TX company_postal_code: "78701" company_country: US logo_url: https://examplelabs.example/logo.png brand_color: "#4F46E5" support_url: https://examplelabs.example/support credit_notes: [] pay_enabled: true livemode: false "404": description: Unknown token, or the invoice is a draft. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": description: Too many requests from this IP address. The public hosted-invoice, payment-update and cost-dashboard routes share one limit of 60 requests a minute per IP. Wait for the seconds in `Retry-After`. headers: Retry-After: $ref: "#/components/headers/RetryAfter" X-RateLimit-Limit: $ref: "#/components/headers/RateLimitLimit" X-RateLimit-Remaining: $ref: "#/components/headers/RateLimitRemaining" X-RateLimit-Reset: $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: rate_limited message: too many requests — please retry after the rate limit resets request_id: req_dbjg652q3gpgqfoq5cd0 /v1/public/invoices/{token}/pdf: get: tags: [Public] security: [] summary: Download the hosted invoice PDF operationId: getHostedInvoicePdf description: | Unauthenticated, like the hosted invoice page. Returns the same PDF as `GET /v1/invoices/{id}/pdf`, shown inline and named after the invoice number. parameters: - name: token in: path required: true description: The invoice's public token (`hc_pinv_…`), from the hosted invoice link. schema: type: string responses: "200": description: PDF file. content: application/pdf: schema: type: string format: binary "404": description: Unknown token, or the invoice is a draft. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": description: Too many requests from this IP address. The public hosted-invoice, payment-update and cost-dashboard routes share one limit of 60 requests a minute per IP. Wait for the seconds in `Retry-After`. headers: Retry-After: $ref: "#/components/headers/RetryAfter" X-RateLimit-Limit: $ref: "#/components/headers/RateLimitLimit" X-RateLimit-Remaining: $ref: "#/components/headers/RateLimitRemaining" X-RateLimit-Reset: $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: rate_limited message: too many requests — please retry after the rate limit resets request_id: req_dbjg652q3gpgqfoq5cd0 /v1/public/invoices/{token}/checkout: post: tags: [Public] security: [] summary: Start paying the invoice in Stripe Checkout operationId: createHostedInvoiceCheckout description: | The hosted page's Pay button. Unauthenticated, like the hosted invoice page. Returns a Stripe Checkout URL for the amount due. Hardcount has already worked out the tax, so Stripe adds none. Calling it again while the page is still open returns the same page, unless the amount due changed. After paying, Stripe sends the customer back to the hosted page with `?paid=1`. The Stripe webhook, not this redirect, marks the invoice paid. The route takes no body. parameters: - name: token in: path required: true description: The invoice's public token (`hc_pinv_…`), from the hosted invoice link. schema: type: string responses: "200": description: Checkout session created. content: application/json: schema: type: object required: [url] properties: url: type: string description: Stripe Checkout URL. example: url: https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4e5F6g7H8i9J0kLmNoPqRsTuVwXyZ "400": $ref: "#/components/responses/InvalidBody" "404": description: Unknown token, or the invoice is a draft. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice cannot be paid now. The code says why: `not_payable` when it is not finalized, nothing is owed, or its payment status is `processing` or `unknown`. `payment_in_progress` when another payment for it has started. `online_payment_unavailable` when the seller has no Stripe keys for the invoice's mode. `amount_below_minimum` when Stripe's latest answer for the amount was that it is below Stripe's minimum charge, so no payment page is made for it. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_payable message: invoice is not in a payable state request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "amount_cents" request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_cents "429": description: Too many requests from this IP address. The public hosted-invoice, payment-update and cost-dashboard routes share one limit of 60 requests a minute per IP. Wait for the seconds in `Retry-After`. headers: Retry-After: $ref: "#/components/headers/RetryAfter" X-RateLimit-Limit: $ref: "#/components/headers/RateLimitLimit" X-RateLimit-Remaining: $ref: "#/components/headers/RateLimitRemaining" X-RateLimit-Reset: $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: rate_limited message: too many requests — please retry after the rate limit resets request_id: req_dbjg652q3gpgqfoq5cd0 "502": description: Stripe could not create the session, or the previous payment page could not be closed. Code `stripe_error`; the customer can try again. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: stripe_error message: failed to create checkout session request_id: req_dbjg652q3gpgqfoq5cd0 /v1/public/payment-updates/{token}: get: tags: [Public] security: [] summary: Get what the payment-update page shows operationId: getPublicPaymentUpdate description: | Public: no API key. The token from the link in a payment setup email is the only credential. Hardcount queues that email when an invoice has no payment method to charge, the customer has an email address and `PAYMENT_UPDATE_URL` is set, and again on each `POST /v1/invoices/{id}/resend-setup-link`. The link is the server's `PAYMENT_UPDATE_URL` with `?token=` and the token. The token is valid for 24 hours, and starting checkout uses it up. Share the page link with customers instead of calling this route. Limited to 60 requests a minute per IP address. The limit is shared with the checkout call below, the hosted invoice page and the public cost dashboard. parameters: - name: token in: path required: true description: The payment-update token, `hc_pt_` and 64 hex characters. schema: type: string responses: "200": description: What the page shows. content: application/json: schema: $ref: "#/components/schemas/PublicPaymentUpdate" example: customer_name: Acme Inc invoice_number: HC-000001 invoice_status: finalized amount_due_cents: 3650 currency: USD branding: company_name: Example Labs company_email: billing@examplelabs.example logo_url: https://examplelabs.example/logo.png brand_color: "#4F46E5" support_url: https://examplelabs.example/support "401": description: | The token is unknown, expired or already used, or its invoice is gone. Code `invalid_token`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: invalid_token message: invalid or expired token request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: | The link's customer no longer exists (code `customer_not_found`), or the invoice it names was deleted while the request ran (code `invoice_not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: customer_not_found message: this payment link is no longer valid — the customer it references is unavailable request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/public/payment-updates/{token}/checkout: post: tags: [Public] security: [] summary: Start adding a card in Stripe Checkout operationId: createPublicPaymentUpdateCheckout description: | Public: no API key. Returns a Stripe Checkout URL in setup mode (card only, no charge) for the customer behind the token. Creates the Stripe customer if there is none. This call uses up the token. If Stripe fails to create the session, the token works again. Stripe sends the customer to the server's `PAYMENT_UPDATE_RETURN_URL` (`http://localhost:5173/payment-method-added` when that is unset) with `?status=success` or `?status=cancel` added. The card is saved when Stripe reports the setup succeeded. Writes an audit row. Send no body. parameters: - name: token in: path required: true description: The payment-update token, `hc_pt_` and 64 hex characters. schema: type: string responses: "201": description: Checkout session created. content: application/json: schema: type: object required: [url] properties: url: type: string description: Stripe Checkout URL. Send the customer there. example: url: https://checkout.stripe.com/c/pay/cs_test_b2C3d4E5f6G7h8I9j0KlMnOpQrStUvWxYzA1 "400": $ref: "#/components/responses/InvalidBody" "401": description: | The token is unknown, expired or already used, or its invoice is gone. Code `invalid_token`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: invalid_token message: invalid or expired token request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The seller has not connected Stripe for this mode, so a card cannot be added online. Code `online_payment_unavailable`. The token still works. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: online_payment_unavailable message: Adding a card online isn't available for this account right now. Please contact your billing administrator. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "return_url" request_id: req_dbjg652q3gpgqfoq5cd0 param: return_url "429": $ref: "#/components/responses/RateLimited" "502": description: | Starting the session failed, at Stripe or on the server. Code `stripe_error`. The token still works, so the customer can try again. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: stripe_error message: We couldn't start the payment update right now. Please try the link again in a few minutes, or contact support if the problem persists. request_id: req_dbjg652q3gpgqfoq5cd0 /v1/meters: post: tags: [Pricing] summary: Create a meter operationId: createMeter description: | Creates a meter. `event_name` is what your usage events send for it, and it cannot change later. `unit` defaults to `unit` and `aggregation` to `sum`. A meter only counts: put its prices on a plan (`POST /v1/plans/{id}/prices`). The create is written to the audit log. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [event_name, name] properties: event_name: type: string description: | The `event_name` usage events will send for this meter. Letters, digits, `-` and `_`; at most 100 characters. Unique in the account and mode. name: type: string description: | The display name. Spaces at the ends are trimmed; then at most 255 bytes (UTF-8). unit: type: string default: unit description: | The unit of the quantity, such as `tokens` or `seconds`. Empty or left out is `unit`. aggregation: type: string enum: [sum, count, max, last] default: sum description: | How the meter rolls its events into one quantity per billing period. Fixed once the meter has a usage event. example: event_name: tokens name: Tokens unit: tokens aggregation: sum responses: "201": description: The new meter, with `has_usage` false. content: application/json: schema: $ref: "#/components/schemas/Meter" example: id: hc_mtr_dajr122q3gpgqfoq5c10 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 event_name: tokens name: Tokens unit: tokens aggregation: sum has_usage: false created_at: "2026-09-15T09:00:00Z" updated_at: "2026-09-15T09:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "409": description: | A meter with this `event_name` already exists in this account and mode (`already_exists`, `param: event_name`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: a meter with event_name "tokens" already exists request_id: req_dbjg652q3gpgqfoq5cd0 param: event_name "422": description: | A field failed validation (`validation_error`, `param` names it): a missing or too long `event_name` or `name`, an `event_name` with other characters than letters, digits, `-` and `_`, an unknown `aggregation`, or an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must contain only alphanumeric characters, hyphens, and underscores request_id: req_dbjg652q3gpgqfoq5cd0 param: event_name "429": $ref: "#/components/responses/RateLimited" get: tags: [Pricing] summary: List meters operationId: listMeters description: | Meters in the mode of your API key, newest first. The list leaves out `has_usage`. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of meters, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Meter" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_mtr_dajr122q3gpgqfoq5c10 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 event_name: tokens name: Tokens unit: tokens aggregation: sum created_at: "2026-09-15T09:00:00Z" updated_at: "2026-09-15T09:00:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/meters/{id}: patch: tags: [Pricing] summary: Update a meter operationId: updateMeter description: | Fields omitted are left unchanged. A meter only counts: its prices are on the plans (`/v1/plans/{id}/prices`). `aggregation` can change until the meter has a usage event, then it is fixed: a change is refused with 409 `meter_has_usage`. Usage events are counted under the aggregation the meter has; to count differently, make a new meter with another event name. Held usage for its `event_name` does not fix it: it is counted under the meter's aggregation once released. `name` and `unit` can always change, at once. The change is written to the audit log. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: name: type: string description: | The new display name, at most 255 bytes (UTF-8) after spaces at the ends are trimmed. It cannot be empty. unit: type: string description: The new unit. Empty sets it to `unit`. aggregation: type: string enum: [sum, count, max, last] description: The new aggregation. Refused once the meter has a usage event. example: name: LLM tokens responses: "200": description: The updated meter. content: application/json: schema: $ref: "#/components/schemas/Meter" example: id: hc_mtr_dajr122q3gpgqfoq5c10 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 event_name: tokens name: LLM tokens unit: tokens aggregation: sum has_usage: true created_at: "2026-09-15T09:00:00Z" updated_at: "2026-10-20T08:30:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No meter with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: meter not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `meter_has_usage`: the meter has usage events, so its `aggregation` cannot change. `invalid_state`: another write changed the meter during this one. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: meter_has_usage message: 'Meter "tokens" has usage events, so its aggregation stays sum: its events are counted under it. To count differently, make a new meter with another event name.' request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A field failed validation (`validation_error`, `param` names it): an empty or too long `name`, an unknown `aggregation`, or an unknown field such as `event_name`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "must be one of: sum, count, max, last" request_id: req_dbjg652q3gpgqfoq5cd0 param: aggregation "429": $ref: "#/components/responses/RateLimited" get: tags: [Pricing] summary: Get a meter operationId: getMeter parameters: - name: id in: path required: true schema: type: string responses: "200": description: The meter, with `has_usage`. content: application/json: schema: $ref: "#/components/schemas/Meter" example: id: hc_mtr_dajr122q3gpgqfoq5c10 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 event_name: tokens name: Tokens unit: tokens aggregation: sum has_usage: true created_at: "2026-09-15T09:00:00Z" updated_at: "2026-09-15T09:00:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no meter with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: meter not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes: post: tags: [CreditNotes] summary: Create a credit note operationId: createCreditNote description: | Creates a credit note against a `finalized` or `paid` invoice. A credit note is a document that reduces what the customer owes, or gives money back after payment. There are two shapes. Lines, for most invoices. Send `lines`; their amounts include tax, and the note's tax is its share of the invoice's tax. - On an unpaid invoice, leave the three `*_amount_cents` fields out: the note lowers the amount due when it is issued. It can be at most the amount due plus the customer credit applied. - On a paid invoice, split the note's total across `refund_amount_cents` (back to the card), `credit_amount_cents` (to the customer's credit balance) and `out_of_band_amount_cents` (paid back outside Hardcount). The three must add up to the total. With all three 0, the whole note goes to the credit balance, or to the channel `refund_type` names. The refund part can be at most what was paid by card less the refunds on earlier notes. - All notes on an invoice together can be at most its total. - The note is a `draft` until you issue it, unless `auto_issue` is true. Issuing applies it (see `POST /v1/credit-notes/{id}/issue`). Commit relief, for paid invoices that bought prepaid credit only. Commit relief takes back unused credit from a prepaid commit (credit the customer paid for up front) and gives back its cash. Send `commit_relief` and no `lines`. Hardcount retires that many credits from the commit and works out the cash to give back at the price the customer paid per credit, so a discounted commit is refunded at the discounted rate, never at face value. The note is created and issued in one step. Its cash goes to the card or out of band, never to the credit balance. To cancel the commit on an unpaid invoice, void the invoice instead. A note with `stripe_refund_id` records a refund already made in Stripe; see that field. Nothing is emailed; send the note with `POST /v1/credit-notes/{id}/send`. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [invoice_id] properties: invoice_id: type: string description: The invoice to credit. It must be `finalized` or `paid`. reason: type: string description: | Why you are crediting, printed on the note. Required for a line-based note. For a relief, `commit_relief.reason` wins; this one is used when that is empty, and `commit_relief` is the reason when both are empty. lines: type: array description: The credited amounts, tax included. At least one line for a line-based note; none with `commit_relief`. items: type: object properties: invoice_line_item_id: type: string description: Optional. The invoice line this credit line reverses. It must be a line of this invoice. description: type: string description: The text printed on the line. quantity: type: integer description: How many units, 1 or more. unit_amount_cents: type: integer description: Credit per unit, tax included, in the currency's minor unit (cents for USD). 1 or more. refund_amount_cents: type: integer description: Part of the total refunded to the card, in the currency's minor unit (cents for USD). Paid invoices only. Not with commit_relief; put the relief's split inside commit_relief. credit_amount_cents: type: integer description: Part of the total added to the customer's credit balance, in the currency's minor unit (cents for USD). Paid invoices only. Not with commit_relief. out_of_band_amount_cents: type: integer description: Part of the total paid back outside Hardcount, in the currency's minor unit (cents for USD). Paid invoices only. Not with commit_relief; put the relief's split inside commit_relief. refund_type: type: string enum: [refund, credit] description: | Older single-channel form. On a paid invoice whose three `*_amount_cents` fields are all 0, `refund` or `credit` puts the whole note in that channel. Not with commit_relief. auto_issue: type: boolean description: | True issues the note in the same call. If the issue fails, the draft is voided and the error is returned. Defaults to false (the note stays a draft). stripe_refund_id: type: string description: | Links a refund made in Stripe outside Hardcount as this note's card refund, like Stripe's `refunds[]`: Hardcount reads the refund from Stripe and calls nothing else there for it. The refund must be of the payment that paid this invoice, not made by Hardcount, and succeeded; `refund_amount_cents` must be its amount, `credit_amount_cents` 0, and `auto_issue` true (the note is issued at once). The caps on the invoice still apply. A refund Hardcount flagged on the invoice (`refunded_in_stripe`) is then marked recorded. Not with commit_relief. commit_relief: type: object description: | Mutually exclusive with lines and credit_amount_cents. The relief's split goes in here, not at the top level: a top-level `refund_amount_cents`, `out_of_band_amount_cents` or `refund_type` is a 422. Example after a lost dispute: `{"invoice_id": "hc_inv_…", "commit_relief": {"retire_all": true, "out_of_band_amount_cents": 5000}}`. properties: invoice_id: type: string description: Optional. Defaults to the top-level invoice_id. reason: type: string description: | Optional. Wins over the top-level `reason`. When both are empty, the note's reason is `commit_relief`. retire_cents: type: integer description: Commit credits to retire, from 1 to what is left of the commit. retire_all: type: boolean description: Retire all the commit credit left at the moment the relief runs, so credit the customer spends meanwhile is not counted. `retire_cents` is then ignored. refund_amount_cents: type: integer description: Optional. Part of the cash refunded to the card. With `out_of_band_amount_cents` it must add up to the cash Hardcount works out. When both are left out, the cash goes to the card if the invoice was paid by card, otherwise out of band. out_of_band_amount_cents: type: integer description: Optional. Part of the cash paid back outside Hardcount. example: invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 reason: Service outage on Oct 14 lines: - description: Credit for the Oct 14 outage quantity: 1 unit_amount_cents: 1000 credit_amount_cents: 1000 responses: "201": description: The credit note, a draft unless `auto_issue` was true or it is a commit relief. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5h tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000004 status: draft reason: Service outage on Oct 14 subtotal_cents: 1000 tax_amount_cents: 0 total_cents: 1000 refund_amount_cents: 0 credit_amount_cents: 1000 out_of_band_amount_cents: 0 currency: USD refund_status: none recorded_from_stripe: false is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-04T08:00:00Z" recorded_at: "2026-11-04T08:00:00Z" updated_at: "2026-11-04T08:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this `invoice_id` in this account and mode. The message says `credit_note not found`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit_note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice's state refuses the note. Code `invalid_state`: the invoice is a draft, voided or written off, already fully credited, or bought prepaid credit (use commit relief when it is paid, void it when it is unpaid); a relief on an unpaid, written-off, voided, mixed-line, zero-total or fully used commit; or a `stripe_refund_id` that a credit note already records, or that is still in flight at Stripe. Codes `payment_in_flight` and `payment_unidentifiable`: a charge on the invoice is unresolved. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot create credit notes for voided invoices request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`, with `param` naming the field: a missing `reason` or `lines`; a line with a quantity or amount below 1, or naming another invoice's line; a note above what the invoice can still be credited; amounts that do not add up to the total; a refund above what was paid by card; a `stripe_refund_id` that does not qualify; an over-sized or zero-cash relief; or an unknown field (the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: refund_amount_cents (10.00) + credit_amount_cents (0.00) + out_of_band_amount_cents (0.00) = 10.00, must equal credit note total 12.50 request_id: req_dbjg652q3gpgqfoq5cd0 param: allocation "429": $ref: "#/components/responses/RateLimited" "502": description: | Code `provider_error`. Stripe could not be asked about the `stripe_refund_id`, for example because Stripe is not connected for this mode. Nothing was created. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: provider_error message: Payment provider rejected the request. Please retry; if the problem persists, contact support. request_id: req_dbjg652q3gpgqfoq5cd0 get: tags: [CreditNotes] summary: List credit notes operationId: listCreditNotes description: | Lists credit notes in this account and mode, newest first by default. Filters are optional and combine. `total` counts every note that matches the filters. parameters: - name: invoice_id in: query description: Only notes against this invoice. schema: type: string - name: customer_id in: query description: Only this customer's notes. schema: type: string - name: status in: query description: Only notes in this status. schema: type: string enum: [draft, issued, voided] - name: refund_status in: query schema: type: string enum: [none, pending, succeeded, failed, unknown, needs_attention] description: | Only notes whose refund is in this state. `needs_attention` returns issued, non-test-clock notes whose refund failed or has an unknown result, has been pending more than 72 hours, or has a refund amount still at `none` after 1 hour. - name: sort in: query description: | The order of the list. `created_at` (the default), `credit_note_number`, `total_cents` (or `amount`), `status` or `issued_at`. Any other value is a 422 with `param: sort`. Ties are broken by `id` in the same direction. schema: type: string enum: [created_at, credit_note_number, total_cents, amount, status, issued_at] default: created_at - name: dir in: query description: | Sort direction: `desc` (the default) or `asc`, in lower case. Any other value is a 422 with `param: dir`. schema: type: string enum: [asc, desc] default: desc - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Credit note list. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/CreditNote" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk2fLkdIwHu7ix1b2c3d4e recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-03T10:15:02Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `limit` or `offset` is out of range or not a whole number, the request sends the removed `after` cursor, `sort` or `dir` is not one of its values. `param` names it, and for `sort` and `dir` the message lists the allowed values. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'sort "nope" is not one of created_at, credit_note_number, total_cents, amount, status, issued_at' request_id: req_dbjg652q3gpgqfoq5cd0 param: sort "429": $ref: "#/components/responses/RateLimited" /v1/credits/grants/{customer_id}: get: tags: [Credits] summary: List a customer's credit grants operationId: getCreditGrants description: | The customer's credit grants. For each grant: how much was granted, how much is drawn, how much is left, and when it expires. The answer also gives what is left in each currency, split by kind: `commit` (credit the customer paid for), `promotional` (free credit) and `other` (older entries and adjustments). Send `include_exhausted=true` to also list grants with nothing left, for history. Each grant's `consumed_cents` also counts credit no invoice used: `expired_cents`, the part that expired unused; `retired_cents`, commit credit taken back when the invoice that funded it was voided or refunded by a credit note; and `deducted_cents`, the part a deduction (a negative adjustment by a dashboard user or an API key) drew from this grant. The credit invoices actually used is `consumed_cents - expired_cents - retired_cents - deducted_cents`. parameters: - name: customer_id in: path required: true description: The customer's ID (`hc_cus_…`). schema: type: string - name: include_exhausted in: query description: | `true` also lists the grants with nothing left (used up, expired, retired or deducted). Any other value leaves them out. schema: type: boolean responses: "200": description: The grants, what is left on each, and subtotals by kind. content: application/json: schema: $ref: "#/components/schemas/CreditGrants" example: grants: - id: hc_ccl_dav2242q3gpgqfoq5c50 entry_type: grant currency: USD grant_kind: promotional description: Welcome credit amount_cents: 5000 consumed_cents: 0 remaining_cents: 5000 expired_cents: 0 retired_cents: 0 deducted_cents: 0 expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" remaining_by_currency: - currency: USD commit_remaining_cents: 0 promotional_remaining_cents: 5000 other_remaining_cents: 0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/credits/adjust: post: tags: [Credits] summary: Add or remove credit description: | Writes one `adjustment` entry to the customer's credit ledger in `currency`. A positive amount adds credit in that currency; a negative amount removes credit in that currency only, taken from the customer's grants in the order invoices draw them (promotional credit first, then the soonest to expire). The answer carries no `metadata`; the ledger list shows which grants a removal drew from (`drained_blocks`). The audit log records it as `credit.adjustment` or `credit.deduction`. Errors (422, `param` names the field): `customer_id`, `currency` or `description` missing; `currency` not supported (an unknown code or a zero-decimal currency such as JPY); `amount_cents` is zero, above 100000000 (1,000,000.00 in that currency), or a removal larger than the credit in that currency that can be drawn (expired grants not yet written off do not count). operationId: adjustCredits parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [customer_id, currency, amount_cents, description] properties: customer_id: type: string description: The customer whose credit to change (`hc_cus_…`). currency: type: string description: ISO 4217 code of the balance to change. Case-insensitive; stored uppercase. example: USD amount_cents: type: integer format: int64 description: | Positive adds credit, negative removes it, in the currency's minor unit (cents for USD). Not zero. description: type: string description: Why you change the balance. Shown on the ledger entry. Must not be blank. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD amount_cents: -1000 description: Goodwill credit reversed after duplicate grant responses: "201": description: The ledger entry written. content: application/json: schema: $ref: "#/components/schemas/CreditLedgerEntry" example: id: hc_ccl_dav2242q3gpgqfoq5c54 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g entry_type: adjustment currency: USD amount_cents: -1000 balance_after: 4000 description: Goodwill credit reversed after duplicate grant created_at: "2026-10-05T11:20:00Z" "400": description: The body is not valid JSON, or a field has a value of the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this `customer_id` in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: Validation failed (see description), or an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "Insufficient balance: only 40.00 USD is available, so 50.00 USD can't be deducted." request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_cents "429": $ref: "#/components/responses/RateLimited" /v1/credits/balances: get: tags: [Credits] summary: List credit balances description: | One row per customer and currency that has any credit ledger entry, highest balance first. `total_granted` is the sum of every credit added in that currency (grants, positive adjustments, and what a void or credit note gave back as new credit). operationId: listCreditBalances parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Balances. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/CreditBalance" total: $ref: "#/components/schemas/ListTotal" example: data: - customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD balance_cents: 5000 total_granted: 5000 total_used: 0 total_expired: 0 total_retired: 0 total_deducted: 0 total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/credits/ledger/{customer_id}: get: tags: [Credits] summary: List a customer's credit ledger description: | Ledger entries for one customer, newest first unless you sort otherwise. `balance_after` is the running balance in the entry's currency, in time order over the whole ledger, so it stays right when you filter by `entry_type`. operationId: listCreditLedgerEntries parameters: - name: customer_id in: path required: true description: The customer's ID (`hc_cus_…`). schema: type: string - name: entry_type in: query description: Only entries of this type. schema: type: string enum: [grant, usage, expiry, adjustment] - name: sort in: query description: | The order of the list. `created_at` (the default), `amount_cents` (or `amount`), `entry_type` or `expires_at`. Any other value is a 422 with `param: sort`. Ties are broken by `id` in the same direction. schema: type: string enum: [created_at, amount_cents, amount, entry_type, expires_at] default: created_at - name: dir in: query description: | Sort direction: `desc` (the default) or `asc`, in lower case. Any other value is a 422 with `param: dir`. schema: type: string enum: [asc, desc] default: desc - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Ledger entries. `total` counts entries matching the filter. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/CreditLedgerEntry" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_ccl_dav2242q3gpgqfoq5c58 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g entry_type: usage currency: USD amount_cents: -5000 balance_after: 0 description: Applied to invoice HC-000042 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 metadata: drained_blocks: - block_id: hc_ccl_dav2242q3gpgqfoq5c50 take_cents: 5000 created_at: "2026-11-01T00:05:00Z" - id: hc_ccl_dav2242q3gpgqfoq5c50 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g entry_type: grant currency: USD amount_cents: 5000 balance_after: 5000 description: Welcome credit expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" consumed_cents: 5000 grant_kind: promotional total: 2 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `limit` or `offset` is out of range or not a whole number, the request sends the removed `after` cursor, `sort` or `dir` is not one of its values. `param` names it, and for `sort` and `dir` the message lists the allowed values. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'sort "nope" is not one of created_at, amount_cents, amount, entry_type, expires_at' request_id: req_dbjg652q3gpgqfoq5cd0 param: sort "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints: post: tags: [Webhooks] summary: Create a webhook endpoint operationId: createWebhookEndpoint description: | Registers a URL that Hardcount sends events to, in the caller's mode (test or live). The answer holds the endpoint and its signing secret (`whsec_…`). The secret is shown only here: store it, and use it to check the `Hardcount-Signature` header of each delivery. The URL must be `https://` on a public address: Hardcount looks up its host and refuses one that points to a private network address. Outside production (`APP_ENV` is not `production`), a `localhost` or loopback URL (`127.0.0.1`, `::1`) is accepted too, over `http://` or `https://`; in production it is refused. `events` lists the event types to send. Leave it out, or send `[]`, for every event (`*`). A prefix such as `invoice.*` matches every event whose type starts with `invoice.`. An event type Hardcount never sends is refused with 422, so a typo cannot leave the endpoint silent. Writes an audit row. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: type: string description: | Where to send events, an `https://` URL on a public address (a loopback URL outside production may use `http://`). Surrounding spaces are trimmed. description: type: string description: Your note about the endpoint, such as what it feeds. events: type: array items: type: string description: Event types to send. `*` means every event; `invoice.*` means every invoice event. Leave it out or send `[]` for every event. example: url: https://example.com/hardcount/webhooks description: Billing sync events: ["invoice.*", "payment.failed", "credit.balance_depleted"] responses: "201": description: Endpoint created. The answer carries the signing secret, shown only once. content: application/json: schema: $ref: "#/components/schemas/WebhookEndpointCreated" example: endpoint: id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync secret_last4: "4e9f" events: ["invoice.*", "payment.failed", "credit.balance_depleted"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-10-02T10:00:00Z" secret: whsec_9b4e2c7a1f5d8e3b6c0a4f2d7e1b9c5a3f8d6e2b1a7c4e9f "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `url` is missing, is not `https://` (only a loopback URL outside production may use `http://`), is a loopback URL in production, cannot be looked up, or points to a private address; an event type is unknown; or the body has an unknown field. `param` names the field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'unknown event type "invoice.payment_failed" — Hardcount never emits it, so this subscription would receive silence; see the event catalog in docs/api/webhook-events.md' request_id: req_dbjg652q3gpgqfoq5cd0 param: events "429": $ref: "#/components/responses/RateLimited" get: tags: [Webhooks] summary: List webhook endpoints operationId: listWebhookEndpoints description: | Returns the endpoints in the caller's mode, newest first. Paused endpoints are included; deleted ones are not. Signing secrets are never returned. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of endpoints. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/WebhookEndpoint" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync secret_last4: "4e9f" events: ["invoice.*", "payment.failed", "credit.balance_depleted"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-10-02T10:00:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints/{id}: get: tags: [Webhooks] summary: Get a webhook endpoint operationId: getWebhookEndpoint description: One endpoint in the caller's mode, without its signing secret. A deleted endpoint is a 404. parameters: - name: id in: path required: true schema: type: string description: The endpoint's id (`hc_whe_…`). responses: "200": description: The endpoint content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" example: id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync secret_last4: "4e9f" events: ["invoice.*", "payment.failed", "credit.balance_depleted"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-10-02T10:00:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No endpoint with this id in this mode, or it was deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" patch: tags: [Webhooks] summary: Update a webhook endpoint operationId: updateWebhookEndpoint description: | Changes the endpoint's `url`, `description`, `events` or `active`. The signing secret stays the same, so your receiver keeps checking signatures while the target or the event list changes. A field you leave out keeps its value. `url` and `events` are checked as on create, and `events: []` means every event. Send `active: false` to pause the endpoint: it gets no new events, and its pending retries end as `failed`. Send `active: true` to resume it. Writes an audit row. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string description: The endpoint's id (`hc_whe_…`). requestBody: required: true content: application/json: schema: type: object properties: url: type: string description: A new URL, checked as on create. It cannot be empty. description: type: string description: A new note. An empty string clears it. events: type: array items: type: string description: A new list of event types, replacing the old one. `[]` means every event. active: type: boolean description: False pauses the endpoint; true resumes it. example: description: Billing sync (v2) events: ["invoice.*", "payment.*"] responses: "200": description: The updated endpoint content: application/json: schema: $ref: "#/components/schemas/WebhookEndpoint" example: id: hc_whe_danslg2q3gpgqfoq5c80 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false url: https://example.com/hardcount/webhooks description: Billing sync (v2) secret_last4: "4e9f" events: ["invoice.*", "payment.*"] active: true created_at: "2026-10-02T10:00:00Z" updated_at: "2026-11-01T11:20:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No endpoint with this id in this mode, or it was deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `url` is empty, is not `https://` (only a loopback URL outside production may use `http://`), is a loopback URL in production, cannot be looked up, or points to a private address; an event type is unknown; or the body has an unknown field. `param` names the field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must start with https:// request_id: req_dbjg652q3gpgqfoq5cd0 param: url "429": $ref: "#/components/responses/RateLimited" delete: tags: [Webhooks] summary: Delete a webhook endpoint operationId: deleteWebhookEndpoint description: | Deletes the endpoint. It leaves every list and lookup and gets no more events, and its pending retries end as `failed`. Its past deliveries stay in event timelines and in the stats. There is no undo: to stop events for a while, pause the endpoint with `PATCH` and `active: false` instead. Writes an audit row. parameters: - name: id in: path required: true schema: type: string description: The endpoint's id (`hc_whe_…`). responses: "200": description: Endpoint deleted content: application/json: schema: type: object required: [status] properties: status: type: string enum: [deleted] description: Always `deleted`. example: status: deleted "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No endpoint with this id in this mode, or it was already deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints/{id}/rotate-secret: post: tags: [Webhooks] summary: Rotate a webhook endpoint's signing secret operationId: rotateWebhookEndpointSecret description: | Makes a new signing secret and returns it once. For the next 72 hours each delivery's `Hardcount-Signature` header carries two `v1=` signatures, one made with each secret, so your receiver can switch to the new secret without rejecting events. After that only the new secret signs. Writes an audit row, without the secret. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string description: The endpoint's id (`hc_whe_…`). responses: "200": description: The new secret. The old one keeps signing until `secondary_valid_until`. content: application/json: schema: $ref: "#/components/schemas/WebhookSecretRotation" example: secret: whsec_1f8a3c6e9b2d5f7a0c4e8b1d3f6a9c2e5b7d0f4a8c1e3b6d secondary_valid_until: "2026-11-04T10:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No endpoint with this id in this mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints/{id}/deliveries: get: tags: [Webhooks] summary: List an endpoint's deliveries operationId: listWebhookEndpointDeliveries description: | The endpoint's deliveries, newest first. Each row names the event it carried. A deleted or unknown endpoint returns 404. The endpoint itself is `GET /v1/webhook-endpoints/endpoints/{id}`. parameters: - name: id in: path required: true schema: type: string description: The endpoint's id (`hc_whe_…`). - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of the endpoint's deliveries. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/WebhookEndpointDelivery" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_whd_dbj84bqq3gpgqfoq5c90 event_id: hc_whevt_dbj84biq3gpgqfoq5c8g event_type: credit.balance_depleted is_replay: false status: succeeded status_code: 200 attempt_count: 1 response_body: '{"received":true}' error: "" created_at: "2026-11-01T00:05:00Z" completed_at: "2026-11-01T00:05:01Z" next_retry_at: null total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No endpoint with this id in this mode, or it was deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints/{id}/deliveries/{deliveryID}/replay: post: tags: [Webhooks] summary: Resend one delivery to its endpoint operationId: replayWebhookDelivery description: | Sends the delivery's event again to this one endpoint only. Other endpoints are not contacted. Hardcount copies the event to a new event (`replay_of` points to the original) and queues a new delivery. The message sent carries the original event's `id` and `created_at`. The original delivery is not changed. Sending happens in the background, so `status` is always `queued`. Writes an audit row. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string description: Endpoint id. - name: deliveryID in: path required: true schema: type: string description: Delivery id. It must belong to this endpoint. responses: "200": description: Replay queued content: application/json: schema: $ref: "#/components/schemas/WebhookDeliveryReplayResult" example: event_id: hc_whevt_dbj84biq3gpgqfoq5c9r replay_of: hc_whevt_dbj84biq3gpgqfoq5c8g delivery_id: hc_whd_dbj84bqq3gpgqfoq5c9k status: queued "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No such delivery in this mode, the delivery belongs to another endpoint, or the endpoint was deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook_delivery not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: "The endpoint is paused (`active` is false). Resume it first. Code `invalid_state`." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: endpoint is paused — resume it before replaying deliveries request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/endpoints/stats: get: tags: [Webhooks] summary: Get delivery counts per webhook endpoint operationId: getWebhookEndpointStats description: | All-time delivery counts for each endpoint that has deliveries, in the caller's mode, ordered by endpoint id. Endpoints with no deliveries are not listed. Deleted endpoints can appear. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One row per endpoint content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/WebhookEndpointStats" total: $ref: "#/components/schemas/ListTotal" example: data: - endpoint_id: hc_whe_danslg2q3gpgqfoq5c80 total_deliveries: 120 succeeded: 117 failed: 1 success_rate: 99.15254237288136 total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/webhook-endpoints/events: get: tags: [Webhooks] summary: List webhook events operationId: listWebhookEvents description: | Returns the outbound events in the caller's mode, newest first, replays included, each with its full `payload`. An event is listed even when no endpoint subscribed to it. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of events. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/WebhookEvent" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_whevt_dbj84biq3gpgqfoq5c8g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false event_type: credit.balance_depleted payload: credit_balance: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD balance_cents: 0 total_granted: 5000 total_used: 5000 total_expired: 0 total_retired: 0 total_deducted: 0 created_at: "2026-11-01T00:05:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/webhook_events/{id}/deliveries: get: tags: [Webhooks] summary: List all deliveries of an event and its replays operationId: listWebhookEventDeliveries description: | Returns one timeline, oldest first, of every delivery for the original event and for all of its replays. If `id` is a replay, the timeline starts from its original event (`root_event_id`). Rows keep the endpoint's URL and description even after the endpoint is deleted. The whole timeline comes in one answer, with no paging. Needs the `apikey:read` permission. parameters: - name: id in: path required: true schema: type: string description: An event id, either the original or a replay. responses: "200": description: Delivery timeline content: application/json: schema: type: object required: [root_event_id, deliveries] properties: root_event_id: type: string description: The original event the timeline is built from. deliveries: type: array items: $ref: "#/components/schemas/WebhookEventDeliveryAttempt" example: root_event_id: hc_whevt_dbj84biq3gpgqfoq5c8g deliveries: - id: hc_whd_dbj84bqq3gpgqfoq5c90 event_id: hc_whevt_dbj84biq3gpgqfoq5c8g endpoint_id: hc_whe_danslg2q3gpgqfoq5c80 endpoint_url: https://example.com/hardcount/webhooks endpoint_description: Billing sync attempt_no: 1 status: succeeded status_code: 200 response_body: '{"received":true}' error: "" request_payload_sha256: 2729692ed25262f63668409fe4ef3b8a010360397231ebf671f77bac8956322e attempted_at: "2026-11-01T00:05:00Z" completed_at: "2026-11-01T00:05:01Z" next_retry_at: null is_replay: false replay_event_id: "" - id: hc_whd_dbj84bqq3gpgqfoq5c9k event_id: hc_whevt_dbj84biq3gpgqfoq5c9r endpoint_id: hc_whe_danslg2q3gpgqfoq5c80 endpoint_url: https://example.com/hardcount/webhooks endpoint_description: Billing sync attempt_no: 2 status: pending status_code: 503 response_body: Service Unavailable error: HTTP 503 request_payload_sha256: 2729692ed25262f63668409fe4ef3b8a010360397231ebf671f77bac8956322e attempted_at: "2026-11-01T10:30:00Z" completed_at: null next_retry_at: "2026-11-01T10:31:12Z" is_replay: true replay_event_id: hc_whevt_dbj84biq3gpgqfoq5c9r "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:read` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:read access" request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No event with this id in this mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook event not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/webhook_events/{id}/replay: post: tags: [Webhooks] summary: Replay an event to all matching endpoints operationId: replayWebhookEvent description: | Copies the event to a new event and queues it for every active endpoint in the same mode that subscribes to its type. Endpoints that already received it get it again, with the original event's `id` and `created_at`. The original event and its deliveries are not changed. Each call makes a new copy. `replay_of` is always the original event, even when `id` is a replay. `status` is `queued`, or `no_matching_endpoints` when no endpoint will receive it. Sending happens in the background. Writes an audit row. Needs only the `apikey:read` permission. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string description: An event id, either the original or a replay. responses: "200": description: Replay created content: application/json: schema: $ref: "#/components/schemas/WebhookReplayResult" example: event_id: hc_whevt_dbj84biq3gpgqfoq5c9r replay_of: hc_whevt_dbj84biq3gpgqfoq5c8g status: queued "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller lacks the `apikey:read` permission (a usage-only key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:read access" request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No event with this id in this mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook event not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/webhook_events/stream: get: tags: [Webhooks] summary: Stream webhook events as they happen operationId: streamWebhookEvents description: | A long-lived Server-Sent Events stream of the account's outbound webhook events. Accepts the dashboard session cookie or a Bearer key with `apikey:read`. It has no 30-second request timeout. On connect, the server sends the 50 newest events, newest first. It then sends a frame for each new event and replay (status `pending`), and for each delivery that succeeds or fails for good. A failed attempt that will be retried sends no frame. Every frame is `event: webhook_event` with a JSON `data:` line. A comment line `: heartbeat` is sent every 15 seconds to keep proxies from closing the connection. The same event can arrive more than once; de-duplicate by `event_id`. A client that reads too slowly can miss frames: reconnect to get the newest 50 again. Frames sent after the first batch are not filtered by mode: check `livemode` on each frame. The first batch holds only the caller's mode. The later frames come from the server instance that handled the event, so with several replicas a client sees only that replica's events. responses: "200": description: | Event stream. Each frame's `data` is a WebhookStreamFrame object in JSON. content: text/event-stream: schema: type: string description: | SSE frames: `event: webhook_event\ndata: \n\n`, plus `: heartbeat\n\n` comments. "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:read` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:read access" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/webhooks/stripe/{endpoint_id}: post: tags: [InboundWebhooks] x-internal: true security: [] summary: Receive a Stripe webhook operationId: receiveStripeWebhook description: | Stripe calls this route; your code does not. It is not part of the integration API. Point a webhook in your Stripe dashboard at it for each connected mode. `endpoint_id` is the `id` of that mode's Stripe connection (`GET /v1/settings/stripe`). The request is checked against the `Stripe-Signature` header and that mode's signing secret, so save the secret first (`PATCH /v1/settings/stripe/{mode}/webhook`). The body can be at most 64 KB. An event whose object carries no Hardcount metadata is answered 200 with `status: skipped`, except setup, refund and dispute events, which are matched through the connection. A 500 means a temporary failure, and Stripe sends the event again. parameters: - name: endpoint_id in: path required: true schema: type: string description: The `id` of the mode's Stripe connection (`hc_spc_…`). - name: Stripe-Signature in: header required: true schema: type: string description: Stripe's signature of the body. Hardcount checks it with the connection's webhook signing secret. requestBody: required: true content: application/json: schema: type: object description: A Stripe event object. additionalProperties: true example: id: evt_3Q2w3E4r5T6y7U8i0a1b2c3d object: event type: payment_intent.succeeded created: 1793491505 livemode: false data: object: id: pi_3Q2w3E4r5T6y7U8i1x2y3z4w object: payment_intent amount: 3650 currency: usd status: succeeded customer: cus_R2w3E4r5T6y7U8 metadata: hardcount_tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 hardcount_invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 responses: "200": description: Processed, or skipped. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [processed, skipped] description: "`processed` when Hardcount handled the event, `skipped` when it is not about a Hardcount object." reason: type: string description: Why the event was skipped. Absent when it was processed. example: status: processed "400": description: | The `Stripe-Signature` check failed, the body could not be read or is not valid JSON, or the event's mode or Hardcount account does not match the connection. Code `invalid_request`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid signature request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No Stripe connection has this `endpoint_id`, or the connection has no webhook signing secret yet. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: webhook_endpoint not found request_id: req_dbjg652q3gpgqfoq5cd0 "413": description: The body is over 64 KB. Code `payload_too_large`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: payload_too_large message: webhook body exceeds the size cap request_id: req_dbjg652q3gpgqfoq5cd0 "500": description: Temporary failure. Stripe retries. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/webhooks/postmark: post: tags: [InboundWebhooks] x-internal: true security: - PostmarkBasicAuth: [] summary: Receive a Postmark delivery webhook operationId: receivePostmarkWebhook description: | Postmark calls this route; your code does not. It is not part of the integration API. Hardcount reads `Delivery`, `Bounce` and `SpamComplaint` records and ignores other record types and soft bounces. Without `POSTMARK_WEBHOOK_USER` and `POSTMARK_WEBHOOK_PASS` set, every request gets 401. See [Set up email](https://gethardcount.com/docs/self-host/email-setup/). Each record is matched to the email Hardcount sent through the `hc-outbox-id` key in its `Metadata`. A delivery, hard bounce or spam complaint updates that email's delivery state when the record is about the email's main recipient, not a copy. A bounce or complaint also marks the account's customers who use that address as bounced or complained, when `HARDCOUNT_EMAIL_BIDX_KEY` is set. A body that a retry cannot fix is answered 200, so Postmark stops retrying: over 64 KB, invalid JSON, or no known Hardcount email id in its metadata. A soft bounce is answered 200 as `ignored`. Errors use the usual error body. requestBody: required: true content: application/json: schema: type: object description: A Postmark webhook payload. additionalProperties: true example: RecordType: Bounce MessageID: 883953f4-6105-42a2-a16a-77a8eac79483 Type: HardBounce TypeCode: 1 Email: billing@acme.example Inactive: true Description: "The server was unable to deliver your message (ex. unknown user, mailbox not found)." Metadata: hc-outbox-id: hc_emob_dbj84cqq3gpgqfoq5cbg responses: "200": description: Processed, skipped or ignored. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [processed, skipped, ignored] description: | `processed`: the record was applied. `skipped`: it could not be used (too large, invalid JSON, or no known email id). `ignored`: Hardcount does not read this record type, or it is a soft bounce. reason: type: string description: | Why the record was skipped or ignored: `payload too large`, `invalid json`, `no correlation metadata`, `unknown outbox id`, `record type not ingested` or `transient bounce`. Absent when it was processed. example: status: processed "401": description: | Wrong or missing Basic auth, or the credentials are not set on the server. The answer carries a `WWW-Authenticate: Basic` header. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: unauthorized request_id: req_dbjg652q3gpgqfoq5cd0 "500": description: Temporary failure. Postmark retries. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/plans: post: tags: [Pricing] summary: Create a plan operationId: createPlan description: | Creates an active plan and its usage prices in one request: if any price is refused, nothing is created. Every price takes the plan's currency. `base_bill_timing` is required when the base fee is above 0. The create is written to the audit log. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [code, name, currency, billing_interval] properties: code: type: string description: | Your short name for the plan. Letters, digits, `-` and `_`; at most 100 characters; unique in the account and mode. Fixed after create. name: type: string description: | The display name. Spaces at the ends are trimmed; then at most 255 bytes (UTF-8). description: type: string description: Your description of the plan. currency: type: string description: | ISO 4217 code, such as `USD`, stored in upper case. Zero-decimal currencies such as JPY are refused. Fixed after create. billing_interval: type: string enum: [monthly, yearly] description: How often a subscription to the plan is billed. Fixed after create. base_amount_cents: type: integer format: int64 description: | The recurring base fee per billing period, in the currency's minor unit (cents for USD). 0 or more; 0 when left out. base_bill_timing: type: string enum: [in_advance, in_arrears] description: | Required when `base_amount_cents` is above 0. With a base of 0 it may be left out, and the plan is in_arrears; `in_advance` with a base of 0 is refused. See `base_bill_timing` on Plan. prices: type: array description: | The plan's usage prices, created with it in one request. Each takes the plan's currency. items: $ref: "#/components/schemas/PriceInput" status: type: string enum: [active] description: Optional. A plan is created active; archive it with PATCH. tax_code: type: string description: | A Stripe product tax code, `txcd_` and 8 digits, such as `txcd_10103001`. Empty or omitted leaves it unset. See `tax_code` on Plan for which invoice lines carry it. example: code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly base_amount_cents: 4900 base_bill_timing: in_arrears prices: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 mode: per_unit unit_amount_cents: "0.0003" responses: "201": description: The new plan, with its prices. content: application/json: schema: $ref: "#/components/schemas/PlanDetail" example: id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" used: false live_subscription_count: 0 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "409": description: | Nothing is created. `already_exists`: a plan with this `code` exists in this account and mode (`param: code`). `price_exists`: two prices name the same meter and `dimension_match`. `price_conflict`: two prices on one meter could both take one event with the same number of labels, so neither would win; the message names the other price. For both price codes, `param` is `prices[i].dimension_match` for the second price. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: plan code "pro" already exists request_id: req_dbjg652q3gpgqfoq5cd0 param: code "422": description: | A field failed validation (a price names an unknown meter, a rate is outside its limits, `base_bill_timing` is missing with a base fee above 0 or is `in_advance` with a base of 0), `status` is set to anything but `active` (a plan is created active; archive it with PATCH), or the body has an unknown field. A failed check on a price's field names it with `prices[i].` first, such as `prices[0].unit_amount_cents` (i counts from 0). An unknown field is named by its bare key at any depth: `price` inside a price gives `param: price`, not `prices[0].price`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "is required when base_amount_cents is above 0: in_advance bills the base fee at the start of each period, in_arrears at the end" request_id: req_dbjg652q3gpgqfoq5cd0 param: base_bill_timing "429": $ref: "#/components/responses/RateLimited" get: tags: [Pricing] summary: List plans operationId: listPlans description: Plans in the mode of your API key, newest first, each with its prices. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of plans, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Plan" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/plans/{id}: get: tags: [Pricing] summary: Get a plan operationId: getPlan parameters: - name: id in: path required: true schema: type: string responses: "200": description: The plan, with its prices. content: application/json: schema: $ref: "#/components/schemas/PlanDetail" example: id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" used: true live_subscription_count: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no plan with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: plan not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" patch: tags: [Pricing] summary: Update a plan description: | A field left out keeps its current value. To clear `description` or `tax_code`, send an empty string. `code`, `currency` and `billing_interval` cannot be changed: sending one is a 422 naming it. The plan's usage prices change through `/v1/plans/{id}/prices`. `base_amount_cents` cannot change while a subscription that is not canceled uses the plan (`live_subscription_count` on Get plan): create a new plan instead. It can be set to 0. `base_bill_timing` cannot change once a subscription has used the plan (`used`). A plan with no base fee must be `in_arrears`. `name`, `description`, `tax_code` and `status` change at once. The change is written to the audit log. operationId: updatePlan parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: name: type: string description: | Whitespace is trimmed. Empty or omitted keeps the current name. Unlike create, the update does not check its length. description: type: string description: | Whitespace is trimmed. Omit to keep the current description; an empty string clears it. base_amount_cents: type: integer format: int64 description: 0 or more. Omit to keep the current base fee. base_bill_timing: type: string enum: [in_advance, in_arrears] description: | When the base fee is billed. Omit to keep the current timing. Refused once a subscription has used the plan. status: type: string enum: [active, archived] description: | `archived` stops new subscriptions and plan changes onto the plan. `active` brings it back. tax_code: type: string description: | A Stripe product tax code, `txcd_` and 8 digits. Omit to keep the current code; an empty string clears it. See `tax_code` on Plan for which invoice lines carry it. example: tax_code: txcd_10103001 responses: "200": description: The updated plan, with its prices. content: application/json: schema: $ref: "#/components/schemas/PlanDetail" example: id: hc_pln_dajr5o2q3gpgqfoq5c1g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: pro name: Pro description: Base fee plus metered tokens. currency: USD billing_interval: monthly status: active base_amount_cents: 4900 base_bill_timing: in_arrears prices: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-10-20T08:45:00Z" tax_code: txcd_10103001 used: true live_subscription_count: 1 "400": description: | `invalid_request`: the body is not valid JSON. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no plan with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: plan not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `invalid_state`: another write changed the plan's base fee during this one. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: This plan changed while the request was running. Refresh the page and try again. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `validation_error`: the base fee changed while subscriptions that are not canceled use the plan (param `plan`), `base_bill_timing` changed on a used plan or is `in_advance` with a base of 0, a negative base fee, a `status` other than `active` or `archived`, an invalid `tax_code`, or an unknown field such as `meter_ids` (a plan's meters are its prices' meters). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 1 subscription uses this plan, so its base fee is fixed. Create a new plan with the new fee instead. request_id: req_dbjg652q3gpgqfoq5cd0 param: plan "429": $ref: "#/components/responses/RateLimited" "500": description: | `internal_error`. When it comes from reading the plan back after the update, the update was saved: get the plan to check. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/plans/{id}/prices: get: tags: [Pricing] summary: List a plan's prices description: The plan's usage prices from now on, in the order they were added. operationId: listPlanPrices parameters: - name: id in: path required: true schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of prices, and how many the plan has. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/PlanPrice" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.0003" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-09-15T09:05:00Z" - id: hc_prc_dajr672q3gpgqfoq5c2a tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: model: claude-opus-4.5 mode: per_unit unit_amount_cents: "0.0015" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-10-20T14:00:00Z" updated_at: "2026-10-20T14:00:00Z" total: 2 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no plan with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: plan not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" post: tags: [Pricing] summary: Add a price to a plan description: | On a plan no subscription has used, the price applies at once (`effective: now`). On a used plan it applies from each subscriber's next billing period (`effective: next_period`); until then the plan's other prices bill the meter's usage as before. A subscriber who first joins the plan in a period after the add pays it from the moment they join (see `effective` in the answer). A price that names a removed price's meter and `dimension_match` adds that price back under its id. Its old customer overrides do not come back. The write is recorded in the audit log. operationId: addPlanPrice parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PriceInput" example: meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: model: claude-opus-4.5 mode: per_unit unit_amount_cents: "0.0015" responses: "201": description: The price, and when it applies. content: application/json: schema: $ref: "#/components/schemas/PriceWrite" example: id: hc_prc_dajr672q3gpgqfoq5c2a tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: model: claude-opus-4.5 mode: per_unit unit_amount_cents: "0.0015" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-10-20T14:00:00Z" updated_at: "2026-10-20T14:00:00Z" effective: next_period "400": description: | `invalid_request`: the body is not valid JSON. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no plan with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: plan not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `price_exists`: the plan already has a price on this meter with this `dimension_match`; edit it instead. `price_conflict`: a price on the plan and meter could take the same events with the same number of labels, so neither would win; the message names it. Add a label to make one more specific, or give a shared label another value. `meter_overlap`: the meter is new to a used plan, and a customer on the plan already has it on another item, of the same subscription or another one. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: price_exists message: Price hc_prc_dajr672q3gpgqfoq5c2a already prices this meter with the same dimension_match. Edit that price instead. request_id: req_dbjg652q3gpgqfoq5cd0 param: dimension_match "422": description: | `validation_error`: an unknown meter, a label that is not a string or breaks the limits, a rate outside its limits, a price shape that cannot be billed, or an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: meter "hc_mtr_dajr122q3gpgqfoq5c1v" not found request_id: req_dbjg652q3gpgqfoq5cd0 param: meter_id "429": $ref: "#/components/responses/RateLimited" /v1/plans/{id}/prices/{price_id}: patch: tags: [Pricing] summary: Change a price's rate description: | Changes the price's shape. Its meter and `dimension_match` are its identity and cannot change: add a price for other events instead. On a plan no subscription has used, the change applies at once; on a used plan, from each subscriber's next billing period. The current period keeps the price it started with. A subscriber who first joins the plan in a period after the change pays it from the moment they join (see `effective` in the answer). Customer overrides of the price keep applying. The write is recorded in the audit log. operationId: updatePlanPrice parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string - name: price_id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PriceShape" example: mode: per_unit unit_amount_cents: "0.00025" responses: "200": description: The price, and when the change applies. content: application/json: schema: $ref: "#/components/schemas/PriceWrite" example: id: hc_prc_dajr672q3gpgqfoq5c20 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: {} mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-09-15T09:05:00Z" updated_at: "2026-10-20T14:00:00Z" effective: next_period "400": description: | `invalid_request`: the body is not valid JSON. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no such plan (`plan not found`), or no such price on it, a removed price included (`price not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: price not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `validation_error`: a rate outside its limits, a price shape that cannot be billed, `meter_id` or `dimension_match` (the price's identity), or another unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: is a price's identity and cannot change; add a price with the other dimension_match and remove this one request_id: req_dbjg652q3gpgqfoq5cd0 param: dimension_match "429": $ref: "#/components/responses/RateLimited" delete: tags: [Pricing] summary: Remove a price from a plan description: | On a plan no subscription has used, the price is gone at once. On a used plan, it stops billing from each subscriber's next billing period; the current period keeps it. A subscriber who first joins the plan in a period after the removal does not pay it (see `effective` in the answer). The removal ends every customer override of the price, each when the removal takes effect for its customer, and the answer names them (`ended_overrides`). Adding the price back later keeps its id but not the overrides. The write is recorded in the audit log. Send no body. operationId: removePlanPrice parameters: - name: id in: path required: true schema: type: string - name: price_id in: path required: true schema: type: string responses: "200": description: The removed price, when the removal applies, and the overrides it ended. content: application/json: schema: $ref: "#/components/schemas/PriceWrite" example: id: hc_prc_dajr672q3gpgqfoq5c2a tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 dimension_match: model: claude-opus-4.5 mode: per_unit unit_amount_cents: "0.0015" tiers: [] package_size: 0 package_amount_cents: "0" created_at: "2026-10-20T14:00:00Z" updated_at: "2026-10-28T10:00:00Z" effective: next_period removed: true ended_overrides: - id: hc_cpo_daof442q3gpgqfoq5cb4 customer_id: hc_cus_dakhnk2q3gpgqfoq5c2k "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no such plan (`plan not found`), or no such price on it, a removed price included (`price not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: price not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "mode" request_id: req_dbjg652q3gpgqfoq5cd0 param: mode "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions: post: tags: [Subscriptions] summary: Create a subscription operationId: createSubscription description: | Creates a subscription that bills a customer for one or more plans. Leave out `status` and it starts now: `active` from today, or `trialing` when `trial_days` is above 0. Send `status: draft` to create it without starting it. All the plans must share one billing interval, one base-fee timing (in advance or in arrears) and one currency. The currency must also match the customer's billing profile and the customer's other `draft`, `trialing` and `active` subscriptions; canceled ones do not count. No two of the plans may bill the same meter, and none may bill a meter that another `draft`, `trialing` or `active` subscription of the customer bills. The customer and the plans must not be archived. An `active` subscription whose plans bill a non-zero base fee in advance is saved together with its first invoice, in one database transaction: both are saved, or neither is. The invoice is then collected like any other invoice. A `trialing` subscription gets that invoice when its trial ends. A customer on a test clock gives the subscription its clock, and the subscription starts at the clock's time. Sends the `subscription.created` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [code, display_name, customer_id, items] properties: code: type: string description: | Your code for the subscription: letters, digits, hyphens and underscores. Unique in your account and mode. display_name: type: string description: The subscription's name. Must not be blank. customer_id: type: string description: | The customer to bill (`hc_cus_…`), in this account and mode. items: type: array description: Plan items — a subscription bills one or more plans. items: type: object required: [plan_id] properties: plan_id: type: string description: A plan of this account (`hc_pln_…`). Each plan can appear once. quantity: type: integer default: 1 description: How many of the plan to bill. At least 1; 0 or absent means 1. billing_time: type: string enum: [calendar, anniversary] default: calendar description: How monthly periods line up. See the subscription's `billing_time`. trial_days: type: integer minimum: 0 maximum: 730 description: | Days of free trial; 0 means no trial. Above 0, the subscription is `trialing` from now and its first paid period starts on the day the trial ends. status: type: string enum: [draft] description: | Leave it out and the subscription starts now. Without `trial_days` it is `active`, its first period starts today in the account's billing time zone (the clock's day for a customer on a test clock), and a non-zero base fee billed in advance is invoiced now. With `trial_days` above 0 it is `trialing` from now. Send `draft` to create it without starting it: it bills nothing until you activate it with `POST /v1/subscriptions/{id}/activate`. A draft cannot have a trial (`trial_days` above 0). An empty or null `status` is refused. example: code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 billing_time: calendar responses: "201": description: Subscription created content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this `customer_id` in this account and mode (code `not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `already_exists` (`param: code`): another subscription in this account and mode has this `code`. Code `invalid_state`: the customer or a plan is archived; a plan bills a meter that another `draft`, `trialing` or `active` subscription of the customer already bills; or the plans' currency differs from each other, from the customer's billing profile or from the customer's other `draft`, `trialing` or `active` subscriptions. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: subscription code "acme-pro" already exists request_id: req_dbjg652q3gpgqfoq5cd0 param: code "422": description: | A field failed validation (`param` names it), for example `trial_days` outside 0 to 730, `status` other than `draft` (empty and null included), or `trial_days` above 0 with `status: draft`. Also: `code`, `display_name`, `customer_id` or `items` missing; a `code` with other characters; a repeated or unknown plan, plans with different billing intervals or base-fee timings, or two plans that bill the same meter (`param: items`); a `quantity` below 1. Or the body has an unknown field (`param` names it; the body must use only the fields listed here). `start_now` is such a field since 2026-10-23: a subscription starts now unless `status` is `draft`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be between 0 and 730 request_id: req_dbjg652q3gpgqfoq5cd0 param: trial_days "429": $ref: "#/components/responses/RateLimited" get: tags: [Subscriptions] summary: List subscriptions operationId: listSubscriptions description: | Lists the subscriptions of this account and mode, with their items, newest first unless you sort otherwise. The filters combine. parameters: - name: customer_id in: query description: Only this customer's subscriptions (`hc_cus_…`). schema: type: string - name: plan_id in: query description: Filter to subscriptions with an item on this plan that has not been removed. schema: type: string - name: status in: query description: | Exact status. Any other value is a 422 with `param: status`. Paused collection is not a status: use `collection=paused`. schema: type: string enum: [draft, trialing, active, canceled] - name: billing in: query description: | `failing` returns only subscriptions that carry a `billing_failure`: those waiting for their next billing attempt and those that are parked (set aside after eight failed billing attempts, until someone retries them). Any other value is a 422 with `param: billing`. schema: type: string enum: [failing] - name: collection in: query description: | `paused` returns only active and trialing subscriptions whose `pause_collection` is in force, whether a dashboard user or an API key set it, or dunning (Hardcount's automatic payment retries) did. A timed pause ends at `resumes_at` (on the subscription's test clock, if it has one), even while the response still carries it. Any other value is a 422 with `param: collection`. schema: type: string enum: [paused] - name: search in: query description: Case-insensitive substring match on display name or code. schema: type: string - name: sort in: query description: | The order of the list. `created_at` (the default), `next_billing_at`, `status`, `display_name` (or `name`) or `trial_end_at`. Any other value is a 422 with `param: sort`. Ties are broken by `id` in the same direction. schema: type: string enum: [created_at, next_billing_at, status, display_name, name, trial_end_at] default: created_at - name: dir in: query description: | Sort direction: `desc` (the default) or `asc`, in lower case. Any other value is a 422 with `param: dir`. schema: type: string enum: [asc, desc] default: desc - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Subscription list content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Subscription" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | Code `validation_error`. A `status`, `billing` or `collection` filter the API does not know, `limit` or `offset` out of range or not a whole number, the removed `after` cursor, or a `sort` or `dir` that is not one of its values. `param` names it, and for the filters, `sort` and `dir` the message lists the allowed values. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "status must be one of draft, trialing, active, canceled; paused collection is not a status, use collection=paused" request_id: req_dbjg652q3gpgqfoq5cd0 param: status "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/activate: post: tags: [Subscriptions] summary: Activate a draft subscription operationId: activateSubscription description: | Starts a `draft` subscription: it becomes `active` now, and its first period starts today in your account's billing time zone (the clock's day for a customer on a test clock). A non-zero base fee billed in advance is invoiced in the same database transaction as the activation: both are saved, or neither is. The plans' currency must still match the customer's billing profile and the customer's other `draft`, `trialing` and `active` subscriptions. Takes no body. Sends the `subscription.activated` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: Subscription activated content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-09-28T16:20:00Z" updated_at: "2026-09-28T16:20:00Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-09-28T16:20:00Z" updated_at: "2026-10-01T09:12:44Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is not a `draft`, or its plans' currency differs from the customer's billing profile or from the customer's other `draft`, `trialing` or `active` subscriptions. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Only a draft subscription can be activated. This one is active. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "start_at" request_id: req_dbjg652q3gpgqfoq5cd0 param: start_at "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/retry-billing: post: tags: [Subscriptions] summary: Retry a subscription's failed billing operationId: retrySubscriptionBilling description: | Clears the subscription's `billing_failure` and bills it now, with the same attempt the billing run makes. Use it after fixing the cause of a failure, to skip the wait, or to restart a parked subscription (one set aside after eight failed billing attempts, until someone retries it). An audit log entry records the retry. Takes no body. Once the failure is cleared, the outcome of the attempt is never an HTTP error. A 200 has three outcomes: - **Billed.** No `billing_failure`, and `still_due` is false. A subscription that was overdue for several periods is billed for each of them, one invoice per period. - **Failed again.** The subscription carries a new `billing_failure` with `attempts: 1`, and the billing run then retries on its own schedule. This includes an attempt that finished without an error but left the subscription due. - **Not completed.** No `billing_failure`, and `still_due` is true. The attempt was cut short by a lost database connection or a deadlock, and nothing was saved. The subscription is still unbilled, and the next billing run attempts it. Calling this endpoint again answers 409, because the failure is already cleared. The call bills inline. If it times out part-way through a long catch-up (billing many overdue periods), the response is an error, the invoices already created stay, and the next billing run finishes the rest. The failure may already be cleared at that point, so a repeat call can answer 409. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: | The retry ran. Read the outcome from two fields. `subscription.billing_failure` is present when the attempt failed again. When it is absent, `still_due` false means the subscription billed or had nothing left to bill, and `still_due` true means the attempt did not complete and the subscription is still unbilled. content: application/json: schema: type: object required: [subscription, invoices_generated, still_due] properties: subscription: $ref: "#/components/schemas/Subscription" invoices_generated: type: integer description: | Period-close invoices this call created. The final invoice of a scheduled cancel and the first invoice at a trial end are not counted, so 0 does not mean the retry failed. Read success from `subscription.billing_failure` and `still_due`. still_due: type: boolean description: | True when, after the attempt, the subscription has no `billing_failure` and is still due for billing. The attempt did not complete and nothing was saved; the next billing run attempts the subscription. example: subscription: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-11-01T00:00:00Z" current_billing_period_end: "2026-12-01T00:00:00Z" current_billing_period_display: "Nov 1, 2026 – Nov 30, 2026" next_billing_at: "2026-12-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-11-01T01:42:10Z" invoices_generated: 1 still_due: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The retry was refused. Code `billing_not_failing`: the subscription has no `billing_failure`, so there is nothing to retry. Code `invalid_state`: the subscription is attached to a test clock; use the clock's retry advance instead. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: billing_not_failing message: billing is not failing on this subscription; there is nothing to retry request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "force" request_id: req_dbjg652q3gpgqfoq5cd0 param: force "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}: get: tags: [Subscriptions] summary: Get a subscription operationId: getSubscription description: Returns one subscription with its items. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The subscription. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode (code `not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/cancel: post: tags: [Subscriptions] summary: Cancel a subscription now operationId: cancelSubscription description: | Cancels the subscription immediately. Works on `draft`, `trialing` and `active` subscriptions. Takes no body. In the transaction that cancels it, Hardcount writes the final invoice for the part of the current period that has been used (usage, and base fees billed in arrears, prorated to the cancel), and the relief for any base fee billed in advance: a credit note for the unused part, written as a draft, or a void of an unpaid invoice of which nothing was used. After the transaction commits, the credit notes are issued, which is when the balance credit and the tax reversal happen (a failed issue is retried), and then the final invoice is collected, so that credit reduces it. A trial that never became active is canceled with no invoice. If a billing period has already ended, it is billed first. If a scheduled cancel was already due, that cancel takes effect at its scheduled time and the canceled subscription is returned. The canceled subscription keeps its last `current_billing_period_*` and `next_billing_at`; nothing is billed at that time. Sends the `subscription.canceled` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The canceled subscription. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: canceled billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" canceled_at: "2026-10-20T16:45:00Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-20T16:45:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is already `canceled`. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. Code `funding_invoice_tax_pending`: an invoice the relief would credit is a draft whose tax is not calculated yet; retry once it is. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot cancel canceled subscription (already terminated) request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "at_period_end" request_id: req_dbjg652q3gpgqfoq5cd0 param: at_period_end "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/schedule-cancel: post: tags: [Subscriptions] summary: Schedule a cancel operationId: scheduleSubscriptionCancel description: | Schedules the subscription to cancel later. Set exactly one of `at_period_end` or `cancel_at`. A new call replaces any earlier schedule. If a billing period has ended and is not invoiced yet, it is billed first, so "the current period" is the one running. - **Active subscription.** `at_period_end` cancels at the end of the current period. `cancel_at` must be on or after the end of the current period. A date inside a later period is allowed, and that period is billed up to the date. - **Trialing subscription.** `at_period_end` cancels at the end of the trial, with no charge. `cancel_at` must be exactly `trial_end_at` (free cancel) or on or after `current_billing_period_end`. The response's `cancel_effective_at` says when the cancel takes effect. Sends the `subscription.cancel_scheduled` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object properties: at_period_end: type: boolean description: Cancel at the end of the current period (or of the trial, while trialing). cancel_at: type: string format: date-time description: Cancel at this instant. Must be in the future. example: at_period_end: true responses: "200": description: The subscription with the schedule set. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: true cancel_effective_at: "2026-11-01T00:00:00Z" current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-20T16:45:00Z" "400": description: The body is not valid JSON, a field has a value of the wrong JSON type, or `cancel_at` is not an RFC 3339 date-time. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is `canceled`, or its status changed while the request ran (read it again and retry). Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: A canceled subscription can't be scheduled to cancel. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`: neither or both of `at_period_end` and `cancel_at` are set (`param: body`), or `cancel_at` is in the past or breaks the date rules above (`param: cancel_at`). Also: the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: one of at_period_end or cancel_at must be set request_id: req_dbjg652q3gpgqfoq5cd0 param: body "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/scheduled-cancel: delete: tags: [Subscriptions] summary: Clear a scheduled cancel operationId: clearSubscriptionScheduledCancel description: | Removes a scheduled cancel and sends the `subscription.cancel_cleared` webhook event. Calling it on a subscription with no schedule returns the subscription unchanged and sends no event. If the scheduled date has already passed, the cancel has taken effect and the request answers 409. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The subscription with no scheduled cancel. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-22T08:10:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the scheduled cancel already took effect when the billing period ended. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: the scheduled cancel already took effect when the billing period ended; the subscription is canceled request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "at_period_end" request_id: req_dbjg652q3gpgqfoq5cd0 param: at_period_end "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/pause-collection: put: tags: [Subscriptions] summary: Pause collection operationId: pauseSubscriptionCollection description: | Pauses payment collection. The subscription keeps running: each period still closes and its invoice is created, but the invoice stays a draft. Hardcount does not finalize it, charge it or start dunning (automatic payment retries). A period that ended before the call and is not invoiced yet is billed first, as it stood. Invoices finalized before the pause are not drafts. Payment retries already under way for them continue, as in Stripe. To stop them, void the invoice or mark it uncollectible. Only `behavior: keep_as_draft` is supported. With `resumes_at`, periods that end before that instant bill as drafts, and later periods bill normally. Without it, the pause lasts until you call `DELETE /v1/subscriptions/{id}/pause-collection`. A new call replaces the current pause, including one dunning set; the pause is then yours (`set_by: operator`), and a payment no longer lifts it. Sends the `subscription.collection_paused` webhook event, with `triggered_by: operator`. If the current pause had already ended (its `resumes_at` has passed), its `subscription.collection_resumed` is sent first. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [behavior] properties: behavior: type: string enum: [keep_as_draft] description: | What happens to invoices built during the pause. Only `keep_as_draft`, which keeps them as drafts. resumes_at: type: string format: date-time description: When collection resumes. Must be in the future. example: behavior: keep_as_draft resumes_at: "2026-12-01T00:00:00Z" responses: "200": description: The subscription with `pause_collection` set. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false pause_collection: behavior: keep_as_draft resumes_at: "2026-12-01T00:00:00Z" set_by: operator current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-20T16:45:00Z" "400": description: The body is not valid JSON, a field has a value of the wrong JSON type, or `resumes_at` is not an RFC 3339 date-time. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is `canceled`. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Collection can't be paused on a canceled subscription. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`: `behavior` is missing or not `keep_as_draft`, or `resumes_at` is not in the future. Also: the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be in the future request_id: req_dbjg652q3gpgqfoq5cd0 param: resumes_at "429": $ref: "#/components/responses/RateLimited" delete: tags: [Subscriptions] summary: Resume collection operationId: resumeSubscriptionCollection description: | Ends a collection pause. The period that is running bills normally; a period that ended during the pause keeps its draft invoice. It works on a canceled subscription too, whose pause still holds its unpaid invoices. Sends the `subscription.collection_resumed` webhook event. If the pause had already ended (its `resumes_at` has passed), the event says what ended it: `triggered_by` is `payment` or `schedule`, not `operator`. Calling it on a subscription that is not paused returns the subscription unchanged and sends no event. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The subscription with no `pause_collection`. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-24T11:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: unbilled_period message: this subscription has a billing period that ended and is not invoiced yet; retry billing for this subscription, then try again request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "behavior" request_id: req_dbjg652q3gpgqfoq5cd0 param: behavior "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/end-trial: post: tags: [Subscriptions] summary: End a trial now operationId: endSubscriptionTrial description: | Ends the trial now and makes the subscription `active`. The first paid period starts today, and `trial_end_at` becomes now. Items whose base fee is billed in advance get their first invoice in the same transaction. A subscription with a scheduled cancel is refused: clear the schedule first. Takes no body. Sends the `subscription.trial_ended` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The active subscription. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar trial_start_at: "2026-10-01T09:12:44Z" trial_end_at: "2026-10-08T10:00:00Z" started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-08T10:00:00Z" cancel_at_period_end: false current_billing_period_start: "2026-10-08T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 8, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-08T10:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is not `trialing`, or it has a scheduled cancel. Clear the scheduled cancel first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: This subscription is scheduled to cancel. Undo the scheduled cancellation first, then end the trial. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "trial_end" request_id: req_dbjg652q3gpgqfoq5cd0 param: trial_end "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/extend-trial: post: tags: [Subscriptions] summary: Extend a trial operationId: extendSubscriptionTrial description: | Moves the end of the trial later. The first paid period is moved to start at the beginning of the new trial end's day, in your account's billing time zone. A cancel scheduled with `at_period_end` moves with the trial. To shorten a trial, use `POST /v1/subscriptions/{id}/end-trial`. Sends the `subscription.trial_extended` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [trial_end] properties: trial_end: type: string format: date-time description: The new trial end. Must be in the future, after the current `trial_end_at`, and at most 730 days after `trial_start_at`. example: trial_end: "2026-10-29T00:00:00Z" responses: "200": description: The subscription with the new `trial_end_at`. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: trialing billing_time: calendar trial_start_at: "2026-10-01T09:12:44Z" trial_end_at: "2026-10-29T00:00:00Z" started_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-29T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 29, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-10T12:00:00Z" "400": description: The body is not valid JSON, a field has a value of the wrong JSON type, or `trial_end` is not an RFC 3339 date-time. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is not `trialing`, or it has a scheduled cancel with an explicit `cancel_at`. Clear the scheduled cancel first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Only a trialing subscription's trial can be extended. This one is active. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `trial_end` is missing, in the past, not after the current `trial_end_at`, or more than 730 days after `trial_start_at` (`param: trial_end`). Also: the body has an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: Pick a date after the current trial end. To end the trial sooner, use End trial now. request_id: req_dbjg652q3gpgqfoq5cd0 param: trial_end "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/billing-thresholds: put: tags: [Subscriptions] summary: Set spend thresholds operationId: setSubscriptionBillingThresholds description: | Sets the spend thresholds (`billing_thresholds`): limits that make Hardcount invoice before the period ends. A threshold bills sooner; it does not cap or block usage. Set `amount_gte`, `item_thresholds`, or both. The first threshold reached triggers the invoice. The threshold invoice bills the period's usage so far, and no base fee. The period keeps its dates, the base fee is billed as it would be with no threshold, and the period's next invoice bills the rest of the usage. `reset_billing_cycle`, which started a new period at the threshold invoice, was removed; a body that sends it is refused with 422. Each call replaces the whole configuration. Item thresholds left out of the body are removed. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object properties: amount_gte: type: integer format: int64 minimum: 1 description: | Invoice early when the period's usage so far reaches this amount, in the subscription currency's minor unit (cents for USD). The base fee is not counted. 0 or absent sets no amount threshold. item_thresholds: type: array description: | Per-item usage thresholds. Each item of this subscription can appear once. Absent or empty sets none. items: type: object required: [subscription_item_id, usage_gte] properties: subscription_item_id: type: string description: An item of this subscription. Each item can appear once. usage_gte: type: string description: >- Invoice early when the item's usage in the period reaches this quantity. A decimal string (e.g. "1000" or "3.14"): more than 0, under 10^26, at most 12 decimal places (trailing zeros do not count), and at most 100 characters. example: amount_gte: 50000 item_thresholds: - subscription_item_id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c usage_gte: "100000000" responses: "200": description: The subscription with `billing_thresholds` set. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false billing_thresholds: amount_gte: 50000 item_thresholds: - subscription_item_id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c usage_gte: "100000000" current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-05T14:30:00Z" "400": description: The body is not valid JSON, or a field has a value of the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: "Code `invalid_state`: the subscription is `canceled`." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Spend thresholds can't be set on a canceled subscription. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Neither `amount_gte` nor `item_thresholds` is set; `amount_gte` is negative; an item id is missing, repeated, or not on this subscription; a `usage_gte` is not a number or is outside its limits (`param` is `item_thresholds[i].usage_gte`); or the subscription's plans use more than one currency. Also: the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: at least one of amount_gte or item_thresholds must be set; to clear use DELETE request_id: req_dbjg652q3gpgqfoq5cd0 param: billing_thresholds "429": $ref: "#/components/responses/RateLimited" delete: tags: [Subscriptions] summary: Clear spend thresholds operationId: clearSubscriptionBillingThresholds description: | Removes all spend thresholds. Calling it on a subscription with no thresholds returns the subscription unchanged. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: The subscription with no `billing_thresholds`. content: application/json: schema: $ref: "#/components/schemas/Subscription" example: id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-01T09:12:44Z" status: active billing_time: calendar started_at: "2026-10-01T09:12:44Z" activated_at: "2026-10-01T09:12:44Z" cancel_at_period_end: false current_billing_period_start: "2026-10-01T00:00:00Z" current_billing_period_end: "2026-11-01T00:00:00Z" current_billing_period_display: "Oct 1, 2026 – Oct 31, 2026" next_billing_at: "2026-11-01T00:00:00Z" created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-06T09:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "amount_gte" request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_gte "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/items: post: tags: [Subscriptions] summary: Add an item to a subscription operationId: addSubscriptionItem description: | Adds a plan to the subscription as a new item. The plan must use the same billing interval, base-fee timing and currency as the other items. It must not bill a meter that another item of this subscription, or another `draft`, `trialing` or `active` subscription of the customer, bills. A period that ended and is not invoiced yet is billed first. The subscription must not be `canceled`. On an active subscription, a plan whose base fee is billed in advance is charged for the rest of the current period, on an invoice saved in the same database transaction as the add, when the period's base-fee invoice exists. An item added on the first day of the period (in your account's billing time zone) pays for the whole period. A plan billed in arrears is billed at the end of the period for the days it was on the subscription. Sends the `subscription.item.added` webhook event. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: type: string description: The plan to add (`hc_pln_…`). Not archived, and not already on the subscription. quantity: type: integer format: int64 minimum: 1 default: 1 description: How many of the plan to bill. 0 or absent means 1. example: plan_id: hc_pln_dajr5o2q3gpgqfoq5c3k quantity: 1 responses: "201": description: The new item. content: application/json: schema: $ref: "#/components/schemas/SubscriptionItem" example: id: hc_si_3f9c0e2a1b4d5c6e7f8a9b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c3k quantity: 1 created_at: "2026-10-16T10:30:00Z" updated_at: "2026-10-16T10:30:00Z" "400": description: The body is not valid JSON, or a field has a value of the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `already_exists`: the subscription already has an item for this plan. Code `invalid_state`: the subscription is `canceled`; the plan is archived; the plan's meters (the meters it has a price for) overlap another item of this subscription or another `draft`, `trialing` or `active` subscription of the customer; or the plan's currency differs from the subscription's, the customer's billing profile or the customer's other `draft`, `trialing` or `active` subscriptions. Code `unpaid_invoice_blocks_change`: the invoice for the current period is unpaid (a written-off invoice is unpaid); settle or void it first. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: subscription already has an item for plan "hc_pln_dajr5o2q3gpgqfoq5c3k" request_id: req_dbjg652q3gpgqfoq5cd0 param: plan_id "422": description: | Code `validation_error`: `plan_id` is missing (`param: plan_id`); the plan is not found, or its billing interval or base-fee timing differs from the other items' (`param: items`); or `quantity` is less than 1. Also: the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: plan_id is required request_id: req_dbjg652q3gpgqfoq5cd0 param: plan_id "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/items/{itemID}: patch: tags: [Subscriptions] summary: Change a subscription item operationId: updateSubscriptionItem description: | Changes the quantity or the plan of one item. Send exactly one of `quantity` or `new_plan_id`. The subscription must be `active`. A period that ended and is not invoiced yet is billed first. - **Quantity.** Takes effect now. - **Plan, `immediate: true`.** Takes effect now. If the new plan has a different billing interval, the current period ends now and a new period starts on the new plan. - **Plan, `immediate: false` (default).** Scheduled for the end of the current period. The item shows it in `pending_plan_id` and `pending_plan_effective_at`. Cancel it with `DELETE /v1/subscriptions/{id}/items/{itemID}/pending-change`. For a change that takes effect now on a plan billed in advance, Hardcount settles the rest of the period at once. An increase is invoiced in the same database transaction as the change. A decrease is credited with credit notes on the invoices that charged the item. The notes are saved as drafts in the same database transaction, and issued right after it is saved (a failed issue is retried): on a paid invoice the credit goes to the customer's balance, on an unpaid one it reduces the amount due. `proration` in the response describes the result. A plan billed in arrears is settled at the end of the period. Changes to one subscription's items run one at a time. Hardcount works out each change's charge or credit from the item as the previous change left it. A request that repeats a change that already finished finds nothing to change and gets 422. Sends `subscription.item.updated`, or `subscription.pending_change.scheduled` for a scheduled plan change. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string - name: itemID in: path required: true description: The item's ID (`hc_si_…`), an item of this subscription. schema: type: string requestBody: required: true content: application/json: schema: type: object properties: quantity: type: integer format: int64 minimum: 1 description: The new quantity. Must differ from the current one. new_plan_id: type: string description: The plan to move the item to. Must differ from the current one and use the same base-fee billing timing (in advance or in arrears). immediate: type: boolean default: false description: Apply a plan change now instead of at the end of the period. Ignored for quantity changes. example: quantity: 2 responses: "200": description: The changed item and when the change takes effect. content: application/json: schema: $ref: "#/components/schemas/SubscriptionItemChangeResult" example: item: id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 2 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-16T10:30:00Z" effective_at: "2026-10-16T10:30:00Z" proration: old_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g new_plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g proration_factor: 0.5161290322580645 amount_cents: 2529 type: invoice invoice_id: hc_inv_dbj84b2q3gpgqfoq5c4k credited_to_balance_cents: 0 reduced_open_invoices_cents: 0 held_on_draft_invoices_cents: 0 "400": description: The body is not valid JSON, or a field has a value of the wrong JSON type. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | No subscription with this ID in this account and mode (message `subscription not found`), or the item is not on this subscription (`subscription item not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription item not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the subscription is not `active`, the new plan is archived, its meters (the meters it has a price for) overlap another item of this subscription or another `draft`, `trialing` or `active` subscription of the customer, or its currency differs from the item's current plan, the other items, the customer's billing profile or the customer's other subscriptions (`draft`, `trialing` or `active`). Code `unpaid_invoice_blocks_change`: an increase would charge against an unpaid invoice for the current period (a written-off invoice is unpaid); settle or void it first. Code `subscription_period_moved`: the billing period changed while the request ran; read the subscription again and retry. Code `item_changed`: a plan change found the subscription's items changed by another request that finished first; read the subscription again and retry. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. Code `funding_invoice_tax_pending`: an invoice a decrease would credit is a draft whose tax is not calculated yet; retry once it is. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Items can only be changed on an active subscription. This one is trialing. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`: neither or both of `quantity` and `new_plan_id` are set (`param: body`); the value equals the current one; `quantity` is less than 1; the new plan is not found; or the new plan's billing timing or interval does not fit the subscription. Also: the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: new quantity is the same as current quantity request_id: req_dbjg652q3gpgqfoq5cd0 param: quantity "429": $ref: "#/components/responses/RateLimited" delete: tags: [Subscriptions] summary: Remove a subscription item operationId: removeSubscriptionItem description: | Removes an item from the subscription. The last item of an active subscription cannot be removed; cancel the subscription instead. A period that ended and is not invoiced yet is billed first. Takes no body. On an active subscription, the unused part of a base fee billed in advance is credited with credit notes on the invoices that charged the item. The notes are saved as drafts in the same database transaction as the removal, and issued right after it is saved (a failed issue is retried). On a paid invoice the credit goes to the customer's balance, on an unpaid one it reduces the amount due. A plan billed in arrears is billed at the end of the period for the days it was on the subscription. Sends the `subscription.item.removed` webhook event. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string - name: itemID in: path required: true description: The item's ID (`hc_si_…`), an item of this subscription. schema: type: string responses: "204": description: Item removed. "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | No subscription with this ID in this account and mode (message `subscription not found`), or the item is not on this subscription (`subscription item not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription item not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: this is the last item of an active subscription. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. Code `funding_invoice_tax_pending`: an invoice the credit would go on is a draft whose tax is not calculated yet; retry once it is. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: A subscription needs at least one item. To stop billing, cancel the subscription instead. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "quantity" request_id: req_dbjg652q3gpgqfoq5cd0 param: quantity "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/items/{itemID}/pending-change: delete: tags: [Subscriptions] summary: Cancel a scheduled plan change operationId: cancelSubscriptionItemPendingChange description: | Cancels the plan change scheduled on an item. Calling it on an item with no scheduled change returns the item unchanged. Sends the `subscription.pending_change.canceled` webhook event, and writes an audit log entry, also when no change was scheduled. If the period has already ended, the change has taken effect and the request answers 409. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string - name: itemID in: path required: true description: The item's ID (`hc_si_…`), an item of this subscription. schema: type: string responses: "200": description: The item with no pending plan change. content: application/json: schema: $ref: "#/components/schemas/SubscriptionItem" example: id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T09:12:44Z" updated_at: "2026-10-18T13:05:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: The item is not found or is not on this subscription. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription item not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the change already took effect when the billing period ended. Code `unbilled_period`: a billing period ended and could not be invoiced; retry billing first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: the pending plan change already took effect when the billing period ended request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "new_plan_id" request_id: req_dbjg652q3gpgqfoq5cd0 param: new_plan_id "429": $ref: "#/components/responses/RateLimited" /v1/subscriptions/{id}/timeline: get: tags: [Subscriptions] summary: Get a subscription's activity timeline operationId: getSubscriptionTimeline description: | Returns the subscription's history from the audit log as display-ready rows, in the order the actions took effect: by `sim_effective_at` for actions on a test-clock subscription, otherwise by `timestamp`. On a test-clock subscription `timestamp` (wall-clock) may therefore not be in ascending order. It holds at most the 100 most recent entries. `truncated` is true when older entries were left out. parameters: - name: id in: path required: true description: The subscription's ID (`hc_sub_…`). schema: type: string responses: "200": description: Timeline events. content: application/json: schema: type: object required: [events, truncated] properties: events: type: array items: $ref: "#/components/schemas/SubscriptionTimelineEvent" truncated: type: boolean description: True when the subscription has more than 100 audit entries, so the earliest events are missing. example: events: - timestamp: "2026-10-01T09:12:44Z" source: audit event_type: create status: neutral description: Subscription created actor_type: api_key actor_name: Production backend actor_id: hc_key_dajqj1iq3gpgqfoq5ca0 line: id: hc_aud_dbj84c2q3gpgqfoq5cb0 kind: subscription.created title: Subscription created detail: [] actor: kind: api_key name: Production backend tone: neutral occurred_at: "2026-10-01T09:12:44Z" - timestamp: "2026-10-20T16:45:00Z" source: audit event_type: cancel status: neutral description: Subscription canceled actor_type: user actor_name: ops@acme.example actor_id: hc_usr_5f2c9a01b7e34d6a8c0e1f92 line: id: hc_aud_dbj84c2q3gpgqfoq5cb8 kind: subscription.canceled title: Subscription canceled detail: [] actor: kind: person name: ops@acme.example tone: neutral occurred_at: "2026-10-20T16:45:00Z" truncated: false "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No subscription with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/usage-events: post: tags: [Usage] summary: Send a usage event operationId: createUsageEvent description: | Records one usage event for a customer and a meter. You name the customer by your `external_id` for it and the meter by its `event_name`. The event counts toward the billing period its `timestamp` falls in, and that period's invoice bills it. Every event needs its own `idempotency_key`. Send the same key when you retry the event: an event whose key is already stored is not stored again, and the call answers 200 with the first event and an `Idempotent-Replayed: true` header. Retrying is safe this way; a new key on each retry bills the usage twice. The 201 can carry a `billing_status`: what Hardcount found when the event arrived, such as a time that is already invoiced. The event is stored all the same. A usage-only key (`hc_ingest_…`) or a secret key can call it. requestBody: required: true content: application/json: schema: type: object required: [external_customer_id, event_name, quantity, idempotency_key] properties: external_customer_id: type: string description: The customer's external_id (your identifier), not the internal hc_cus_ id. event_name: type: string description: The meter's `event_name` (e.g. "tokens"). quantity: oneOf: - type: number - type: string description: | A JSON number (`5`) or a string holding one (`"5.5"`, for exact decimals). At most 12 decimal places; trailing zeros do not count. Its size must be under 10^26, and it is at most 100 characters. A negative value is a correction. A missing quantity, or any other value, is refused with 422 and `param: quantity`. dimensions: type: object additionalProperties: true description: Scalar labels of the event (e.g. {"model", "token_type"}). A price takes an event whose labels include the price's `dimension_match`. idempotency_key: type: string maxLength: 255 description: | Your unique ID for this event. Pick it once, before the first send, and send the same key on every retry of the event. Keys are unique per account and mode (live or test), forever. An event whose key was already used is not stored again: the first event wins and the new body is discarded. So never reuse a key for a different event, such as a batch index or a timestamp that can repeat, or that usage is lost. Empty or whitespace-only keys are refused with 422, and so are keys longer than 255 characters. This is the only dedup key: the usage routes ignore the `Idempotency-Key` header. timestamp: type: string format: date-time description: | When the usage happened, as an RFC 3339 string. Leave it out to use the time Hardcount receives the event (the clock's time for a customer on a test clock). It may be at most 5 minutes ahead of that time. Any past time is accepted, but an event in a period that is already invoiced is stored and not billed (`billing_status: after_close`). A value that is not an RFC 3339 string is refused with 422 and `param: timestamp`. example: external_customer_id: acme-42 event_name: tokens quantity: 1200 dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" responses: "201": description: | Event ingested. A `billing_status` is what Hardcount found when the event arrived (see the `billing_status` field); the event is stored all the same. content: application/json: schema: $ref: "#/components/schemas/UsageEvent" example: id: hc_evt_db6eh6qq3gpgqfoq5c3g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "1200" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" origin: api provider_cost_cents: "0.3" provider_cost_source: table "200": description: | Idempotency replay — the original event is returned unchanged with an `Idempotent-Replayed: true` header. No duplicate row is written. The new body is not compared with the original. content: application/json: schema: $ref: "#/components/schemas/UsageEvent" example: id: hc_evt_db6eh6qq3gpgqfoq5c3g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "1200" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" origin: api provider_cost_cents: "0.3" provider_cost_source: table "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "422": description: | Validation failed and nothing was stored. `param` names the field: `idempotency_key` when it is missing, blank or longer than 255 characters, `quantity` when it is missing or not as described above, `external_customer_id` or `event_name` when missing or for an unknown customer or meter, `timestamp` for a malformed one or one more than 5 minutes ahead, `dimensions` for more than 16 keys or a value that is an object or an array, and `properties` when it has a key (it was renamed `dimensions`; `null` or `{}` is ignored). An unknown field is refused the same way. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: idempotency_key is required — give each event its own key and send the same key when you retry that event request_id: req_dbjg652q3gpgqfoq5cd0 param: idempotency_key "429": $ref: "#/components/responses/RateLimited" "503": description: | `ingest_unavailable`: a customer or meter lookup, the read of the customer's billing periods, or the read of the meter's aggregation could not be answered. Nothing was recorded; retry. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: ingest_unavailable message: usage store temporarily unavailable — nothing was recorded; retry the request request_id: req_dbjg652q3gpgqfoq5cd0 get: tags: [Usage] summary: List usage events operationId: listUsageEvents description: | Lists the usage events of this account and mode, newest first by `timestamp`. The filters combine; `total` counts every matching event. The rows carry no `origin` and no `billing_status`. Needs a secret key; a usage-only key cannot call it. parameters: - name: customer_id in: query description: Only this customer's events (`hc_cus_…`, the Hardcount ID, not your external ID). schema: type: string - name: meter_id in: query description: Only this meter's events (`hc_mtr_…`). schema: type: string - name: dimensions in: query description: | Comma-separated key=value pairs matched against the event's dimensions; every pair must match. Values that read as JSON booleans or numbers match typed values ("cached=true", "attempt=2"); everything else matches as a string. A malformed pair is refused with 422. Example: `model=gpt-4o,token_type=input`. schema: type: string - name: from in: query description: Only events at or after this time. RFC 3339, or a date (YYYY-MM-DD) meaning 00:00:00 UTC. schema: type: string - name: to in: query description: Only events before this time, exclusive. RFC 3339, or a date (YYYY-MM-DD) meaning 23:59:59.999999 UTC that day, so the day is included. schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Event list content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/UsageEvent" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_evt_db6eh6qq3gpgqfoq5c3k tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "350" dimensions: model: gpt-4o provider: openai token_type: output idempotency_key: req-7f3a9c-out timestamp: "2026-10-12T14:03:07Z" provider_cost_cents: "0.35" provider_cost_source: table - id: hc_evt_db6eh6qq3gpgqfoq5c3g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "1200" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" provider_cost_cents: "0.3" provider_cost_source: table total: 4210 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`validation_error`: a `from` or `to` that cannot be parsed, `to` before `from`, or a `limit` or `offset` out of range (`param` names the field); or a malformed `dimensions` pair (no `param`)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD request_id: req_dbjg652q3gpgqfoq5cd0 param: from "429": $ref: "#/components/responses/RateLimited" /v1/usage-events/batch: post: tags: [Usage] summary: Send a batch of usage events operationId: createUsageEventBatch description: | Records up to 1,000 usage events in one call, in a body of at most 1 MB. Each event has the same fields as `POST /v1/usage-events`. The batch is atomic: every event validates, then all of them commit in one transaction. On any validation failure nothing is ingested. Every event must carry its own `idempotency_key`; an event without one fails validation, so the whole batch is refused. Retrying the whole batch is safe because of those keys: events already stored are counted in `deduplicated`, not stored again, and are not errors. A `deduplicated` count above zero on a batch you never sent before means some keys were already used, by an earlier event or by another event in the batch. Those events were not stored. A usage-only key (`hc_ingest_…`) or a secret key can call it. requestBody: required: true content: application/json: schema: type: array items: type: object required: [external_customer_id, event_name, quantity, idempotency_key] properties: external_customer_id: type: string description: The customer's external_id (your identifier), not the internal hc_cus_ id. event_name: type: string description: The meter's `event_name` (e.g. "tokens"). quantity: oneOf: - type: number - type: string description: | A JSON number (`5`) or a string holding one (`"5.5"`, for exact decimals). At most 12 decimal places; trailing zeros do not count. Its size must be under 10^26, and it is at most 100 characters. A negative value is a correction. A missing quantity, or any other value, fails validation. dimensions: type: object additionalProperties: true description: Scalar labels of the event, at most 16. A price takes an event whose labels include the price's `dimension_match`. idempotency_key: type: string maxLength: 255 description: | Your unique ID for this event. Pick it once, before the first send, and send the same key on every retry of the event. Keys are unique per account and mode (live or test), forever. An event whose key was already used is not stored again: the first event wins and the new body is discarded. So never reuse a key for a different event, such as a batch index or a timestamp that can repeat, or that usage is lost. Empty or whitespace-only keys fail validation, and so do keys longer than 255 characters. This is the only dedup key: the usage routes ignore the `Idempotency-Key` header. timestamp: type: string format: date-time description: | When the usage happened, as an RFC 3339 string. Leave it out to use the time Hardcount receives the batch. The same rules as for `POST /v1/usage-events` apply. example: - external_customer_id: acme-42 event_name: tokens quantity: 1200 dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: req-7f3a9c timestamp: "2026-10-12T14:03:07Z" - external_customer_id: acme-42 event_name: tokens quantity: 350 dimensions: model: gpt-4o provider: openai token_type: output idempotency_key: req-7f3a9c-out timestamp: "2026-10-12T14:03:07Z" responses: "201": description: Batch committed content: application/json: schema: $ref: "#/components/schemas/UsageEventBatchResult" example: ingested: 2 deduplicated: 0 total: 2 after_close: 0 no_price: 0 not_on_plan: 0 outside_subscription: 0 results: - status: ingested - status: ingested "400": description: | Code `invalid_request`: the body is not a JSON array of event objects, or an event has a field of the wrong JSON type (message `expected JSON array of events`); the array is empty (`at least one event is required`); or it holds more than 1,000 events (`maximum 1000 events per batch`). Nothing was ingested. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: maximum 1000 events per batch request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "422": description: | Batch rejected — one or more events failed validation and nothing was ingested. The body is the error envelope (code `batch_rejected`) plus `errors[]` (`event[i]: reason`), `ingested: 0` and `total`. The events are checked in two rounds, and `errors[]` lists the failures of one round only. The first round checks every event for: a missing field, a missing, blank or too long `idempotency_key`, an unknown field or a `properties` key, the `quantity` format and length, the `timestamp` format, and an unknown customer or meter. The second round runs only when no event failed the first, and checks: more than 16 dimensions or a value that is not a scalar, a `quantity` of 10^26 or more or with more than 12 decimal places, and a `timestamp` more than 5 minutes ahead. So a fixed batch can be refused again, for its second-round failures. Fix the listed events and retry the whole batch. content: application/json: schema: allOf: - $ref: "#/components/schemas/Error" - type: object required: [errors, ingested, total] properties: errors: type: array description: | One entry per event that failed the round that refused the batch, `event[i]: reason`, where `i` is its index in the request. items: type: string ingested: type: integer description: Always 0. A rejected batch writes nothing. total: type: integer description: Events in the request. example: error: type: invalid_request_error code: batch_rejected message: batch rejected — nothing was ingested; fix the listed events and retry the whole batch request_id: req_dbjg652q3gpgqfoq5cd0 errors: - "event[1]: idempotency_key is required — give each event its own key and send the same key when you retry that event" ingested: 0 total: 2 "413": description: | Code `batch_too_large`: the request body is over the 1 MB cap (1,048,576 bytes). Nothing was ingested; send smaller batches. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: batch_too_large message: request body exceeds 1048576 bytes — send smaller batches request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" "503": description: | `ingest_unavailable`: a customer or meter lookup, the read of a customer's billing periods, or the read of a meter's aggregation could not be answered. Nothing was recorded; retry the whole batch. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: ingest_unavailable message: usage store temporarily unavailable — nothing was recorded; retry the request request_id: req_dbjg652q3gpgqfoq5cd0 /v1/usage-events/backfill: post: tags: [Usage] summary: Backfill a past usage event description: | Records one usage event with a past timestamp. Same body as `POST /v1/usage-events`, but `timestamp` is required and must not be after the customer's current time (a test clock's time when the customer is on one). The event is marked `origin: backfill` and an audit log entry is written. The event counts toward the billing period its timestamp falls in only if that period's invoice has not been built yet. An event for an already-invoiced period is stored, but that invoice does not bill it; bill it with a one-off invoice if needed. Its 201 carries the `billing_status` Hardcount found (see the `billing_status` field): `after_close` while the subscription that invoiced the period is active or trialing, and `outside_subscription` once it has ended. If another subscription has a billing period that holds the event's time and is not invoiced yet, that period decides the status instead. Uses the same idempotency keys as `POST /v1/usage-events` and the batch route: a repeated `idempotency_key` returns 200 with the original event and an `Idempotent-Replayed: true` header, and writes nothing. Needs a secret key: a usage-only key cannot backfill. operationId: backfillUsageEvent requestBody: required: true content: application/json: schema: type: object required: [external_customer_id, event_name, quantity, timestamp, idempotency_key] properties: external_customer_id: type: string description: The customer's external_id (your identifier), not the internal hc_cus_ id. event_name: type: string description: The meter's `event_name` (e.g. "tokens"). quantity: oneOf: - type: number - type: string description: | A JSON number (`5`) or a string holding one (`"5.5"`, for exact decimals). At most 12 decimal places; trailing zeros do not count. Its size must be under 10^26, and it is at most 100 characters. A negative value is a correction. A missing quantity, or any other value, is refused with 422 and `param: quantity`. dimensions: type: object additionalProperties: true maxProperties: 16 description: Scalar labels of the event. A price takes an event whose labels include the price's `dimension_match`. idempotency_key: type: string maxLength: 255 description: | Your unique ID for this event. Pick it once, before the first send, and send the same key on every retry of the event. Keys are unique per account and mode (live or test), forever. An event whose key was already used is not stored again: the first event wins and the new body is discarded. So never reuse a key for a different event, such as a batch index or a timestamp that can repeat, or that usage is lost. Empty or whitespace-only keys are refused with 422, and so are keys longer than 255 characters. This is the only dedup key: the usage routes ignore the `Idempotency-Key` header. timestamp: type: string format: date-time description: RFC 3339. Must not be in the future. example: external_customer_id: acme-42 event_name: tokens quantity: "48000" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: import-2026-09-30-0001 timestamp: "2026-09-30T23:10:00Z" responses: "201": description: | Event recorded. A `billing_status` is what Hardcount found when the event arrived (see the `billing_status` field); the event is stored all the same. content: application/json: schema: $ref: "#/components/schemas/UsageEvent" example: id: hc_evt_db6eh6qq3gpgqfoq5c3o tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "48000" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: import-2026-09-30-0001 timestamp: "2026-09-30T23:10:00Z" origin: backfill provider_cost_cents: "12" provider_cost_source: table billing_status: outside_subscription "200": description: Idempotency replay. The original event, unchanged, with an `Idempotent-Replayed` header. headers: Idempotent-Replayed: schema: type: string enum: ["true"] content: application/json: schema: $ref: "#/components/schemas/UsageEvent" example: id: hc_evt_db6eh6qq3gpgqfoq5c3o tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g meter_id: hc_mtr_dajr122q3gpgqfoq5c10 quantity: "48000" dimensions: model: gpt-4o provider: openai token_type: input idempotency_key: import-2026-09-30-0001 timestamp: "2026-09-30T23:10:00Z" origin: backfill provider_cost_cents: "12" provider_cost_source: table "400": description: "`invalid_request`: the body is not valid JSON, or a field other than `quantity` and `timestamp` has a value of the wrong JSON type." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `validation_error`: a missing, empty or whitespace-only `idempotency_key`, or one longer than 255 characters (param `idempotency_key`); missing `external_customer_id`, `event_name` or `timestamp`; unknown customer or meter; a future or badly formatted `timestamp`; more than 16 dimensions or a non-scalar dimension value; a missing or malformed `quantity`, one with more than 12 decimal places, one of 10^26 or more, or one longer than 100 characters; a `properties` field with a key (it was renamed `dimensions`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must not be in the future for backfill — use POST /usage-events for real-time ingest request_id: req_dbjg652q3gpgqfoq5cd0 param: timestamp "429": $ref: "#/components/responses/RateLimited" "503": description: | `ingest_unavailable`: the customer or meter lookup, the read of the customer's billing periods, or the read of the meter's aggregation could not be answered. Nothing was recorded; retry. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: ingest_unavailable message: usage store temporarily unavailable — nothing was recorded; retry the request request_id: req_dbjg652q3gpgqfoq5cd0 /v1/usage-events/held: get: tags: [Usage] summary: List held usage description: | Usage rows the LiteLLM integration received for a customer or meter that did not exist yet. They are kept with their idempotency keys, not billed, until you release or discard them. Grouped by external customer ID, event name and reason, most recent arrival first. Lists the 200 external customer IDs with the most recent arrivals, with every group for each; `total_customers` and `total_rows` count all held rows, listed or not. Needs a secret key; a usage-only key cannot call it. operationId: listHeldUsage responses: "200": description: Held groups content: application/json: schema: type: object required: [data, total_customers, total_rows] properties: data: type: array items: $ref: "#/components/schemas/HeldUsageGroup" total_customers: type: integer description: External customer IDs with rows held, including those not listed in `data`. total_rows: type: integer description: Rows held, including those not listed in `data`. example: data: - external_customer_id: globex-7 event_name: tokens reason: customer_not_found rows: 12 total_quantity: "18400" first_event_at: "2026-10-14T08:00:12Z" last_event_at: "2026-10-14T09:41:55Z" last_received_at: "2026-10-14T09:41:56Z" customer_id: "" meter_exists: true total_customers: 1 total_rows: 12 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/usage-events/held/release: post: tags: [Usage] summary: Release held usage description: | Writes the held rows for one external customer ID as usage events for a customer, each under its original idempotency key and time, marked `origin: backfill`, in one transaction. A row that is already a usage event under the same key is not written again. Releasing twice writes nothing the second time. Rows whose meter still does not exist, or whose time is after the customer's current time, stay held. One call releases at most 5,000 rows; `more_to_release` counts the rest, and another call takes them. A released row counts toward the billing period its time falls in only if that period's invoice has not been built yet; Hardcount does not move it onto a later invoice. A call that takes any rows writes an audit log entry. Needs a secret key; a usage-only key cannot call it. operationId: releaseHeldUsage requestBody: required: true content: application/json: schema: type: object required: [external_customer_id] properties: external_customer_id: type: string description: The external ID the rows were held under. customer_id: type: string description: | Release into this customer (an `hc_cus_` id) instead of the one whose external ID matches, for a `user` value that had a typo. example: external_customer_id: globex-7 responses: "200": description: Release result content: application/json: schema: $ref: "#/components/schemas/HeldUsageReleaseResult" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0k released: 12 deduplicated: 0 still_held: 0 more_to_release: 0 in_open_period: 12 no_price: 0 not_on_plan: 0 pending_threshold_invoice: 0 outside_open_period: 0 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `validation_error`: `external_customer_id` is missing; no customer has that external ID yet and no `customer_id` was given; `customer_id` was not found; or the body has an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: no customer has external ID "globex-7" yet — create it, or pass customer_id to release into another customer request_id: req_dbjg652q3gpgqfoq5cd0 param: external_customer_id "429": $ref: "#/components/responses/RateLimited" /v1/usage-events/held/discard: post: tags: [Usage] summary: Discard held usage description: | Marks the held rows for one external customer ID (and one event name, when given) as discarded. They are never billed. The rows stay stored as a record. A call that discards any rows writes an audit log entry. Needs a secret key; a usage-only key cannot call it. operationId: discardHeldUsage requestBody: required: true content: application/json: schema: type: object required: [external_customer_id] properties: external_customer_id: type: string description: The external ID the rows were held under. event_name: type: string description: Discard only the rows with this `event_name`. Omit for all. example: external_customer_id: globex-7 event_name: tokens responses: "200": description: Rows discarded content: application/json: schema: type: object required: [discarded] properties: discarded: type: integer description: Held rows marked discarded. 0 when none were held. example: discarded: 12 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`validation_error`: `external_customer_id` is missing, or the body has an unknown field (`param` names it; the body must use only the fields listed here)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: external_customer_id is required request_id: req_dbjg652q3gpgqfoq5cd0 param: external_customer_id "429": $ref: "#/components/responses/RateLimited" /v1/usage-events/aggregate: get: tags: [Usage] summary: Get usage totals description: | Event count, quantity totals, and a per-meter breakdown over every usage event matching the filters (no paging). Each `by_meter` row carries `value`, the meter's usage under its own aggregation: a sum, an event count, each customer's peak (Maximum) or each customer's latest reading (Latest value), added across customers. `total` stays the plain sum of reported quantities. `total_units` adds quantities across all meters whatever their units, and is kept for compatibility only. operationId: aggregateUsageEvents parameters: - name: customer_id in: query description: Only this customer's events (`hc_cus_…`, the Hardcount ID, not your external ID). schema: type: string - name: meter_id in: query description: Only this meter's events (`hc_mtr_…`). schema: type: string - name: from in: query description: Events at or after this time. RFC 3339, or a date (YYYY-MM-DD) meaning 00:00:00 UTC. schema: type: string - name: to in: query description: Events before this time, exclusive. RFC 3339, or a date (YYYY-MM-DD) meaning 23:59:59.999999 UTC that day, so the day is included. schema: type: string - name: dimensions in: query description: | Comma-separated key=value pairs matched against event dimensions, same syntax as `GET /v1/usage-events`. Example: `model=gpt-4o,token_type=input`. schema: type: string - name: tz in: query description: | IANA time zone for the calendar days of `by_meter[].daily` (default UTC). An unknown name returns 422. schema: type: string responses: "200": description: Totals content: application/json: schema: $ref: "#/components/schemas/UsageAggregate" example: total_events: 268 total_units: "805500" active_meters: 1 active_customers: 1 by_meter: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 total: "805500" aggregation: sum value: "805500" events: 268 customers: 1 peak: "48000" daily: - date: "2026-10-12" value: "410000" - date: "2026-10-13" value: "395500" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`validation_error`: a `from` or `to` that cannot be parsed, or `to` before `from` (`param` names the field); or a malformed `dimensions` pair or an unknown `tz` (no `param`)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "tz: unknown time zone \"Europe/Berln\" (use an IANA name such as Europe/Berlin)" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices: get: tags: [Invoices] summary: List invoices operationId: listInvoices description: | Lists invoices in this account and mode, newest first by default. Every filter is optional, and filters combine. Each invoice carries its `attention`, if any. `total` counts every invoice that matches the filters. A `from` or `to` that is not a date answers 422 with `param` naming it, and so does a `to` before `from`. parameters: - name: customer_id in: query description: Only this customer's invoices. schema: type: string - name: subscription_id in: query description: Only invoices that bill this subscription. schema: type: string - name: status in: query description: | Only invoices in this status: `draft`, `finalized`, `paid`, `voided` or `uncollectible`. Another value matches nothing. schema: type: string - name: payment_status in: query description: | Only invoices with this payment status: `pending`, `processing`, `succeeded`, `failed` or `unknown`. Another value matches nothing. schema: type: string - name: search in: query description: | Case-insensitive substring match on the invoice number, or on the customer's name or email, also when customer data is encrypted (`HARDCOUNT_ENCRYPTION_KEY`). Names and emails are matched among the 5,000 customers created last in this mode; an invoice of an older customer is still found by its number. schema: type: string - name: from in: query description: | Lower bound on created_at, inclusive. RFC3339 instant or bare YYYY-MM-DD (anchored at 00:00:00Z). schema: type: string - name: to in: query description: | Upper bound on created_at, inclusive. RFC3339 instant or bare YYYY-MM-DD (anchored at 23:59:59.999999Z, the end of that day). schema: type: string - name: overdue in: query description: | `true` keeps only open invoices past their due date: `finalized`, `due_at` before the current real time, and `payment_status` not `succeeded`, `processing` or `unknown`. Any other value applies no filter. The dashboard's Past due view uses it. schema: type: boolean - name: ids in: query description: Comma-separated invoice IDs to fetch exactly. schema: type: string - name: sort in: query description: | The order of the list. `created_at` (the default), `invoice_number`, `amount_due_cents` (or `amount_due`), `billing_period_start` (or `period`), `due_at`, `issued_at`, `status` or `payment_status`. `amount_due_cents` sorts a paid invoice by what paid it (`amount_paid_cents` plus `credits_applied_cents`, since paying zeroes `amount_due_cents`) and every other invoice by `amount_due_cents`, which a voided or written-off invoice keeps. Any other value is a 422 with `param: sort`. Ties are broken by `id` in the same direction. schema: type: string enum: [created_at, invoice_number, amount_due_cents, amount_due, billing_period_start, period, due_at, issued_at, status, payment_status] default: created_at - name: dir in: query description: | Sort direction: `desc` (the default) or `asc`, in lower case. Any other value is a 422 with `param: dir`. schema: type: string enum: [asc, desc] default: desc - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Invoice list content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Invoice" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_period_display: Oct 1, 2026 – Oct 31, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `limit` or `offset` is out of range or not a whole number, the request sends the removed `after` cursor, `sort` or `dir` is not one of its values, `from` or `to` is neither an RFC 3339 time nor a YYYY-MM-DD date, or `to` is before `from`. `param` names it, and for `sort` and `dir` the message lists the allowed values. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'sort "nope" is not one of created_at, invoice_number, amount_due_cents, amount_due, billing_period_start, period, due_at, issued_at, status, payment_status' request_id: req_dbjg652q3gpgqfoq5cd0 param: sort "429": $ref: "#/components/responses/RateLimited" post: tags: [Invoices] summary: Create a draft invoice operationId: createInvoice description: | Creates a one-off invoice in `draft` status with billing reason `manual`. Send `line_items` to create the invoice and its lines in one transaction, or omit them and add lines later with `POST /v1/invoices/{id}/line-items`. Nothing is charged or emailed until you finalize it with `POST /v1/invoices/{id}/finalize`. A manual invoice uses the customer's currency. A `currency` that differs from the customer's billing-profile currency, or from the plan currency of any of the customer's draft, trialing or active subscriptions, is refused with 409. A customer with neither may be invoiced in any supported currency. Defaults when a field is left out: - `currency`: the customer's billing-profile currency, then the plan currency of the customer's draft, trialing or active subscriptions, then the account's default currency. - `net_payment_term_days`: the account's net payment terms. A negative value is stored as 0 (due on receipt). - `billing_period_start` / `billing_period_end`: now. The invoice gets its number (`invoice_number`) now, and an issue and due date that finalize moves to the moment of finalizing. Tax is not calculated here. It is calculated when the invoice is finalized, so totals equal the subtotal until then. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InvoiceCreateRequest" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD net_payment_term_days: 30 memo: Onboarding for the Acme rollout line_items: - description: Onboarding and setup line_type: add_on quantity: 1 unit_amount_cents: 25000 responses: "201": description: Draft invoice created. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: draft payment_status: pending currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_status: ok total_amount_cents: 25000 amount_due_cents: 25000 amount_paid_cents: 0 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC issued_at: "2026-11-02T15:30:00Z" due_at: "2026-12-02T15:30:00Z" paid_out_of_band: false net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-11-02T15:30:00Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer, or no subscription, with this id in this account and mode (code `not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Validation error: missing `customer_id`, an unsupported currency, a subscription of another customer, an invalid line item (the message names the line number, `param` the field), a commit expiry in the past, more than one commit line, line items whose total does not fit 64-bit cents (`param` is `line_items`), or an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "line item 1: must be greater than 0" request_id: req_dbjg652q3gpgqfoq5cd0 param: quantity "409": description: | `currency` is not the customer's (code `customer_currency_conflict`). The message names the billing profile or subscription plan currency it conflicts with. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: customer_currency_conflict message: this customer is billed in EUR (billing profile); a manual invoice uses the customer's currency, so it cannot be in USD request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" "503": description: | The customer's currency could not be read (code `currency_unavailable`). Retry the request. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: currency_unavailable message: could not load the customer's billing currency — please retry request_id: req_dbjg652q3gpgqfoq5cd0 /v1/invoices/create_preview: post: tags: [Invoices] summary: Preview a customer's next invoice operationId: createInvoicePreview description: | Answers "what is my next bill going to look like?" from the usage recorded so far. Nothing is saved, charged or sent, and tax is not included. Without `period`, the preview mirrors the invoice the subscription's current period will close with: base fees prorate for a short first period and per segment after a mid-period item add, quantity change, plan swap or removal, and each meter's usage counts only while a plan carrying it was on the subscription. For a trialing subscription it is the first invoice after the trial: the trial-end invoice when a plan bills in advance, otherwise the close of the first paid period, prorated the same way. With an explicit `period`, it is a flat estimate over that window, at today's prices, and warns when a mid-period change makes the invoice differ. Without `subscription_id`, Hardcount uses the customer's active or trialing subscription whose current period started last. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [customer_id] properties: customer_id: type: string description: Customer to preview the invoice for. subscription_id: type: string description: | Optional. The subscription to preview; it must belong to `customer_id`. Defaults to the customer's active or trialing subscription whose current period started last. period: type: object description: | Optional explicit window, `from` before `to`. Both bounds must be supplied together; partial windows are rejected. Defaults to the subscription's current period. required: [from, to] properties: from: type: string format: date-time description: Start of the window (included), RFC 3339. to: type: string format: date-time description: End of the window (not included), RFC 3339. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g responses: "200": description: Preview result content: application/json: schema: $ref: "#/components/schemas/InvoicePreview" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_name: Pro billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" lines: - line_type: base_fee description: Pro - base fee (qty 1) currency: USD quantity: "1" unit_amount_cents: 4900 amount_cents: 4900 - line_type: usage description: Tokens (tokens) meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens price_id: hc_prc_dajr672q3gpgqfoq5c20 currency: USD quantity: "12500000" unit_amount_cents: 0 amount_cents: 3750 pricing_mode: per_unit totals: - currency: USD amount_cents: 8650 warnings: [] generated_at: "2026-10-31T18:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer, or no subscription, with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer or subscription not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Validation error. Missing `customer_id`; a subscription of another customer (`param` `subscription_id`); a `period` with one bound, `from` not before `to`, or a bound that is not RFC 3339 (`param` `period`, `period.from` or `period.to`); or an unknown field. Code `customer_has_no_subscription`: no `subscription_id` was given and the customer has no active or trialing subscription. Code `subscription_has_no_cycle`: the subscription has no current period, so pass `period`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: customer_has_no_subscription message: customer has no active or trialing subscription; pass subscription_id explicitly to preview a non-active sub request_id: req_dbjg652q3gpgqfoq5cd0 param: customer_id "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}: get: tags: [Invoices] summary: Get an invoice with its line items operationId: getInvoice description: | Returns the invoice and its lines as two keys, `invoice` and `line_items`. The invoice carries its `attention`, if any. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Invoice with its line items. content: application/json: schema: $ref: "#/components/schemas/InvoiceWithLineItems" example: invoice: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_period_display: Oct 1, 2026 – Oct 31, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_9f2c4e1a7b3d5f6081a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f701 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false line_items: - id: hc_ili_dbj84baq3gpgqfoq5c51 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: base_fee description: Pro - base fee (qty 1) quantity: "1" unit_amount_decimal: "4900" amount_cents: 4900 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 4900 currency: USD billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" created_at: "2026-11-01T00:05:00Z" - id: hc_ili_dbj84baq3gpgqfoq5c4g invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: usage meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_unit: tokens description: Tokens (tokens) quantity: "12500000" unit_amount_decimal: "0.0003" amount_cents: 3750 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 3750 currency: USD pricing_mode: per_unit price_id: hc_prc_dajr672q3gpgqfoq5c20 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" created_at: "2026-11-01T00:05:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/pdf: get: tags: [Invoices] summary: Download an invoice PDF operationId: getInvoicePdf description: | Returns the invoice as a PDF, shown inline and named after the invoice number (`HC-000042.pdf`). An issued invoice prints the customer and company details it was issued with, and every issued credit note on it. A draft prints the current details. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: PDF file content: application/pdf: schema: type: string format: binary "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/send: post: tags: [Invoices] summary: Email an invoice to the customer operationId: sendInvoice description: | Queues the invoice email: the invoice PDF attached, the amount due and a link to the hosted invoice page, where the customer can pay. The invoice must be issued: `finalized`, `paid` or `uncollectible`. A draft or voided invoice is refused with 409, and so is an invoice whose charge is still unresolved. `email` is the recipient. `additional_emails` sets who is copied: - absent: the customer's stored `additional_emails` are copied, leaving out any that equals `email`; - `[]`: nobody is copied on this send; - a list: exactly these addresses are copied, after the same checks as the customer field: each must be one plain address, they are lowercased and duplicates dropped, the recipient's own address is refused, and at most 10 are allowed. This email does not set the invoice's `invoice_emailed_at`. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email description: The recipient, one plain address such as `billing@acme.example`, with no display name. additional_emails: type: array maxItems: 10 description: Addresses to copy on this send. See the description for absent, empty and a list. items: type: string example: email: billing@acme.example additional_emails: - finance@acme.example responses: "200": description: Email queued. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [sent] description: Always `sent`, meaning the email is queued. example: status: sent "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing was sent. Codes: `invoice_not_sendable` (the invoice is a draft, not issued yet, or voided), `email_not_configured` (this server has no SMTP set up), or `payment_in_flight` / `payment_unidentifiable` (a charge on the invoice is unresolved). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invoice_not_sendable message: a draft invoice can't be emailed because it isn't issued yet. Finalize it first, then email it request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`email` is missing or invalid, a copied address is invalid (`param` `additional_emails`), or the body has an unknown field (`param` names it)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: email is required request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/line-items: post: tags: [Invoices] summary: Add a line item to a draft invoice operationId: addInvoiceLineItem description: | Adds one line to a `draft` invoice and recomputes its subtotal, total and amount due. `amount_cents` is `quantity` × `unit_amount_cents`, and the line takes the invoice's currency. Only draft invoices you created (`billing_reason` `manual`) accept new lines; their tax is calculated at finalize. A draft the billing engine made for a subscription, such as a period that closed while collection was paused, keeps the lines and tax it was built with: a line added to it answers 409 `invoice_not_manual`. Bill a one-off charge on its own invoice (`POST /v1/invoices`). parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The draft invoice's id (`hc_inv_…`). schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InvoiceLineItemInput" example: description: Onboarding and setup line_type: add_on quantity: 1 unit_amount_cents: 25000 responses: "201": description: Line item created. content: application/json: schema: $ref: "#/components/schemas/InvoiceLineItem" example: id: hc_ili_dbj84baq3gpgqfoq5c52 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 line_type: add_on description: Onboarding and setup quantity: "1" unit_amount_decimal: "25000" amount_cents: 25000 tax_rate: 0 tax_amount_cents: 0 total_amount_cents: 25000 currency: USD created_at: "2026-11-02T15:30:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `invoice_not_manual`: the draft was made by the billing engine for a subscription, so it keeps its lines and tax. Create a separate invoice for a one-off charge. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invoice_not_manual message: "Lines can be added only to an invoice you create. This draft was made by billing for a subscription, so it keeps billing's lines and tax. For a one-off charge, create a separate invoice." request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Validation error: the invoice is not a draft, a line field is invalid (for example a quantity of 0, `param` names the field), a second commit line, or the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "can only add line items to draft invoices, current status: finalized" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/void: post: tags: [Invoices] summary: Void an invoice operationId: voidInvoice description: | Cancels a `draft`, `finalized` or `uncollectible` invoice. Voiding is final. In the same transaction, credits that were applied to the invoice go back to the customer's credit balance, and if the invoice bought a prepaid commit, the unspent part of that credit is retired. After the void, Hardcount stops collection at Stripe (expires open Checkout sessions and cancels the PaymentIntent where Stripe allows it), sends the `invoice.voided` webhook event, reverses the tax with the tax provider, and resolves any active dunning run with `invoice_voided`. A spend-threshold invoice (`billing_reason = threshold`) that is voided before its period's invoice is built gives its charges back: that invoice bills them too. Voided after that, its charges are billed on no invoice; bill them with a one-off invoice if they should be charged. A paid invoice cannot be voided: issue a credit note instead. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Invoice voided. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: voided payment_status: pending currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok total_amount_cents: 25000 amount_due_cents: 25000 amount_paid_cents: 0 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-02T16:00:00Z" due_at: "2026-12-02T16:00:00Z" voided_at: "2026-11-05T09:12:00Z" paid_out_of_band: false public_token: hc_pinv_3b8e1d0c9f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-11-05T09:12:00Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice cannot be voided in its current state. Codes: `invalid_state` (already paid or already voided), `payment_in_flight` (a charge is still running or being confirmed with Stripe), or `payment_unidentifiable` (Stripe's answer to a charge was unclear and named no payment; Hardcount finds out from Stripe within a day). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot void a paid invoice — issue a credit note instead request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "reason" request_id: req_dbjg652q3gpgqfoq5cd0 param: reason "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/resend-setup-link: post: tags: [Invoices] summary: Resend the payment-method setup link operationId: resendInvoiceSetupLink description: | Emails the customer the link to add a payment method again. It is the same email Hardcount sends when an invoice is finalized and the customer has no card on file: a single-use link to the payment-update page (`PAYMENT_UPDATE_URL`), sent to the customer's email address only, with no copies. Once a card is added, the automatic charge collects a `finalized` invoice; a written-off one is not charged automatically. Works on unpaid invoices that are `finalized` or `uncollectible`. This is different from `POST /v1/invoices/{id}/send`, which emails the invoice itself with a link to pay it. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Email queued. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [sent] description: Always `sent`, meaning the email is queued. example: status: sent "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing was sent. Codes: `invoice_not_collectible` (the invoice is draft, voided or paid), `payment_in_flight` or `payment_unidentifiable` (a charge is unresolved), `email_not_configured` (this server has no SMTP set up), or `no_email_on_file` (the customer has no email address). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: no_email_on_file message: customer has no email on file — add one on the customer record, or copy the setup link from the customer page and share it directly request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "email" request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/mark-uncollectible: post: tags: [Invoices] summary: Write off an invoice as uncollectible operationId: markInvoiceUncollectible description: | Moves a `finalized` invoice to `uncollectible` (a bad-debt write-off). Hardcount stops collecting it: the automatic charge and dunning leave it alone. The invoice can still be paid later, for example with `POST /v1/invoices/{id}/record-payment`. Tax is not reversed. The route takes no body. Resolves any active dunning run with `invoice_not_collectible`, so payment retries stop. Fires the `invoice.marked_uncollectible` webhook event. A written-off spend-threshold invoice keeps its charges billed: no later invoice bills them again unless the invoice is then voided. `POST /v1/invoices/{id}/void` says when a void gives them back. Asks Stripe to expire any Checkout session still open for the invoice. If Stripe refuses or cannot be reached, the session stays payable until it expires, at most an hour after it opened. If the invoice billed its subscription's current period in advance, it still counts as unpaid for that subscription. While it has an amount due, a plan change, added item or seat increase that would charge for the rest of the period is refused with `unpaid_invoice_blocks_change`, unless a charge on the invoice is still being confirmed with Stripe. A cancel, downgrade, removed item or plan swap lowers what it asks for by the unused time; a cancel or a change of billing interval on the period's first day voids it instead, if nothing is paid on it, no charge on it is being confirmed with Stripe and it bills nothing else. A written-off invoice from an earlier period, or one not tied to a subscription's period, does not affect subscription changes. An invoice whose last charge got an unclear answer from Stripe with no payment named (payment status `unknown`, no `stripe_payment_intent_id`) can be written off; if the charge did succeed, the invoice is still marked paid when Stripe shows it. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Invoice written off. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: uncollectible payment_status: failed currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok total_amount_cents: 25000 amount_due_cents: 25000 amount_paid_cents: 0 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-02T16:00:00Z" due_at: "2026-12-02T16:00:00Z" uncollectible_at: "2026-12-20T10:00:00Z" stripe_payment_intent_id: pi_3QHk9aLkdIwHu7ix5f6a7b8c paid_out_of_band: false last_payment_error: Your card has insufficient funds. public_token: hc_pinv_3b8e1d0c9f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-12-20T10:00:00Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice cannot be written off in its current state. Codes: `invalid_state` (draft, paid, voided, or already uncollectible) or `payment_in_flight` (a charge is still running). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot mark paid invoice uncollectible — issue a credit note instead request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "reason" request_id: req_dbjg652q3gpgqfoq5cd0 param: reason "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/record-payment: post: tags: [Invoices] summary: Record a payment made outside Stripe operationId: recordInvoicePayment description: | Marks a `finalized` or `uncollectible` invoice as paid for money received another way, such as a bank transfer, cheque or cash. The full amount due is recorded; partial payments are not supported. The invoice gets `paid_out_of_band: true` and no `stripe_payment_intent_id`, so there is no card to refund later. Resolves any active dunning run with `payment_recovered`. Sends the `invoice.paid` and `invoice.payment_recorded` webhook events; the second carries `recovered_from`, the status the invoice was paid from. The optional `note` is stored in the audit log, cut to 500 bytes. On a written-off invoice, read its `recovery_warning` first: it says when the payment will not reconcile on its own. The payment is still recorded. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string requestBody: required: false content: application/json: schema: type: object properties: note: type: string description: 'Free text, for example "Cheque 1234". Do not put personal data here: the audit log cannot be edited.' example: note: Bank transfer received Nov 20, ref 88-3141 responses: "200": description: Invoice marked paid. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: paid payment_status: succeeded currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok total_amount_cents: 25000 amount_due_cents: 0 amount_paid_cents: 25000 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-02T16:00:00Z" due_at: "2026-12-02T16:00:00Z" paid_at: "2026-11-20T11:00:00Z" paid_out_of_band: true public_token: hc_pinv_3b8e1d0c9f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-11-20T11:00:00Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The payment cannot be recorded. Codes: `invalid_state` (draft, voided or already paid), `payment_in_flight` or `payment_unidentifiable` (a card charge is unresolved). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: invoice is already paid request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The body has a field other than `note` (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "amount_cents" request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_cents "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/rotate-public-token: post: tags: [Invoices] summary: Replace the hosted invoice link operationId: rotateInvoicePublicToken description: | Creates a new `public_token` for the hosted invoice page. The old link, and the PDF link under it, stop working at once. Use it when a link was shared with the wrong people. Nothing is emailed: send the new link yourself, or use `POST /v1/invoices/{id}/send`. Draft invoices have no public link. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: | Invoice with its new `public_token`. Its `updated_at` is the value from before the rotation; read the invoice again for the new one. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c40 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000042 status: paid payment_status: succeeded currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 8650 amount_due_cents: 0 amount_paid_cents: 8650 credits_applied_cents: 0 billing_period_start: "2026-10-01T00:00:00Z" billing_period_end: "2026-11-01T00:00:00Z" billing_period_display: Oct 1, 2026 – Oct 31, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-01T00:05:00Z" due_at: "2026-12-01T00:05:00Z" paid_at: "2026-11-01T00:05:03Z" stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d paid_out_of_band: false public_token: hc_pinv_c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7019f2c4e1a7b3d5f6081a2b3 net_payment_term_days: 30 created_at: "2026-11-01T00:05:00Z" updated_at: "2026-11-01T00:05:03Z" billing_reason: subscription_cycle is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The invoice is a draft (code `invalid_state`); finalize it first. Or the request has a body with a field: this route takes no body (code `validation_error`, `param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: draft invoices do not have a public token — finalize first request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/collect: post: tags: [Invoices] summary: Charge a finalized invoice now operationId: collectInvoicePayment description: | Collects a `finalized`, unpaid invoice right away instead of waiting for the automatic charge, which runs on a schedule (hourly by default). 1. The customer's credit balance is applied first. If it covers the whole amount, the invoice is marked paid and no card is charged. 2. Otherwise Hardcount charges the remaining amount to the customer's saved payment method with a Stripe PaymentIntent. A paid invoice sends the `invoice.paid` webhook event (and `payment.succeeded` for a card payment), and any active dunning run is resolved with `payment_recovered`. A declined card answers 502; the invoice's `payment_status` becomes `failed`, and dunning starts when the customer's policy is enabled. Check `payment_status` on the returned invoice. A written-off (`uncollectible`) invoice cannot be charged here. Record an offline payment or issue a new invoice instead. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Invoice after the credit and charge steps. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: paid payment_status: succeeded currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 25000 amount_due_cents: 0 amount_paid_cents: 25000 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-02T16:00:00Z" due_at: "2026-12-02T16:00:00Z" paid_at: "2026-11-03T10:00:02Z" stripe_payment_intent_id: pi_3QHk9aLkdIwHu7ix5f6a7b8c paid_out_of_band: false public_token: hc_pinv_3b8e1d0c9f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-11-03T10:00:02Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | An earlier charge must be resolved first. Code `payment_in_flight`: a charge on the invoice is running, or its result was lost and Hardcount is confirming it with Stripe (within about an hour). Code `payment_unidentifiable`: Stripe's answer to the earlier charge was unclear and named no payment; Hardcount records a payment that appears, and the invoice is charged again as usual if none has appeared 24 hours after that charge. Code `amount_below_minimum`: the amount is below Stripe's minimum charge, so nothing was charged; the invoice stays open with `payment_status` `pending`, and no dunning starts. Collect applies the credit balance first and asks Stripe each time anything is still owed: for a converted charge, Stripe's minimum follows the account's settlement currency. Code `card_refused`: the customer's bank refused the saved card for good on this invoice (a hard decline such as `lost_card`), so Hardcount does not charge it again and nothing reached Stripe; the customer needs a different card, or can pay on the hosted invoice page. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: payment_in_flight message: A charge on this invoice is being confirmed with Stripe. Try again in a few minutes; an interrupted charge is confirmed within about an hour. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`. The invoice is not finalized, is already paid, or has no amount due; the customer is paying on the hosted page; payments are not configured; the customer has no payment method set up; or the request has a body with a field (this route takes no body, and `param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: customer has no payment method set up request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" "502": description: | Stripe declined or refused the charge (code `provider_error`), so nothing was charged. The message gives the reason in plain words. Code `stripe_key_unusable`: Stripe refused this account's API key, or none is connected for this mode; nothing was charged and the invoice is unchanged. Update the key under Settings → Payments, then retry. Code `payment_outcome_unknown`: Stripe never answered, so the card may have been charged; Hardcount checks with Stripe and updates the invoice. Do not retry until it does. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: provider_error message: "Card was declined: Insufficient funds." request_id: req_dbjg652q3gpgqfoq5cd0 /v1/invoices/{id}/refund: post: tags: [Invoices] summary: Refund a paid invoice operationId: refundInvoice description: | Refunds a `paid` invoice to the customer's card. Hardcount creates and issues a credit note (a document that reduces what the customer owes) for the refund, and sends the refund to Stripe. Leave out `amount_cents`, or send 0, to refund what was paid by card less the refunds on the invoice's credit notes that are not voided. A refund that failed or is not finished counts too: retry it with `POST /v1/credit-notes/{id}/retry-refund`. If other credit notes already cover part of the invoice, the refund stops at what is left of the invoice total. The response is the credit note. Check its `refund_status`: if Stripe refused the refund, it is `failed`. If Stripe gave no clear answer, it is `unknown`: the customer may already be refunded, so don't refund again in the Stripe Dashboard. Retry either with `POST /v1/credit-notes/{id}/retry-refund`. Only invoices paid by card through Stripe can be refunded here. For an invoice paid outside Stripe, create a credit note with `out_of_band_amount_cents`. For an invoice that bought prepaid credit, use commit relief (`commit_relief` on `POST /v1/credit-notes`). Nothing is emailed; send the credit note with `POST /v1/credit-notes/{id}/send`. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The paid invoice's id (`hc_inv_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [reason] properties: amount_cents: type: integer format: int64 minimum: 0 description: Amount to refund. 0 or absent refunds what was paid by card less the refunds on the invoice's credit notes that are not voided, up to what is left of the invoice total. reason: type: string enum: [duplicate, fraudulent, requested_by_customer, other] description: Why you are refunding, stored as the credit note's `reason`. description: type: string description: 'Text for the credit note line. Defaults to "Refund: ".' example: amount_cents: 3750 reason: requested_by_customer description: Refund of October token usage responses: "200": description: The issued refund credit note. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk2fLkdIwHu7ix1b2c3d4e recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-03T10:15:02Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`. The invoice is not paid, was paid outside Stripe, has no payment to refund, is already fully refunded or credited, or bought prepaid credit (use commit relief). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: can only refund paid invoices request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Validation error: missing or unknown `reason`, a negative amount, an amount above what is left to refund, refunds are not configured, or an unknown field (`param` names it; the body must use only the fields listed here). A misspelled amount such as `amount` is refused rather than read as a full refund. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "reason must be one of: duplicate, fraudulent, requested_by_customer, other" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/payment-timeline: get: tags: [Invoices] summary: Get the invoice activity timeline operationId: getInvoicePaymentTimeline description: | Returns everything that happened to an invoice, oldest first: lifecycle steps (created, finalized, paid, voided, marked uncollectible), charge attempts (source `payment`, event_type `charge_attempt.succeeded|failed|unknown|below_minimum`), dunning retries, credit notes, customer emails, and card disputes (source `dispute`, a `payment.dispute_opened` row and, once Stripe closes the dispute, a `payment.dispute_closed` row; both at Stripe's own time), and refunds made in Stripe outside Hardcount that no credit note records (source `stripe_refund`, event_type `payment.refunded_in_stripe`, at Stripe's own time). A refund recorded as a credit note shows as that note's row, titled "Refunded in Stripe". A draft invoice returns an empty list. Each row carries `line`, the row with every word written by the server: a title, an amount, a problem line, the meta line and who did it. `description`, `detail`, `error`, `status` and `amount_cents` repeat it for clients that read the older fields. Rows for the same instant are ordered cause before effect. If one source cannot be read, the rest is still returned and the source is named in `degraded`. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string responses: "200": description: Timeline events. content: application/json: schema: $ref: "#/components/schemas/InvoicePaymentTimeline" example: events: - id: lifecycle:created:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:00Z" source: lifecycle event_type: invoice.created status: neutral description: Invoice created detail: Renewal for Oct 1, 2026 – Oct 31, 2026 · Acme Pro line: id: lifecycle:created:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.created title: Invoice created detail: - - text: Renewal for Oct 1, 2026 – Oct 31, 2026 - - link: kind: subscription id: hc_sub_dauq502q3gpgqfoq5c2g label: Acme Pro tone: neutral occurred_at: "2026-11-01T00:05:00Z" - id: lifecycle:finalized:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:00Z" source: lifecycle event_type: invoice.finalized status: neutral description: Invoice finalized detail: Automatic amount_cents: 8650 currency: USD line: id: lifecycle:finalized:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.finalized title: Invoice finalized amount: cents: 8650 currency: USD detail: [] actor: kind: automatic tone: neutral occurred_at: "2026-11-01T00:05:00Z" - id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 timestamp: "2026-11-01T00:05:03Z" source: lifecycle event_type: invoice.paid status: succeeded description: Invoice paid detail: Visa •••• 4242 amount_cents: 8650 currency: USD line: id: lifecycle:paid:hc_inv_dbj84b2q3gpgqfoq5c40 kind: invoice.paid title: Invoice paid amount: cents: 8650 currency: USD detail: - - text: Visa •••• 4242 tone: success occurred_at: "2026-11-01T00:05:03Z" degraded: [] truncated: false "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/disputes: get: tags: [Invoices] summary: List the card disputes on an invoice operationId: listInvoiceDisputes description: | Returns the card disputes (chargebacks) Stripe reported against the invoice's payments, oldest first. An invoice without any returns an empty list. Hardcount records them from Stripe's `charge.dispute.*` webhook events, which must be turned on for the Stripe webhook endpoint. Hardcount never changes the invoice because of a dispute: respond to it in Stripe, and if it is lost, record the loss with a credit note or a write-off in your books. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: The invoice's disputes. content: application/json: schema: $ref: "#/components/schemas/InvoiceDisputeList" example: data: - id: hc_dsp_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_dispute_id: du_1QHk2fLkdIwHu7ix2c3d4e5f stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d status: needs_response reason: fraudulent amount_cents: 8650 currency: USD evidence_due_by: "2026-11-25T23:59:59Z" opened_at: "2026-11-10T08:30:00Z" livemode: false created_at: "2026-11-10T08:30:04Z" updated_at: "2026-11-10T08:30:04Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/stripe-refunds: get: tags: [Invoices] summary: List the refunds made in Stripe on an invoice operationId: listInvoiceStripeRefunds description: | Returns the refunds made in Stripe outside Hardcount on the invoice's payments, oldest first, whatever Hardcount did with each: recorded as a credit note, flagged for review, or neither. An invoice without any returns an empty list. Hardcount records them from Stripe's refund webhook events, of which `refund.created` must be turned on for the Stripe webhook endpoint. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: The invoice's refunds made in Stripe. content: application/json: schema: $ref: "#/components/schemas/InvoiceStripeRefundList" example: data: - id: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_refund_id: re_3QHk2fLkdIwHu7ix3d4e5f6a stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d amount_cents: 2000 currency: USD status: succeeded reason: requested_by_customer outcome: needs_review review_reason: seen_late refund_created_at: "2026-11-05T16:20:00Z" livemode: false created_at: "2026-11-07T09:00:12Z" updated_at: "2026-11-07T09:00:12Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/stripe-refunds/{stripe_refund_id}/dismiss: post: tags: [Invoices] summary: Dismiss a refund made in Stripe operationId: dismissInvoiceStripeRefund description: | Records that a refund made in Stripe, which Hardcount flagged on the invoice (`outcome` `needs_review`), was handled outside Hardcount. The refund's `outcome` becomes `dismissed` and the `refunded_in_stripe` notice clears. Nothing else changes: no credit note, no money, no email. The note is kept on the refund and in the audit log. parameters: - name: id in: path required: true description: The invoice id (`hc_inv_…`). schema: type: string - name: stripe_refund_id in: path required: true description: Stripe's id of the refund (`re_…`), as in the refund's `stripe_refund_id`. schema: type: string - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [note] properties: note: type: string description: How the refund was handled. 1 to 1000 characters; leading and trailing spaces are removed. example: note: Refunded as a goodwill gesture; booked in Xero. responses: "200": description: The dismissed refund. content: application/json: schema: $ref: "#/components/schemas/InvoiceStripeRefund" example: id: hc_srf_6f1c0a7e9b2d4c8e1a3b5d7f invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 stripe_refund_id: re_3QHk2fLkdIwHu7ix3d4e5f6a stripe_payment_intent_id: pi_3QHk2fLkdIwHu7ix0a1b2c3d stripe_charge_id: ch_3QHk2fLkdIwHu7ix0a1b2c3d amount_cents: 2000 currency: USD status: succeeded reason: requested_by_customer outcome: dismissed review_reason: seen_late dismissed_note: Refunded as a goodwill gesture; booked in Xero. dismissed_at: "2026-11-07T10:00:00Z" refund_created_at: "2026-11-05T16:20:00Z" livemode: false created_at: "2026-11-07T09:00:12Z" updated_at: "2026-11-07T10:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode, or no refund with this Stripe id on it. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: stripe_refund not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: The refund does not need review (it is recorded, dismissed or not a refund of the invoice's payment). Code `invalid_state`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: only a refund that needs review can be dismissed request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: Missing or too long `note` (`param` `note`), or an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: note is required request_id: req_dbjg652q3gpgqfoq5cd0 param: note "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/stats: get: tags: [CreditNotes] summary: Get credit note totals operationId: getCreditNoteStats description: | Counts of every credit note by status, and the money on issued notes, one entry per currency. Covers every note in this mode, not a page of them. Draft and voided notes are counted but move no money. responses: "200": description: Credit note totals. content: application/json: schema: $ref: "#/components/schemas/CreditNoteStats" example: draft_count: 1 issued_count: 3 voided_count: 0 by_currency: - currency: USD total_cents: 12500 refunded_cents: 3750 refund_pending_cents: 0 refund_failed_cents: 0 credited_cents: 8750 out_of_band_cents: 0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/{id}/send: post: tags: [CreditNotes] summary: Email a credit note to the customer operationId: sendCreditNote description: | Queues an email with the credit note's PDF attached. Only issued credit notes can be sent: a draft or voided note answers 422. The recipients work as for `POST /v1/invoices/{id}/send`: `email` is the recipient, and `additional_emails` absent copies the customer's stored list, `[]` copies nobody, and a list copies exactly those addresses. The send shows on the invoice's activity timeline and in the customer's sent emails. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The credit note id (`hc_cn_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email description: The recipient, one plain address such as `billing@acme.example`, with no display name. additional_emails: type: array maxItems: 10 description: Addresses to copy on this send. Absent copies the customer's stored list; `[]` copies nobody. items: type: string example: email: billing@acme.example responses: "200": description: Email queued. content: application/json: schema: type: object required: [status] properties: status: type: string enum: [sent] description: Always `sent`, meaning the email is queued. example: status: sent "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing was sent. `email_not_configured`: this server has no SMTP set up. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: email_not_configured message: Email isn't set up on this server, so nothing was sent. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "The note is not issued (draft or voided), `email` is missing or invalid, a copied address is invalid (`param` `additional_emails`), or the body has an unknown field (`param` names it)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: issue the credit note before sending — drafts are not final documents request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/{id}: get: tags: [CreditNotes] summary: Get a credit note operationId: getCreditNote description: Returns the credit note without its line items. The PDF has the lines. parameters: - name: id in: path required: true description: The credit note id (`hc_cn_…`). schema: type: string responses: "200": description: Credit note. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk2fLkdIwHu7ix1b2c3d4e recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-03T10:15:02Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/{id}/issue: post: tags: [CreditNotes] summary: Issue a draft credit note operationId: issueCreditNote description: | Moves a `draft` credit note to `issued` and applies it. The route takes no body. - Unpaid invoice (`finalized` or `uncollectible`): the invoice's amount due goes down, and customer credit the note displaces goes back to the customer's balance. If nothing is left due, the invoice is marked `paid`, `invoice.paid` is sent and its dunning run closes. - Paid invoice: the refund part is sent to Stripe, the credit part is added to the customer's credit balance, and the out-of-band part is recorded. A note created while the invoice was unpaid, with no split, puts its whole total in the credit balance. - Either way, the note's share of the tax is reversed with the tax provider when the provider recorded the invoice's tax. Check the returned `status` and `refund_status`: - A refund Stripe refused still returns 200, with `refund_status` `failed`. A refund with no clear answer from Stripe returns 200 with `unknown`: the customer may already be refunded. Retry either with `POST /v1/credit-notes/{id}/retry-refund`. - If the invoice's charge is still running, or the invoice is still a draft, nothing changes and the note stays `draft`. Hardcount issues it later on its own only when `issue_pending` is true (a note Hardcount created); otherwise issue it again once the charge has settled. - If the invoice was voided, the draft is voided instead and returned with `status` `voided`. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The draft credit note's id (`hc_cn_…`). schema: type: string responses: "200": description: Credit note after the issue attempt. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5h tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000004 status: issued reason: Service outage on Oct 14 subtotal_cents: 1000 tax_amount_cents: 0 total_cents: 1000 refund_amount_cents: 0 credit_amount_cents: 1000 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-04T08:05:00Z" refund_status: none recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-04T08:00:00Z" recorded_at: "2026-11-04T08:00:00Z" updated_at: "2026-11-04T08:05:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing was issued. Code `invalid_state`: the credit note is not a draft. Code `credit_note_exceeds_invoice`: on an unpaid invoice, the note is now more than the amount due plus the credit applied (the invoice changed after the draft was made). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: can only issue draft credit notes request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "auto_issue" request_id: req_dbjg652q3gpgqfoq5cd0 param: auto_issue "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/{id}/attach-stripe-refund: post: tags: [CreditNotes] summary: Attach a refund made in Stripe to a credit note operationId: attachStripeRefundToCreditNote description: | Makes a refund made in Stripe outside Hardcount the card refund of an `issued` credit note whose own refund `failed`: use it when you refunded in the Stripe dashboard what the note's refund was for. Hardcount reads the refund from Stripe first. It must be of the payment that paid the invoice, not made by Hardcount, succeeded, and of exactly the note's `refund_amount_cents`. The note then holds the refund (`stripe_refund_id`, `refund_status`, `recorded_from_stripe`), and a flagged row for it is marked recorded. No new credit note, no second tax reversal and no Stripe refund call. Hardcount never matches a refund to a note by amount on its own. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The issued credit note's id (`hc_cn_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [stripe_refund_id] properties: stripe_refund_id: type: string description: Stripe's id of the refund you made (`re_…`). example: stripe_refund_id: re_3QHk8cLkdIwHu7ix3d4e5f6a responses: "200": description: The credit note, now holding the refund. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk8cLkdIwHu7ix3d4e5f6a recorded_from_stripe: true bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-06T09:30:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: Code `invalid_state`. The note is not issued or its refund did not fail, another note already holds the refund, its row was decided another way, or the note changed since it was read. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: a refund made in Stripe can be attached only to an issued credit note whose card refund failed request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: Code `validation_error`. `stripe_refund_id` is missing; Stripe has no such refund in this mode; the refund is not of this invoice's payment, was made by Hardcount, has not succeeded, or is in another currency or of another amount (`param` `stripe_refund_id`); or the body has an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: this refund is not of the payment that paid this invoice request_id: req_dbjg652q3gpgqfoq5cd0 param: stripe_refund_id "429": $ref: "#/components/responses/RateLimited" "502": description: | Code `provider_error`. Stripe could not be asked about the refund, for example because Stripe is not connected for this mode. Nothing changed. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: provider_error message: Payment provider rejected the request. Please retry; if the problem persists, contact support. request_id: req_dbjg652q3gpgqfoq5cd0 /v1/credit-notes/{id}/retry-refund: post: tags: [CreditNotes] summary: Retry a failed credit-note refund operationId: retryCreditNoteRefund description: | Sends the refund part of an `issued` credit note to Stripe again, when its `refund_status` is `failed`, `unknown`, `pending` or `none`. Before creating a refund, Hardcount asks Stripe for one already made for this note and records it if found. For `unknown` and `none`, Hardcount then resends the earlier request whose result it never recorded, with the same idempotency key. While Stripe keeps that key (at least 24 hours), Stripe returns the earlier request's result instead of making a second refund. A `pending` refund whose Stripe refund is still processing is not resent. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The issued credit note's id (`hc_cn_…`). schema: type: string responses: "200": description: Credit note with its updated `refund_status`. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000003 status: issued reason: requested_by_customer subtotal_cents: 3750 tax_amount_cents: 0 total_cents: 3750 refund_amount_cents: 3750 credit_amount_cents: 0 out_of_band_amount_cents: 0 currency: USD issued_at: "2026-11-03T10:15:00Z" refund_status: succeeded stripe_refund_id: re_3QHk7bLkdIwHu7ix2c3d4e5f recorded_from_stripe: false bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-03T10:15:00Z" recorded_at: "2026-11-03T10:15:00Z" updated_at: "2026-11-03T12:00:04Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`. The note is not issued, has no refund part, the refund already succeeded or is still processing at Stripe, or the invoice was paid outside Stripe (void the note and issue a new one with `out_of_band_amount_cents`). Code `stripe_refund_unresolved`: the invoice has a refund made in Stripe that is not recorded yet (flagged for review, or still in flight). Record it, attach it to this note with `POST /v1/credit-notes/{id}/attach-stripe-refund` if it replaces this refund, or dismiss it, then retry: a retry now could refund the customer twice. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: refund already succeeded — nothing to retry request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "amount_cents" request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_cents "429": $ref: "#/components/responses/RateLimited" "502": description: | Stripe refused the refund (code `provider_error`): the note's `refund_status` is `failed`. Or Stripe gave no clear answer (code `payment_outcome_unknown`): it is `unknown`, and the customer may already be refunded. Retry later in both cases. Stripe not connected for this mode also answers `provider_error`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: provider_error message: Stripe didn't make the refund, so the customer was not refunded. Check the payment in Stripe, then retry the refund. request_id: req_dbjg652q3gpgqfoq5cd0 /v1/credit-notes/{id}/void: post: tags: [CreditNotes] summary: Void a draft credit note operationId: voidCreditNote description: | Cancels a `draft` credit note. Nothing is applied or refunded. Issued credit notes are final and cannot be voided. The route takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The credit note id (`hc_cn_…`). schema: type: string responses: "200": description: Credit note voided. content: application/json: schema: $ref: "#/components/schemas/CreditNote" example: id: hc_cn_dbkrtc2q3gpgqfoq5c5h tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g credit_note_number: CN-000004 status: voided reason: Service outage on Oct 14 subtotal_cents: 1000 tax_amount_cents: 0 total_cents: 1000 refund_amount_cents: 0 credit_amount_cents: 1000 out_of_band_amount_cents: 0 currency: USD voided_at: "2026-11-04T09:00:00Z" refund_status: none recorded_from_stripe: false is_simulated: false issue_pending: false tax_reversal_pending: false created_at: "2026-11-04T08:00:00Z" recorded_at: "2026-11-04T08:00:00Z" updated_at: "2026-11-04T09:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`. The note is already voided, is issued, or its refund was already sent to Stripe. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot void an issued credit note — issued credit notes are final financial documents request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "reason" request_id: req_dbjg652q3gpgqfoq5cd0 param: reason "429": $ref: "#/components/responses/RateLimited" /v1/credit-notes/{id}/pdf: get: tags: [CreditNotes] summary: Download credit note PDF operationId: getCreditNotePdf description: | Returns the credit note as a PDF, shown inline and named after the credit note number (`CN-000003.pdf`). An issued note prints the customer and company details it was issued with; a draft prints the current ones. parameters: - name: id in: path required: true description: The credit note id (`hc_cn_…`). schema: type: string responses: "200": description: PDF file. content: application/pdf: schema: type: string format: binary "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No credit note with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: credit note not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/finalize: post: tags: [Invoices] summary: Finalize a draft invoice operationId: finalizeInvoice description: | Issues a `draft` invoice and then collects it at once. The route takes no body. Finalizing: - On an invoice you created with `POST /v1/invoices`, calculates tax with the account's tax provider, and sets `issued_at` to now and `due_at` to `net_payment_term_days` later. An invoice the billing engine made keeps the tax and dates it was built with. - Refuses with 409 while tax cannot be calculated (`tax_status` `pending`): the invoice stays a draft. See `POST /v1/invoices/{id}/retry-tax`. - Moves the invoice to `finalized`, copies the customer and company details onto it (`bill_to_snapshot`, `supplier_snapshot`, `footer`), grants the credit of a prepaid commit line, issues any credit note held on the draft, creates the hosted-page `public_token`, and records the tax with Stripe Tax when that is the provider. - Sends the `invoice.finalized` webhook event. Its `amount_due_cents` is before customer credit is applied. Collecting, right after, in the same request (the same steps as the automatic charge): 1. The customer's credit balance is applied. If it covers the amount due, the invoice is marked paid and no card is charged. 2. Otherwise the saved card is charged with a Stripe PaymentIntent. A successful charge marks the invoice paid, sends `invoice.paid` and `payment.succeeded`, and queues a receipt email to the customer when they have an email address. A declined charge sets `payment_status` to `failed`, sends `payment.failed`, and starts dunning when the customer's policy is enabled. 3. With no card on file, an email with a link to add one is queued for the customer, once, when they have an email address and the server's `PAYMENT_UPDATE_URL` is set; `no_pm_notified_at` records it. It reaches the customer only when the server can send email (SMTP is set up). `POST /v1/invoices/{id}/resend-setup-link` sends it again. Dunning starts when the policy is enabled. 4. When Stripe is not connected for the invoice's mode, the customer is emailed the invoice itself, card or not, when they have an email address and the server can send email. `invoice_emailed_at` records it, and it goes once. Dunning starts when the policy is enabled. The response is the invoice after collection: paid, failed, or still queued. A collection step that does not finish is not an error here; the automatic charge, which runs on a schedule (hourly by default), picks up an invoice that is still queued. Finalizing does not otherwise email the invoice; send it with `POST /v1/invoices/{id}/send`. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The draft invoice's id (`hc_inv_…`). schema: type: string responses: "200": description: The invoice, finalized and after its collection. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c41 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: "" invoice_number: HC-000043 status: paid payment_status: succeeded currency: USD subtotal_cents: 25000 discount_cents: 0 tax_amount_cents: 0 tax_rate: 0 tax_provider: manual tax_status: ok payment_card_brand: visa payment_card_last4: "4242" total_amount_cents: 25000 amount_due_cents: 0 amount_paid_cents: 25000 credits_applied_cents: 0 billing_period_start: "2026-11-02T15:30:00Z" billing_period_end: "2026-11-02T15:30:00Z" billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-11-02T16:00:00Z" due_at: "2026-12-02T16:00:00Z" paid_at: "2026-11-02T16:00:02Z" stripe_payment_intent_id: pi_3QHk9aLkdIwHu7ix5f6a7b8c paid_out_of_band: false public_token: hc_pinv_3b8e1d0c9f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c net_payment_term_days: 30 memo: Onboarding for the Acme rollout created_at: "2026-11-02T15:30:00Z" updated_at: "2026-11-02T16:00:02Z" billing_reason: manual is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice cannot be finalized now, and nothing changed. Code `invalid_state`: it is not a draft, or its tax is still pending (the message says whether Hardcount retries it or you must act). Code `tax_calculation_expired`: its Stripe Tax calculation is more than 23 hours old and its tax was never recorded with Stripe (Stripe keeps a calculation for 24 hours); retry tax, then finalize. Code `invoice_lines_changed`: a line was added to the invoice while its tax was being calculated; finalize again. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: "can only finalize draft invoices, current status: finalized" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | Code `validation_error`. The request has a body with a field: this route takes no body (`param` names the field). Or the invoice's prepaid commit line has an expiry that has already passed (`param` is `commit_expires_at`): the granted credit would expire on arrival. A line cannot be edited: void the draft and create a new invoice with a later `commit_expires_at`. Nothing changed. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "send_email" request_id: req_dbjg652q3gpgqfoq5cd0 param: send_email "429": $ref: "#/components/responses/RateLimited" /v1/invoices/{id}/retry-tax: post: tags: [Invoices] summary: Retry tax calculation on a draft invoice operationId: retryInvoiceTax description: | Calculates tax again on a draft invoice whose `tax_status` is `pending`, after you fixed the cause (for example the customer's address). It also refreshes a draft whose Stripe Tax calculation is more than 23 hours old and was never recorded, which finalize refuses. The route takes no body. Each call rewrites the tax on the lines and the invoice. A retry of pending tax adds one to `tax_retry_count`; refreshing an expired calculation does not. A retry that succeeds clears `tax_pending_reason` and `tax_error_code`. A retry that still fails records the new error code and answers 200 with the updated invoice, so check its `attention`. When the tax resolves on an invoice the billing engine made, the call also finalizes it, unless collection is paused on its subscription. If nothing is then due, the invoice is marked paid at once; otherwise the automatic charge collects it on its next run. An invoice you created stays a draft for you to finalize. If the finalize fails, the call returns 500 or 409 and the invoice stays a draft with its tax saved: finalize it. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The draft invoice's id (`hc_inv_…`). schema: type: string responses: "200": description: Invoice updated; check `attention` for the new state. content: application/json: schema: $ref: "#/components/schemas/Invoice" example: id: hc_inv_dbj84b2q3gpgqfoq5c42 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g subscription_id: hc_sub_dauq502q3gpgqfoq5c2g invoice_number: HC-000044 status: finalized payment_status: pending currency: USD subtotal_cents: 8650 discount_cents: 0 tax_amount_cents: 735 tax_rate: 8.5 tax_name: Sales Tax tax_country: US tax_provider: stripe_tax tax_calculation_id: taxcalc_1QHkAbLkdIwHu7ixZ1y2x3w4 tax_transaction_id: tax_1QHkAcLkdIwHu7ixV5u6t7s8 tax_status: ok tax_retry_count: 1 attention: severity: info reason: payment_scheduled message: The card on file is charged automatically, usually within an hour; use Charge now to collect it right away. actions: - code: charge_now label: Charge now code: payment.scheduled detail: Automatic charges run on a schedule, hourly by default. since: "2026-12-01T09:30:00Z" due_by: "2026-12-31T00:05:00Z" total_amount_cents: 9385 amount_due_cents: 9385 amount_paid_cents: 0 credits_applied_cents: 0 billing_period_start: "2026-11-01T00:00:00Z" billing_period_end: "2026-12-01T00:00:00Z" billing_period_display: Nov 1, 2026 – Nov 30, 2026 billing_timezone: UTC bill_to_snapshot: display_name: Acme Inc legal_name: Acme Incorporated email: billing@acme.example address_line1: 100 Market Street city: San Francisco state: CA postal_code: "94105" country: US supplier_snapshot: name: Example Labs Inc email: billing@examplelabs.example address_line1: 1 Congress Avenue city: Austin state: TX postal_code: "78701" country: US issued_at: "2026-12-01T00:05:00Z" due_at: "2026-12-31T00:05:00Z" paid_out_of_band: false auto_charge_pending: true public_token: hc_pinv_c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7019f2c4e1a7b3d5f6081a2b3 net_payment_term_days: 30 created_at: "2026-12-01T00:05:00Z" updated_at: "2026-12-01T09:30:00Z" billing_reason: subscription_cycle is_simulated: false "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No invoice with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invoice not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`. The invoice is not a draft, or its tax is not pending and its calculation is not an expired one. Also returned when the tax was saved but the invoice's state then stopped the finalize. Code `invoice_lines_changed`: a line was added to the invoice while its tax was being calculated, and nothing was saved; retry. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: "tax retry only valid on draft invoices (current: finalized)" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "force" request_id: req_dbjg652q3gpgqfoq5cd0 param: force "429": $ref: "#/components/responses/RateLimited" /v1/auth/login: post: tags: [Dashboard] x-internal: true security: [] summary: Sign in to the dashboard with email and password operationId: login description: | Used by the dashboard; not part of the integration API. Checks the email and password. On success it starts a dashboard session in test mode and puts it in the `hardcount_session` cookie (`HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` in staging and production). The session lasts 7 days. It belongs to the user, not to an API key, so revoking API keys does not sign anyone out. Switch the session to live mode with `POST /v1/auth/mode`. A successful sign-in also sets `hardcount_device` (HttpOnly, `Path=/v1/auth`, 180 days). It marks the browser as one that signed in to this email, so the sign-in throttle counts its attempts on their own. It never replaces the password. A wrong password and an unknown email get the same 401, so the answer does not reveal which emails have accounts. requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: type: string format: email description: The user's sign-in email. password: type: string description: The user's password. example: email: maya@northwind.example password: violet-harbor-lantern-42 responses: "200": description: Signed in. The answer sets the session cookie. content: application/json: schema: type: object required: [user_id, tenant_id, email, livemode, expires_at] properties: user_id: type: string description: The signed-in user's id (`hc_usr_…`). tenant_id: type: string description: The account the session works in. email: type: string description: The user's sign-in email. livemode: type: boolean description: The session's mode. Always false (test) at sign-in. expires_at: type: string format: date-time description: When the session ends, 7 days after sign-in. example: user_id: hc_usr_8d3f1a6c2e9b4d7f0a5c3e1b tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 email: maya@northwind.example livemode: false expires_at: "2026-11-08T09:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": description: The email and password do not match an account. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: invalid email or password request_id: req_dbjg652q3gpgqfoq5cd0 "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: The password is right, but the user belongs to no account (a removed member). Code `invalid_state`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: user has no tenant membership; contact your administrator request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`email` or `password` is empty, or the body has an unknown field. `param` names it." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: email is required request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": description: | Too many attempts. Two limits answer 429, and neither locks the account: - Code `login_throttled`: the sign-in throttle. Repeated wrong passwords for one email, from one address or from many, start a wait of 1 to 15 minutes. The message names the wait in minutes and `Retry-After` gives it in seconds. The answer is the same for an email with no account. A browser that signed in to the email before is counted on its own. - Code `rate_limited`: the shared per-address request limit on `/v1/auth`. headers: Retry-After: description: Seconds until an attempt is counted again. schema: type: integer content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: rate_limit_error code: login_throttled message: Too many sign-in attempts. Try again in 2 minutes. request_id: req_dbjg652q3gpgqfoq5cd0 "503": description: | Code `authentication_unavailable`: the sign-in throttle could not read the database, so the attempt was refused before the password check. Try again shortly. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: authentication_unavailable message: Sign-in is unavailable right now. Try again in a minute. request_id: req_dbjg652q3gpgqfoq5cd0 /v1/auth/logout: post: tags: [Dashboard] x-internal: true security: - SessionCookie: [] - {} summary: Sign out of the dashboard operationId: logout description: | Used by the dashboard; not part of the integration API. Ends the session named by the `hardcount_session` cookie on the server, then clears the cookie. A cookie for a session that already ended also gets 204. A request with no cookie gets 204 and changes nothing, not even a cookie. If the session cannot be ended, the answer is 500 and the cookie stays, so a browser is never told it signed out while its session still works. Writes an audit row when a session ended. responses: "204": description: Signed out, or there was nothing to sign out. "400": $ref: "#/components/responses/InvalidBody" "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" "500": description: The session could not be ended. It still works, and the cookie was not cleared. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/auth/mode: post: tags: [Dashboard] x-internal: true security: - SessionCookie: [] summary: Switch the dashboard session between test and live mode operationId: switchSessionMode description: | Used by the dashboard; not part of the integration API. Sets the mode of the signed-in dashboard session. Every later request with this session reads and writes that mode's data: customers, invoices, subscriptions, API keys and the rest. No request needs its own mode parameter, and the user does not sign in again. A body without `livemode` switches to test mode. Writes an audit row, filed in test mode whichever mode is chosen. requestBody: required: true content: application/json: schema: type: object required: [livemode] properties: livemode: type: boolean description: True for live mode, false for test mode. example: livemode: true responses: "200": description: Mode updated content: application/json: schema: type: object required: [livemode] properties: livemode: type: boolean description: The mode the session is now in. example: livemode: true "400": $ref: "#/components/responses/InvalidBody" "401": description: | The request has no session cookie (`missing session — sign in at /login`), or its session has ended (`invalid or expired session`). The cookie is checked before the body. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: unauthorized message: invalid or expired session request_id: req_dbjg652q3gpgqfoq5cd0 "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The body has an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "mode" request_id: req_dbjg652q3gpgqfoq5cd0 param: mode "429": $ref: "#/components/responses/RateLimited" /v1/auth/password-reset/request: post: tags: [Dashboard] x-internal: true security: [] summary: Request a password reset link operationId: requestPasswordReset description: | Used by the dashboard; not part of the integration API. Always returns the same 200 body, whether or not an account has the email, so the answer does not reveal which emails have accounts. If an account matches, Hardcount creates a single-use reset token that works for 1 hour and emails a reset link built from `DASHBOARD_BASE_URL`. Sending needs SMTP (`SMTP_HOST`) and `DASHBOARD_BASE_URL`; there is no other way to deliver the link. One account is sent at most 3 reset emails an hour; later requests in that hour send nothing. `email_delivery` says whether this server can deliver reset emails at all. It describes the server's setup and does not depend on the email sent. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email description: The sign-in email of the account to reset. example: email: maya@northwind.example responses: "200": description: Request accepted (identical body regardless of email existence) content: application/json: schema: type: object required: [message, email_delivery] properties: message: type: string description: The same sentence for every request. email_delivery: type: string enum: [ok, not_configured] description: | Whether this server can deliver reset emails: `ok` when SMTP is set up and `DASHBOARD_BASE_URL` is set, otherwise `not_configured`. example: message: if an account exists for that email, a password-reset link has been sent email_delivery: ok "400": $ref: "#/components/responses/InvalidBody" "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`email` is empty, or the body has an unknown field. `param` names it." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: email is required request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" /v1/auth/password-reset/confirm: post: tags: [Dashboard] x-internal: true security: [] summary: Set a new password using a reset token operationId: confirmPasswordReset description: | Used by the dashboard; not part of the integration API. Uses up a reset token and sets the user's new password. Every session the user had is then signed out, so a stolen session cookie stops working too. The new password is checked before the token, so a password that fails the check does not use the token up. An unknown, expired or already used token is a 422 with `param: token`. Writes an audit row. requestBody: required: true content: application/json: schema: type: object required: [token, password] properties: token: type: string description: The token from the reset link. password: type: string minLength: 12 description: | The new password: at least 12 characters, at most 72 bytes, and not one of the most common passwords. example: token: 9f2c4e7a1b3d5f6081a2c3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e password: violet-harbor-lantern-42 responses: "200": description: Password updated. Every session the user had is signed out. content: application/json: schema: type: object required: [message] properties: message: type: string description: The same sentence for every successful reset. example: password updated — sign in with your new password example: message: password updated — sign in with your new password "400": $ref: "#/components/responses/InvalidBody" "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `token` or `password` is empty, the password fails the check (`param: password`), the token is unknown, expired or already used (`param: token`), or the body has an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: reset token is invalid, expired, or already used request_id: req_dbjg652q3gpgqfoq5cd0 param: token "429": $ref: "#/components/responses/RateLimited" /v1/auth/password-reset/check: get: tags: [Dashboard] x-internal: true summary: Check a password reset token operationId: checkPasswordResetToken description: | Used by the dashboard; not part of the integration API. Tells whether a reset token can still be used, without using it up. The reset-password page calls it on load, so it can show "this link is no longer valid" before the user types a new password. An unknown, expired or already-used token returns 422 with code `invalid_or_expired`. security: [] parameters: - name: token in: query required: true schema: type: string description: The token from the reset link. responses: "200": description: Token can be used content: application/json: schema: type: object required: [valid] properties: valid: type: boolean enum: [true] description: Always true. A token that cannot be used gets 422. example: valid: true "422": description: "`invalid_or_expired` (param `token`): the token is missing, unknown, expired or already used" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_or_expired message: the reset link is unknown, expired or already used request_id: req_dbjg652q3gpgqfoq5cd0 param: token "429": $ref: "#/components/responses/RateLimited" /v1/auth/invite/{token}: get: tags: [Dashboard] x-internal: true security: [] summary: Preview a team invitation operationId: getInvitation description: | Used by the dashboard; not part of the integration API. The accept page calls it first. No sign-in is needed: the token from the invite link is the credential, as with a password reset link. Returns the invited email, the account's name, and whether the invitee has to create an account. An unknown, expired, revoked or used token gets the same 422, so the answer does not tell a guesser which tokens exist. parameters: - name: token in: path required: true schema: type: string description: The token from the invite link. responses: "200": description: Invitation preview content: application/json: schema: type: object required: [email, tenant_id, tenant_name, needs_new_account, expires_at, invited_by_email] properties: email: type: string description: The invited address. tenant_id: type: string description: The account the invitation is for. tenant_name: type: string description: The account's name. Empty if it could not be read. needs_new_account: type: boolean description: True when no account has this email, so accepting needs a password. invited_by_email: type: string description: The email of the member who sent the invitation. Empty when that user no longer exists. expires_at: type: string format: date-time description: When the invite link stops working. example: email: sam@northwind.example tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 tenant_name: Northwind AI needs_new_account: true invited_by_email: maya@northwind.example expires_at: "2026-11-04T15:00:00Z" "422": description: "The token is unknown, expired, revoked or already used (`param: token`)." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: this invitation link is invalid, expired, revoked, or already used request_id: req_dbjg652q3gpgqfoq5cd0 param: token "429": $ref: "#/components/responses/RateLimited" /v1/auth/accept-invite: post: tags: [Dashboard] x-internal: true security: [] summary: Accept a team invitation operationId: acceptInvitation description: | Used by the dashboard; not part of the integration API. Uses up the invite token and adds the invitee to the account as a member. The token works once: of two requests that race, or an accept racing a revoke, only one wins. - No account has the invited email: `password` is required. Hardcount creates the account, signs it in with a new session cookie, and answers `session_minted: true`. - An account has the email: Hardcount adds the membership but does not sign the user in, because holding the email alone must not open an existing account. The user signs in with their own password afterwards, and `password` is ignored. The token is used up before the new account is created. If the new account's password fails the check (422, `param: password`), the invitation stays used and the inviter has to send a new one. If only the session cannot be started, the membership still stands and `session_minted` is false. Writes an audit row. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string description: The token from the invite link. password: type: string minLength: 12 description: | Required when no account has the invited email: at least 12 characters, at most 72 bytes, and not one of the most common passwords. Ignored otherwise. example: token: 4b8e1d6a9c2f5e7b0d3a6c9f2e5b8d1a4c7f0e3b6d9a2c5f8e1b4d7a0c3f6e9b password: violet-harbor-lantern-42 responses: "200": description: Membership added. The answer sets the session cookie when `session_minted` is true. content: application/json: schema: type: object required: [user_id, email, tenant_id, session_minted, livemode, expires_at] properties: user_id: type: string description: The member's user id (`hc_usr_…`). email: type: string description: The member's sign-in email. tenant_id: type: string description: The account the member joined. session_minted: type: boolean description: True when this answer signed the new account in and set the session cookie. livemode: type: boolean description: The new session's mode. Always false (test). expires_at: type: string description: Session expiry (RFC 3339) when `session_minted` is true; an empty string otherwise. example: user_id: hc_usr_2b7e9c4a1f6d3b8e5c0a7f2d email: sam@northwind.example tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 session_minted: true livemode: false expires_at: "2026-11-08T15:30:00Z" "400": $ref: "#/components/responses/InvalidBody" "403": description: A browser page on another site sent the request (code `forbidden`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: cross-site request blocked request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The token is unknown, expired, revoked or already used (`param: token`), a new account's password fails the check (`param: password`), or the body has an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: this invitation link is invalid, expired, revoked, or already used request_id: req_dbjg652q3gpgqfoq5cd0 param: token "429": $ref: "#/components/responses/RateLimited" /v1/members: get: tags: [Dashboard] x-internal: true operationId: listMembers security: - SessionCookie: [] summary: List team members and invitations description: | Used by the dashboard; not part of the integration API. Lists the account's members, oldest user first, and every invitation the account has sent, newest first. Only a dashboard session can call it: an API key gets 403, because a team change is made by a person. Roles are recorded but not enforced yet: every member can do everything in the dashboard. responses: "200": description: Members and invitations content: application/json: schema: type: object required: [members, invitations] properties: members: type: array items: type: object required: [user_id, email, role, joined_at] properties: user_id: type: string description: The member's user id (`hc_usr_…`). email: type: string description: The member's sign-in email. role: type: string description: "`owner` for the user created at setup, `member` for an invited one. Not enforced yet." joined_at: type: string format: date-time description: | When the user's sign-in account was created. For someone who already had an account when invited, this is earlier than the day they joined. invitations: type: array items: $ref: "#/components/schemas/MemberInvitation" example: members: - user_id: hc_usr_8d3f1a6c2e9b4d7f0a5c3e1b email: maya@northwind.example role: owner joined_at: "2026-10-01T08:00:00Z" invitations: - id: hc_minv_davn542q3gpgqfoq5ccg email: sam@northwind.example role: member status: pending invited_by_email: maya@northwind.example expires_at: "2026-11-04T15:00:00Z" created_at: "2026-10-28T15:00:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller is not a dashboard session (an API key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: this endpoint requires a dashboard session — sign in to the dashboard to manage team membership request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/members/invite: post: tags: [Dashboard] x-internal: true operationId: inviteMember security: - SessionCookie: [] summary: Invite a teammate by email description: | Used by the dashboard; not part of the integration API. Creates an invitation that works for 7 days and queues an email with the accept link. The link is built from `DASHBOARD_BASE_URL`, never from the request's headers. An email can have only one invitation that is not accepted or revoked, even an expired one: revoke it to send a new one. Writes an audit row. Nothing is created when the server cannot deliver the link: the answer is 409 with code `invalid_state` when `DASHBOARD_BASE_URL` is not set, or `email_not_configured` when this server has no SMTP. `can_send_invites` on `GET /v1/whoami` tells you in advance. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email description: The teammate's email, one plain address. It is stored in lower case. example: email: sam@northwind.example responses: "201": description: Invitation created and its email queued content: application/json: schema: $ref: "#/components/schemas/MemberInvitation" example: id: hc_minv_davn542q3gpgqfoq5ccg email: sam@northwind.example role: member status: pending invited_by_email: maya@northwind.example expires_at: "2026-11-04T15:00:00Z" created_at: "2026-10-28T15:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller is not a dashboard session (an API key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: this endpoint requires a dashboard session — sign in to the dashboard to manage team membership request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | No invitation was created. Codes: `invalid_state` (`DASHBOARD_BASE_URL` is not set), `email_not_configured` (this server has no SMTP, so the link could not be delivered), or `already_exists` with `param: email` (the email is already a member, or already has an invitation that is not accepted or revoked). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: a pending invitation for this email already exists — revoke it first to re-send request_id: req_dbjg652q3gpgqfoq5cd0 param: email "422": description: "`email` is empty or not one plain address, or the body has an unknown field. `param` names it." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: invalid email request_id: req_dbjg652q3gpgqfoq5cd0 param: email "429": $ref: "#/components/responses/RateLimited" /v1/members/invitations/{id}: delete: tags: [Dashboard] x-internal: true operationId: revokeMemberInvitation security: - SessionCookie: [] summary: Revoke an invitation description: | Used by the dashboard; not part of the integration API. Revokes an invitation that is not accepted or revoked yet, an expired one included. Its accept link stops working at once; if an accept races the revoke, only one of them wins. An accepted or already revoked invitation is a 404. Writes an audit row. parameters: - name: id in: path required: true schema: type: string description: The invitation's id (`hc_minv_…`). responses: "204": description: Invitation revoked "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller is not a dashboard session (an API key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: this endpoint requires a dashboard session — sign in to the dashboard to manage team membership request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No invitation with this id that can still be revoked (code `not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: invitation not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/members/{userID}: delete: tags: [Dashboard] x-internal: true operationId: removeMember security: - SessionCookie: [] summary: Remove a member from the account description: | Used by the dashboard; not part of the integration API. Removes the user from the account and ends all of their sessions, so a browser they are signed in on loses access at once. You cannot remove yourself (another member must do it), and the account's last member cannot be removed. The user's sign-in account itself is kept. Writes an audit row. parameters: - name: userID in: path required: true schema: type: string description: The member's user id (`hc_usr_…`). responses: "204": description: Member removed and their sessions ended "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller is not a dashboard session (an API key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: this endpoint requires a dashboard session — sign in to the dashboard to manage team membership request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: The user is not a member of this account (code `not_found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: member not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: You tried to remove yourself, or the account's last member. Code `invalid_state`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: you cannot remove yourself — another member must remove you request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/whoami: get: tags: [Auth] summary: Get the account and mode of the current credential operationId: getWhoami description: | Tells you which account and mode the request's credential works in. Any valid credential can call it, a usage-only key included. With an API key the answer has `key_id` and `key_type`; with a dashboard session it has `user_id` and `email` instead. Every answer has `tenant_id`, `livemode`, `can_email_setup_links`, `can_send_invites` and `smtp_configured`. responses: "200": description: The credential's account, mode and server email setup content: application/json: schema: type: object required: [tenant_id, livemode, can_email_setup_links, can_send_invites, smtp_configured] properties: tenant_id: type: string description: The account the credential belongs to. livemode: type: boolean description: The mode the credential works in, true for live. can_email_setup_links: type: boolean description: | Whether this server can email a customer a link to add a payment method. False when email sending (SMTP) or PAYMENT_UPDATE_URL is not set up; resending a setup link then fails. can_send_invites: type: boolean description: | Whether this server can invite a teammate. False when email sending (SMTP) or DASHBOARD_BASE_URL is not set up; inviting then fails with 409. smtp_configured: type: boolean description: | Whether this server has SMTP set up (SMTP_HOST). False means no email of any kind is sent. Settings → Email (`GET /v1/settings/email`) has the details. user_id: type: string description: Dashboard session only. The signed-in user's id (`hc_usr_…`). email: type: string description: Dashboard session only. The user's sign-in email; absent if it could not be read. key_id: type: string description: API key only. The key's id (`hc_key_…`). key_type: type: string enum: [secret, ingest] description: API key only. The key's type. example: tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false can_email_setup_links: true can_send_invites: true smtp_configured: true key_id: hc_key_dajqj1iq3gpgqfoq5ca0 key_type: secret "401": $ref: "#/components/responses/Unauthorized" "429": $ref: "#/components/responses/RateLimited" /v1/billing/run: post: tags: [Billing] summary: Run billing now operationId: runBilling description: | Runs the billing run now for this account and mode, the same run the scheduler makes. It invoices every subscription that is due: an `active` or `trialing` subscription whose period ended, or whose scheduled mid-period cancel came, more than the invoice grace ago (`INVOICE_GRACE_PERIOD`, 1 hour by default), so usage that arrives late still makes its invoice. A subscription overdue for several periods gets one invoice per period. Subscriptions on a test clock are not included; advance the clock instead. A subscription whose billing is failing is skipped, both while it waits for its next attempt and while it is parked (set aside after eight failed billing attempts, until someone retries it). To retry one, use `POST /v1/subscriptions/{id}/retry-billing`. An audit log entry records the run. Takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: Every due subscription was billed. `errors` is empty. content: application/json: schema: type: object required: [invoices_generated, errors] properties: invoices_generated: type: integer description: Invoices the run created, on a partial run too. errors: type: array description: One entry per failure, listed in the 206 response. Empty on a 200. items: type: string example: invoices_generated: 3 errors: [] "206": description: | `errors` is not empty. The invoices the run made are kept. Each entry is one of: - `subscription : billing failed`: that subscription could not be billed. - `billing run failed`: the run stopped before it reached every due subscription, because the list of due subscriptions could not be read or the request ran out of time (30 seconds). The subscriptions it did not reach are still due, and the next billing run bills them. - `billing run completed but its audit record failed`: the run's audit record could not be written. content: application/json: schema: type: object required: [invoices_generated, errors] properties: invoices_generated: type: integer description: Invoices the run created, on a partial run too. errors: type: array description: One entry per failure, listed in the 206 response. Empty on a 200. items: type: string example: invoices_generated: 2 errors: - "subscription hc_sub_dauq502q3gpgqfoq5c2k: billing failed" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "customer_id" request_id: req_dbjg652q3gpgqfoq5cd0 param: customer_id "429": $ref: "#/components/responses/RateLimited" /v1/provider-costs: get: tags: [ProviderCosts] summary: List provider cost rates operationId: listProviderCosts description: | Your provider cost rates: what you pay AI providers per token, one rate per provider, model and token type, ordered by provider, model and token type. A rate is edited in place. The cost stamped on each usage event when it arrived is the history, so changing a rate never changes past events. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of rates, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/ProviderCostRate" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_pcr_9c3e5a7b1d2f4e6a8c0b2d4f tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 provider: anthropic model: claude-sonnet-4.5 token_type: input unit_cost_cents: "0.0002" currency: USD created_at: "2026-09-15T09:10:00Z" updated_at: "2026-09-15T09:10:00Z" - id: hc_pcr_9c3e5a7b1d2f4e6a8c0b2d61 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 provider: anthropic model: claude-sonnet-4.5 token_type: output unit_cost_cents: "0.001" currency: USD created_at: "2026-09-15T09:10:00Z" updated_at: "2026-09-15T09:10:00Z" total: 2 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" put: tags: [ProviderCosts] summary: Create or update a provider cost rate operationId: upsertProviderCost description: | Sets the rate for one provider, model and token type: it creates the rate, or changes it when one exists for that key. `provider` and `token_type` are stored in lower case. The rate costs usage events that arrive from now on; events already stamped keep their cost. The write is recorded in the audit log. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [provider, model, token_type, unit_cost_cents] properties: provider: type: string description: | The provider, such as `anthropic`. Stored in lower case; it must equal the `provider` label of the usage events it costs. model: type: string description: | The model, such as `claude-sonnet-4.5`, or a raw model snapshot id. An event's `model_raw` label is matched first, then its `model` label. token_type: type: string description: | The kind of token, such as `input`, `output` or `cache_read`. Stored in lower case; it must equal the `token_type` label of the usage events it costs. unit_cost_cents: type: string description: >- US cents per token, as a decimal string (e.g. "0.0003" = $3.00 per 1,000,000 tokens). 0 or more, under 10^10, at most 12 decimal places (trailing zeros do not count), and at most 100 characters. A JSON number is also read. currency: type: string enum: [USD] default: USD description: | Rates are in USD only, the currency LiteLLM reports cost in. Any other value is 422. example: provider: anthropic model: claude-sonnet-4.5 token_type: input unit_cost_cents: "0.0002" responses: "200": description: The rate as stored. content: application/json: schema: $ref: "#/components/schemas/ProviderCostRate" example: id: hc_pcr_9c3e5a7b1d2f4e6a8c0b2d4f tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 provider: anthropic model: claude-sonnet-4.5 token_type: input unit_cost_cents: "0.0002" currency: USD created_at: "2026-09-15T09:10:00Z" updated_at: "2026-09-15T09:10:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `validation_error`: a required field is missing (`param` then lists `provider, model, token_type` together, or `unit_cost_cents`), `unit_cost_cents` is not a number or is outside its limits, `currency` is not USD, or the body has an unknown field such as `cost_per_token` (`param` names the field; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unit_cost_cents has more than 12 decimal places request_id: req_dbjg652q3gpgqfoq5cd0 param: unit_cost_cents "429": $ref: "#/components/responses/RateLimited" /v1/provider-costs/{id}: delete: tags: [ProviderCosts] summary: Delete a provider cost rate operationId: deleteProviderCost description: | Deletes the rate. Usage events it already costed keep their stamped cost. Events that arrive later with its labels get no cost from it. When no other rate matches them (a rate on the event's other model label can), they get no cost at all, and the margin report counts them in `unresolved_events`, until you set a rate again. The delete is recorded in the audit log. Send no body. parameters: - name: id in: path required: true description: The rate's ID, `hc_pcr_` and 24 hex characters. schema: type: string responses: "204": description: Deleted. No body. "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No rate with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: provider_cost_rate not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "provider" request_id: req_dbjg652q3gpgqfoq5cd0 param: provider "429": $ref: "#/components/responses/RateLimited" /v1/customers/{id}/margin: get: tags: [ProviderCosts] summary: Get a customer's margin report operationId: getCustomerMargin description: | The customer's rated usage revenue against the provider cost stamped on its usage events, for a window. It is a usage unit-economics view: base fees, credits and taxes are not included. Per-model margin shows only where the price that billed the usage names the model; other revenue is reported as unattributed, never split by a guess. Provider costs never appear on the public cost dashboard. The window is `[from, to)`: `from` inclusive, `to` exclusive. A missing bound defaults to the last 30 days ending at the customer's current time, which is the test clock's time when the customer is on a test clock. `from` and `to` take RFC 3339 instants only (no bare dates), and `from` must be before `to`. The window may be at most one year long. parameters: - name: id in: path required: true schema: type: string - name: from in: query description: | Window start, inclusive. RFC 3339. Default is 30 days before the customer's current time. schema: type: string format: date-time - name: to in: query description: | Window end, exclusive. RFC 3339. Default is the customer's current time (test clock time for a customer on a test clock). schema: type: string format: date-time responses: "200": description: The margin report. content: application/json: schema: $ref: "#/components/schemas/MarginReport" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g from: "2026-10-01T00:00:00Z" to: "2026-11-01T00:00:00Z" revenue_cents: 3750 revenue_currency: USD cost_cents: "2500" cost_currency: USD margin_bps: 3333 costed_events: 4210 cost_rates_configured: true by_model: - model: claude-sonnet-4.5 cost_cents: "2500" attributed: false unattributed_revenue_cents: 3750 unresolved_events: 0 "400": description: | `invalid_request`: `from` or `to` is not RFC 3339, or `from` is not before `to`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: "from: must be RFC3339" request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The window is longer than one year (`validation_error`, `param: period`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "rate usage window: window must be ≤ 1 year" request_id: req_dbjg652q3gpgqfoq5cd0 param: period "429": $ref: "#/components/responses/RateLimited" /v1/credits/grant: post: tags: [Credits] summary: Grant credit to a customer operationId: grantCredits description: | Adds a credit grant in `currency`. The credit pays only the customer's invoices in that currency; it is never converted. A grant in a currency the customer is not billed in is accepted and shows as its own balance. Invoices draw the credit when they are billed. The audit log records the grant. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [customer_id, currency, amount_cents, description] properties: customer_id: type: string description: The customer to grant credit to (`hc_cus_…`). currency: type: string description: | ISO 4217 code of the credit. Required. Case-insensitive; stored uppercase. Zero-decimal currencies (such as JPY) are not supported yet. example: USD amount_cents: type: integer format: int64 description: | The credit to add, in the currency's minor unit (cents for USD). From 1 to 100000000 (1,000,000.00). description: type: string description: | What the credit is for, shown on the ledger entry. Required: a grant without one, or with a blank one, is refused with 422. At most 500 bytes after surrounding spaces are trimmed (fewer characters when it has non-ASCII letters). expires_at: type: string format: date-time description: | Optional expiry; must be in the future. Omitted = never expires. The credit can be drawn up to and including this instant; a period invoice draws it when its period ends at or before it. The dashboard sends the local midnight after the picked day. grant_kind: type: string enum: [promotional] description: | Optional cost-basis class. `promotional` marks free or marketing credit, which invoices draw before any other credit. Omitted means unclassified credit. `commit` is refused here: commit credit comes only from finalizing a commit invoice. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD amount_cents: 5000 description: Welcome credit expires_at: "2027-01-01T00:00:00Z" grant_kind: promotional responses: "201": description: Credits granted content: application/json: schema: $ref: "#/components/schemas/CreditLedgerEntry" example: id: hc_ccl_dav2242q3gpgqfoq5c50 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g entry_type: grant currency: USD amount_cents: 5000 balance_after: 5000 description: Welcome credit expires_at: "2027-01-01T00:00:00Z" created_at: "2026-10-01T09:00:00Z" grant_kind: promotional "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this `customer_id` in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A field failed validation (`param` names it): `customer_id` or `description` missing; a missing or unsupported `currency`; `amount_cents` not above 0 or above 100000000; a `description` over 500 bytes; an `expires_at` not in the future; a `grant_kind` other than `promotional`. Or the body has an unknown field (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be greater than 0 request_id: req_dbjg652q3gpgqfoq5cd0 param: amount_cents "429": $ref: "#/components/responses/RateLimited" /v1/credits/balance/{customer_id}: get: tags: [Credits] summary: Get a customer's credit balance operationId: getCreditBalance description: | The customer's balances, one per currency the customer's credit ledger holds, ordered by currency. Empty when the customer has no ledger entry. parameters: - name: customer_id in: path required: true description: The customer's ID (`hc_cus_…`). schema: type: string responses: "200": description: Credit balances, one per currency. content: application/json: schema: type: object required: [data] properties: data: type: array items: $ref: "#/components/schemas/CreditBalance" example: data: - customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g currency: USD balance_cents: 5000 total_granted: 5000 total_used: 0 total_expired: 0 total_retired: 0 total_deducted: 0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/credits/balance/{customer_id}/live: get: tags: [Credits] summary: Get a customer's live credit balance operationId: getLiveCreditBalance description: | How much credit the customer has left right now, per currency: the settled balance less the usage no invoice has billed yet in that currency. Use it to decide in your own code whether to keep serving a prepaid customer; Hardcount records usage and never refuses it. The read aggregates the customer's usage events for the current period on every call, so its cost grows with the number of events. Poll it on an interval (for example once a minute per active customer) and cache the answer. Do not call it on every request. A top-up shows on the next read. Not included, so the figure can read high: tax, base fees billed at period end, and amounts due on unpaid invoices. Requires a secret key. parameters: - name: customer_id in: path required: true description: The customer's ID (`hc_cus_…`). schema: type: string responses: "200": description: Live credit balance content: application/json: schema: $ref: "#/components/schemas/CreditLiveBalance" example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g balances: - currency: USD balance_cents: 5000 unbilled_usage_cents: 2400 estimated_remaining_cents: 2600 computed_at: "2026-10-20T12:00:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/test-clocks: get: tags: [TestClocks] summary: List test clocks operationId: listTestClocks description: | Lists the test clocks of this account, newest first. A test clock lets you walk a customer through months of billing in minutes: customers attached to it are billed on its time, and you move that time forward with `POST /v1/test-clocks/{id}/advance`. Test mode only: a live-mode key or session gets 403. Every test-clock route needs the `testclock:write` permission, reads included. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Test clocks content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/TestClock" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-11-02T00:00:00Z" status: ready created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:04:12Z" last_advance_summary: advanced_from: "2026-10-01T00:00:00Z" advanced_to: "2026-11-02T00:00:00Z" invoices_generated: 1 trials_activated: 0 pauses_resumed: 0 thresholds_fired: 0 tax_retried: 0 charges_retried: 1 clawbacks_issued: 0 credits_expired: 0 dunning_advanced: 0 had_errors: false total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" post: tags: [TestClocks] summary: Create a test clock operationId: createTestClock description: | Creates a test clock in `ready` state at `frozen_time`. Attach customers to it, then use `POST /v1/test-clocks/{id}/advance` to move it forward. Test mode only. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [frozen_time] properties: name: type: string maxLength: 200 description: | Your name for the clock. Optional; at most 200 bytes after surrounding spaces are trimmed (fewer characters when it has non-ASCII letters). frozen_time: type: string format: date-time description: The clock's starting time, RFC 3339. Any instant, past or future. example: name: October renewal walk frozen_time: "2026-10-01T00:00:00Z" responses: "201": description: Clock created content: application/json: schema: $ref: "#/components/schemas/TestClock" example: id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-10-01T00:00:00Z" status: ready created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `frozen_time` is missing, or `name` is longer than 200 bytes (`param` names the field). Or the body has an unknown field (`param` names it). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: frozen_time is required request_id: req_dbjg652q3gpgqfoq5cd0 param: frozen_time "429": $ref: "#/components/responses/RateLimited" /v1/test-clocks/{id}: get: tags: [TestClocks] summary: Get a test clock operationId: getTestClock description: | Returns one test clock. While an advance runs, poll this route until `status` leaves `advancing`. Test mode only. parameters: - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } responses: "200": description: Clock content: application/json: schema: $ref: "#/components/schemas/TestClock" example: id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-11-02T00:00:00Z" status: ready created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:04:12Z" last_advance_summary: advanced_from: "2026-10-01T00:00:00Z" advanced_to: "2026-11-02T00:00:00Z" invoices_generated: 1 trials_activated: 0 pauses_resumed: 0 thresholds_fired: 0 tax_retried: 0 charges_retried: 1 clawbacks_issued: 0 credits_expired: 0 dunning_advanced: 0 had_errors: false "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No test clock with this ID in this account. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" delete: tags: [TestClocks] summary: Delete a test clock and its simulated data operationId: deleteTestClock description: | Deletes the clock and everything simulated on it, in one transaction. Every customer attached to the clock is deleted, with all of that customer's: - subscriptions, with their items and item history; - invoices, with their line items, tax calculations, dunning runs and their events, and refunds recorded from Stripe; - credit notes and their lines; - usage events and credit ledger entries; - price overrides, billing profile, payment methods, payment-update links, and portal sessions and links. The emails queued for the clock and the clock's own effective dates on plan price versions are deleted too. Nothing that belongs to the account is touched: meters, plans and their prices, dunning policies, API keys and settings stay, and so does the audit log, which records the deletion. Webhook events already sent are not recalled. Takes no body. parameters: - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } responses: "200": description: Clock deleted content: application/json: schema: type: object required: [status] properties: status: type: string enum: [deleted] description: Always `deleted`. example: status: deleted "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No test clock with this ID in this account, including one already deleted. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "force" request_id: req_dbjg652q3gpgqfoq5cd0 param: force "429": $ref: "#/components/responses/RateLimited" /v1/test-clocks/{id}/subscriptions: get: tags: [TestClocks] summary: List a test clock's subscriptions operationId: listTestClockSubscriptions description: | Lists the subscriptions that run on the clock: those of the customers attached to it, oldest first. The rows carry no `current_billing_period_display`. Test mode only. parameters: - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Subscriptions content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Subscription" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_sub_dauq502q3gpgqfoq5c2g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 code: acme-pro display_name: Acme Pro customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g items: - id: hc_si_3f9c0e2a1b4d5c6e7f8a9b0c tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 subscription_id: hc_sub_dauq502q3gpgqfoq5c2g plan_id: hc_pln_dajr5o2q3gpgqfoq5c1g quantity: 1 created_at: "2026-10-01T00:00:00Z" updated_at: "2026-10-01T00:00:00Z" status: active billing_time: calendar started_at: "2026-10-01T00:00:00Z" activated_at: "2026-10-01T00:00:00Z" cancel_at_period_end: false current_billing_period_start: "2026-11-01T00:00:00Z" current_billing_period_end: "2026-12-01T00:00:00Z" next_billing_at: "2026-12-01T00:00:00Z" test_clock_id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d created_at: "2026-10-01T00:00:00Z" updated_at: "2026-11-01T00:00:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No test clock with this ID in this account. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/test-clocks/{id}/advance: post: tags: [TestClocks] summary: Advance a test clock operationId: advanceTestClock description: | Moves the clock forward to `frozen_time` and catches billing up for every customer and subscription attached to it. The call returns 200 as soon as the clock is `advancing`; the catch-up (the billing of everything that fell due in the skipped time) runs in the background. Poll `GET /v1/test-clocks/{id}` until `status` leaves `advancing`. While it runs, another advance answers 409. One advance moves the clock at most one year; split a longer span into several advances. The catch-up runs these steps in order: trials that ended, period invoices (every period that came due), collection pauses that ended, spend thresholds, tax retries, charges, credit notes that were waiting to be issued, dunning steps, and credit expiry. `last_advance_summary` counts what each step did. If a step fails, the later steps still run, and the clock ends in `internal_failure` with a `last_failure_reason`. Resume it with `POST /v1/test-clocks/{id}/retry-advance`; you do not need to delete it. Every period that came due is invoiced before payment retries and dunning run. So when a dunning policy cancels or pauses a subscription during one long advance, the next period's invoice may already exist. The cancel or pause is dated at the escalation: the time of the invoice's final retry, or when that retry's failure was recorded if that is later. It is never dated before the start of the subscription's current period. When the escalation falls before that start, the action lands at the period's start, which uses none of the period and relieves any base fee billed in advance for it in full. To see the steps as a real clock would, advance to the time of the invoice's last retry first, then to the next billing date. parameters: - $ref: "#/components/parameters/IdempotencyKey" - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [frozen_time] properties: frozen_time: type: string format: date-time description: | The clock's new time, RFC 3339. Must be after its current `frozen_time` and at most one year after it. example: frozen_time: "2026-11-02T00:00:00Z" responses: "200": description: Advance accepted — clock returned in `advancing` state content: application/json: schema: $ref: "#/components/schemas/TestClock" example: id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-11-02T00:00:00Z" status: advancing created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:04:10Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No test clock with this ID in this account. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Code `invalid_state`: the clock is not `ready`. It is still `advancing`, or in `internal_failure` (retry the advance instead). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: clock is advancing, must be ready to advance request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `frozen_time` is missing, not strictly after the current `frozen_time`, or more than one year after it (`param: frozen_time`). Or the body has an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: advance cannot exceed 1 year per call — chunk longer ranges into successive advances request_id: req_dbjg652q3gpgqfoq5cd0 param: frozen_time "429": $ref: "#/components/responses/RateLimited" "503": description: | Code `catchup_queue_full`: too many test-clock catch-ups are waiting, so this catch-up did not start. The clock is set to `internal_failure`. Once the running catch-ups finish, resume it with `POST /v1/test-clocks/{id}/retry-advance`; sending the same advance again answers 409. The answer has no `Retry-After`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: catchup_queue_full message: "Too many test-clock catch-ups are waiting, so this clock's catch-up did not start and the clock is set to internal_failure. Resume it with POST /v1/test-clocks/hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d/retry-advance once the running catch-ups finish." request_id: req_dbjg652q3gpgqfoq5cd0 /v1/test-clocks/{id}/retry-advance: post: tags: [TestClocks] summary: Retry a failed test clock advance operationId: retryTestClockAdvance description: | Resumes a clock left in `internal_failure` after a failed catch-up (the billing Hardcount runs for the time a clock skipped). The catch-up bills only what is still due at the clock's time, so resuming where the previous attempt stopped is safe; `frozen_time` keeps the value the failed advance set. Asynchronous like an advance: the 200 returns the clock back in `advancing`; poll `GET /v1/test-clocks/{id}` until `status` leaves `advancing`. Takes no body. parameters: - $ref: "#/components/parameters/IdempotencyKey" - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } responses: "200": description: Retry accepted — clock returned in `advancing` state content: application/json: schema: $ref: "#/components/schemas/TestClock" example: id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: October renewal walk frozen_time: "2026-11-02T00:00:00Z" status: advancing created_at: "2026-10-28T15:00:00Z" updated_at: "2026-10-28T15:20:31Z" last_advance_summary: advanced_from: "2026-10-01T00:00:00Z" advanced_to: "2026-11-02T00:00:00Z" invoices_generated: 1 trials_activated: 0 pauses_resumed: 0 thresholds_fired: 0 tax_retried: 0 charges_retried: 0 clawbacks_issued: 0 credits_expired: 0 dunning_advanced: 0 had_errors: true "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No test clock with this ID in this account. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: Code `invalid_state`. The clock is not in `internal_failure`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: "retry only valid on clocks in internal_failure (current: ready)" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field (`param` names it). This route takes no body: send none, `{}` or `null`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "frozen_time" request_id: req_dbjg652q3gpgqfoq5cd0 param: frozen_time "429": $ref: "#/components/responses/RateLimited" "503": description: | Code `catchup_queue_full`: too many test-clock catch-ups are waiting, so this catch-up did not start. The clock is set to `internal_failure` again. Send this retry again once the running catch-ups finish. The answer has no `Retry-After`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: catchup_queue_full message: "Too many test-clock catch-ups are waiting, so this clock's catch-up did not start and the clock is set to internal_failure. Resume it with POST /v1/test-clocks/hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d/retry-advance once the running catch-ups finish." request_id: req_dbjg652q3gpgqfoq5cd0 /v1/test-clocks/{id}/customers: get: tags: [TestClocks] summary: List customers attached to a test clock operationId: listTestClockCustomers description: | Lists the customers attached to the clock, one page at a time. Test mode only; a live-mode key gets 403. parameters: - in: path name: id required: true description: The clock's ID (`hc_tclk_…`). schema: { type: string } - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Customers content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/Customer" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_cus_dakhnk2q3gpgqfoq5c0g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 external_id: acme-42 display_name: Acme Inc email: billing@acme.example status: active email_status: unknown test_clock_id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d created_at: "2026-10-28T15:01:00Z" updated_at: "2026-10-28T15:01:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": description: Caller is in live mode, or the key lacks the `testclock:write` permission (needed even to read test clocks). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: test clocks are only available in test mode — use a test-mode API key (hc_secret_test_...) request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: Clock not found content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: test_clock not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/recipes: get: tags: [Recipes] summary: List recipes description: | Every recipe built into Hardcount, with its values at their defaults. A recipe installed in this account and mode carries its install record in `instantiated`. Installing again changes nothing, and there is no uninstall. operationId: listRecipes parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of recipes, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/RecipeListItem" total: $ref: "#/components/schemas/ListTotal" example: data: - key: replicate_style version: 7.0.0 name: GPU compute pricing (per-second) summary: | Ready-made pricing for reselling GPU compute. Bills each customer per second of GPU time, at a separate rate for each GPU type (H100, A100, A40, T4, CPU), on a monthly plan. description: | Sets up everything you need to bill customers for GPU compute time: • A usage meter that counts GPU-seconds. Each event you send must carry the seconds consumed since that job's previous report (a per-interval delta), not a cumulative running total — the meter sums deltas, and cumulative totals would under-bill whenever two jobs share a GPU class • A per-second price for each GPU type — H100, A100 (80GB), A40, T4, and CPU • A monthly plan your customers subscribe to Prices start at Replicate's public per-second rates — a starting point you'll edit to your own margins after setup. Before installing, you can set the currency and the plan's name. overridable: - key: currency type: string default: USD enum: - USD - EUR - GBP - key: plan_name type: string default: GPU Compute max_length: 80 - key: plan_code type: string default: gpu_compute_pro pattern: ^[a-z][a-z0-9_]*$ meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" creates: meters: 1 plans: 1 prices: 5 instantiated: id: hc_rec_6b1d3f5a7c9e0b2d4f6a8c1e tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 recipe_key: replicate_style recipe_version: 7.0.0 overrides: plan_name: GPU Pro created_objects: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e created_at: "2026-10-05T10:00:00Z" created_by: hc_key_dajqj1iq3gpgqfoq5ca0 total: 3 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/recipes/instances: get: tags: [Recipes] summary: List installed recipes description: | The install records of the recipes installed in this account and mode, one per recipe. operationId: listRecipeInstances parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of install records, and how many there are. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/RecipeInstance" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_rec_6b1d3f5a7c9e0b2d4f6a8c1e tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 recipe_key: replicate_style recipe_version: 7.0.0 overrides: plan_name: GPU Pro created_objects: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e created_at: "2026-10-05T10:00:00Z" created_by: hc_key_dajqj1iq3gpgqfoq5ca0 total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/recipes/{key}: get: tags: [Recipes] summary: Get a recipe description: | One recipe, with its values at their defaults. It does not say whether the recipe is installed: the list does. operationId: getRecipe parameters: - in: path name: key required: true description: The recipe's key, such as `replicate_style`. schema: { type: string } responses: "200": description: The recipe, with how many objects it makes. content: application/json: schema: $ref: "#/components/schemas/RecipeDetail" example: key: replicate_style version: 7.0.0 name: GPU compute pricing (per-second) summary: | Ready-made pricing for reselling GPU compute. Bills each customer per second of GPU time, at a separate rate for each GPU type (H100, A100, A40, T4, CPU), on a monthly plan. description: | Sets up everything you need to bill customers for GPU compute time: • A usage meter that counts GPU-seconds. Each event you send must carry the seconds consumed since that job's previous report (a per-interval delta), not a cumulative running total — the meter sums deltas, and cumulative totals would under-bill whenever two jobs share a GPU class • A per-second price for each GPU type — H100, A100 (80GB), A40, T4, and CPU • A monthly plan your customers subscribe to Prices start at Replicate's public per-second rates — a starting point you'll edit to your own margins after setup. Before installing, you can set the currency and the plan's name. overridable: - key: currency type: string default: USD enum: - USD - EUR - GBP - key: plan_name type: string default: GPU Compute max_length: 80 - key: plan_code type: string default: gpu_compute_pro pattern: ^[a-z][a-z0-9_]*$ meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Compute currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" creates: meters: 1 plans: 1 prices: 5 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No recipe with this key. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: recipe not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/recipes/{key}/preview: post: tags: [Recipes] summary: Preview a recipe description: | Shows what installing the recipe with your overrides would create: its meters and plans with their prices. Nothing is written, and nothing goes to the audit log. The body is optional. operationId: previewRecipe parameters: - $ref: "#/components/parameters/IdempotencyKey" - in: path name: key required: true description: The recipe's key, such as `replicate_style`. schema: { type: string } requestBody: content: application/json: schema: type: object properties: overrides: type: object additionalProperties: true description: | Values for the recipe's `overridable` keys, such as `{"currency": "EUR"}`. A key the recipe does not list, or a value that breaks the key's rules, is refused with 422. Keys you leave out take their defaults. example: overrides: plan_name: GPU Pro responses: "200": description: What an install would create. content: application/json: schema: $ref: "#/components/schemas/RecipePreviewResult" example: key: replicate_style version: 7.0.0 objects: meters: - event_name: gpu_seconds name: GPU seconds unit: seconds aggregation: sum plans: - code: gpu_compute_pro name: GPU Pro currency: USD billing_interval: monthly base_amount_cents: 0 prices: - meter: gpu_seconds dimension_match: gpu_type: h100 mode: per_unit unit_amount_cents: "0.1525" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a100 mode: per_unit unit_amount_cents: "0.14" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: a40 mode: per_unit unit_amount_cents: "0.0575" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: t4 mode: per_unit unit_amount_cents: "0.0225" tiers: [] package_size: 0 package_amount_cents: "0" - meter: gpu_seconds dimension_match: gpu_type: cpu mode: per_unit unit_amount_cents: "0.01" tiers: [] package_size: 0 package_amount_cents: "0" warnings: [] "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No recipe with this key. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: recipe not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | An override failed the recipe's rules, or the body has an unknown field (`validation_error`, `param` is `overrides` or the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown override key "plan_price" for recipe "replicate_style" request_id: req_dbjg652q3gpgqfoq5cd0 param: overrides "429": $ref: "#/components/responses/RateLimited" /v1/recipes/{key}/instantiate: post: tags: [Recipes] summary: Install a recipe description: | Installs the recipe in one transaction: it creates the recipe's meters, reusing a meter that has the same `event_name` and aggregation, then a new plan with the recipe's prices, then the install record. If a step fails, nothing is installed. It creates nothing else: no dunning policy and no webhook endpoint. Reusing a meter that other plans price changes none of their prices. When the plan's code is taken, the new plan gets the first free code with `_2`, `_3` and so on added. Installing a recipe that is already installed in this account and mode returns the existing record with 201 and creates nothing. Two installs that run at the same time can conflict: the second gets a 409 and installs nothing. A retry returns the first install, or, for another recipe, reuses the meter it made. An install is written to the audit log; a repeat that installs nothing is not. The body is optional. operationId: instantiateRecipe parameters: - $ref: "#/components/parameters/IdempotencyKey" - in: path name: key required: true description: The recipe's key, such as `replicate_style`. schema: { type: string } requestBody: content: application/json: schema: type: object properties: overrides: type: object additionalProperties: true description: | Values for the recipe's `overridable` keys, such as `{"currency": "EUR"}`. A key the recipe does not list, or a value that breaks the key's rules, is refused with 422. Keys you leave out take their defaults. example: overrides: plan_name: GPU Pro responses: "201": description: | The install record: a new one, or the existing one when the recipe was already installed. content: application/json: schema: $ref: "#/components/schemas/RecipeInstance" example: id: hc_rec_6b1d3f5a7c9e0b2d4f6a8c1e tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 recipe_key: replicate_style recipe_version: 7.0.0 overrides: plan_name: GPU Pro created_objects: meter_ids: - hc_mtr_dajr122q3gpgqfoq5c18 plan_ids: - hc_pln_dajr5o2q3gpgqfoq5c1s price_ids: - hc_prc_dajr672q3gpgqfoq5c3a - hc_prc_dajr672q3gpgqfoq5c3b - hc_prc_dajr672q3gpgqfoq5c3c - hc_prc_dajr672q3gpgqfoq5c3d - hc_prc_dajr672q3gpgqfoq5c3e created_at: "2026-10-05T10:00:00Z" created_by: hc_key_dajqj1iq3gpgqfoq5ca0 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No recipe with this key. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: recipe not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | Nothing is installed (`already_exists`). Either a meter with the recipe's `event_name` has another aggregation, so the recipe cannot use it (`param: meter`), or another install that ran at the same time created the meter (`param: event_name`), the plan code (`param: code`) or this recipe's install (`param: recipe_key`) first. Retry those. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_exists message: 'Meter "gpu_seconds" already exists and adds up usage another way (usage aggregation: recipe wants sum, existing is max), so the recipe can''t use it. Its aggregation can change only until the meter has a usage event; after that it is fixed.' request_id: req_dbjg652q3gpgqfoq5cd0 param: meter "422": description: | An override failed the recipe's rules, or the body has an unknown field (`validation_error`, `param` is `overrides` or the field). The plan the overrides make must also pass the checks of `POST /v1/plans`: a `plan_code` longer than 100 bytes, for example, is refused with `param: code`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: override "currency" value "JPY" not in allowed enum [USD EUR GBP] request_id: req_dbjg652q3gpgqfoq5cd0 param: overrides "429": $ref: "#/components/responses/RateLimited" /v1/exports/customers.csv: get: tags: [Dashboard] x-internal: true summary: Export customers as CSV description: | Used by the dashboard; not part of the integration API. Streams every customer in the caller's mode, newest first, from one consistent snapshot. Columns: id, external_id, display_name, email, status, email_status, created_at, updated_at. Each export writes an `export` row to the audit log before any data is sent. If that row cannot be written, the call returns 500 and sends nothing. If the export fails after it started, the last row's first cell is `EXPORT_INCOMPLETE`: discard the file and retry. Text cells that start with `=`, `+`, `-`, `@`, a tab or a carriage return get a leading `'`, so spreadsheets do not run them as formulas. The file comes as an attachment named like `customers-20261101-090000.csv` (the time is UTC). operationId: exportCustomersCsv parameters: - name: from in: query description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: to in: query description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC). schema: type: string responses: "200": description: CSV file (sent as an attachment). content: text/csv: schema: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`from` or `to` is malformed, or `to` is before `from`." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: from "429": $ref: "#/components/responses/RateLimited" "500": description: The export's audit row could not be written; nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/exports/invoices.csv: get: tags: [Dashboard] x-internal: true summary: Export invoices as CSV description: | Used by the dashboard; not part of the integration API. Streams every invoice in the caller's mode, newest first, from one consistent snapshot. Amount columns are whole numbers in the currency's minor unit (cents for USD). Columns: id, invoice_number, customer_id, subscription_id, status, payment_status, currency, subtotal_cents, tax_amount_cents, discount_cents, total_amount_cents, amount_due_cents, amount_paid_cents, credits_applied_cents, billing_period_start, billing_period_end, tax_provider, tax_transaction_id, tax_reversed_at, issued_at, due_at, paid_at, voided_at, uncollectible_at, created_at. Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work as in the customers export. operationId: exportInvoicesCsv parameters: - name: from in: query description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: to in: query description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC). schema: type: string responses: "200": description: CSV file (sent as an attachment). content: text/csv: schema: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`from` or `to` is malformed, or `to` is before `from`." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: from "429": $ref: "#/components/responses/RateLimited" "500": description: The export's audit row could not be written; nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/exports/subscriptions.csv: get: tags: [Dashboard] x-internal: true summary: Export subscriptions as CSV description: | Used by the dashboard; not part of the integration API. Streams every subscription in the caller's mode, newest first, from one consistent snapshot. Columns: id, code, display_name, customer_id, status, billing_time, trial_start_at, trial_end_at, started_at, activated_at, canceled_at, current_billing_period_start, current_billing_period_end, next_billing_at, plan_ids (plan IDs joined with `|`), created_at. Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work as in the customers export. operationId: exportSubscriptionsCsv parameters: - name: from in: query description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: to in: query description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC). schema: type: string responses: "200": description: CSV file (sent as an attachment). content: text/csv: schema: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`from` or `to` is malformed, or `to` is before `from`." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: from "429": $ref: "#/components/responses/RateLimited" "500": description: The export's audit row could not be written; nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/exports/usage-events.csv: get: tags: [Dashboard] x-internal: true summary: Export usage events as CSV description: | Used by the dashboard; not part of the integration API. Streams the usage events in the caller's mode whose `timestamp` falls in the window, newest first, from one consistent snapshot. `from` and `to` are both required and may span at most 366 days. `customer_id`, `meter_id` and `dimensions` narrow it like the event list. Columns: id, customer_id, meter_id, quantity, timestamp, idempotency_key, origin, dimensions_json, provider_cost_cents (what the event cost you at your provider, in US cents as a decimal; empty when unknown), provider_cost_source. Audit row, `EXPORT_INCOMPLETE` marker and formula escaping work as in the customers export. operationId: exportUsageEventsCsv parameters: - name: from in: query required: true description: Lower bound on the event timestamp, inclusive. RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: to in: query required: true description: Upper bound on the event timestamp, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC). schema: type: string - name: customer_id in: query description: Only this customer's events. schema: type: string - name: meter_id in: query description: Only this meter's events. schema: type: string - name: dimensions in: query description: Comma-separated key=value pairs, same syntax as `GET /v1/usage-events`. schema: type: string responses: "200": description: CSV file (sent as an attachment). content: text/csv: schema: type: string "400": description: "`from` or `to` is missing, or the window is longer than 366 days." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: 'usage-events export requires `from` and `to` query params (RFC3339)' request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: "`from` or `to` is malformed, `to` is before `from`, or a `dimensions` pair is malformed." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: from "429": $ref: "#/components/responses/RateLimited" "500": description: The export's audit row could not be written; nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/exports/audit-log.csv: get: tags: [Dashboard] x-internal: true summary: Export the audit log as CSV description: | Used by the dashboard; not part of the integration API. Streams the audit log for the caller's mode (test or live), newest first, with no row limit. Settings changes, sign-ins and the other account events that `GET /v1/audit-log` lists as test-mode rows appear only in a test-mode export. Takes the same filters as the audit-log list, so the dashboard's filters and its export give the same rows. The date filters are `date_from`/`date_to`; `from`/`to` are ignored here. The export's own `export` row is written before the file is sent. An unfiltered export includes it as the first data row; a filtered export includes it only if it matches the filters. Columns: id, created_at, actor_type, actor_id, actor_name, action, resource_type, resource_id, resource_label, ip_address, request_id, sim_effective_at, test_clock_id, metadata_json. `EXPORT_INCOMPLETE` marker and formula escaping work as in the customers export. operationId: exportAuditLogCsv parameters: - name: date_from in: query description: Lower bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: date_to in: query description: Upper bound on created_at, inclusive. RFC 3339 or `YYYY-MM-DD` (end of that day, UTC). schema: type: string - name: resource_type in: query description: Only rows about this kind of resource, such as `invoice` or `api_key`. schema: type: string - name: resource_id in: query description: Only rows about this resource id. schema: type: string - name: action in: query description: Only rows with this action, such as `create` or `revoke`. schema: type: string - name: actor_id in: query description: Only rows by this actor (a user id or an API key id). schema: type: string - name: test_clock_id in: query description: Only rows from this test clock's simulation. schema: type: string - name: sim_from in: query description: Lower bound on simulated time (sim_effective_at). RFC 3339 or `YYYY-MM-DD`. schema: type: string - name: sim_to in: query description: Upper bound on simulated time (sim_effective_at). RFC 3339 or `YYYY-MM-DD`. schema: type: string responses: "200": description: CSV file (sent as an attachment). content: text/csv: schema: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: A date filter is malformed, or an upper bound is before its lower bound. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: date_from "429": $ref: "#/components/responses/RateLimited" "500": description: The export's audit row could not be written; nothing was sent. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: internal_error message: an internal error occurred request_id: req_dbjg652q3gpgqfoq5cd0 /v1/analytics/overview: get: tags: [Dashboard] x-internal: true summary: Get headline billing metrics operationId: getAnalyticsOverview description: | Used by the dashboard; not part of the integration API. Returns the dashboard's headline metrics for a rolling window that ends now, with values for the window before it where a comparison makes sense. Money is in the minor unit (cents for USD) of the account's default currency, named in `currency` (USD when none is set). Invoices and plans in other currencies are left out, except in `outstanding_by_currency` and `credit_balance_by_currency`, which list each currency. MRR (monthly recurring revenue) counts plan base fees only: base amount times quantity, with yearly plans divided by 12. Usage charges are not included. Data from test clocks is left out. parameters: - name: period in: query description: The window. Any other value is treated as `30d`. `12m` is 365 days. schema: $ref: "#/components/schemas/AnalyticsPeriod" responses: "200": description: Headline metrics. content: application/json: schema: $ref: "#/components/schemas/AnalyticsOverview" example: period: "30d" currency: USD mrr: 24500 mrr_prev: 19600 arr: 294000 arr_prev: 235200 revenue: 61850 revenue_prev: 48200 outstanding_ar: 8650 outstanding_by_currency: - currency: USD amount_cents: 8650 avg_invoice_value: 8836 credit_balance_by_currency: - currency: USD amount_cents: 12000 active_customers: 6 new_customers: 1 live_customers: 5 live_customers_prev: 4 active_subscriptions: 5 trialing_subscriptions: 0 paid_invoices: 7 failed_payments: 1 failed_payments_prev: 0 open_invoices: 1 dunning_active: 1 refunds_needing_attention: 0 usage_events: 48210 logo_churn_rate: 0.0 revenue_churn_rate: 0.0 nrr: 1.0 dunning_recovery_rate: 0.5 mrr_movement: new: 4900 expansion: 0 contraction: 0 churned: 0 net: 4900 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/analytics/revenue-chart: get: tags: [Dashboard] x-internal: true summary: Get revenue over time operationId: getAnalyticsRevenueChart description: | Used by the dashboard; not part of the integration API. Returns paid-invoice revenue per day, or per month for `12m`. Only invoices in the account's default currency are counted; the currency is the `currency` of `GET /v1/analytics/overview`. Buckets with no paid invoices are left out. Data from test clocks is left out. parameters: - name: period in: query description: The window. Any other value is treated as `30d`. `12m` is 365 days. schema: $ref: "#/components/schemas/AnalyticsPeriod" responses: "200": description: Revenue series. content: application/json: schema: type: object required: [period, data] properties: period: $ref: "#/components/schemas/AnalyticsPeriod" data: type: array items: $ref: "#/components/schemas/AnalyticsRevenuePoint" example: period: "30d" data: - date: "2026-10-15" revenue_cents: 4900 invoice_count: 1 - date: "2026-11-01" revenue_cents: 8650 invoice_count: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/analytics/mrr-movement: get: tags: [Dashboard] x-internal: true summary: Get MRR movement over time operationId: getAnalyticsMrrMovement description: | Used by the dashboard; not part of the integration API. Returns how MRR (monthly recurring revenue from plan base fees) changed, per day, or per month for `12m`, with totals. - `new`: subscriptions activated in the bucket. - `churned`: subscriptions canceled in the bucket. - `expansion` and `contraction`: item adds, removals, and plan or quantity changes on subscriptions that were active for the whole window. Only plans in the account's default currency are counted; the currency is the `currency` of `GET /v1/analytics/overview`. Buckets with no movement are left out. Data from test clocks is left out. parameters: - name: period in: query description: The window. Any other value is treated as `30d`. `12m` is 365 days. schema: $ref: "#/components/schemas/AnalyticsPeriod" responses: "200": description: MRR movement series and totals. content: application/json: schema: type: object required: [period, data, totals] properties: period: $ref: "#/components/schemas/AnalyticsPeriod" data: type: array items: $ref: "#/components/schemas/AnalyticsMrrMovementPoint" totals: $ref: "#/components/schemas/AnalyticsMrrMovementTotals" example: period: "30d" data: - date: "2026-10-14" new: 4900 expansion: 0 contraction: 0 churned: 0 net: 4900 - date: "2026-10-22" new: 0 expansion: 2000 contraction: 0 churned: 1900 net: 100 totals: new: 4900 expansion: 2000 contraction: 0 churned: 1900 net: 5000 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/analytics/usage: get: tags: [Dashboard] x-internal: true summary: Get usage over time operationId: getAnalyticsUsage description: | Used by the dashboard; not part of the integration API. Returns usage-event counts and summed quantities per day, or per month for `12m`, the 10 meters with the most events, and totals. Buckets and meters with no events are left out. Data from customers on test clocks is left out. parameters: - name: period in: query description: The window. Any other value is treated as `30d`. `12m` is 365 days. schema: $ref: "#/components/schemas/AnalyticsPeriod" responses: "200": description: Usage series, top meters and totals. content: application/json: schema: type: object required: [period, data, top_meters, totals] properties: period: $ref: "#/components/schemas/AnalyticsPeriod" data: type: array items: $ref: "#/components/schemas/AnalyticsUsagePoint" top_meters: type: array maxItems: 10 items: $ref: "#/components/schemas/AnalyticsTopMeter" totals: type: object required: [events, quantity] description: Totals over the whole window. properties: events: type: integer format: int64 description: Usage events with a timestamp in the window. quantity: type: string description: Summed quantity, as a decimal string. example: period: "30d" data: - date: "2026-10-12" events: 1840 quantity: "412500" - date: "2026-10-13" events: 2210 quantity: "530000" top_meters: - meter_id: hc_mtr_dajr122q3gpgqfoq5c10 meter_name: Tokens event_name: tokens events: 48210 quantity: "12500000" totals: events: 48210 quantity: "12500000" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/dunning/runs: get: tags: [Dunning] summary: List dunning runs operationId: listDunningRuns description: | Dunning is the process of retrying a failed invoice payment on a schedule. A dunning run tracks those retries for one invoice. Runs are listed newest first, each with its invoice's number, amount due and currency, and its policy's retry count. parameters: - name: invoice_id in: query description: Only the run for this invoice. schema: type: string - name: state in: query description: "Only runs in this state. Any other value is a 422 with `param: state`." schema: type: string enum: [active, escalated, resolved] - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Dunning run list. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/DunningRun" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g state: active reason: payment_failed attempt_count: 1 last_attempt_at: "2026-11-04T00:05:03Z" next_action_at: "2026-11-08T00:05:03Z" paused: false created_at: "2026-11-01T00:05:03Z" updated_at: "2026-11-04T00:05:04Z" invoice_number: HC-000042 invoice_amount_due_cents: 8650 invoice_currency: USD max_retry_attempts: 3 total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | Code `validation_error`. `state` is not `active`, `escalated` or `resolved`, `limit` or `offset` is out of range or not a whole number, or the request sends the removed `after` cursor. `param` names it. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: state must be one of active, escalated, resolved request_id: req_dbjg652q3gpgqfoq5cd0 param: state "429": $ref: "#/components/responses/RateLimited" /v1/dunning/runs/{id}: get: tags: [Dunning] summary: Get a dunning run with its events operationId: getDunningRun description: | Returns the run and its history, oldest first. Each event carries `line`, the event in the words the invoice's activity timeline uses. A source that cannot be read is named in `degraded` instead of failing the call. parameters: - name: id in: path required: true description: The dunning run id (`hc_drun_…`). schema: type: string responses: "200": description: The run and its event history. `events` is empty if the event history could not be read, and `degraded` then names it. content: application/json: schema: $ref: "#/components/schemas/DunningRunDetail" example: run: id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g state: active reason: payment_failed attempt_count: 1 last_attempt_at: "2026-11-04T00:05:03Z" next_action_at: "2026-11-08T00:05:03Z" paused: false created_at: "2026-11-01T00:05:03Z" updated_at: "2026-11-04T00:05:04Z" events: - id: hc_devt_dc7m3maq3gpgqfoq5c6v run_id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 event_type: dunning_started state: active reason: payment_failed attempt_count: 0 created_at: "2026-11-01T00:05:03Z" recorded_at: "2026-11-01T00:05:03Z" line: id: dunning:hc_devt_dc7m3maq3gpgqfoq5c6v kind: payment_recovery.started title: Payment recovery started detail: [] actor: kind: automatic tone: neutral occurred_at: "2026-11-01T00:05:03Z" - id: hc_devt_dc7m3maq3gpgqfoq5c7g run_id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 event_type: retry_attempted state: active reason: "payment failed: Your card has insufficient funds." attempt_count: 1 metadata: payment_intent_id: pi_3QHk2fLkdIwHu7ix4e5f6a7b created_at: "2026-11-04T00:05:03Z" recorded_at: "2026-11-04T00:05:03Z" line: id: dunning:hc_devt_dc7m3maq3gpgqfoq5c7g kind: payment_recovery.retry_failed title: Payment retry 1 failed amount: cents: 8650 currency: USD problem: "Card declined: insufficient funds" detail: [] actor: kind: automatic tone: failure occurred_at: "2026-11-04T00:05:03Z" degraded: [] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No dunning run with this id in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning run not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" /v1/dunning/runs/{id}/resolve: post: tags: [Dunning] summary: Resolve a dunning run operationId: resolveDunningRun description: | Stops payment retries for a run and applies the chosen outcome to its invoice first: - `payment_recovered`: records an offline payment for the full amount due, as `POST /v1/invoices/{id}/record-payment` does. - `invoice_voided`: voids the invoice and stops collection at Stripe, as `POST /v1/invoices/{id}/void` does. - `invoice_not_collectible`: writes the invoice off, as `POST /v1/invoices/{id}/mark-uncollectible` does. If the invoice step fails, the run stays open and the error is returned. If the invoice is already in the requested state, that step is skipped. Fires the `dunning.resolved` webhook event once. The other two resolutions, `retries_exhausted` and `action_failed`, are set only by Hardcount. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The dunning run id (`hc_drun_…`). schema: type: string requestBody: required: true content: application/json: schema: type: object required: [resolution] properties: resolution: type: string enum: [payment_recovered, invoice_voided, invoice_not_collectible] description: How the run ends, and what happens to its invoice (see above). example: resolution: payment_recovered responses: "200": description: | The run as stored after the call. If another process resolved it first, its existing resolution is returned. content: application/json: schema: $ref: "#/components/schemas/DunningRun" example: id: hc_drun_dc7m3m2q3gpgqfoq5c70 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 invoice_id: hc_inv_dbj84b2q3gpgqfoq5c40 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g policy_id: hc_dpol_dajqj1aq3gpgqfoq5c6g state: resolved reason: payment_failed attempt_count: 1 last_attempt_at: "2026-11-04T00:05:03Z" paused: false resolved_at: "2026-11-06T13:00:00Z" resolution: payment_recovered created_at: "2026-11-01T00:05:03Z" updated_at: "2026-11-06T13:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No dunning run with this id in this account and mode (`dunning_run not found`), or its invoice is gone (`invoice not found`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning_run not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | The invoice step was refused because of the invoice's state, for example voiding a paid invoice or a charge still running. Codes as on the matching invoice endpoint. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: "void the invoice: cannot void a paid invoice — issue a credit note instead" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `resolution` is missing or not one of the three values (`param` `resolution`), or the body has an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: resolution must be one of payment_recovered, invoice_voided, invoice_not_collectible request_id: req_dbjg652q3gpgqfoq5cd0 param: resolution "429": $ref: "#/components/responses/RateLimited" /v1/dunning/stats: get: tags: [Dunning] summary: Get dunning totals operationId: getDunningStats description: | Counts of dunning runs by state, and the amount still due on active and escalated runs whose invoice is still open. `at_risk_cents` covers only invoices in the account's default currency, named in `currency`; `at_risk_by_currency` has every currency. responses: "200": description: Dunning totals. content: application/json: schema: $ref: "#/components/schemas/DunningStats" example: active_count: 2 escalated_count: 1 resolved_count: 14 recovered_count: 11 at_risk_cents: 25950 currency: USD at_risk_by_currency: - currency: USD amount_cents: 25950 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/dunning/policies: get: tags: [Dunning] summary: List dunning policies description: | Every dunning policy in this account and mode, the default first, then oldest first. Each one carries how many customers are assigned to it. operationId: listDunningPolicies parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Policies content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/DunningPolicyListItem" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Default retries enabled: true is_default: true seeded: true retry_days: [3, 4, 7] final_subscription_action: none final_invoice_action: none created_at: "2026-09-15T12:00:00Z" updated_at: "2026-09-15T12:00:00Z" assigned_customers: 0 total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" post: tags: [Dunning] summary: Create a dunning policy description: | A dunning policy decides how failed payments are retried and what happens when the retries run out. Every account starts with one policy per mode, "Default retries", as the default. The first enabled policy you create in a mode takes the default from it, unless you have edited it or made it the default yourself; later ones do not (use set-default). Customers without an assigned policy use the default, so check `is_default` in the response. Assign a policy to a customer with `dunning_policy_id` on `PATCH /v1/customers/{id}`. `enabled` defaults to false. A disabled policy starts no dunning, so send `enabled: true` for the policy to take effect. A policy created disabled never takes the default from "Default retries". A run that has started keeps following its policy as it is when each retry runs. operationId: createDunningPolicy parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DunningPolicyInput" example: name: Strict retries enabled: true retry_days: [2, 3, 5] final_subscription_action: cancel final_invoice_action: mark_uncollectible responses: "201": description: The new policy content: application/json: schema: $ref: "#/components/schemas/DunningPolicy" example: id: hc_dpol_dajqj1aq3gpgqfoq5c6h tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Strict retries enabled: true is_default: true seeded: false retry_days: [2, 3, 5] final_subscription_action: cancel final_invoice_action: mark_uncollectible created_at: "2026-11-02T10:00:00Z" updated_at: "2026-11-02T10:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | `validation_error`, with `param` naming the field: a replaced field was sent (`final_action`, `retry_schedule`, `max_retry_attempts`, `grace_period_days`), `retry_days` is missing, out of bounds or not whole days, an unknown final action, a `name` over 255 bytes, a field the server sets (`id`, `is_default`, `seeded` and the like), or an unknown field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: entry 1 is 45; each wait is a whole number of days from 1 to 30 request_id: req_dbjg652q3gpgqfoq5cd0 param: retry_days "429": $ref: "#/components/responses/RateLimited" /v1/dunning/policies/{id}: get: tags: [Dunning] summary: Get a dunning policy operationId: getDunningPolicy parameters: - name: id in: path required: true description: The dunning policy id (`hc_dpol_…`). schema: type: string responses: "200": description: The policy content: application/json: schema: $ref: "#/components/schemas/DunningPolicy" example: id: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Default retries enabled: true is_default: true seeded: true retry_days: [3, 4, 7] final_subscription_action: none final_invoice_action: none created_at: "2026-09-15T12:00:00Z" updated_at: "2026-09-15T12:00:00Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`not_found`: no policy with this id in this account and mode." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning_policy not found request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" patch: tags: [Dunning] summary: Replace a dunning policy's settings description: | Replaces every setting of the policy with the body. This is not a partial update: an omitted field takes its default (`enabled` false, `name` empty, pause the subscription, leave the invoice open), and `retry_days` is required. Send the full policy. `is_default` cannot be changed here; use set-default. Editing the "Default retries" policy the account started with sets its `seeded` to false. Runs already started follow the new settings from their next step. operationId: updateDunningPolicy parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The dunning policy id (`hc_dpol_…`). schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DunningPolicyInput" example: name: Default retries enabled: true retry_days: [3, 4, 7] final_subscription_action: pause final_invoice_action: none responses: "200": description: The updated policy content: application/json: schema: $ref: "#/components/schemas/DunningPolicy" example: id: hc_dpol_dajqj1aq3gpgqfoq5c6g tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 name: Default retries enabled: true is_default: true seeded: false retry_days: [3, 4, 7] final_subscription_action: pause final_invoice_action: none created_at: "2026-09-15T12:00:00Z" updated_at: "2026-11-02T10:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`not_found`: no policy with this id in this account and mode." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning_policy not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: Same validation as create. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: "retry_days is required: the days to wait before each retry, for example [3, 4, 7]" request_id: req_dbjg652q3gpgqfoq5cd0 param: retry_days "429": $ref: "#/components/responses/RateLimited" delete: tags: [Dunning] summary: Delete a dunning policy description: | Deletes the policy. Refused for the default policy, for a policy that customers are still assigned to, and for a policy that has run payment retries (their history refers to it). The route takes no body. operationId: deleteDunningPolicy parameters: - name: id in: path required: true description: The dunning policy id (`hc_dpol_…`). schema: type: string responses: "200": description: Deleted content: application/json: schema: type: object required: [status] properties: status: type: string enum: [deleted] description: Always `deleted`. example: status: deleted "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`not_found`: no policy with this id in this account and mode." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning_policy not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `invalid_state`: the policy is the default (make another policy the default first), customers are assigned to it (reassign them first), or it has run payment retries on invoices. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot delete policy — 2 customer(s) still assigned; reassign them first request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "force" request_id: req_dbjg652q3gpgqfoq5cd0 param: force "429": $ref: "#/components/responses/RateLimited" /v1/dunning/policies/{id}/set-default: post: tags: [Dunning] summary: Make a dunning policy the default description: | Makes this policy the default and the previous default a normal policy, in one step. Customers without an assigned policy use the new default for dunning started from now on. A policy made default this way is no longer `seeded`. The route takes no body. operationId: setDefaultDunningPolicy parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true description: The dunning policy id (`hc_dpol_…`). schema: type: string responses: "200": description: Default changed content: application/json: schema: type: object required: [status] properties: status: type: string enum: [default_updated] description: Always `default_updated`. example: status: default_updated "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`not_found`: no policy with this id in this account and mode." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: dunning_policy not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "is_default" request_id: req_dbjg652q3gpgqfoq5cd0 param: is_default "429": $ref: "#/components/responses/RateLimited" /v1/price-overrides: post: tags: [Pricing] summary: Create a customer price override description: | Gives one customer its own rate for one plan price. The override is keyed by the price, so it keeps applying when the price's rate changes; removing the price ends it. If the customer already has an active override for that price, it is closed and replaced by this one. The override takes the plan's currency. A plan swap does not carry it: the new plan's price is another price. A new or replaced rate applies from the customer's next billing period, in the customer's time (its test clock's, if it has one); the current period keeps the rate it started with. It applies from the join when a subscription of the customer first joins the plan in a period after it is created: an override created before a subscription starts (after a trial, before the trial ends), even on the same day, applies from its first period. On a test clock, it takes effect just after the clock's time, so a subscription created at that same clock time bills the list price; advance the clock first. The create is written to the audit log. operationId: createPriceOverride parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [customer_id, price_id, mode] properties: customer_id: type: string description: | The customer who gets the rate. It must be a customer in this account and mode. price_id: type: string description: The plan price to override. mode: type: string enum: [per_unit, graduated, package] description: >- As on PriceShape, also for the fields a mode does not use. unit_amount_cents: type: string description: >- Price per unit in cents, as a decimal string. Used when `mode` is `per_unit`. 0 or more, under 10^18, at most 12 decimal places (trailing zeros do not count), and at most 100 characters. A JSON number is also read. A value outside them is refused with 422 and `param` naming the field. tiers: type: array description: >- Used when `mode` is `graduated`. Each `up_to` is a whole number above the one before. The last tier has `up_to: null` (no upper bound), and only the last. Otherwise 422 with `param` `tiers`. items: type: object properties: up_to: type: integer format: int64 nullable: true description: | The tier's upper bound in whole units. `null` on the last tier, and only there. unit_amount_cents: type: string description: >- Price per unit in cents in this tier, as a decimal string, with the limits of `unit_amount_cents`. A refusal names `tiers[i].unit_amount_cents` (i counts from 0). package_size: type: integer format: int64 description: Used when `mode` is `package`. Must be above 0. package_amount_cents: type: string description: >- Price of one package in cents, as a decimal string, with the limits of `unit_amount_cents`. Used when `mode` is `package`. reason: type: string description: | Your note on why the customer has this rate, such as a contract reference. Spaces at the ends are trimmed. example: customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g price_id: hc_prc_dajr672q3gpgqfoq5c20 mode: per_unit unit_amount_cents: "0.00025" reason: Annual commitment discount responses: "201": description: The new override. content: application/json: schema: $ref: "#/components/schemas/CustomerPriceOverride" example: id: hc_cpo_daof442q3gpgqfoq5cag tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g price_id: hc_prc_dajr672q3gpgqfoq5c20 mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" reason: Annual commitment discount active: true created_at: "2026-10-15T12:30:00Z" updated_at: "2026-10-15T12:30:00Z" "400": description: | `invalid_request`: the body is not valid JSON. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no customer with this ID in this account and mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: customer not found request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | `already_exists`: another request created an override for the same customer and price at the same moment. Retry. `invalid_state`: the price was removed from its plan. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: Price hc_prc_dajr672q3gpgqfoq5c20 was removed from its plan, so it takes no part from now on. Override a price the plan has. request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | `validation_error`: a missing field, an unknown price, a rate that is not a number or is outside its limits, a price shape that cannot be billed (bad tier table, package size of 0), or an unknown field at any depth (`param` names it; the body must use only the fields listed here). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: price "hc_prc_dajr672q3gpgqfoq5c2v" not found request_id: req_dbjg652q3gpgqfoq5cd0 param: price_id "429": $ref: "#/components/responses/RateLimited" get: tags: [Pricing] summary: List active price overrides description: Active overrides only, newest first. Ended overrides are not listed. operationId: listPriceOverrides parameters: - name: customer_id in: query schema: type: string description: Only this customer's overrides. - name: price_id in: query schema: type: string description: Only this price's overrides. - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of active overrides, and how many match. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/CustomerPriceOverride" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_cpo_daof442q3gpgqfoq5cag tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g price_id: hc_prc_dajr672q3gpgqfoq5c20 mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" reason: Annual commitment discount active: true created_at: "2026-10-15T12:30:00Z" updated_at: "2026-10-15T12:30:00Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/price-overrides/{id}: delete: tags: [Pricing] summary: End a customer price override description: | Marks the override inactive. The row is kept. The customer goes back to the price's rate from its next billing period, in the customer's time; the current period keeps the rate it started with. A subscription of the customer that first joins the plan in a period after the override ends (a new subscription, an immediate plan change, an added item) pays the price's rate from the join. The end is written to the audit log. Send no body. operationId: deletePriceOverride parameters: - name: id in: path required: true schema: type: string responses: "200": description: The override, now with `active` false and `deactivated_at` set. content: application/json: schema: $ref: "#/components/schemas/CustomerPriceOverride" example: id: hc_cpo_daof442q3gpgqfoq5cag tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 customer_id: hc_cus_dakhnk2q3gpgqfoq5c0g price_id: hc_prc_dajr672q3gpgqfoq5c20 mode: per_unit unit_amount_cents: "0.00025" tiers: [] package_size: 0 package_amount_cents: "0" reason: Annual commitment discount active: false created_at: "2026-10-15T12:30:00Z" updated_at: "2026-10-28T09:00:00Z" deactivated_at: "2026-10-28T09:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `not_found`: no active override with this ID (it may already be ended). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: price_override not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | The request has a body with a field. This route takes no body (`param` names the field). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: unknown field "reason" request_id: req_dbjg652q3gpgqfoq5cd0 param: reason "429": $ref: "#/components/responses/RateLimited" /v1/integrations/litellm/spend: post: tags: [Usage] summary: Receive LiteLLM spend logs description: | Target for LiteLLM Proxy's `generic_api` logging callback. Accepts each of LiteLLM's log formats: a JSON array of records (`log_format: json_array`, the default), records one a line (`log_format: ndjson`, any number of them), or one record (`log_format: single`). It also takes `{"events": [...]}`. LiteLLM sends every format with `Content-Type: application/json`; the route reads the body the same way whatever the `Content-Type`. The body is read one record at a time, up to 32 MB. Each record with token counts becomes up to five usage events on the `tokens` meter, one per `token_type` dimension: `input` (prompt tokens not read from or written to the cache), `cache_read`, `cache_write_5m`, `cache_write_1h` and `output`. A record's events are recorded in one transaction. Each event also carries `model` (a model family name when recognised), `model_raw` and `provider`, plus `team_id` and `request_tags` from `metadata` when present. Every call type bills on its token counts (chat, text completions, embeddings, `/v1/messages`, `/v1/responses`, Gemini `generateContent`, and any type LiteLLM adds later) except those Hardcount does not price: images, audio, realtime, moderation, rerank, search, OCR, video, batches, files and other management calls. Those, records with no token counts, failed calls and LiteLLM response-cache hits are counted as `skipped`. The customer is the record's `user` or `end_user` (or `metadata.user_api_key_end_user_id`), matched against the customer's `external_id`. Event idempotency keys are the record `id` plus the token type, so a resent record is counted as `deduplicated`, not billed twice. A token row whose customer or meter does not exist yet is held, not billed and not lost: it is counted in `held` and listed by `GET /v1/usage-events/held` until you release or discard it. A resent row that is already held is counted as `deduplicated`. A record with no `user` or no `id`, or an invalid event, is listed in `errors` and the rest of the batch still goes in, with status 200. If the usage store cannot be reached, the whole request fails with 503 so LiteLLM retries it. Give the proxy a usage-only key (`hc_ingest_…`), which can do nothing else. A secret key works too. operationId: ingestLitellmSpend requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/LitellmSpendLog" - type: array items: $ref: "#/components/schemas/LitellmSpendLog" - type: object required: [events] properties: events: type: array items: $ref: "#/components/schemas/LitellmSpendLog" example: id: chatcmpl-AZx81kq4ZtR2 status: success call_type: acompletion model: gpt-4o-2024-08-06 custom_llm_provider: openai user: acme-42 usage: prompt_tokens: 1200 completion_tokens: 350 total_tokens: 1550 prompt_tokens_details: cached_tokens: 200 cost_breakdown: input_cost: 0.00275 output_cost: 0.0035 cache_read_cost: 0.00025 total_cost: 0.00625 startTime: 1791813785.8 endTime: 1791813787.0 metadata: team_id: team-research responses: "200": description: The batch was processed. Check `errors` for records that were not recorded. content: application/json: schema: $ref: "#/components/schemas/LitellmSpendResult" example: accepted: 3 deduplicated: 0 held: 0 skipped: 0 cache_hits: 0 after_close: 0 no_price: 0 not_on_plan: 0 outside_subscription: 0 "400": description: | Code `invalid_request`. Nothing of the batch was recorded. The message says which case: - `empty body`: the body is empty. - `invalid JSON body at line N (record M); the batch was not saved`: record M, starting on line N of the body, is not valid JSON or is not a log record. - `invalid JSON body`: the body is not valid JSON outside any record, such as a stray `]`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid JSON body at line 3 (record 3); the batch was not saved request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "413": description: | Code `batch_too_large`: the body is over the 32 MB limit (33,554,432 bytes). Nothing was recorded. Send fewer records per batch (in LiteLLM, lower `DEFAULT_BATCH_SIZE`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: batch_too_large message: "the request body is over the 32 MB limit, so the batch was not saved — send fewer records per batch (LiteLLM: lower DEFAULT_BATCH_SIZE)" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" "503": description: | `ingest_unavailable`: the usage store could not be reached. Retry the whole batch; events already recorded come back as `deduplicated`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: ingest_unavailable message: usage store temporarily unavailable — retry the whole batch; rows already recorded replay as deduplicated request_id: req_dbjg652q3gpgqfoq5cd0 /v1/api-keys: post: tags: [Auth] summary: Create an API key operationId: createApiKey description: | Creates a key in the caller's mode: a test-mode caller gets a test key, a live-mode caller gets a live key. The full key (`raw_key`) is returned once and is never shown again. Hardcount stores only a salted hash. `key_type` defaults to `secret`. An `ingest` key (`hc_ingest_…`) can only send usage events: give it to LiteLLM or another gateway, so the key in its config cannot refund, void or read your data. Writes an audit row (never the key value). parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string description: Label shown in the key list. Must not be blank. key_type: type: string enum: [secret, ingest] default: secret description: "`secret` for full access, or `ingest` for a key that can only send usage events." expires_at: type: string format: date-time description: Optional. Must be in the future. Omit for a key that does not expire. example: name: Backend (test) key_type: secret responses: "201": description: Key created. `raw_key` is shown only in this response. content: application/json: schema: $ref: "#/components/schemas/APIKeyCreated" example: key: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" raw_key: hc_secret_test_4f9c2a7e1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller lacks the `apikey:write` permission (a usage-only key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`name` is blank, `key_type` is not `secret` or `ingest`, `expires_at` is not in the future, or the body has an unknown field. `param` names it." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: name is required request_id: req_dbjg652q3gpgqfoq5cd0 param: name "429": $ref: "#/components/responses/RateLimited" get: tags: [Auth] summary: List API keys operationId: listApiKeys description: | Returns the keys in the caller's mode (test or live), newest first. Revoked and expired keys are included; check `revoked_at` and `expires_at`. Key values are never returned. parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: One page of keys. content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/APIKey" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" last_used_at: "2026-11-01T00:04:12Z" total: 1 "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:write` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/InvalidPage" "429": $ref: "#/components/responses/RateLimited" /v1/api-keys/{id}: delete: tags: [Auth] summary: Revoke an API key operationId: revokeApiKey description: | Sets `revoked_at` to now. The key fails authentication on its next use. A caller may revoke the key it is using; its next request then gets 401. Dashboard sessions are not tied to API keys, so they are not affected. Writes an audit row. parameters: - name: id in: path required: true schema: type: string description: The key's id (`hc_key_…`). example: hc_key_dajqj1iq3gpgqfoq5ca0 responses: "200": description: The revoked key content: application/json: schema: $ref: "#/components/schemas/APIKey" example: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" revoked_at: "2026-11-01T09:12:30Z" last_used_at: "2026-11-01T09:10:05Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller lacks the `apikey:write` permission (a usage-only key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "404": description: No key with this id in this mode, or it is already revoked. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: api key not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/api-keys/{id}/rotate: post: tags: [Auth] summary: Rotate an API key operationId: rotateApiKey description: | Issues a new key with the same name, type, mode and `expires_at` as the old one, and returns its full value once (`raw_key`). The body is optional. With no body, or `expires_in_seconds: 0`, the old key is revoked at once. A positive value keeps the old key working for that many seconds (at most 604800, 7 days), so deployed clients can switch without downtime. Writes an audit row. parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: id in: path required: true schema: type: string description: The key's id (`hc_key_…`). requestBody: required: false content: application/json: schema: type: object properties: expires_in_seconds: type: integer format: int64 minimum: 0 maximum: 604800 default: 0 description: Grace period for the old key. 0 revokes it immediately. example: expires_in_seconds: 86400 responses: "200": description: Rotation done. `raw_key` is the new key, shown only in this response. content: application/json: schema: $ref: "#/components/schemas/APIKeyRotation" example: old_key: id: hc_key_dajqj1iq3gpgqfoq5ca0 key_prefix: hc_secret_test_4f9c2a7e1b3d key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-10-01T08:15:00Z" expires_at: "2026-11-02T10:00:00Z" last_used_at: "2026-11-01T09:58:40Z" new_key: id: hc_key_dajqj1iq3gpgqfoq5cak key_prefix: hc_secret_test_a81c4e7f02d9 key_type: secret livemode: false name: Backend (test) tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 created_at: "2026-11-01T10:00:00Z" raw_key: hc_secret_test_a81c4e7f02d95b3e6c1f8a04d7b2e9c3516f0a8d2c4b7e91f3a6d05c8b2e4f71 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "404": description: No key with this id in this mode. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: api_key not found request_id: req_dbjg652q3gpgqfoq5cd0 "403": description: | The caller lacks the `apikey:write` permission (a usage-only key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: The key is already revoked (code `invalid_state`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_state message: cannot rotate a revoked key request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`expires_in_seconds` is negative or over 604800 (`param: expires_in_seconds`), the old key's `expires_at` has passed so the new key would copy it (`param: expires_at`), or the body has an unknown field." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be <= 604800 (7 days) request_id: req_dbjg652q3gpgqfoq5cd0 param: expires_in_seconds "429": $ref: "#/components/responses/RateLimited" /v1/settings: get: tags: [Dashboard] x-internal: true summary: Get account settings operationId: getTenantSettings description: | Used by the dashboard; not part of the integration API. Returns the account's billing settings: currency, timezone, invoice numbering, payment terms, tax setup and the company details printed on invoices. Settings are shared by test and live mode, except `next_invoice_sequence`, which counts the caller's mode. An account with no stored settings gets the defaults (USD, UTC, invoice prefix `HC`, credit note prefix `CN`, net 30, tax provider `manual`). Requires the `apikey:write` permission (secret keys and dashboard sessions). responses: "200": description: Current settings content: application/json: schema: $ref: "#/components/schemas/TenantSettings" example: tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 default_currency: USD timezone: America/Los_Angeles invoice_prefix: NW credit_note_prefix: NWCN net_payment_terms: 30 credit_balance_low_threshold_cents: 1000 tax_provider: manual tax_rate: 8.625 tax_name: Sales tax tax_inclusive: false tax_on_failure: block company_name: Northwind AI, Inc. company_address_line1: 500 Market Street company_address_line2: Suite 400 company_city: San Francisco company_state: CA company_postal_code: "94105" company_country: US company_email: billing@northwind.example company_phone: "+1 415 555 0132" logo_url: https://northwind.example/logo.png brand_color: "#1f6feb" tax_id: "12-3456789" support_url: https://northwind.example/support invoice_footer: Thank you for your business. next_invoice_sequence: 43 created_at: "2026-10-01T08:00:00Z" updated_at: "2026-10-20T16:45:10Z" "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:write` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited" put: tags: [Dashboard] x-internal: true summary: Update account settings operationId: updateTenantSettings description: | Used by the dashboard; not part of the integration API. Merges the body into the current settings. A field you leave out keeps its stored value. A field you send is set, even to `""` or `0`. Send `credit_balance_low_threshold_cents: null` to turn low-balance alerts off. Empty `default_currency`, `timezone`, `invoice_prefix`, `credit_note_prefix`, `tax_provider` and `tax_on_failure` fall back to their defaults. `default_currency`, `company_country`, `invoice_prefix` and `credit_note_prefix` are stored in upper case, and `brand_color` in lower case. Choosing `stripe_tax` with no `default_product_tax_code` sets it to `txcd_10103001` (SaaS, business use). The server sets `tenant_id`, `created_at`, `updated_at` and `next_invoice_sequence`: a body that sends one is refused with 422. A save that changes a field writes one audit row listing each change. The row is filed in test mode, whichever mode you save from. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TenantSettingsInput" example: net_payment_terms: 15 invoice_footer: Thank you for your business. responses: "200": description: Saved settings content: application/json: schema: $ref: "#/components/schemas/TenantSettings" example: tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 default_currency: USD timezone: America/Los_Angeles invoice_prefix: NW credit_note_prefix: NWCN net_payment_terms: 15 credit_balance_low_threshold_cents: 1000 tax_provider: manual tax_rate: 8.625 tax_name: Sales tax tax_inclusive: false tax_on_failure: block company_name: Northwind AI, Inc. company_address_line1: 500 Market Street company_address_line2: Suite 400 company_city: San Francisco company_state: CA company_postal_code: "94105" company_country: US company_email: billing@northwind.example company_phone: "+1 415 555 0132" logo_url: https://northwind.example/logo.png brand_color: "#1f6feb" tax_id: "12-3456789" support_url: https://northwind.example/support invoice_footer: Thank you for your business. next_invoice_sequence: 43 created_at: "2026-10-01T08:00:00Z" updated_at: "2026-11-01T11:00:00Z" "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller lacks the `apikey:write` permission (a usage-only key), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:write access" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: A field failed validation, the body sends a field the server sets, or it has an unknown field. `error.param` names the field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must differ from the invoice prefix request_id: req_dbjg652q3gpgqfoq5cd0 param: credit_note_prefix "429": $ref: "#/components/responses/RateLimited" /v1/settings/email: get: tags: [Dashboard] x-internal: true summary: Get the email setup status operationId: getEmailSettings description: | Used by the dashboard; not part of the integration API. Read-only. Email is configured in the server environment, not per account: `SMTP_HOST` and friends, plus the base URLs email links are built from. Never returns credentials. responses: "200": description: Email setup content: application/json: schema: type: object required: [smtp_configured, smtp_host, from_address, tls_mode, links, can_email_setup_links, can_send_invites] properties: smtp_configured: type: boolean description: False when `SMTP_HOST` is unset; every email then fails. smtp_host: type: string description: The SMTP relay host (`SMTP_HOST`), without credentials. Empty when not configured. from_address: type: string description: The envelope sender (`SMTP_FROM`, default `billing@hardcount.local`). tls_mode: type: string enum: [starttls, implicit, none] description: How Hardcount connects to the SMTP server (`SMTP_TLS`). `starttls` is the default. links: type: array description: Every environment variable an email link is built from, set or not. items: type: object required: [env, set, effect] properties: env: type: string enum: [HOSTED_INVOICE_BASE_URL, PAYMENT_UPDATE_URL, DASHBOARD_BASE_URL] description: The environment variable's name. set: type: boolean description: Whether the variable is set. effect: type: string description: What doesn't work while it is unset. can_email_setup_links: type: boolean description: Whether this server can email a customer a link to add a payment method (SMTP and `PAYMENT_UPDATE_URL` are set up). can_send_invites: type: boolean description: Whether this server can invite a teammate (SMTP and `DASHBOARD_BASE_URL` are set up). example: smtp_configured: true smtp_host: smtp.postmarkapp.com from_address: billing@northwind.example tls_mode: starttls links: - env: HOSTED_INVOICE_BASE_URL set: true effect: Invoice, receipt and reminder emails go out without a link to view and pay the invoice. - env: PAYMENT_UPDATE_URL set: true effect: Emails asking a customer to add a payment method can't be sent. - env: DASHBOARD_BASE_URL set: true effect: Password reset and team invite emails can't be sent. can_email_setup_links: true can_send_invites: true "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/settings/email/test: post: tags: [Dashboard] x-internal: true summary: Send a test email operationId: sendTestEmail description: | Used by the dashboard; not part of the integration API. Dashboard sessions only. Sends one plain-text email to the signed-in member's own address, directly over SMTP (not queued), and returns the server's answer. It waits up to 20 seconds for the SMTP server. Writes an audit row on success. parameters: - $ref: "#/components/parameters/IdempotencyKey" responses: "200": description: Sent content: application/json: schema: type: object required: [sent_to] properties: sent_to: type: string description: The address the test email went to, the signed-in member's own. example: sent_to: maya@northwind.example "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": description: | The caller is an API key, not a dashboard session (a usage-only key gets the `apikey:write` permission message instead), or a browser page on another site sent the request (`cross-site request blocked`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: Sign in to the dashboard to send a test email. It goes to your own address. request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: "`email_not_configured`: SMTP_HOST is not set." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: email_not_configured message: "This server can't send email: SMTP_HOST is not set. Set the SMTP_* variables and restart the server." request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" "502": description: "`smtp_error`: the SMTP server refused the email or could not be reached. The message carries its answer." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: api_error code: smtp_error message: "The email server didn't accept the test email: 535 5.7.8 Authentication failed" request_id: req_dbjg652q3gpgqfoq5cd0 /v1/settings/stripe: post: tags: [Settings] summary: Connect a Stripe account operationId: connectStripe description: | Saves your own Stripe keys for one mode (`livemode` in the body picks the mode, not the caller's mode). Calling it again for the same mode replaces the keys; a webhook secret saved before is kept when the body sends none. Key prefixes must match the mode: `sk_test_`/`rk_test_` and `pk_test_` for test, `sk_live_`/`rk_live_` and `pk_live_` for live. The secret key and webhook secret are never returned. They are stored encrypted when `HARDCOUNT_ENCRYPTION_KEY` is set, which production requires. Hardcount then calls Stripe to check the key. A failed check still returns 200: the keys are saved and `last_verified_error` says why the check failed. On success, `verified_at` and the account fields are set, and invoices that were stuck waiting for Stripe keys are retried in the background (`retries_queued` gives the count). Stripe sends payment webhooks to `/v1/webhooks/stripe/{endpoint_id}`, where `{endpoint_id}` is the returned `id`. Writes an audit row. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [livemode, secret_key, publishable_key] properties: livemode: type: boolean description: false = test-mode keys, true = live-mode keys. secret_key: type: string description: Secret (`sk_`) or restricted (`rk_`) key for this mode. publishable_key: type: string description: Publishable (`pk_`) key for this mode. webhook_secret: type: string description: Optional. The `whsec_` signing secret of the Stripe webhook endpoint. Can be added later with PATCH. example: livemode: false secret_key: sk_test_51Q2w3E4r5T6y7U8iN0rthw1ndS3cr3tK3yExamp1e000000007fQa publishable_key: pk_test_51Q2w3E4r5T6y7U8iN0rthw1ndAIpub11shab1eK3yExamp1e00aBcDeF webhook_secret: whsec_5d8b2e7f1a4c9e3b6d0f2a8c5e1b7d4f9a3c6e0bc41d responses: "200": description: Keys saved. Check `last_verified_error` to see whether Stripe accepted them. content: application/json: schema: $ref: "#/components/schemas/StripeConnection" example: id: hc_spc_5e2a9c7f1b4d8e3a6c0f2b9d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false stripe_account_id: acct_1Q2w3E4r5T6y7U8i stripe_account_name: Northwind AI secret_key_prefix: sk_test_51Q2 secret_key_last4: 7fQa publishable_key: pk_test_51Q2w3E4r5T6y7U8iN0rthw1ndAIpub11shab1eK3yExamp1e00aBcDeF webhook_secret_last4: c41d has_webhook_secret: true verified_at: "2026-10-01T08:30:02Z" created_at: "2026-10-01T08:30:00Z" updated_at: "2026-10-01T08:30:00Z" retries_queued: 1 "400": $ref: "#/components/responses/InvalidBody" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: | A key is missing or has the wrong prefix for the mode, `webhook_secret` does not start with `whsec_`, or the body has an unknown field. `error.param` names the field. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must start with "sk_test_" for this mode' request_id: req_dbjg652q3gpgqfoq5cd0 param: secret_key "429": $ref: "#/components/responses/RateLimited" get: tags: [Dashboard] x-internal: true summary: List Stripe connections operationId: listStripeConnections description: | Used by the dashboard; not part of the integration API. Returns the account's Stripe connections for both modes in one list (at most two rows, live first), whatever the caller's mode. No secret values are returned, only prefixes and last four characters. responses: "200": description: Connections content: application/json: schema: type: object required: [data] properties: data: type: array maxItems: 2 description: One connection per connected mode, live first. Empty when Stripe is not connected. items: $ref: "#/components/schemas/StripeConnection" example: data: - id: hc_spc_5e2a9c7f1b4d8e3a6c0f2b9d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false stripe_account_id: acct_1Q2w3E4r5T6y7U8i stripe_account_name: Northwind AI secret_key_prefix: sk_test_51Q2 secret_key_last4: 7fQa publishable_key: pk_test_51Q2w3E4r5T6y7U8iN0rthw1ndAIpub11shab1eK3yExamp1e00aBcDeF webhook_secret_last4: c41d has_webhook_secret: true verified_at: "2026-10-01T08:30:02Z" created_at: "2026-10-01T08:30:00Z" updated_at: "2026-10-01T08:30:02Z" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /v1/settings/stripe/{mode}/webhook: patch: tags: [Settings] summary: Set the Stripe webhook signing secret operationId: setStripeWebhookSecret description: | Saves the `whsec_` signing secret for a mode that is already connected, without entering the API keys again. Hardcount does not call Stripe here; the secret is checked on each incoming webhook. Until a secret is saved, Stripe webhooks for the mode are refused. Writes an audit row (never the secret). parameters: - $ref: "#/components/parameters/IdempotencyKey" - name: mode in: path required: true schema: type: string enum: [test, live] description: The connection's mode. requestBody: required: true content: application/json: schema: type: object required: [webhook_secret] properties: webhook_secret: type: string description: Must start with `whsec_`. example: webhook_secret: whsec_5d8b2e7f1a4c9e3b6d0f2a8c5e1b7d4f9a3c6e0bc41d responses: "200": description: Updated connection content: application/json: schema: $ref: "#/components/schemas/StripeConnection" example: id: hc_spc_5e2a9c7f1b4d8e3a6c0f2b9d tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 livemode: false stripe_account_id: acct_1Q2w3E4r5T6y7U8i stripe_account_name: Northwind AI secret_key_prefix: sk_test_51Q2 secret_key_last4: 7fQa publishable_key: pk_test_51Q2w3E4r5T6y7U8iN0rthw1ndAIpub11shab1eK3yExamp1e00aBcDeF webhook_secret_last4: c41d has_webhook_secret: true verified_at: "2026-10-01T08:30:02Z" created_at: "2026-10-01T08:30:00Z" updated_at: "2026-10-01T08:42:17Z" "400": description: "`mode` is not `test` or `live` (code `invalid_request`), or the body is not valid JSON" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: 'mode must be "test" or "live"' request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Stripe is not connected for this mode. Connect it first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: stripe_credentials not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`webhook_secret` is missing or does not start with `whsec_`, or the body has an unknown field" content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must start with "whsec_"' request_id: req_dbjg652q3gpgqfoq5cd0 param: webhook_secret "429": $ref: "#/components/responses/RateLimited" /v1/settings/stripe/{mode}: delete: tags: [Dashboard] x-internal: true summary: Disconnect Stripe for one mode operationId: disconnectStripe description: | Used by the dashboard; not part of the integration API. Deletes the saved Stripe keys and webhook secret for the mode. Hardcount cannot reach Stripe in that mode until you connect again, and Stripe webhooks for it are refused. Connecting again creates a connection with a new `id`, so the webhook URL in Stripe must change too. Writes an audit row. parameters: - name: mode in: path required: true schema: type: string enum: [test, live] description: The mode to disconnect. responses: "204": description: Disconnected "400": description: | `mode` is not `test` or `live` (code `invalid_request`), or the body is not valid JSON. The body is checked first. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: 'mode must be "test" or "live"' request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Stripe is not connected for this mode content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: not_found message: stripe_credentials not found request_id: req_dbjg652q3gpgqfoq5cd0 "422": $ref: "#/components/responses/BodyNotAllowed" "429": $ref: "#/components/responses/RateLimited" /v1/bootstrap: post: tags: [System] x-internal: true summary: Set up a new installation operationId: bootstrap description: | Run once, by hand, to create the first account on a new install; not part of the integration API. One-time setup for a new installation. It works only while no account exists and only when the server has `HARDCOUNT_BOOTSTRAP_TOKEN` set. Send that token as `token` in the body or as `Authorization: Bearer `. Never send it in the query string. In one transaction it creates the first account with its default settings and default dunning policies, its owner user, a test secret key and a live secret key. The response is the only place the keys appear, and the password too when Hardcount generated it. The response has `Cache-Control: no-store`. Defaults: account name "Demo Tenant", owner email `admin@hardcount.local`, and a generated password when `owner_password` is empty. The `hardcount-bootstrap` command does the same setup from the shell. security: [] requestBody: required: false content: application/json: schema: type: object properties: token: type: string description: The value of `HARDCOUNT_BOOTSTRAP_TOKEN`. Or send it as a Bearer token. tenant_name: type: string default: Demo Tenant description: The account's name. owner_email: type: string default: admin@hardcount.local description: The owner's dashboard sign-in email. One plain address whose domain has a dot. owner_password: type: string minLength: 12 description: 12 or more characters, at most 72 bytes, and not a common password from a leaked-password list. Omit to have Hardcount generate one. example: token: 3f9d2a7c5e1b8d4f6a0c2e9b7d5f1a3c tenant_name: Northwind AI owner_email: maya@northwind.example responses: "201": description: Installation set up. Save these credentials; they are not shown again. content: application/json: schema: $ref: "#/components/schemas/BootstrapResult" example: tenant: id: hc_ten_dajqj02q3gpgqfoq5c00 name: Northwind AI status: active created_at: "2026-10-01T08:00:00Z" updated_at: "2026-10-01T08:00:00Z" owner_email: maya@northwind.example owner_password: 7f3a9c1e5b2d8f4a6c0e3b1d password_generated: true secret_key_test: hc_secret_test_4f9c2a7e1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8 secret_key_live: hc_secret_live_6d2b8f4a1c9e3d7b5f0a2c8e4b6d1f9a3c7e5b0d2f8a4c6e1b9d3f7a5c0e2b84 message: Bootstrap complete. Save these credentials — they will not be shown again. "400": $ref: "#/components/responses/InvalidBody" "403": description: Bootstrap is disabled (`HARDCOUNT_BOOTSTRAP_TOKEN` not set) or the token is wrong. Code `forbidden`. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: invalid bootstrap token request_id: req_dbjg652q3gpgqfoq5cd0 "409": description: | An account already exists (code `already_bootstrapped`; this is checked before the token), or a user with `owner_email` already exists (code `already_exists`). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: already_bootstrapped message: bootstrap already completed — tenants exist request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: "`owner_email` is not one plain email address (`param: owner_email`), `owner_password` fails the check (`param: password`), or the body has an unknown field. Nothing was written." content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: must be at least 12 characters request_id: req_dbjg652q3gpgqfoq5cd0 param: password "429": $ref: "#/components/responses/RateLimited" /v1/audit-log: get: tags: [Dashboard] x-internal: true summary: List audit log entries operationId: listAuditLog description: | Used by the dashboard; not part of the integration API. Returns the account's audit entries in the caller's mode, newest first (by `created_at`, then `id`). The `resource_type`, `resource_id`, `action`, `actor_id` and `test_clock_id` filters are exact matches. All filters can be combined. Paged with `limit` and `offset`, like every list. Here `total` stops counting at 10,001, so a value above 10,000 means "more than 10,000". Settings changes, sign-ins, sign-outs, mode switches, password resets and accepted invites are always recorded in test mode, so they do not appear when you read the log in live mode. The account, key and owner rows written at setup are in live mode. Every other row, team invites and removals included, is in the mode the change was made in. parameters: - name: resource_type in: query schema: type: string description: For example `invoice`, `api_key`, `setting`. See `/v1/audit-log/filters`. - name: resource_id in: query schema: type: string description: Only entries about this resource id. - name: action in: query schema: type: string description: For example `create`, `update`, `revoke`. See `/v1/audit-log/filters`. - name: actor_id in: query schema: type: string description: Only entries by this actor, an `actor_id` value such as a user id or an API key id. - name: date_from in: query schema: type: string description: Lower bound on `created_at`, inclusive. RFC3339 or YYYY-MM-DD (start of day, UTC). - name: date_to in: query schema: type: string description: Upper bound on `created_at`, inclusive. RFC3339 or YYYY-MM-DD (23:59:59.999999 UTC, the end of that day). - name: test_clock_id in: query schema: type: string description: Only entries made inside this test clock's simulation. Works for deleted clocks too. - name: sim_from in: query schema: type: string description: | Lower bound on `sim_effective_at` (simulated time), inclusive. RFC3339 or YYYY-MM-DD, like `date_from`. Excludes entries with no simulated time. - name: sim_to in: query schema: type: string description: | Upper bound on `sim_effective_at`, inclusive. RFC3339 or YYYY-MM-DD, like `date_to`. Excludes entries with no simulated time. - name: order in: query schema: type: string enum: [created_at] description: The only supported order. Any other value is a 400. - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Offset" responses: "200": description: Audit entries content: application/json: schema: type: object required: [data, total] properties: data: type: array items: $ref: "#/components/schemas/AuditLogEntry" total: $ref: "#/components/schemas/ListTotal" example: data: - id: hc_aud_dbj84c2q3gpgqfoq5cb0 tenant_id: hc_ten_dajqj02q3gpgqfoq5c00 actor_type: user actor_id: hc_usr_8d3f1a6c2e9b4d7f0a5c3e1b actor_name: maya@northwind.example action: revoke resource_type: api_key resource_id: hc_key_dajqj1iq3gpgqfoq5ca0 resource_label: Backend (test) metadata: key_type: secret ip_address: 203.0.113.24 request_id: req_dbjg652q3gpgqfoq5cd0 created_at: "2026-11-01T09:12:30Z" total: 1 "400": description: Unsupported `order` (code `invalid_request`) content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: invalid_request message: invalid `order` — audit rows are ordered by created_at request_id: req_dbjg652q3gpgqfoq5cd0 "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:read` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:read access" request_id: req_dbjg652q3gpgqfoq5cd0 "422": description: | A date is not RFC3339 or YYYY-MM-DD, a range ends before it starts, `limit` or `offset` is out of range or sent twice, or the removed `after` cursor is sent. `param` names the parameter. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: invalid_request_error code: validation_error message: 'must be RFC3339 (e.g. 2026-01-01T00:00:00Z) or YYYY-MM-DD' request_id: req_dbjg652q3gpgqfoq5cd0 param: date_from "429": $ref: "#/components/responses/RateLimited" /v1/audit-log/filters: get: tags: [Dashboard] x-internal: true summary: List audit log filter values operationId: getAuditLogFilters description: | Used by the dashboard; not part of the integration API. Returns the distinct `action` and `resource_type` values recorded for the account in the caller's mode, plus the test clocks that appear in the log. Deleted test clocks are included, named from their audit entries. Use these to fill filter menus. responses: "200": description: Filter values content: application/json: schema: $ref: "#/components/schemas/AuditLogFilters" example: actions: [create, export, finalize, revoke, rotate, update] resource_types: [api_key, customer, invoice, setting, subscription, test_clock] test_clocks: - id: hc_tclk_8e1f3a5c7b9d0e2f4a6c8b1d name: October renewal test "401": $ref: "#/components/responses/Unauthorized" "403": description: The caller lacks the `apikey:read` permission (a usage-only key). content: application/json: schema: $ref: "#/components/schemas/Error" example: error: type: authentication_error code: forbidden message: "insufficient permissions: this key type does not have apikey:read access" request_id: req_dbjg652q3gpgqfoq5cd0 "429": $ref: "#/components/responses/RateLimited"