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 invoicelease.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 outlease.reoffered:Truewhen 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.
| Attribute | Type | Notes |
|---|---|---|
invoice.invoice_token | str (UUID) | The public handle |
invoice.status | str | PendingIntegration, PendingResult, ... |
invoice.status_info | {code, key, group, label, description} | Numeric and named status |
invoice.fields | list[InvoiceField] | Header fields, template-driven |
invoice.lines | list[InvoiceLine] | Each line has its own .fields |
invoice.addresses | list[InvoiceAddress] | Up to 4: vendor, remit_to, ship_to, bill_to |
invoice.taxes | list[InvoiceTax] | Tax breakdown rows |
invoice.charges | list[InvoiceCharge] | Fees, surcharges |
invoice.discounts | list[InvoiceDiscount] | Discount terms |
invoice.deductions | list[InvoiceDeduction] | Retainage, withholding tax, deposits: subtracted after the total |
invoice.amount_due | number or None | Gross minus deductions: what is left to pay |
invoice.attachments | list[InvoiceAttachment] | PDFs, source email, etc. |
invoice.ai_matches | list[InvoiceAIMatch] | AI-suggested PO matches |
invoice.email | `InvoiceEmail | None` |
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
- SFTP push pattern: full worked example using the claim flow.
- BaseLoad: the read/write patterns that apply to *non-*invoice entities.
- Invoice reference: field by field.