Skip to main content

The invoice claim flow

InvoiceLoad is different from every other Load: invoices in Nuntiq are produced by the document-processing pipeline (email intake, manual upload, API import), not created by connectors. Connectors consume them via a claim-based workflow.

from lib.objects.invoice import InvoiceLoad
load = InvoiceLoad(context)

The claim flow runs under the push_invoice entrypoint key. Its job token may claim, acknowledge and release invoices, report results and post lifecycle messages.

push_invoice has two callers. The scheduler runs it in batch mode: claim, then the steps below. The process engine runs it for one invoice: the invoice token is in job_params['invoice_token'], the invoice waits at 94 (PendingConnectorIntegration), and you post it and report load.integration_result(token, ...) with no claim, no acknowledge and no lease.

The five steps​

1. claim() ← leases N invoices to you (90/91 → 92)
2. get_by_token() ← read full detail for each
3. push to ERP ← your code
4. acknowledge(lease) ← "the ERP has it" (92 → 93)
5. integration_result(lease, ...) ← final "did it post?" (→ 100 success | 97 failed)

release(lease) gives a claimed invoice back instead (status 91, offered again).

Leases​

A claim leases each invoice to you for lease_ttl_seconds. The lease has a lease_token. While the invoice is claimed (status 92) or acknowledged (93), acknowledge, release and integration_result must carry that lease token. Pass the InvoiceLease object and the framework sends it for you.

  • Without a lease token the call is refused: 409 lease_token_required.
  • A lease that ran out still works for you until someone else claims the invoice. A claimed invoice (92) whose lease ran out is offered again: the next claim can take it, and it comes back with reoffered = True.
  • When someone else has claimed it since, your lease token no longer matches: 409 lease_mismatch. Do not post the invoice: another worker owns it now.

So a worker that crashes after the claim never strands an invoice: once the lease runs out, the invoice goes to the next claim.

Acknowledging ends the lease's time limit. An acknowledged invoice (93) is never offered again; it waits for its result, which needs the lease token. Keep the lease token until you have reported the result (store it next to your ERP document if a result can come in a later run).

1. claim()​

leases = load.claim(
state='PendingIntegration', # the only state a claim takes
limit=10, # 1..50
lease_ttl_seconds=300, # 60..1800
idempotency_key=None,
ext_reference_1=None, # optional filter, case-insensitive pattern (% wildcards)
# ext_reference_2..5=None,
)

Returns a list of InvoiceLease objects:

  • lease.invoice_token: the invoice
  • lease.lease_token: send it with every later call for this invoice (the framework does when you pass the lease)
  • lease.lease_expires_at: when the lease runs out
  • lease.reoffered: True when an earlier claim's lease had run out on this invoice. Check your ERP for the document before posting it again.

A claim takes invoices at 90/91 and claimed invoices whose lease ran out. claim() is atomic: two workers claiming at the same time never get the same invoice.

Idempotency: with an idempotency_key, a retry of the claim returns your live leases for that key first (with their time extended), then takes new invoices up to limit. Safe to retry after a network error. Without a key a claim takes only free invoices, so two workers that share an API key never get each other's leases.

2. get_by_token()​

invoice = load.get_by_token(lease.invoice_token)

Returns a read-only Invoice with the full graph: header fields, lines, addresses, taxes, charges, discounts, deductions, attachments, AI matches and the origin email, in one call.

AttributeTypeNotes
invoice.invoice_tokenstr (UUID)The public handle
invoice.statusstrPendingIntegration, PendingResult, ...
invoice.status_info{code, key, group, label, description}Numeric and named status
invoice.fieldslist[InvoiceField]Header fields, template-driven
invoice.lineslist[InvoiceLine]Each line has its own .fields
invoice.addresseslist[InvoiceAddress]Up to 4: vendor, remit_to, ship_to, bill_to
invoice.taxeslist[InvoiceTax]Tax breakdown rows
invoice.chargeslist[InvoiceCharge]Fees, surcharges
invoice.discountslist[InvoiceDiscount]Discount terms
invoice.deductionslist[InvoiceDeduction]Retainage, withholding tax, deposits: subtracted after the total
invoice.amount_duenumber or NoneGross minus deductions: what is left to pay
invoice.attachmentslist[InvoiceAttachment]PDFs, source email, etc.
invoice.ai_matcheslist[InvoiceAIMatch]AI-suggested PO matches
invoice.email`InvoiceEmailNone`

Reading fields​

Header and line fields are template-driven: the keys come from the customer's invoice template configuration and are typically camelCase. The defaults shipped by Nuntiq use keys like invoiceNumber, invoiceDate, grossAmount, taxAmount, netAmount, currencyCode, poNumber. Customer templates can override or extend these, so the keys your code calls get_field() with must match what the customer's template emits.

