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: yourcustomer_number, derived from the connector instance.Content-Type: application/jsonon calls that carry a body.
You don't manage any of these. The token expires when the job ends.
Methods on context.api
Generic:
| Method | When 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):
| Method | Endpoint |
|---|---|
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:
| Method | What 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:
| Method | What 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:
| Method | What 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']
| Argument | Meaning |
|---|---|
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_identifier | The inbox the invoices arrive in (an address, or an alias of an inbox); else the default inbox. |
company_code, supplier_code | Capture only: hints for the capture. |
upload_batch_id | E-invoice only: made by the framework when left out and sent with the call; pass your own to spread one batch over calls. |
allow_duplicate | True processes files this connector sent in the same roles within 24 hours again. |
check | True: 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:
| Method | What it does |
|---|---|
delta_get_state, delta_begin, delta_complete, delta_fail, delta_reset | The 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
| Exception | Module | When it fires |
|---|---|---|
ApiError | lib.api_client | The server returned a non-2xx response, or the call could not reach the server. |
RecordsRejected | lib.objects.exceptions (also lib.objects) | save_all(), patch_all() or delete_keys() sent everything, and the API rejected (or did not find) some records. |
ValidationError | lib.objects.exceptions | A Load rejected your object before the HTTP call: a required field missing, a wrong type. |
NotFoundError | lib.objects.exceptions | Reserved for lookups that find nothing. |
AuthError | lib.objects.exceptions | Reserved 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:
| Attribute | Type | Meaning |
|---|---|---|
e.status_code | int | HTTP status, or 0 when the request couldn't reach the server |
e.response_body | str | Raw 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.4xxotherwise, except429: client-side problem, do not retry the same payload.429and503: back off; the response carriesRetry-After.5xx: server-side, usually transient, safe to retry. Send anIdempotency-Keyon 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-Keyacross 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.