Skip to main content

context.api: the API client

The framework gives you an authenticated HTTP client to the Nuntiq customer API as context.api. Two ways to use it.

Two styles, same backend​

1. Typed entity Loads: preferred for master data​

from lib.objects.supplier import SupplierLoad

load = SupplierLoad(context)
for s in load.get_all():
print(s.supplier_number, s.supplier_name)

new_supplier = load.new()
new_supplier.supplier_number = 'SUP-100'
new_supplier.supplier_name = 'Acme Corp'
load.save_all()

You get cursor paging, batches of 1000, per-record results, sync runs for full loads and field accessors. See BaseLoad for the full pattern and the entity reference for per-object field lists.

2. Low-level: for endpoints without a Load wrapper, or one-off calls​

# GET: list endpoints page with a cursor
resp = context.api.get('/v1/supplier', params={'limit': 100, 'organization_code': 'EMEA'})
# {"data": [...], "next_cursor": "eyJ0Ijoi..." | null}
resp = context.api.get('/v1/supplier', params={'limit': 100, 'cursor': resp['next_cursor']})

# POST with a JSON body
context.api.post('/v1/invoices/7f3a.../lifecycle-messages', data={
'code': 'ACCEPTED',
'note_supplier': 'Posted to AP.',
})

# POST / PATCH / DELETE on an engine resource: per-record results, also on 207 and 422
resp = context.api.write('PATCH', '/v1/supplier',
data=[{'source_record_id': 'V-100', 'is_active': False}])
# {"results": [{"index": 0, "status": "updated", "id": "...", "changed": ["is_active"]}],
# "summary": {"updated": 1}}

Every method returns the parsed JSON response, raises ApiError on 4xx/5xx and adds the auth headers. write() is the exception for engine writes: when the API answers 404 or 422 with per-record results (every record rejected or not found, or an all_or_none rollback), it returns that body instead of raising, so you can read each record's result.

What's already authenticated​

The framework constructs the client with:

  • Authorization: Bearer <job_token>: your short-lived job token, scoped to this run, this customer and the operations of the entrypoint.
  • X-Customer-Number: your customer_number, derived from the connector instance.
  • Content-Type: application/json on calls that carry a body.

You don't manage any of these. The token expires when the job ends.

Methods on context.api​

Generic:

MethodWhen to use
get(path, params=None, extra_headers=None)Any GET. Returns parsed JSON.
post(path, data=None, extra_headers=None)POST with a JSON body.
put(path, data=None, extra_headers=None)PUT with a JSON body.
patch(path, data=None, params=None, extra_headers=None)PATCH with a JSON body.
delete(path, data=None, params=None, extra_headers=None)DELETE. Engine resources take a JSON array of keys as the body.
write(method, path, data=None, params=None, extra_headers=None)POST / PATCH / DELETE on an engine resource; returns the per-record results also on 404 and 422. Sends an Idempotency-Key and retries (see below).

Master data (raw helpers; the Load classes wrap the same calls):

MethodEndpoint
get_suppliers(params), get_supplier(id), create_supplier(data), update_supplier(id, data), delete_suppliers(keys, source_system=None)/v1/supplier
get_organizations(params), get_organization(id), create_organization(data), update_organization(id, data), delete_organizations(keys, source_system=None)/v1/organization
get_purchase_orders(params), get_purchase_order(id), create_purchase_order(data), update_purchase_order(id, data), delete_purchase_orders(keys, source_system=None)/v1/purchaseorder
get_purchase_order_lines(params), get_purchase_order_line(id), upsert_purchase_order_lines(data), delete_purchase_order_lines(keys, source_system=None)/v1/purchaseorderline
get_receipts(params), get_receipt(id), create_receipt(data), update_receipt(id, data), delete_receipts(keys, source_system=None)/v1/receipt
get_custom_data(table, params), upsert_custom_data(table, data), delete_custom_data(table, keys, source_system=None)/v1/customerdatatable/{table}

create_* / upsert_* POST full records (always a list, up to 1000 records), update_* PATCHes one record by id (only the sent fields change) and delete_* soft-deletes by key ([{'id': ...}], the source key or the natural key). The delete_* helpers go through write(). The create_*, upsert_* and update_* helpers use post() / patch(): they carry an Idempotency-Key and retry like every write, but a 422 raises ApiError instead of returning the per-record results.

Invoices and payments:

MethodWhat it does
get_invoices(params)GET /v1/invoices: invoice tokens by state (lowerCamel: pendingIntegration, ...), cursor paging
get_pending_connector_invoices(params)The same list for state pendingConnectorIntegration (status 94)
get_invoice(token)Full invoice detail
claim_invoices(data)POST /v1/invoices/claim
acknowledge_invoice(token, lease_token=None)Acknowledge (92 to 93)
release_invoice(token, lease_token=None, reason=None)Give a claimed invoice back (status 91)
integration_result(token, data)Report the result; data carries success, the external ids and lease_token
download_attachment(attachment_token)An attachment's raw bytes
upsert_payment(data), get_payment_by_external_id(source, id), get_payments_by_invoice(token)Payments
get_fx_rates(params), post_fx_rates(rates), delete_fx_rates(params), get_effective_fx_rates(params)Exchange rates

