Skip to content

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 } }
        }
      ]
    }
  }'
Response
{
  "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:

"buyer": { "partyKey": "party_01H..." }
"buyer": { "inline": { "legalName": "Acme Trading Co.", "address": { "...": "..." } } }

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 } }
        }
      ]
    }
  }'
Response
{
  "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"
Response
{
  "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" }
  }'
Response
{
  "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
PDF /api/v2/pdf Branded PDF rendering
Regulations /api/v2/regulations Enrollment, status, and ZATCA onboarding