Simple invoicing (v1)¶
The Simple surface issues tax invoices, credit notes, and debit notes through one ergonomic API. You provide the business essentials — buyer, lines, taxes, dates — and the platform computes every total, renders a regulation-compliant document, generates the QR code, and submits to the tax authority when you freeze.
Simple vs. the Documents API¶
Invora exposes two invoicing surfaces over the same engine:
| Simple (this guide) | Documents (v2) | |
|---|---|---|
| Path | /api/v1/simple/... |
/api/v2/documents |
| Input | Minimal business fields; the platform computes UBL | Raw UBL 2.1 you control field-by-field |
| Best for | Most integrations — invoices, credit notes, debit notes | Power users needing full UBL or the 65 UBL document types |
| Lifecycle | 2 states (draft → frozen) | 3-state model with cancel |
Start with Simple. Reach for the Documents API only when you need the full UBL surface.
Three services, one shape¶
The three document types share an identical request shape and the same operations:
| Document | Service | Base path | Use it to |
|---|---|---|---|
| Invoice | SimpleInvoiceService |
/api/v1/simple/invoices |
Bill a customer |
| Credit note | SimpleCreditNoteService |
/api/v1/simple/credit-notes |
Reduce a previously issued invoice (refund, return, post-sale discount) |
| Debit note | SimpleDebitNoteService |
/api/v1/simple/debit-notes |
Increase a previously issued invoice (extra charge, under-billing) |
The examples below use invoices. Debit notes are identical with the path segment swapped; credit notes add two required fields (sourceDocumentKey, reasonCode) — see self-billed credit notes. Note the path segment is hyphenated: credit-notes, not credit_notes.
All three also accept an optional seller party, which is what turns an ordinary document into a self-billed or third-party one — see Self-billed and third-party issuance.
Lifecycle: draft → frozen¶
A document carries a single frozen flag — there are two states, no separate validated step and no cancel:
| State | frozen |
Editable? | Meaning |
|---|---|---|---|
| Draft | false |
Yes | Work in progress. Edit with Update, remove with Delete. |
| Frozen | true |
No | Finalized. The regulation pipeline has run; the QR code and signed artifact are available. Immutable. |
To adjust a frozen invoice you never edit it: issue a credit note to lower the amount owed, or a debit note to raise it.
flowchart LR
C["Create<br/>(draft)"] --> U["Update<br/>(draft only)"]
U --> F["Freeze<br/>(regulation pipeline runs)"]
C -- "freezeImmediately: true" --> F
F --> A["Artifacts:<br/>QR code, signed XML"]
Operations¶
Eight RPCs per service:
| RPC | HTTP | Scope | Notes |
|---|---|---|---|
| Create | POST /api/v1/simple/invoices |
Invora.Simple.Invoices.v1.Modify.Create |
Draft by default; set freezeImmediately to finalize in one call |
| Get | GET /api/v1/simple/invoices/{key} |
Invora.Simple.Invoices.v1.Get |
Full document with computed totals and authority results |
| Update | PUT /api/v1/simple/invoices/{key} |
Invora.Simple.Invoices.v1.Modify.Update |
Draft only; requires concurrencyStamp |
| Delete | POST /api/v1/simple/invoices/delete |
Invora.Simple.Invoices.v1.Modify.Delete |
Draft only; body { "keys": ["..."] } |
| Freeze | POST /api/v1/simple/invoices/{key}/freeze |
Invora.Simple.Invoices.v1.Modify.Freeze |
Requires concurrencyStamp; submits to the authority |
| List | POST /api/v1/simple/invoices/list |
Invora.Simple.Invoices.v1.List |
Filter, sort, cursor pagination; returns summaries |
| Calculate | POST /api/v1/simple/invoices:calculate |
Invora.Simple.Invoices.v1.Calculate |
Preview totals without persisting |
| GetArtifact | GET /api/v1/simple/invoices/{documentKey}/artifacts/{artifactId} |
Invora.Simple.Invoices.v1.GetArtifact |
Download a frozen-document artifact |
Money values¶
Every monetary amount and rate is an exact decimal {units, nanos} where value = units + nanos / 1,000,000,000. Both parts are numbers (never strings) and share the same sign:
{ "units": 100, "nanos": 0 } // 100.00
{ "units": 1000, "nanos": 500000000 } // 1000.50
{ "units": 15, "nanos": 0 } // 15 (e.g. a 15% tax rate)
See gRPC & JSON transcoding for the full rule.
Create and freeze in one call¶
Set freezeImmediately: true to create and submit in a single round-trip. Send only quantities, unit prices, and tax rates — the platform computes the rest.
curl -X POST https://gateway.invora.app/api/v1/simple/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"freezeImmediately": true,
"changes": {
"issueAt": "2026-06-11T10:30:00Z",
"supplyDate": { "year": 2026, "month": 6, "day": 11 },
"currencyCode": "SAR",
"buyer": {
"inline": {
"legalName": "Acme Trading Co.",
"vatRegistrationNumber": "310122393500003",
"address": {
"streetName": "King Fahd Road",
"buildingNumber": "1234",
"citySubdivisionName": "Al Olaya",
"cityName": "Riyadh",
"postalZone": "12345",
"countryCode": "SA"
}
}
},
"lines": [
{
"description": "Consulting services",
"quantity": { "value": { "units": 10, "nanos": 0 }, "unitCode": "HUR" },
"unitPrice": { "units": 100, "nanos": 0 },
"tax": { "taxRate": { "units": 15, "nanos": 0 } }
}
]
}
}'
{
"details": {
"key": "siv_01J7...",
"frozen": true,
"concurrencyStamp": "8f2c1a44",
"calculations": {
"lineExtensionTotal": { "units": 1000, "nanos": 0 },
"taxTotal": { "units": 150, "nanos": 0 },
"payableAmount": { "units": 1150, "nanos": 0 }
},
"regulation": {
"status": "SIMPLE_REGULATION_STATUS_ACCEPTED",
"artifacts": [
{ "id": "qr-code", "contentType": "image/png" },
{ "id": "signed-xml", "contentType": "application/xml" }
]
}
}
}
To create a draft instead, omit freezeImmediately (or set it false), then call Freeze later.
Parties: stored or inline¶
buyer (required) and seller (optional) are both SimpleParty — a oneof between a stored party you created earlier and inline details. Send exactly one key:
A bare party object ("buyer": { "legalName": "..." }) and both keys at once are equally invalid.
Manage stored parties through the Parties service (/api/v2/parties) and reference them by partyKey. seller is the supplier of record and is set only when it differs from the issuing tenant — see Self-billed and third-party issuance.
B2B vs B2C and regulation submission¶
The buyer's VAT status drives how the document is treated. When you freeze, active regulations run automatically:
| Buyer | Document | Saudi Arabia (ZATCA) |
|---|---|---|
Has vatRegistrationNumber |
B2B tax invoice | Cleared before it is valid (blocking) |
| No VAT number | Simplified (B2C) invoice | Reported after issuance (async) |
For simplified (B2C) invoices above the regulatory threshold, Saudi Arabia requires at least one additionalIds entry on the inline buyer (national ID, commercial registration, passport, etc.). Other regulations (Egypt ETA, Peppol) follow their own submission model. See ZATCA integration.
Self-billed and third-party issuance¶
By default the issuing tenant is the supplier. Two regulated variants change that: self-billing (you, the buyer, issue the invoice on your supplier's behalf — KSA VAT IR Art. 53(2)) and third-party issuance (you issue on behalf of two other parties — Art. 53(3)).
There is no self-billing endpoint and no self-billing flag
Do not send selfBilled, invoiceTypeCode, documentType or transactionType — none of those fields exist on any create request, and reaching for one is a common cause of a rejected self-billing integration. You use the ordinary POST /api/v1/simple/invoices; the case is derived from the parties you send.
(selfBilled does exist on the separate billing API — but only as a read/filter field on already-issued documents, never as a create input.)
How the case is derived¶
Invora compares three VAT registration numbers: the issuing branch's, the buyer's, and the seller's. The issuing branch is branchId on the request, or your primary branch when omitted.
| What you send | Derived case |
|---|---|
seller omitted |
Normal — the issuing tenant is the supplier |
seller present, issuing branch VAT == buyer VAT |
Self-billed (Art. 53(2)) |
seller present, issuing branch VAT is neither the buyer's nor the seller's |
Third-party (Art. 53(3)) |
So a self-billed invoice is an ordinary Create where you add a seller (your supplier of record) and set buyer to your own VAT-registered self-party:
curl -X POST https://gateway.invora.app/api/v1/simple/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"changes": {
"issueAt": "2026-06-11T10:30:00Z",
"supplyDate": { "year": 2026, "month": 6, "day": 11 },
"currencyCode": "SAR",
"buyer": { "partyKey": "party_your_own_self_party" },
"seller": { "partyKey": "party_your_supplier" },
"lines": [
{
"description": "Provider service, June 2026",
"quantity": { "value": { "units": 1, "nanos": 0 }, "unitCode": "EA" },
"unitPrice": { "units": 4000, "nanos": 0 },
"tax": { "taxRate": { "units": 15, "nanos": 0 } }
}
]
}
}'
seller and buyer are both SimpleParty, a oneof: send {"partyKey": "..."} or {"inline": {...}} — never a bare party object, and never both keys at once.
What you get back¶
You never set a type code; it is derived and stamped for you. The document's UBL type code and its ZATCA 7-character @name subtype come out as:
| Document | Case | Type code | @name |
|---|---|---|---|
| Invoice | normal, B2B buyer | 388 |
0100000 |
| Invoice | normal, B2C buyer (no buyer VAT) | 388 |
0200000 |
| Invoice | third-party, B2B buyer | 388 |
0110000 |
| Invoice | third-party, B2C buyer | 388 |
0210000 |
| Invoice | self-billed | 388 |
0100001 |
| Invoice | summary (isSummary: true) |
388 |
position 6 set, e.g. 0100010 |
| Credit note | normal, B2B buyer | 381 |
0100000 |
| Credit note | third-party, B2B buyer | 381 |
0110000 |
| Credit note | self-billed | 381 |
0100001 |
| Debit note | any | 383 |
— |
@name positions are NNPNESB: 1–2 subtype (01 standard/clearance when the buyer carries a VAT number, 02 simplified/reporting when it does not), 3 third-party, 6 summary, 7 self-billed. Positions 4 (nominal) and 5 (exports) are always 0 on the Simple surface. Self-billing is inherently B2B — the case only resolves when the buyer (you) carries a VAT number — so a self-billed document is always subtype 01. Credit notes have no summary concept, so their position 6 is always 0.
A self-billed invoice or credit note is additionally stored under its own document type (SelfBilledInvoice / SelfBilledCreditNote). Debit notes never promote: UBL 2.1 has no self-billed debit-note type, so a self-billed debit note keeps the plain DebitNote type — express a self-billed debit obligation as a self-billed credit note in the reverse direction instead. Debit notes also carry no type code on the document itself; 383 is applied later, when the document is transformed for ZATCA submission.
Three rejections to expect¶
All three are hard 400s raised before anything is persisted:
| Cause | Message |
|---|---|
| The seller is not VAT-registered | "The seller of record must be VAT-registered for a self-billed or third-party invoice (KSA VAT IR Art. 53(2)/(3)). Provide the seller's vat_registration_number (inline) or reference a VAT-registered party." |
| The issuing branch has no VAT number on file | "The issuing branch must be VAT-registered to resolve a self-billed or third-party transaction (KSA VAT IR Art. 53(2)/(3)) when a seller of record is provided. Configure the branch's vat_registration_number." |
seller equals the issuing tenant |
"The seller of record equals the issuing tenant. Omit seller for a normal invoice (the issuing tenant is the supplier by default)." |
A partyKey seller must resolve to a stored party that carries a VAT number; an inline seller must carry vatRegistrationNumber directly. Set the issuing branch's VAT number through Branches (/api/v2/branches) or Settings' self-party before your first self-billed document.
Self-billed credit notes¶
Credit notes take the same seller field and the same derivation, on the hyphenated path /api/v1/simple/credit-notes (not credit_notes, not creditnotes — the proto package is credit_notes, so the underscore spelling is the natural wrong guess).
They require two fields invoices do not:
| Field | Meaning |
|---|---|
sourceDocumentKey |
Required. The key returned by the invoice this note adjusts. |
reasonCode |
Required. Why the note is issued — for Saudi Arabia, a standard ZATCA reason code (cancellation, wrong amount, goods returned, post-sale discount). Pair with optional free-text reasonText. |
The case is re-derived on both Create and Update, so changing the parties on a draft keeps the persisted document type consistent.
Regulator-readiness
Self-billed credit notes are promoted to SelfBilledCreditNote and carry cbc:CreditNoteTypeCode 381 with @name position 7 set (0100001). This landed recently (invora-backend!438); earlier builds accepted and validated a self-billed credit note but never promoted it, and emitted no credit-note type code at all. Confirm your environment is on a build that includes it before relying on self-billed credit notes for compliance.
For the regulatory background — when each shape is lawful, the four Art. 53(2) conditions, the deemed-supplier rule, and worked marketplace scenarios — see the intermediary-platform invoicing study.
Preview totals with Calculate¶
Calculate returns the fully computed document — per-line and document totals, taxes, multi-currency restatements — without persisting anything. Send the same changes you would on Create.
curl -X POST https://gateway.invora.app/api/v1/simple/invoices:calculate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"changes": {
"currencyCode": "SAR",
"lines": [
{
"description": "Consulting services",
"quantity": { "value": { "units": 10, "nanos": 0 }, "unitCode": "HUR" },
"unitPrice": { "units": 100, "nanos": 0 },
"tax": { "taxRate": { "units": 15, "nanos": 0 } }
}
]
}
}'
{
"details": {
"calculations": {
"lineExtensionTotal": { "units": 1000, "nanos": 0 },
"taxTotal": { "units": 150, "nanos": 0 },
"payableAmount": { "units": 1150, "nanos": 0 }
}
}
}
Edit a draft¶
curl -X PUT https://gateway.invora.app/api/v1/simple/invoices/siv_01J7... \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"concurrencyStamp": "8f2c1a44",
"changes": { "currencyCode": "SAR", "lines": [ ] }
}'
concurrencyStamp is returned by every read; passing it back lets the platform reject conflicting concurrent edits (ABORTED / HTTP 409). Frozen invoices reject Update. See Entity versioning.
Freeze a draft¶
curl -X POST https://gateway.invora.app/api/v1/simple/invoices/siv_01J7.../freeze \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "concurrencyStamp": "8f2c1a44" }'
Freeze runs the regulation pipeline and returns the QR code, the signed artifact reference, and the authority status. After this call the invoice is immutable.
Artifacts: QR code and signed XML¶
A frozen document carries a list of regulation artifacts (QR-code image, signed XML, clearance response) under regulation.artifacts. Download one by its id:
curl -X GET https://gateway.invora.app/api/v1/simple/invoices/siv_01J7.../artifacts/signed-xml \
-H "Authorization: Bearer $TOKEN"
{
"bytes": "PD94bWwgdmVyc2lvbj0i...",
"contentType": "application/xml",
"sha256": "3a7bd3e2...42dd4f1b"
}
bytes is base64-encoded; sha256 matches the hash advertised for the artifact in regulation.artifacts. Artifacts are produced only after a successful freeze.
List with filters¶
curl -X POST https://gateway.invora.app/api/v1/simple/invoices/list \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"textSearch": "Acme",
"part": { "onlyFrozen": true }
},
"sort": { "rules": [ { "issueAt": "SORT_DIRECTION_DESCENDING" } ] },
"pagination": { "limit": "20" }
}'
{
"items": [
{ "key": "siv_01J7...", "frozen": true, "payableAmount": { "units": 1150, "nanos": 0 } }
],
"totalCount": "1",
"nextPageCursor": null
}
List returns lightweight summaries (no line detail). Filter part is a single condition built from one of key, issueAt, dueDate, branchId, buyerPartyKey, currencyCode, onlyFrozen, onlyDrafts; combine several with nested and / or / not. See List & filtering for the full conventions.
Supporting tenant services¶
You will use these alongside the Simple surface:
| Service | Base path | Purpose |
|---|---|---|
| Parties | /api/v2/parties |
Stored customers/suppliers referenced by buyer.partyKey |
| Branches | /api/v2/branches |
Multi-branch issuing with distinct tax registrations (branchId) |
| Settings | /api/v2/settings |
Tenant invoicing configuration and self-party |
/api/v2/pdf |
Branded PDF rendering | |
| Regulations | /api/v2/regulations |
Enrollment, status, and ZATCA onboarding |
Related¶
- Quickstart — registration to a frozen invoice, end to end.
- Documents API (v2) — the full UBL surface.
- gRPC & JSON transcoding — decimals, dates, enums, camelCase.
- List & filtering — filters, sorting, pagination.
- Entity versioning — optimistic concurrency with
concurrencyStamp. - ZATCA integration — onboarding and submission.
- Intermediary-platform invoicing study — the regulatory background for self-billed and third-party issuance.
- Error handling — status codes and error details.