Lifecycle messages:

MethodWhat it does
get_invoice_lifecycle_messages(token, tier=None, include_internal_notes=False)One invoice's timeline
post_invoice_lifecycle_message(token, data, idempotency_key=None)Post a message by invoice token
post_invoice_lifecycle_message_by_reference(data, idempotency_key=None)Post a message by invoice number + supplier code
get_invoice_lifecycle_messages_delta(since, tz, ext_references, ...)The cross-invoice feed, one page

Inbound documents:

MethodWhat it does
upload_files(process, files, receiving_inbox=None, company_code=None, supplier_code=None, external_identifier=None, upload_batch_id=None, allow_duplicate=False, check=True)Files with a role each: 'capture' (one source document: 'document' / 'attachment') or 'einvoice' (one batch: 'data' / 'attachment'); see below
get_source_document(id_or_token)Processing status of a source document, its files and invoices
get_inbound_uploads(upload_batch_id)Per-file status of an e-invoice batch
upload_source_documents(files, ...), upload_inbound_files(data_files, attachments=None, ...)Older helpers; call upload_files() with roles taken from the file types
upload_invoice_attachments(files)Files for a later invoice_import(); returns document tokens
invoice_import(data)POST /v1/invoice-import: a structured invoice
create_upload(filename, size, content_type=None), put_presigned(upload, content)A presigned upload for one large file (up to 500 MB)
post_invoice(data), get_receiving_inboxes(include_inactive=False)Single-document invoice POST; the tenant's receiving inboxes

upload_files() and upload_invoice_attachments() switch to presigned uploads by themselves when a file is over 50 MB.

upload_files()​

# a PDF to capture, its XML kept as an attachment: one source document
context.api.upload_files('capture', [('INV-1001.pdf', pdf, 'document'), ('INV-1001.xml', xml, 'attachment')])

# an e-invoice with the PDF it references: one batch
r = context.api.upload_files('einvoice', [('INV-1002.xml', xml, 'data'), ('INV-1002.pdf', pdf, 'attachment')])
r['data']['upload_batch_id']
ArgumentMeaning
process'capture': POST /v1/source-document, OCR and capture (job scope write_sourcedocument). 'einvoice': POST /v1/inbound-files, mapped by the customer's file formats (job scope write_invoice).
files(name, bytes, role) or (name, bytes, role, content_type). Capture roles: 'document' (a PDF or ZIP of PDFs, captured), 'attachment' (kept and attached to the invoices, never captured; a PDF too). E-invoice roles: 'data' (XML, EDI, CSV, JSON, TXT), 'attachment' (PDF, PNG, JPEG, TIFF).
receiving_inbox, external_identifierThe inbox the invoices arrive in (an address, or an alias of an inbox); else the default inbox.
company_code, supplier_codeCapture only: hints for the capture.
upload_batch_idE-invoice only: made by the framework when left out and sent with the call; pass your own to spread one batch over calls.
allow_duplicateTrue processes files this connector sent in the same roles within 24 hours again.
checkTrue: read the bytes as the API will and raise ValueError naming a file that does not fit its role before sending. False: leave it to the API (ApiError 415 / 422).

A missing or unknown role always raises ValueError before any call. Returns the API answer: for capture data.sourceDocumentId, pdfFilesQueued, attachmentsStored; for e-invoice data.upload_batch_id, files (per file inbound_upload_id, file_kind, duplicate) and data_files_enqueued. The three typical cases are on Sending files.

classify_file()​

from lib.file_types import classify_file: what a file is, read from its bytes with the API's rules: category (capture, inbound_data, attachment, unsupported), kind (pdf, xlsx, msg, ...) and reason. Optional; upload_files() runs the same check itself.

Delta state and the job:

MethodWhat it does
delta_get_state, delta_begin, delta_complete, delta_fail, delta_resetThe delta-state service; use delta_run() (see Delta state)
submit_result(data), submit_log_entries(entries)Job result and structured log entries (the framework calls these)

The full path list is in the Customer API reference, with request and response schemas for every endpoint.

Idempotency-Key​

Every POST, PATCH and DELETE accepts an Idempotency-Key header. The same key with the same request within 24 hours replays the first answer (header Idempotent-Replayed: true) instead of doing the work again. The same key with a different request (another path or body) is refused with 422 idempotency_key_reused; a retry while the first request still runs gets 409 idempotency_in_progress. Keys are per API user.

Pass one through extra_headers on a raw call:

context.api.post('/v1/invoice-import', data=body,
extra_headers={'Idempotency-Key': f'import-{source_id}'})

