Lifecycle messages
Lifecycle messages are the timeline of events on an invoice. Some are emitted
by Nuntiq itself (RECEIVED, VALIDATION_FAILED, READY), others come from
external systems (your ERP push, your supplier portal, your AP automation)
via the Customer API and surface in the Nuntiq admin UI and supplier
portal as a single chronological feed.
Use the LifecycleMessageLoad
class as the entry point — it wraps all four endpoints (read per-invoice,
poll cross-invoice, write by token, write by reference) into one
ergonomic API with input validation:
from lib.objects.lifecycle_message import LifecycleMessageLoad
load = LifecycleMessageLoad(context)
# Write
msg = load.new()
msg.code = 'ACCEPTED'
msg.reference_type = 'PAYMENT_REF'
msg.reference_value = 'ERP-INV-12345'
msg.note_supplier = 'Posted to AP.'
msg.post_to(invoice_token='7f3a...', idempotency_key='erp-accept-12345')
# Read — one invoice
for m in load.get_for_invoice('7f3a...'):
print(m.recorded_at, m.code)
# Read — cross-invoice delta
for m in load.poll_since(since='2026-04-25T08:00:00Z',
ext_references={1: 'CUSTOMER-A'}):
print(m.invoice_token, m.code)
The underlying context.api.post_invoice_lifecycle_message* /
get_invoice_lifecycle_messages* helpers are still available as escape
hatches; the Load class is the recommended path.
Posting after an ERP success
The most common case: your push-to-ERP connector got back a 2xx from the ERP and wants Nuntiq to know the invoice is now sitting in the AP queue.
from lib.objects.invoice import InvoiceLoad
from lib.objects.lifecycle_message import LifecycleMessageLoad
def run(context):
inv_load = InvoiceLoad(context)
lcm_load = LifecycleMessageLoad(context)
leases = inv_load.claim(state='PendingIntegration', limit=10)
for lease in leases:
invoice = inv_load.get_by_token(lease.invoice_token)
erp_id = push_to_erp(invoice)
# Mark the result on the invoice itself (status machine)
inv_load.integration_result(
lease.invoice_token,
success=True,
external_id_1=erp_id,
external_message_1='POSTED',
)
# Write a lifecycle event so the timeline reflects what just happened
msg = lcm_load.new()
msg.code = 'ACCEPTED' # see code catalog below
msg.reference_type = 'PAYMENT_REF'
msg.reference_value = erp_id
msg.note_supplier = 'Invoice has been posted to AP queue and is awaiting payment.'
msg.post_to(
invoice_token=lease.invoice_token,
idempotency_key=f'erp-accept-{erp_id}',
)
integration_result() advances the invoice's numeric status (100 = Processed,
97 = IntegrationFailed). Lifecycle messages are richer — they carry an event
code, an optional note for the supplier, an optional internal note, and a
reference to the external system's id.
Posting an ERP rejection
Same idea, the other direction. When the ERP rejects an invoice, the connector should record both the failed integration result AND a lifecycle message explaining the reason — so the AP operator opening the invoice in Nuntiq sees the same context the ERP returned.
try:
erp_id = push_to_erp(invoice)
# success path...
except ErpRejection as e:
inv_load.integration_result(
lease.invoice_token,
success=False,
failure_code='ERP_REJECTED',
failure_message=str(e)[:500],
)
msg = lcm_load.new()
msg.code = 'UNDER_QUERY'
msg.clarification_code = e.error_code
msg.note_internal = f'ERP returned {e.error_code}: {e.message}'
msg.note_supplier = 'We need clarification on this invoice — please contact AP.'
msg.post_to(
invoice_token=lease.invoice_token,
idempotency_key=f'erp-reject-{invoice.invoice_token}',
)
Posting by reference (no invoice token needed)
When a downstream system (your AP automation, a treasury platform) wants
to write a message but only knows the invoice by its invoice_number and
supplier_code (the keys used in the source system), use the by-reference
endpoint:
msg = lcm_load.new()
msg.code = 'PAID'
msg.reference_type = 'PAYMENT_REF'
msg.reference_value = 'PMT-77-3'
msg.note_supplier = 'Payment sent.'
msg.post_by_reference(
invoice_number='INV-2026-0042',
supplier_code='SUP-100',
supplier_location_code='MAIN', # optional, narrows the match
erp_company_code='EMEA', # optional
idempotency_key='pmt-77-3-paid',
)
The (old) raw helper form is still equivalent — useful when you want to build the dict outside the SDK helpers:
context.api.post_invoice_lifecycle_message_by_reference(
{
'invoice_number': 'INV-2026-0042',
'supplier_code': 'SUP-100',
'supplier_location_code': 'MAIN', # optional, narrows the match
'erp_company_code': 'EMEA', # optional
'code': 'PAID',
'reference_type': 'PAYMENT_REF',
'reference_value': 'PMT-77-3',
'note_supplier': 'Payment sent.',
},
idempotency_key='pmt-77-3-paid',
)
If the (invoice_number, supplier_code) pair matches more than one invoice,
add supplier_location_code and/or erp_company_code until you narrow to
one. Nuntiq returns 400 if the lookup is ambiguous.
Idempotency
Pass an idempotency_key whenever the lifecycle event corresponds to an
external operation that has its own id (a payment run, an ERP posting, an
approval). On retry — same key, same body — Nuntiq returns the original
message instead of inserting a duplicate.
The key is scoped per invoice + per API user. Reuse a stable id from the upstream system; don't generate a fresh UUID on every call (that defeats the point).
What goes in note_internal vs note_supplier
| Field | Visible to | Use for |
|---|---|---|
note_internal | AP team in the Nuntiq admin portal only | Raw ERP error text, debug context, internal triage notes |
note_supplier | The supplier in the supplier portal AND the AP team | Human-friendly explanation, action requested ("please resend with VAT"), confirmation ("invoice accepted, expected payment 30 days") |
Both are optional, both cap at 4000 characters. Per-customer config can
require either or both for specific codes — if your call fails with
NOTE_REQUIRED, fill in what was missing and retry.
Code catalog
All 28 lifecycle codes are externally writable as of 2026-05-17 — any
code in the catalog can be POSTed via the Customer API (or via
LifecycleMessageLoad.new().post_to()), subject to the state-machine
ordering rules.
The full code-by-code reference — including tier, label, description, default portal/email behavior, and the state-transition graph — lives on its own page: Lifecycle code catalog.
State transition rules
Nuntiq enforces a state machine — you can't post PAID before the invoice
has reached ACCEPTED, for example. Two helpful properties of the engine:
- Compensating events.
APPROVAL_REVOKED,PAYMENT_RUN_CANCELLED, andPAYMENT_REVERSEDcan each be posted to undo a prior state and put the invoice back into an earlier one. They're how you handle real-world reversals. - Hard terminal states.
REJECTEDandCANCELLEDare final. Once posted, the API rejects every subsequent message with 409TERMINAL_STATE. Make sure you actually mean it before posting these.
If a transition is invalid, the response is:
{
"error": "LIFECYCLE_TRANSITION_INVALID",
"message": "...",
"current_latest_code": "IN_APPROVAL",
"valid_next_codes": ["APPROVED", "REJECTED", "UNDER_QUERY", "ON_HOLD"]
}
Read valid_next_codes to discover what's actually allowed next. The tenant
can also be configured for lenient transition mode, in which case any
transition is accepted — defer to the customer's strictness setting rather
than hard-coding what you think is correct.
Common pitfalls
| Symptom | Cause |
|---|---|
400 INVALID_CODE "Unknown lifecycle code: ..." | Typo in the code string. Check against LIFECYCLE_CODES or the code catalog. |
400 NOTE_REQUIRED | The customer requires a note (internal or supplier) for this code. Fill it in. |
400 CLARIFICATION_CODE_REQUIRED | Same idea — tenant config requires clarification_code on this LCM code. |
422 INVOICE_NOT_READY | You're trying to post before intake finished (invoice status < 90). Wait for READY before pushing external events. |
409 LIFECYCLE_TRANSITION_INVALID | State-machine refused the transition. Read valid_next_codes. |
409 TERMINAL_STATE | The invoice is at REJECTED or CANCELLED. No further messages possible. |
Got a 200 with idempotent: true and an old timestamp | You posted with an idempotency_key that already exists. The server returned the original message; your write was deduped. |
What's next
- Invoice claim flow — the read side (polling lifecycle deltas across invoices).
- ApiClient — for direct
context.api.post(...)calls when you need fields the helper methods don't expose.