inv_number = invoice.get_field('invoiceNumber')
gross = invoice.get_field('grossAmount')
currency = invoice.get_field('currencyCode')
po_number = invoice.get_field('poNumber')

for line in invoice.lines:
qty = line.get_field('quantity')
price = line.get_field('unitPrice')
amt = line.get_field('netAmount')

vendor = invoice.get_address('vendor')
if vendor:
print(vendor.address_name, vendor.street, vendor.city)

Don't hardcode field keys if you can avoid it. Iterate invoice.fields if you need everything, or check the customer's template configuration before writing the connector.

3. Push to your target​

Whatever that looks like: REST POST, SOAP envelope, SFTP file, EDI message. See Integration patterns for end-to-end worked examples.

If the claim came back with lease.reoffered = True, an earlier run may have posted the invoice already. Look the document up in the target system (by invoice number and supplier) before creating it.

4. acknowledge()​

load.acknowledge(lease)

Moves the invoice from 92 (PendingAcknowledgement) to 93 (PendingResult). Calling it twice is safe (the second call answers unchanged). Acknowledge as soon as the target system confirmed receipt, even before you have a final outcome. From then on the invoice is never offered to another claim, so a crash before the result cannot lead to a second posting.

5. integration_result()​

Success:

load.integration_result(
lease,
success=True,
external_id_1='ERP-INV-12345', # the ERP-side invoice id
external_message_1='POSTED', # short status string
# external_id_2/3 + external_message_2/3 are also available
)

Moves the invoice to 100 (Processed) and clears the lease.

Failure:

load.integration_result(
lease,
success=False,
failure_code='LINE_MISMATCH', # business-level code, your taxonomy
failure_message='Line 3 did not match any PO line',
)

Moves the invoice to 97 (IntegrationFailed). failure_code and failure_message appear in the Nuntiq admin UI for whoever investigates.

A result is accepted at 92 and 93 (with the lease), and on invoices that were never claimed. A failed invoice can be rescued later with success=True. If the invoice is already Processed and you send success=False, the call is refused with 409 already_processed unless you pass force_override=True.

Release​

load.release(lease, reason='ERP closed for month-end')

Gives a claimed (92) or acknowledged (93) invoice back: status 91, lease cleared, offered to the next claim. Use it when you claimed an invoice you cannot handle now.

Many invoices at once​

acknowledge_many(), release_many() and integration_results() send up to 100 invoices per request (the framework splits longer lists). Each invoice is handled on its own and gets its own result.

leases = load.claim(limit=50)
# ... post them ...
load.acknowledge_many(leases)

results = load.integration_results([
{'lease': lease, 'success': True, 'external_id_1': erp_ids[lease.invoice_token]}
for lease in leases
])
for r in results:
if r['status'] in ('rejected', 'not_found'):
context.logger.warn(f"{r.get('invoice_token')}: {r.get('errors')}")

Each result has index, invoice_token, status (acknowledged, released, updated, unchanged, not_found or rejected), previous_status, new_status and, when rejected, errors. The bulk methods return the results and do not raise for rejected invoices; check them.

Listing without claiming​

for record in load.list_all(state='PendingIntegration'):
print(record['invoice_token'], record['status'], record['updated_at'])

# One page at a time
page = load.list(state='PendingResult', page_size=200)
page = load.list(state='PendingResult', page_size=200, cursor=page['next_cursor'])

# Invoices a result stored an ERP id on, in any state
for record in load.find_by_external_id(external_id_1='ERP-INV-12345'):
print(record['invoice_token'], record['status_group'])

list() and list_all() don't change any state. States: PendingIntegration (what a claim can take), PendingAcknowledgement (92), PendingResult (93) and PendingConnectorIntegration (94: invoices the process engine handed to a connector job; for housekeeping, never for claiming). Each record has invoice_token, status, status_group, updated_at and external_id_1..3. Paging uses a cursor ordered by (updated_at, id); pass updated_since= to read only what changed. ext_reference_1..5= filter with a case-insensitive pattern.

Cross-invoice lifecycle polling​

Separate from the claim flow. Use this when you want to react to changes on invoices over time, not push them outward.

for msg in load.lifecycle_messages_since(
since='2026-04-25T00:00:00Z', # ISO-8601, UTC
ext_references={1: 'CUSTOMER-A'}, # at least one required
):
context.logger.info(f"{msg['recorded_at']} {msg['code']} {msg['invoice_token']}")

since is inclusive. For the next poll, start from the recorded_at of the last message you processed and skip the message ids you already handled. Filtering is case-insensitive equality on ext_reference_1..5, with no wildcards. See Lifecycle messages.

For a full incremental cursor with stuck-run handling, wrap this in delta_run.

What's next​