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.

Reading lifecycle messages needs only invoice read access. Posting them needs write_lifecycle, which the job tokens of push_invoice, get_invoice_status and get_payment carry. Other entrypoint keys get a 403 on a post.

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,
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,
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). The framework sends it as the Idempotency-Key header.

  • Within 24 hours, the same key with the same request replays the first answer (header Idempotent-Replayed: true); nothing is written twice.
  • After that, the key still dedupes per invoice: a message with a key the invoice already has returns the original message (200) instead of inserting a duplicate.
  • A replay is answered before the state machine runs, so retrying a message that already moved the invoice on is never refused as an invalid transition.
  • Keys are per API user. Within 24 hours the same key with a different request (another invoice, another body) is refused with 422 idempotency_key_reused. Make the key unique per invoice and event, for example f'erp-accept-{erp_id}'.

Reuse a stable id from the upstream system; don't generate a fresh UUID on every call (that defeats the point).

Polling the feed​

LifecycleMessageLoad.poll_since() (and InvoiceLoad.lifecycle_messages_since()) read the cross-invoice feed: the messages recorded since a moment, on invoices whose ext_reference_1..5 match. Both follow next_cursor until the last page.

  • Pass since as UTC with a Z or offset (2026-04-25T08:00:00Z). A timestamp without one is read in tz.
  • Messages come in recorded_at order, then by id. The cursor between pages keeps the database's full precision, so messages recorded in the same millisecond are neither skipped nor repeated within one poll.
  • since is inclusive and recorded_at comes back in milliseconds. For the next poll, start from the recorded_at of the last message you processed and skip the message ids you already handled. Adding a millisecond instead can skip a message.

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 an old recorded_at (or header Idempotent-Replayed: true)You posted with an idempotency_key that already exists. The server returned the original message; your write was deduped.
422 idempotency_key_reusedYou sent the same idempotency_key with a different request within 24 hours. Use one key per invoice and event.

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.