Invoices
The Invoices API returns your finalized invoices, so you can pull them into your own accounting or AP system, forward them, or archive them without signing in.
All three invoice endpoints are read-only.
Permissions
Invoice access requires the billing_report permission. An API key carries the permissions of the user it belongs to, so a key created by a user without billing access cannot read invoices.
Draft Invoices Are Excluded
This API returns only invoices that have been finalized. Drafts are never listed, and requesting one by ID returns 404 from every endpoint.
A draft is a bill Stripe is still assembling. Its amounts can still change, lines can still be added or removed, and it may be revised or discarded before it is ever issued — it has no invoice number and no PDF. Re-billing a tenant from one would charge for an amount that was never owed, and the correction would not reach the customer you had already invoiced.
Only draft is withheld — treat status as an open set. In practice you will see open, paid, uncollectible, and void, and note that void and uncollectible invoices were issued and are still returned. But this API filters on draft specifically rather than allowlisting the others, so if Stripe introduces a new status it will reach you rather than being silently dropped from your export. Accept an unfamiliar non-draft status instead of rejecting it.
Base Endpoint Group
All invoice endpoints live under:
/v1/payments/invoices
Amounts Are in Minor Units
Every monetary field ends in _cents and is an integer in the smallest unit of the invoice's currency — never a decimal amount. Always read it together with the currency field on the same invoice.
For two-decimal currencies, which is what Hatz bills in today, divide by 100:
currency |
total_cents |
Displays as |
|---|---|---|
usd |
103500 |
$1,035.00 |
eur |
103500 |
€1,035.00 |
Dividing by 100 is not universally correct. Zero-decimal currencies such as jpy and krw have no minor unit, so the integer is already the whole amount — 103500 in jpy is ¥103,500, not ¥1,035. Three-decimal currencies such as bhd divide by 1000. If you may receive currencies beyond USD and EUR, take the exponent from a currency table rather than hardcoding 100; Stripe publishes the zero-decimal list in its currency documentation.
The _cents suffix is a reminder that the value is not a decimal amount — it is not a promise that the divisor is always 100.
Endpoints
List Invoices
GET /v1/payments/invoices
Returns your finalized invoices, newest first. Drafts are omitted.
Query parameters:
start,end: whole UTC dates (YYYY-MM-DD). Both optional.limit: 1–100, default 25starting_after: invoice ID to page afterentity_id: which of your organizations to read — required only if you hold billing access to more than one
curl -H "X-API-Key: $HATZ_API_KEY" \
"https://ai.hatz.ai/v1/payments/invoices?start=2026-07-01&end=2026-09-30&limit=100"
{
"invoices": [
{
"id": "in_1TvmbzLB3VQuvRiYUrkb7C5H",
"number": "HSLNEQJJ-0005",
"status": "paid",
"created": "2026-07-21T22:58:07Z",
"period_start": "2026-06-21T22:57:58Z",
"period_end": "2026-07-21T22:57:58Z",
"currency": "usd",
"total_cents": 103500,
"amount_due_cents": 103500,
"amount_paid_cents": 103500,
"hosted_invoice_url": "https://invoice.stripe.com/i/...",
"invoice_pdf": "https://pay.stripe.com/invoice/.../pdf"
}
],
"has_more": false,
"next_cursor": null
}
Get Invoice PDF Link
GET /v1/payments/invoices/{invoice_id}/pdf
Returns the download link for one invoice's PDF.
curl -H "X-API-Key: $HATZ_API_KEY" \
"https://ai.hatz.ai/v1/payments/invoices/in_1TvmbzLB3VQuvRiYUrkb7C5H/pdf"
{
"id": "in_1TvmbzLB3VQuvRiYUrkb7C5H",
"invoice_pdf": "https://pay.stripe.com/invoice/.../pdf",
"hosted_invoice_url": "https://invoice.stripe.com/i/..."
}
The link points at Stripe and carries its own access token, so you download the file directly from Stripe rather than through the Hatz API:
curl -L -o invoice.pdf "$INVOICE_PDF_URL"
The URL is not a stable identifier. Requests for the same invoice at different times return different invoice_pdf values — the signed token embeds when it was issued. Two calls in quick succession usually match, but do not rely on it: never use this URL as a cache key, a deduplication key, or a way to tell two invoices apart. The invoice id is the durable reference; re-request the link when you need the file. The same applies to hosted_invoice_url, and to both fields on the list endpoint.
Stripe controls how long a given link stays valid, so if you store one, be ready for it to stop working and to re-request it.
Because the token travels in the URL, anyone holding it can fetch that invoice without a Hatz API key — so keep these links out of logs, analytics, and anywhere you would not paste the invoice itself.
A draft invoice returns 404, as it does everywhere else in this API. An invoice belonging to another organization also returns 404, and so does a finalized invoice whose PDF Stripe has not yet produced.
Get Invoice Tenant Breakdown
GET /v1/payments/invoices/{invoice_id}/breakdown
Splits one invoice's total across the tenants it charges for. Use this to re-bill: the list endpoint tells you what you owe, this tells you who incurred it.
curl -H "X-API-Key: $HATZ_API_KEY" \
"https://ai.hatz.ai/v1/payments/invoices/in_1TvmbzLB3VQuvRiYUrkb7C5H/breakdown"
{
"invoice_id": "in_1TvmbzLB3VQuvRiYUrkb7C5H",
"status": "paid",
"currency": "usd",
"total_cents": 103500,
"unallocated_cents": 45000,
"tenants": [
{
"tenant_entity_id": "3f7c1e28-5a94-4b60-9d31-8c2ea6f04b17",
"tenant_name": "Acme Corp",
"line_count": 2,
"amount_cents": 58500
}
],
"lines_truncated": false,
"lines": [
{
"description": "Hatz Business - 30 seats",
"amount_cents": 46800,
"quantity": 24,
"period_start": "2026-06-21T22:57:58Z",
"period_end": "2026-07-21T22:57:58Z",
"tenant_entity_id": "3f7c1e28-5a94-4b60-9d31-8c2ea6f04b17",
"unallocated_reason": null
},
{
"description": "Remaining time on 6 x Hatz Business",
"amount_cents": 11700,
"quantity": 6,
"period_start": "2026-07-05T00:00:00Z",
"period_end": "2026-07-21T22:57:58Z",
"tenant_entity_id": "3f7c1e28-5a94-4b60-9d31-8c2ea6f04b17",
"unallocated_reason": null
},
{
"description": "Credit pack",
"amount_cents": 45000,
"quantity": 1,
"period_start": "2026-07-21T22:57:58Z",
"period_end": "2026-07-21T22:57:58Z",
"tenant_entity_id": null,
"unallocated_reason": "no_subscription_item"
}
]
}
Each invoice line is traced through its subscription item to the tenant holding that license, and amounts are summed per tenant. Tenants are ordered by amount, largest first.
status is returned alongside the split. A void or uncollectible invoice splits across tenants exactly like a live one, so check it before re-billing: the split is real, but the underlying bill is not owed and should not be re-issued to your customer.
The split always reconciles:
total_cents == sum(tenants[].amount_cents) + unallocated_cents
unallocated_cents is whatever the total leaves over after tenant-attributable amounts are claimed. It absorbs charges that belong to no single tenant:
- one-off items with no subscription behind them
- subscription items whose tenant could not be resolved
- invoice-level adjustments — overall discounts, applied credits, customer balance, tax
It is derived from the total rather than by adding up unattributed lines, so an invoice-level discount that never appears on any line still lands there instead of quietly disappearing. That also means it can be negative when a credit exceeds the unattributed charges.
An invoice whose lines are all one-off charges returns an empty tenants array with the full total unallocated — that is a correct answer, not a lookup failure.
When the Invoice Does Not Split by Tenant
tenants covers only what traces to a license. An invoice can be billed as a single consolidated charge, or as one-off items with no subscription behind them, and then nothing traces — tenants comes back empty and unallocated_cents equals total_cents.
lines is what you read in that case. It carries the invoice's lines in Stripe's own order, whether or not each reached a tenant — so the same field answers both the clean split and the one that does not split at all. It is complete only when lines_truncated is false; above the cap you get that same order, truncated to a prefix:
| Field | Meaning |
|---|---|
description |
The line as it reads on the PDF |
amount_cents |
This line's amount, in minor units. Negative for credits and discounts billed as their own line |
quantity |
Units billed, typically seats. null when the line has none |
period_start, period_end |
The period this line covers, which can differ from the invoice's own period on a proration |
tenant_entity_id |
The tenant it was attributed to, or null |
unallocated_reason |
Why it was not attributed. null when it was |
Exactly one of those two is ever set: an attributed line has a tenant_entity_id and a null reason, an unattributed line has a reason and a null tenant. Neither both nor neither.
unallocated_reason has two values, and they mean different things:
no_subscription_item— the line is not tied to a subscription item. One-off charges, credits, manual lines and inline tax are the straightforward cases, and for those there genuinely is no tenant to find. Metered usage and overage lines also land here, and those often do name a tenant in theirdescriptioneven though this API cannot resolve it to atenant_entity_id; read the description before concluding a usage charge is unattributable.unmapped_subscription_item— the line is a subscription charge, but no license on your account holds that subscription item, so no tenant owns it. Usually a license moved or was removed after the invoice was issued. If you see this on a charge you can otherwise account for, tell us rather than reassigning it yourself.
Reconciling by hand: sum amount_cents across lines and you get the invoice's pre-adjustment subtotal, not total_cents. The difference is invoice-level discounts, credits, and tax, which Stripe applies to the invoice rather than to any line. total_cents remains the authoritative figure — always split from it, never from the line sum. (An applied customer balance sits between total_cents and what is actually charged, so it will not close this gap either.)
A credit note does not reduce total_cents. Stripe tracks credit notes separately from the invoice total, so a credit-noted invoice owes less than total_cents and no entry in lines records the difference. Do not treat total_cents as the final amount owed on an invoice you know has been credited.
lines is capped. Check lines_truncated before reconciling line by line: when it is true the invoice carried more lines than the response returned. The tenant amounts and unallocated_cents are computed from every line either way and stay exact — only the listing is shortened.
Filtering by Period
start and end filter on the date an invoice was issued (its created date), not on the billing period it covers. An invoice issued on 1 October for September usage is matched by start=2026-10-01, not by end=2026-09-30.
Both bounds are whole UTC days, and end includes that entire day. end=2026-09-30 matches an invoice issued at 22:58 UTC on 30 September.
To select by the period covered instead, request a wider issue-date range and filter the results on period_start and period_end.
Pagination
When has_more is true, pass next_cursor as starting_after to fetch the next page. Repeat until has_more is false — not until a page comes back empty.
Drafts are removed from a page after it has already been assembled, so a page can hold fewer invoices than limit, occasionally none at all, while has_more is still true. Treating an empty page as the end of the list would cut an export short. next_cursor is present whenever has_more is true, including on a page that returned no invoices.
next_cursor is an opaque token — pass it back verbatim and nothing else. It is Stripe's position in the unfiltered list, so it may name an invoice that never appeared in your invoices array, including a draft that this API will 404 if you request it by ID. Do not store it as "the last invoice I processed", and do not derive your own cursor from the last invoice you received: on a page whose trailing rows were drafts, that cursor would rewind and re-read them forever.
Paging is cursor-based rather than offset-based, so an invoice created while you are paging cannot shift rows onto a page you have already read. It is not a frozen snapshot, though: invoices are ordered newest first, so one created mid-pagination sorts ahead of your cursor and simply will not appear in any later page.
For an export that must be complete, pin the range before you start — set end to a day that has already finished — or re-run from the first page.
# first page
curl -H "X-API-Key: $HATZ_API_KEY" \
"https://ai.hatz.ai/v1/payments/invoices?limit=100"
# next page
curl -H "X-API-Key: $HATZ_API_KEY" \
"https://ai.hatz.ai/v1/payments/invoices?limit=100&starting_after=in_1TvmbzLB3VQuvRiYUrkb7C5H"
Multiple Organizations
If you hold billing access to more than one organization, the API will not guess which one you mean. The request returns 409 and names your options, each with the id to pass:
{
"error": {
"type": "HTTPException",
"message": "You have billing access to more than one account: Acme MSP (tenant_OrrJZ5pWAKQkDTzz1OBXEnjWM7); Globex MSP (tenant_9dKpQm2XvBnLtWzR7YsHcF4jTa). Pass `entity_id` with one of these ids."
}
}
Each entry is name (id), separated by a semicolon and a space, ordered by name. Retry with entity_id set to the id in parentheses for the organization you want — you do not need to look it up anywhere else. Most callers hold access to a single organization and never see this.
Errors
| Status | Meaning |
|---|---|
400 |
entity_id is not a recognizable id — not a UUID, tenant external id, or tenant_… HashID |
401 |
Missing or invalid API key |
403 |
Your user lacks the billing_report permission, or the account is billed by an external distributor |
404 |
No billing account for this organization, or no such invoice for it — including an invoice that is still a draft |
409 |
You have billing access to several organizations — pass entity_id |
422 |
Invalid parameters, for example start after end or a limit outside 1–100 |
503 |
Billing account or permissions could not be verified — safe to retry |
A 404 is returned for an invoice that does not exist, one belonging to another organization, and one that is still a draft. These are deliberately indistinguishable so the endpoint cannot be used to discover other organizations' invoice IDs.
400 and 404 say different things about entity_id: 400 means the value is not a well-formed id at all, so fix how you are producing it. 404 means the id is well-formed but is not an organization you have billing access to — and, like invoice ids, it does not distinguish "no such organization" from "not yours".