API reference
Every route, field, limit and error of the Koutei PDF API. New here? Start with the quickstart.
Overview
- Base URL:
https://kouteisystems.com. Every path starts with/api/v1/. - Requests and answers are JSON in UTF-8, except the PDF itself. Times are in UTC, such as
2026-10-12T09:14:03Z. - The API version is
2026-10-01. Send it asKoutei-Version: 2026-10-01; every answer repeats it. See Versions. - The same description is available to machines as OpenAPI 3.1 at
https://kouteisystems.com/api/v1/openapi.json. - Only the public routes can be called from a web page. The routes that take a key send no CORS headers, because a key belongs on a server.
Keys and scopes
Send your key in the Authorization header: Authorization: Bearer kt_live_…. A key is never accepted in the URL or the body. You create keys in Settings, up to 3 active at a time. A key is shown once and stored only as a hash, and your email address must be confirmed before one is issued.
A key has scopes. Each scope allows:
| Scope | Allows |
|---|---|
documents:create | POST /api/v1/documents with mode "final" |
documents:preview | POST /api/v1/documents with mode "preview" |
documents:read | GET /api/v1/documentsGET /api/v1/documents/{id}GET /api/v1/documents/{id}/file |
account:read | GET /api/v1/account |
POST /api/v1/documents/validate works with any valid key, whatever its scopes. Settings offers two kinds of key:
| Kind | Scopes | Can use credits |
|---|---|---|
| Full access | documents:create, documents:preview, documents:read, account:read | Yes |
| Preview only | documents:preview, documents:read, account:read | No |
A revoked or regenerated key stops working from the next request at the latest. A request that is still running when its key is revoked may be refused before it is charged.
Routes
| Route | Key | What it does |
|---|---|---|
GET /api/v1/templates | none (public) | List the templates the API offers. |
GET /api/v1/templates/{slug} | none (public) | One template, with the JSON Schema of its fields, its rules and an example request. |
GET /api/v1/openapi.json | none (public) | This API, described as OpenAPI 3.1 JSON. |
POST /api/v1/documents | documents:create or documents:preview | Render one document and return it. |
POST /api/v1/documents/validate | any valid key | Run exactly the checks POST /documents runs before rendering. No render, no credit. |
GET /api/v1/documents | documents:read | The records of the documents your account made through the API in the last 90 days, newest first. |
GET /api/v1/documents/{id} | documents:read | One document's stored metadata (never the file or its fields). |
GET /api/v1/documents/{id}/file | documents:read | Reserved. Always 404 file_not_available, because Koutei stores no documents. |
GET /api/v1/account | account:read | Plan, credits, the calling key and its limits. |
Every route takes the Koutei-Version header, except GET /api/v1/openapi.json. A method a path does not take answers 405 method_not_allowed. Any other path under /api/v1/ answers 404 not_found.
GET /api/v1/templates
List the templates the API offers. Public. Sends Access-Control-Allow-Origin: *, Vary: Koutei-Version, and may be cached for 5 minutes. An OPTIONS preflight gets 204 with Access-Control-Allow-Methods: GET, OPTIONS and Access-Control-Allow-Headers: Koutei-Version.
Key: none (public). Success: 200. It can also answer 400, 404, 429, 500.
GET /api/v1/templates/{slug}
One template, with the JSON Schema of its fields, its rules and an example request. Public. Sends Access-Control-Allow-Origin: *, Vary: Koutei-Version, and may be cached for 5 minutes. An OPTIONS preflight gets 204 with Access-Control-Allow-Methods: GET, OPTIONS and Access-Control-Allow-Headers: Koutei-Version.
Key: none (public). Success: 200. It can also answer 400, 404, 429, 500.
| Parameter | Where | What it is |
|---|---|---|
slug | path, required | A template name from the list of templates. |
GET /api/v1/openapi.json
This API, described as OpenAPI 3.1 JSON. Public. Sends Access-Control-Allow-Origin: *, Vary: Koutei-Version, and may be cached for 5 minutes. An OPTIONS preflight gets 204 with Access-Control-Allow-Methods: GET, OPTIONS and Access-Control-Allow-Headers: Koutei-Version.
Key: none (public). Success: 200. It can also answer 404, 429, 500.
POST /api/v1/documents
Render one document and return it. mode final needs documents:create and costs 1 credit, charged only after the PDF exists; mode preview needs documents:preview and is free and watermarked. The response is the PDF unless the Accept header lists application/json and lists none of application/pdf, application/* or */*; errors are always JSON. Every response states credit_used.
Key: documents:create or documents:preview. Success: 201. It can also answer 400, 401, 402, 403, 404, 409, 413, 415, 422, 429, 500, 502, 503, 504.
| Parameter | Where | What it is |
|---|---|---|
Idempotency-Key | header | 1 to 255 visible ASCII characters; a double-quoted value (IETF draft form) is read without the quotes. Per account, 24 hours. Same key and same normalised request (which includes reference) replays the document free; same key and a different request gets 422 idempotency_key_reused. A taken-over attempt can no longer commit, so at most one document is ever charged per key. |
POST /api/v1/documents/validate
Run exactly the checks POST /documents runs before rendering. No render, no credit. Any valid key. 200 when the request would render; 422 validation_failed, with every problem listed, when it would not. Idempotency-Key is ignored.
Key: any valid key. Success: 200. It can also answer 400, 401, 404, 409, 413, 415, 422, 429, 500, 503.
GET /api/v1/documents
The records of the documents your account made through the API in the last 90 days, newest first.
Key: documents:read. Success: 200. It can also answer 400, 401, 403, 404, 429, 500, 503.
| Parameter | Where | What it is |
|---|---|---|
reference | query | Exact, case-sensitive match on the reference label. 1 to 128 characters |
limit | query | How many documents to return. 1 to 100; default 20 |
starting_after | query | A document id from the previous page; it must be one of your listable documents. at most 64 characters |
GET /api/v1/documents/{id}
One document's stored metadata (never the file or its fields).
Key: documents:read. Success: 200. It can also answer 400, 401, 403, 404, 429, 500, 503.
| Parameter | Where | What it is |
|---|---|---|
id | path, required | A document id, as returned in Koutei-Document-Id. |
GET /api/v1/documents/{id}/file
Reserved. Always 404 file_not_available, because Koutei stores no documents. The file is returned only in the response to POST /api/v1/documents. Within 24 hours, repeat that request with the same Idempotency-Key to get it again for free. A later API version may serve files here; that would be additive.
Key: documents:read. It has no success answer. It can also answer 400, 401, 403, 404, 429, 500, 503.
| Parameter | Where | What it is |
|---|---|---|
id | path, required | A document id, as returned in Koutei-Document-Id. |
GET /api/v1/account
Plan, credits, the calling key and its limits.
Key: account:read. Success: 200. It can also answer 400, 401, 403, 404, 429, 500, 503.
The request
The body of POST /api/v1/documents and POST /api/v1/documents/validate is one JSON object, sent with Content-Type: application/json:
| Name | Required | Accepts | What it is |
|---|---|---|---|
template | Yes | one of "invoice", "quote", "receipt" | Which document to make. |
mode | Yes | one of "final", "preview" | There is no default: a request never spends a credit unless it says "final". A preview is free and watermarked. |
fields | Yes | an object | The details of the document. Each template has its own fields, listed below. |
template_version | No | a major version as digits, such as "2" | Major version pin, digits only. |
reference | No | 1 to 128 characters | The caller's own label for this document, never printed. Stored in the 90-day history, echoed as Koutei-Reference, filterable with GET /documents?reference=. Printable ASCII, no leading or trailing space. |
paper | No | one of "a4", "letter"; default "a4" | The page size. |
locale | No | one of "en"; default "en" | BCP 47. Only en in 2026-10-01; other values get 400 invalid_request. |
output | No | only {"format":"pdf"} | Only {"format": "pdf"} (exact, case-sensitive), the same as leaving it out. The planned formats "pdf-a3" and "xml", and every other member of output, are reserved (400 reserved_field); any other value, such as "PDF", is 400 invalid_request (unsupported_value). |
A name the API does not know is refused with 400 invalid_request; nothing is silently dropped. A null for an optional parameter is 400 invalid_request (wrong_type; for template_version, invalid_template_version); it is not the same as leaving the parameter out.
Reserved names
These names are held for later versions. Today each is refused with 400 reserved_field, so a name is never ignored now and given a meaning later.
At the top level of the body: callback_url, webhook, file_url, einvoice, validation, jurisdiction, metadata, logo.
Inside output, only {"format":"pdf"} is accepted today. The format values "pdf-a3" and "xml" are reserved, and so is every other member of output, such as pdf_standard, standard and attachments.
Inside fields, for every template: seller, buyer, payment, document, items[].tax.
Templates
You send facts in fields; the template lays them out. GET /api/v1/templates/{slug} returns the same field list as a JSON Schema, with an example request.
invoice · Invoice, version 2.0.0
An invoice a seller issues to a client: 1 to 50 line items, an optional discount, one optional tax line, an optional amount already paid and the balance due. Paper: "a4" or "letter".
| Field | Type | Required | Notes |
|---|---|---|---|
seller_name | text | Yes | |
seller_details | multiline | No | |
tax_id | text | No | Printed as "Tax / VAT No." under the seller. |
client_name | text | Yes | Printed under "Bill to". |
client_details | multiline | No | |
invoice_number | text | Yes | You supply it; Koutei never allocates or checks numbers. |
date | date | Yes | Issue date. |
due_date | date | No | |
po_number | text | No | The buyer's purchase order number only, printed "PO no.". |
related_document | text | No | Number of the document this invoice follows (e.g. the quote), printed "Ref.". |
currency | currency | Yes | |
items | line_items | Yes | |
discount | discount | No | |
tax_rate | percent | No | Tax on the subtotal after discount, on its own line. |
tax_label | enum | No | One of "VAT", "GST", "Sales tax", "Tax". Name of the tax line; needs tax_rate. Without it the line is called Tax. |
amount_paid | money | No | |
payment_terms | text | No | |
payment_instructions | multiline | No | |
payment_link | url | No | Your own payment link (PayPal.me, a Stripe Payment Link, a bank pay-me link), printed in full under "Pay online" as a clickable link. Koutei takes no payments. |
notes | multiline | No |
Rules across fields:
- At least one line item. (
no_items) - At most 50 line items. (
too_many_items) - The subtotal (the sum of the rounded lines) must be at least 0. (
negative_total) - The subtotal must be below 10^15. (
total_too_large) - The discount may not exceed the subtotal. (
discount_too_large) - amount_paid may not exceed the total. (
amount_paid_too_large) - tax_label needs tax_rate. (
requires_field)
What it is, and is not:
- A seller-issued invoice with the heading INVOICE.
- Totals are calculated from the line items: each amount is quantity × unit price, rounded to the currency’s minor unit.
- Not a tax invoice: the heading is never TAX INVOICE.
- Not checked for tax or legal compliance: Koutei never asserts that a document is tax-compliant or legally valid.
- Not an e-invoice: it carries no Factur-X, ZUGFeRD or XRechnung data, and it is not PDF/A.
- Not numbered by Koutei: you supply the document number, and it is never allocated or checked for uniqueness.
- Not stored: neither the fields nor the PDF are kept, and nothing is sent to anyone.
{
"template": "invoice",
"template_version": "2",
"mode": "final",
"reference": "JOB-0017",
"paper": "a4",
"fields": {
"seller_name": "Aiko Tanaka Design",
"seller_details": "aiko@example.com\n12 Example Street, Cape Town",
"tax_id": "4000000000",
"client_name": "Northwind Pty Ltd",
"client_details": "accounts@northwind.example",
"invoice_number": "INV-0042",
"date": "2026-10-12",
"due_date": "2026-10-26",
"po_number": "PO-7731",
"related_document": "Q-0042",
"currency": "ZAR",
"items": [
{
"description": "Brand workshop",
"quantity": 1,
"unit_price": "6000.00"
},
{
"description": "Design hours",
"quantity": "7.5",
"unit_price": "850.00"
}
],
"discount": "10%",
"tax_rate": "15",
"tax_label": "VAT",
"amount_paid": "2000.00",
"payment_terms": "Due within 14 days",
"payment_instructions": "Bank: Example Bank · Account no.: 000000000\nReference: INV-0042"
}
}quote · Quote / Estimate, version 1.0.0
A quote or an estimate: the scope of work with either 1 to 50 line items and their total, or one fixed amount, plus optional terms and an acceptance block. Paper: "a4" or "letter".
| Field | Type | Required | Notes |
|---|---|---|---|
document_title | enum | Yes | One of "Quote", "Estimate". |
seller_name | text | Yes | |
seller_details | multiline | No | |
tax_id | text | No | |
client_name | text | Yes | Printed under "Prepared for". |
client_details | multiline | No | |
quote_number | text | Yes | |
date | date | Yes | |
valid_until | date | No | |
related_document | text | No | Printed "Ref.", e.g. the quote this one revises. |
scope | multiline | Yes | |
currency | currency | Yes | |
items | line_items | No | |
amount | money | No | A fixed total instead of items; printed normalised (e.g. JPY 110,000). |
terms | multiline | No | |
notes | multiline | No | |
show_acceptance | boolean | No | Adds an "Accepted by" block. Omitted means false. |
Rules across fields:
- Exactly one of items (1 to 50) and amount. (
items_or_amount) - An items list needs at least one line item. (
no_items) - At most 50 line items. (
too_many_items)
What it is, and is not:
- A seller-issued quote or estimate; document_title chooses the heading.
- Either line items with a calculated total, or a fixed amount. There is no tax arithmetic.
- Not a contract: the acceptance block is a place to sign, nothing is signed electronically.
- Not checked for tax or legal compliance: Koutei never asserts that a document is tax-compliant or legally valid.
- Not an e-invoice: it carries no Factur-X, ZUGFeRD or XRechnung data, and it is not PDF/A.
- Not numbered by Koutei: you supply the document number, and it is never allocated or checked for uniqueness.
- Not stored: neither the fields nor the PDF are kept, and nothing is sent to anyone.
{
"template": "quote",
"mode": "final",
"reference": "JOB-0020",
"fields": {
"document_title": "Estimate",
"seller_name": "Kumo Studio",
"seller_details": "hello@kumo.example",
"client_name": "Sakura Cafe",
"quote_number": "Q-0107",
"date": "2026-10-12",
"valid_until": "2026-11-11",
"scope": "Menu photography and layout for the autumn menu.",
"currency": "JPY",
"items": "Photography (half day) | 1 | 60000\nMenu layout | 2 | 25,000",
"terms": "50% deposit to start.",
"show_acceptance": true
}
}receipt · Payment Receipt, version 1.0.0
A receipt a seller issues after a client has paid: who paid, the amount, the payment date and method, what it was for and the invoice it settles. Paper: "a4" or "letter".
| Field | Type | Required | Notes |
|---|---|---|---|
seller_name | text | Yes | |
seller_details | multiline | No | |
tax_id | text | No | |
client_name | text | Yes | Printed under "Received from". |
client_details | multiline | No | |
receipt_number | text | Yes | |
payment_date | date | Yes | |
related_document | text | No | The invoice this payment settles, printed "Invoice no.". |
description | multiline | Yes | |
currency | currency | Yes | |
amount | money | Yes | More than 0; printed normalised (e.g. KWD 1,250.500) under "Amount received". |
payment_method | text | Yes | |
payment_reference | text | No | |
notes | multiline | No |
Older names, still accepted: invoice_reference (use related_document).
Rules across fields:
- amount must be more than 0. (
must_be_positive) - Send related_document or its deprecated alias invoice_reference, not both. (
conflicting_fields)
What it is, and is not:
- A seller-issued payment receipt with a PAID stamp under the amount received.
- Not a till or point-of-sale receipt, and it carries no third-party branding.
- Not checked for tax or legal compliance: Koutei never asserts that a document is tax-compliant or legally valid.
- Not an e-invoice: it carries no Factur-X, ZUGFeRD or XRechnung data, and it is not PDF/A.
- Not numbered by Koutei: you supply the document number, and it is never allocated or checked for uniqueness.
- Not stored: neither the fields nor the PDF are kept, and nothing is sent to anyone.
{
"template": "receipt",
"mode": "final",
"reference": "JOB-0021",
"paper": "letter",
"fields": {
"seller_name": "Gulf Freight Brokers",
"client_name": "Al Noor Trading",
"receipt_number": "R-0051",
"payment_date": "2026-10-14",
"related_document": "INV-0051",
"description": "Settlement of invoice INV-0051 (customs brokerage, October).",
"currency": "KWD",
"amount": "1250.5",
"payment_method": "Bank transfer",
"payment_reference": "TRF-88213"
}
}Value types
| Type | Rules |
|---|---|
text | One line, at most 200 characters (Unicode code points), or a JSON integer (used as its digits). null or blank on an optional field leaves it off the document. Bidi controls (U+202A-U+202E, U+2066-U+2069), U+FEFF and U+200B are rejected (invalid_characters); U+200C-U+200F are allowed. |
multiline | At most 2,000 characters. Line breaks are LF or CR LF; a CR not followed by LF is invalid_characters. |
date | A real calendar date as YYYY-MM-DD, printed as sent. |
enum | One of the values listed for the field, exact and case-sensitive. |
boolean | true or false. Leaving it out means false. |
currency | ISO 4217 List One (published 2026-09-17) minus fund codes and codes without minor units. Capitals only; no symbols. |
money | A decimal of 0 or more with at most the currency's minor digits. |
percent | 0 to 100, at most 4 fraction digits, optionally ending % (strings only). |
discount | A money amount ("50.00") or a percentage of the subtotal ending % ("10%"). |
line_items | 1 to 50 items: an array of line item objects, or a string with one "description | quantity | unit price" per non-blank line (at most 12,000 characters; the last two | parts are the numbers). Each amount is quantity x unit price rounded half away from zero to the currency's minor unit; the subtotal is the sum of the rounded amounts. |
url | An https:// link of at most 300 characters once leading and trailing spaces are trimmed, in plain ASCII with no spaces, no user name or password, and the host written plainly after https://. http:, javascript:, data:, mailto: and every other scheme are refused, as is any other breach of these rules (invalid_url). The document prints the trimmed text with the scheme in lower case, and links to exactly that text. null or blank on an optional field leaves it off the document. The schema states no pattern or length of its own, so a client that validates against it never refuses a padded or blank value that the server accepts; the server applies the rules. |
decimal | "1,250.00", "7.5", "-100" or a finite JSON number (read as its shortest round-trip decimal). Commas only as thousands separators; at most 10 integer and 4 fraction digits. |
A line item
In the array form of line_items, each item is an object with these 3 names. An item with any other name is refused.
| Name | Type | What it is |
|---|---|---|
description | string | What the line is for, on one line. |
quantity | string | number | How many. It may be negative, for a credit line. |
unit_price | string | number | The price of one. It may be negative, for a credit line. |
currencyaccepts 155 currency codes. The currency decides how many decimal places an amount may have and how it is printed.- A required field that is missing, null or blank is refused. An optional field that is missing, null or blank is left off the document, with its label.
- Text is printed as you send it: characters that mean something in a markup language are printed as themselves.
The response
A document is answered with 201, whether it is new or a repeat. The body is the PDF, unless the Accept header lists application/json and none of application/pdf, application/* or */*. So curl, a browser and most HTTP tools get the file; only an explicit Accept: application/json gets JSON. Errors are always JSON.
Headers of a document
| Header | What it is |
|---|---|
Location | The path of the document’s record. |
Content-Disposition | Binary PDF form only; the JSON form carries the name in its body instead. RFC 6266: attachment; filename="<ASCII fallback>", plus filename*=UTF-8''<percent-encoded> when the name is not pure ASCII. The filename* value percent-encodes every byte outside RFC 8187 attr-char, including , ; ( ) [ ] and space. |
Koutei-Document-Id | Stable document id; a replay returns the original id. |
Koutei-Document-Sha256 | SHA-256 of the PDF bytes, lowercase hex. |
Koutei-Template | Template slug used. |
Koutei-Template-Version | Full semantic version of the template used. |
Koutei-Mode | Whether the document is a final document or a preview. one of "final", "preview" |
Koutei-Credit-Used | Whether this call used a credit (on 201 responses). one of "true", "false" |
Koutei-Credits-Remaining | Credit balance after this call. |
Koutei-Idempotent-Replay | Whether this answer repeats a document made earlier with the same Idempotency-Key. one of "true", "false" |
Koutei-Reference | The request's reference label, echoed when one was sent. |
Koutei-Totals-Currency | totals.currency, the request's currency code. On every 201. |
Koutei-Totals-Subtotal | totals.subtotal: sent when the document prints a subtotal (every invoice; a quote with items). |
Koutei-Totals-Discount | totals.discount, never negative; sent when the invoice has a discount. |
Koutei-Totals-Tax | totals.tax; sent when the invoice has a tax_rate. |
Koutei-Totals-Total | totals.total; sent for an invoice and a quote. |
Koutei-Totals-Amount-Paid | totals.amount_paid, never negative; sent for an invoice with amount_paid, and for a receipt (the amount received). |
Koutei-Totals-Balance-Due | totals.balance_due; sent for an invoice. |
Koutei-Version | The API version that served the request. |
Koutei-Request-Id | Opaque request id (also error.request_id). Quote it in support requests. |
RateLimit-Limit | Quota of the applicable bucket with the smallest remaining (a tie goes to the one that resets later), in its current window. requests applies to every request; final to new finals; the preview buckets to previews. Only your own quotas are reported (requests, final, the per-key and per-account preview buckets), never the shared preview ceiling or the failed-authentication counter; a response that ends before the mode's buckets are counted, a replay included, reports requests only. |
RateLimit-Remaining | Requests left in that window. |
RateLimit-Reset | Seconds until that window resets. |
The file is named after the template and your document number, for example invoice-INV-0042.pdf; a preview ends -preview.pdf.
The JSON form
The JSON form of a 201 (Accept application/json only).
| Name | Type | What it is |
|---|---|---|
document | Document | Stored metadata of one document; the same object in every response. |
filename | string | Full UTF-8 file name. Not stored. |
file_base64 | string | The PDF, base64 (at most 3,000,000 bytes before encoding). |
credit_used | boolean | Whether this request used a credit. A repeat never does. |
credits_remaining | integer | The credit balance after this request. |
idempotent_replay | boolean | Whether this answer repeats a document made earlier with the same Idempotency-Key. |
totals | Totals | The figures the document prints: see The totals. |
The totals
The money figures the document prints, on every valid document request's answer: currency, then each figure the document prints, as a money string. A member the document does not print is absent, never null. invoice: subtotal, total and balance_due always; discount, tax and amount_paid when the request sent discount, tax_rate and amount_paid. quote: subtotal with items; total always. receipt: amount_paid (the amount received). The figures come from the same arithmetic as the document, run on the normalised request the document is rendered from; a replay states the same totals.
| Name | Type | What it is |
|---|---|---|
currency | string | The request's currency code. |
subtotal | string | A figure as the document prints it, without the currency code and the thousands separators: "." as the decimal point and exactly the currency's minor digits ("10808.13", JPY "110000", KWD "1250.500"). Only a quote with credit lines can be below zero. A figure can be sent back as a money value only when it is 0 or more with at most 10 digits before the point, and as the receipt's amount only when it is also more than 0. |
discount | string | The discount, never negative (0 when sent as 0); the invoice prints it with a minus sign. |
tax | string | The tax line, calculated on the subtotal after the discount. |
total | string | The subtotal less the discount, plus the tax (a quote's total is its subtotal or its fixed amount). |
amount_paid | string | What was paid, never negative: the invoice's amount_paid (printed as a plain figure, with Balance due under it), or the receipt's amount received. |
balance_due | string | The total less the amount paid; the total when nothing was paid. |
| Member | invoice | quote | receipt |
|---|---|---|---|
currency | always | always | always |
subtotal | always | with items | not stated |
discount | with discount | not stated | not stated |
tax | with tax_rate | not stated | not stated |
total | always | always | not stated |
amount_paid | with amount_paid | not stated | always |
balance_due | always | not stated | not stated |
On a 201, each member is also a header, present exactly when the member is: Koutei-Totals-Currency, Koutei-Totals-Subtotal, Koutei-Totals-Discount, Koutei-Totals-Tax, Koutei-Totals-Total, Koutei-Totals-Amount-Paid and Koutei-Totals-Balance-Due. The PDF form has no body to hold them, so the headers are where an automation reads the total due. A check (POST /api/v1/documents/validate) states the same totals before anything is made, and a repeat with the same Idempotency-Key states the same totals as the first answer. Errors have none.
The Document object
Stored metadata of one document; the same object in every response. The values you sent in fields are never in it.
| Name | Type | What it is |
|---|---|---|
id | string | The document’s id. A repeat of the same request returns the same id. |
object | "document" | What kind of object this is. |
template | string | The template used. |
template_version | string | The full version of the template used. |
mode | "final" | "preview" | Whether it is a final document or a preview. |
paper | "a4" | "letter" | The page size used. |
locale | string | The language used. |
reference | string | null | Your label, or null if you sent none. |
source | string | How the document was made, such as "api". New values may be added. |
credits_consumed | 0 | 1 | Credits spent when this document was created: 1 for a final, 0 for a preview. A replay does not change it. The call-level credit_used is separate. |
sha256 | string | The SHA-256 fingerprint of the PDF, in lower-case hex. |
bytes | integer | The size of the PDF in bytes. |
content_type | "application/pdf" | The type of the file. |
created_at | string (date-time) | When the document was first made. |
file_url | null | Reserved; always null in 2026-10-01. |
Other answers
POST /api/v1/documents/validate:
| Name | Type | What it is |
|---|---|---|
valid | true | The request would make a document. |
template | string | The template the request names. |
template_version | string | The version of the template that would be used. |
totals | Totals | The figures the document would print: see The totals. |
credit_used | false | A check never uses a credit. |
GET /api/v1/documents: Ordered by created_at, then id, both descending. A starting_after that is not one of your listable documents gets 400 invalid_request (invalid_query).
| Name | Type | What it is |
|---|---|---|
object | "list" | What kind of object this is. |
data[] | Document | The records, newest first. |
has_more | boolean | Whether there are older records. Pass the last id as starting_after to get them. |
GET /api/v1/account:
| Name | Type | What it is |
|---|---|---|
object | "account" | What kind of object this is. |
plan | string | free, starter or pro (an open set). |
credits | object | The account’s credits. |
credits.remaining | integer | Credits left in the current period. |
credits.monthly_allowance | integer | Credits the plan gives each month. |
credits.period_end | string | null | When the current credit period ends, or null if there is none. |
key | object | The key that made this request. Never its secret. |
key.id | string | The key’s id. |
key.name | string | The name you gave the key. |
key.prefix | string | The first characters of the key, as Settings shows them. |
key.scopes[] | "documents:create" | "documents:preview" | "documents:read" | "account:read" | What the key may do. |
key.created_at | string (date-time) | When the key was created. |
key.last_used_at | string | null | When the key was last used, or null. |
limits | object | Current operational values (not frozen; new names may be added). _key values apply to this key alone; _account values are shared by every key and the web app. |
limits.final_per_minute_key | integer | New final documents this key may make each minute. |
limits.requests_per_minute_key | integer | Requests this key may send each minute. |
limits.preview_per_hour_key | integer | Previews this key may make each hour. |
limits.preview_per_day_key | integer | Previews this key may make each day. |
limits.preview_per_hour_account | integer | Previews the whole account may make each hour. |
limits.preview_per_day_account | integer | Previews the whole account may make each day. |
GET /api/v1/templates returns a list of these; GET /api/v1/templates/{slug} returns one, with the detail rows:
| Name | Type | What it is |
|---|---|---|
object | "template" | What kind of object this is. |
slug | string | The name to send as template. |
name | string | The template’s name in words. |
description | string | What the document is. |
category | string | The group the template belongs to. |
version | string | The template’s current version. |
output | "pdf" | The kind of file it makes. |
papers[] | "a4" | "letter" | The page sizes it can be made on. |
locales[] | string | The languages it can be made in. |
required_fields[] | string | The fields a request must send. |
optional_fields[] | string | The fields a request may send. |
fields_schema | object | JSON Schema 2020-12 of the template's fields. |
rules[] | object | The rules that involve more than one field. |
rules[].code | string | The detail code a broken rule answers with. |
rules[].description | string | The rule, in words. |
example | object | A complete, valid request body. |
notes | object | What the document is, and what it is not. |
notes.is[] | string | What it is. |
notes.is_not[] | string | What it is not. |
deprecated_fields[] | object | Older field names that are still accepted. |
deprecated_fields[].name | string | The older name. |
deprecated_fields[].use_instead | string | The name to send instead. |
Idempotency
- Send an
Idempotency-Keyheader with every request for a final document. Without one, every successful request makes a new document and uses a credit, including one you send twice by mistake. - A key is remembered for 24 hours, for the whole account. The same key with the same request returns the same document, free: the document is made again, checked against its recorded fingerprint, and answered with
Koutei-Idempotent-Replay: true. - The same key with a different request is refused with
422 idempotency_key_reused. A differentreferencecounts as a different request; a differentAcceptheader does not. - If the first request is still running, the answer is
409 idempotency_in_progresswithRetry-After: 5. A request that has been running for more than 35 seconds is treated as dead and can be taken over. At most one document is ever charged for one key. - If the template has changed since, the document cannot be made byte for byte again, and the answer is
409 idempotency_replay_unavailable. You never get different bytes under an old document id. POST /api/v1/documents/validateignores the header.
Credits and previews
- A
finaldocument uses 1 credit from the same monthly allowance as the app. The credit is taken only after the PDF has been made, and the PDF is sent only after the credit is recorded. - A
preview, a validation, a repeat with the same Idempotency-Key and a request that fails before the PDF is made use no credit. A preview works with 0 credits. - Every page of a preview carries the words "PREVIEW · NOT A VALID DOCUMENT", its PDF title is "Preview – not a valid document", and its file name ends
-preview.pdf. A final document never has the watermark. - With no credit left, a final request answers
402 insufficient_credits, with the balance and the end of the current credit period. - Every JSON answer to a document request states
credit_used:true,false, ornullwhen the outcome is unknown. - Need more than 10,000 documents a month? Email support@kouteisystems.com.
Limits
Rate limits count in fixed windows on the UTC clock: a minute on the minute, an hour on the hour, a day from 00:00 UTC. A request over a limit is refused with a Retry-After header. These are the launch values, and they can change.
The RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers report one limit: of the limits that apply to the request and are marked Yes in the last column, the one you are closest to. That is 4 of the 8 below: your own key's and your own account's. The headers never report a limit marked No.
| What is counted | Free | Starter | Pro | Refused with | In the headers |
|---|---|---|---|---|---|
| New final documents, per key. Replays are not counted. | 10 per minute | 60 per minute | 120 per minute | 429 rate_limited (final_key_limit) | Yes |
| Every authenticated request, per key. Replays are counted. | 120 per minute | same | same | 429 rate_limited (request_key_limit) | Yes |
| Previews, per account: every key and the web app together. | 60 per hour and 300 per day | same | same | 429 rate_limited (preview_user_limit) | Yes |
| Previews, per key. | 30 per hour and 200 per day | same | same | 429 rate_limited (preview_key_limit) | Yes |
| Previews, for everyone together. | 1,500 per hour and 10,000 per day | same | same | 503 service_busy (preview_global_limit) | No |
| Failed sign-ins (a missing or wrong key), per network. | 20 per 10 minutes | same | same | 429 rate_limited (auth_failure_limit) | No |
| Idempotent replays, per account: every key together. A replay renders the document again; it uses no credit. | finals: 120 per minute and 600 per hour; previews: 60 per hour and 300 per day | finals: 120 per minute and 1,200 per hour; previews: 60 per hour and 300 per day | finals: 120 per minute and 3,000 per hour; previews: 60 per hour and 300 per day | 429 rate_limited (replay_limit) | No |
| Idempotent replays, per key. | finals: 60 per minute; previews: 30 per hour and 200 per day | same | same | 429 rate_limited (replay_limit) | No |
These limits are part of the API version and do not change within it:
| Limit | Value |
|---|---|
| Request body | 128 KiB (131,072 bytes) |
| Names in fields | 64 |
| A text value | 200 characters |
| A multiline value | 2,000 characters |
| Line items, at least | 1 |
| Line items, at most | 50 |
| A line item description | 200 characters |
| Line items sent as one text value | 12,000 characters |
| A url value (the invoice’s payment link) | 300 characters |
| Digits before the decimal point of a number | 10 |
| Digits after the decimal point of a number | 4 |
| The reference label | 128 characters |
| The Idempotency-Key header | 255 characters |
| How long an Idempotency-Key is remembered | 24 hours |
| The PDF, sent as a file | 4,000,000 bytes |
| The PDF, sent inside JSON | 3,000,000 bytes, before base64 |
| How long a document stays in your history | 90 days |
Versions
- The API version is a date,
2026-10-01, sent as theKoutei-Versionheader. It is the only version today. Without the header you get the newest version; a version the API does not have is refused with400 invalid_request. - What can change within a version: only additions. New routes, headers, response fields, error codes, templates, optional parameters and accepted values may appear under a new version date. Write your code to ignore response fields and headers it does not know, and to treat an unknown error code by its HTTP status.
- What cannot: nothing is removed or renamed, nothing optional becomes required, input that was accepted is not refused, and no status code, default or meaning changes. A change of that kind would be a new API, under
/api/v2. - Rate limits are not part of a version. The values in the first table above can change.
- Each template has its own version, such as 2.0.0, returned in
Koutei-Template-Version. A new first number means its fields changed in a way that could break a request. A new second number may add an optional field or change how the document looks. A new third number changes nothing in the output. - Pinning. Send
template_versionwith the first number, such as"2", to hold a template at that major version. If the current major is a different one, the answer is409 template_version_unavailable, never a different document. - With one template version and one request, the PDF is the same bytes every time.
What is kept
- The values you send in fields and the PDF are never stored. The PDF exists only in the response.
- A record of each document is kept for 90 days: the Document object above, and the key that made it.
GET /api/v1/documentslists these records. - Don't put personal information in
reference: it is stored with the record and appears in request addresses when you filter by it. - The Privacy Policy has the full list.
Errors
An error is JSON with a code, a message, sometimes details, a request_id and a docs_url. Message texts may change; codes do not. Errors explains each code.
| Status | Code | credit_used |
|---|---|---|
| 400 | invalid_request | false |
| 400 | reserved_field | false |
| 401 | unauthorized | false |
| 402 | insufficient_credits | false |
| 403 | forbidden_scope | false |
| 404 | not_found | false |
| 404 | template_not_found | false |
| 404 | document_not_found | not stated (this is not a document request) |
| 404 | file_not_available | not stated (this is not a document request) |
| 405 | method_not_allowed | false |
| 409 | template_version_unavailable | false |
| 409 | idempotency_in_progress | false |
| 409 | idempotency_replay_unavailable | false |
| 413 | payload_too_large | false |
| 413 | response_too_large | false |
| 415 | unsupported_media_type | false |
| 422 | validation_failed | false |
| 422 | idempotency_key_reused | false |
| 422 | render_rejected | false |
| 429 | rate_limited | false |
| 500 | commit_status_unknown | null (unknown: retry with the same Idempotency-Key) |
| 500 | internal_error | false, or null (unknown: retry with the same Idempotency-Key) |
| 502 | engine_unavailable | false |
| 503 | engine_busy | false |
| 503 | service_busy | false |
| 504 | render_timeout | false |
Good to know
detailslists at most 50 problems. Themessagecounts all of them.- Send amounts and quantities as strings when the exact digits matter. A JSON number is read as the shortest decimal that gives the same number, so
7.50is read as7.5. - In a line item object, a number sent as a string must have no spaces around it. In the one-text form of line items, the spaces around each
|are ignored. - A
429that is not JSON comes from the firewall in front of the API, not from the API itself. Wait a minute and try again.
The API does not send documents to anyone, does not allocate or check document numbers, and does not state that a document is tax-compliant, an e-invoice or legally valid.