Every POST, PATCH, PUT and DELETE the client sends carries one by itself: a new key per call, unless you pass your own in extra_headers. That covers the Load classes, the invoice calls (claim, acknowledge, release, results, single and bulk), lifecycle messages, payments, exchange rates and uploads.

A write is retried on a connection error, a 502, 503 or 504, and a 409 idempotency_in_progress, up to 3 times (after 1, 3 and 9 seconds) with the same key. A write the API already applied is answered from the replay instead of being applied twice. get() does not retry.

Error handling​

ExceptionModuleWhen it fires
ApiErrorlib.api_clientThe server returned a non-2xx response, or the call could not reach the server.
RecordsRejectedlib.objects.exceptions (also lib.objects)save_all(), patch_all() or delete_keys() sent everything, and the API rejected (or did not find) some records.
ValidationErrorlib.objects.exceptionsA Load rejected your object before the HTTP call: a required field missing, a wrong type.
NotFoundErrorlib.objects.exceptionsReserved for lookups that find nothing.
AuthErrorlib.objects.exceptionsReserved for SDK-detected auth failures. Auth problems surface as ApiError(401).

RecordsRejected, ValidationError, NotFoundError and AuthError inherit from ConnectFxError; ApiError from Exception. No single class covers both families.

ApiError​

Raised by every method on context.api when the response status is 4xx/5xx, or when the request can't reach the server. Two attributes:

AttributeTypeMeaning
e.status_codeintHTTP status, or 0 when the request couldn't reach the server
e.response_bodystrRaw response body as decoded UTF-8

str(e) gives "API error {status}: {message}" for logging.

The body of an error is a problem document (application/problem+json):

{
"type": "https://developers.nuntiq.com/errors/lease_mismatch",
"title": "Conflict",
"status": 409,
"code": "lease_mismatch",
"detail": "another claim holds this invoice (your lease ran out and the invoice was claimed again)",
"request_id": "6f1c…",
"errors": []
}

Branch on code, show detail, and quote request_id to support (it is also in the X-Request-Id response header). errors lists field errors as {path, code, message}. On 5xx the detail is generic.

import json
from lib.api_client import ApiError

try:
resp = context.api.post('/v1/invoices/claim', data={'state': 'PendingIntegration', 'limit': 10})
except ApiError as e:
if e.status_code == 0:
# Couldn't reach Nuntiq at all (DNS, TCP, TLS). Probably transient.
raise
try:
problem = json.loads(e.response_body or '{}')
except ValueError:
problem = {}
context.logger.error(
f"claim failed: {e.status_code} {problem.get('code')}: {problem.get('detail')}",
detail={'request_id': problem.get('request_id'), 'errors': problem.get('errors')},
)
raise

RecordsRejected​

Raised after every batch was sent. The accepted records are saved; e.results holds the rejected ones, each with index, status (rejected, not_found or not_applied), errors and record (the payload that was sent).

from lib.objects.exceptions import RecordsRejected

try:
load.save_all()
except RecordsRejected as e:
for r in e.results:
context.logger.warn(f"not saved: {r.get('errors')}", detail={'record': r['record']})

ValidationError​

Raised by save_all() and obj.save() when an object is missing a required field or has a wrong type. Carries a field_errors dict that maps field names to messages.

from lib.objects.exceptions import ValidationError

load = SupplierLoad(context)
for row in external_rows:
s = load.new()
s.supplier_number = row['number']
# NOTE: forgot to set supplier_name

try:
load.save_all()
except ValidationError as e:
context.logger.error(f"Batch not sent: {e}", detail={'field_errors': e.field_errors})
# field_errors looks like {'supplier_name': 'Required field is missing'}
raise

These checks run before the first request, so nothing is sent when one object fails them.

Retry vs. re-raise: quick rules​

  • status_code == 0: reach failure, retry-safe. write() already retried it three times.
  • 409 idempotency_in_progress: the first request is still running; retry later.
  • 4xx otherwise, except 429: client-side problem, do not retry the same payload.
  • 429 and 503: back off; the response carries Retry-After.
  • 5xx: server-side, usually transient, safe to retry. Send an Idempotency-Key on writes you retry.

If you wrap your run in delta_run(), an unhandled ApiError fails the delta cursor cleanly: the next scheduled run starts from the same lower bound and retries the window.

When to drop to low-level​

  • The endpoint has no Load (delta state, receiving inboxes, source-document status).
  • You need your own stable Idempotency-Key across runs (the Loads make a new key per call).
  • You want raw JSON instead of typed objects (debugging, ad-hoc scripts).

Otherwise, prefer the Load classes. They batch, they page, they handle sync runs, and your code reads like domain logic instead of HTTP plumbing.

What's next​

  • BaseLoad: read and write patterns shared by every entity.
  • Logging: how to surface what your client calls did.