Skip to main content

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

FieldVisible toUse for
note_internalAP team in the Nuntiq admin portal onlyRaw ERP error text, debug context, internal triage notes
note_supplierThe supplier in the supplier portal AND the AP teamHuman-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, and PAYMENT_REVERSED can 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. REJECTED and CANCELLED are final. Once posted, the API rejects every subsequent message with 409 TERMINAL_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

SymptomCause
400 INVALID_CODE "Unknown lifecycle code: ..."Typo in the code string. Check against LIFECYCLE_CODES or the code catalog.
400 NOTE_REQUIREDThe customer requires a note (internal or supplier) for this code. Fill it in.
400 CLARIFICATION_CODE_REQUIREDSame idea — tenant config requires clarification_code on this LCM code.
422 INVOICE_NOT_READYYou're trying to post before intake finished (invoice status < 90). Wait for READY before pushing external events.
409 LIFECYCLE_TRANSITION_INVALIDState-machine refused the transition. Read valid_next_codes.
409 TERMINAL_STATEThe invoice is at REJECTED or CANCELLED. No further messages possible.
Got a 200 with idempotent: true and an old timestampYou 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.