Getting started with the AXS API
Submit a standard UAE tax invoice with a machine credential, interpret the response, and retrieve its current status. Run the commands in order in one Bash session. Use an agreed test environment and test participants for this exercise. The sample contains fictional trading details; replace all business and tax data before using it for a real transaction.
Before you start
Ask your AXS contact for the following handover. A token alone is insufficient.
- An onboarded tenant ID and a participant registered uniquely to that tenant.
For this standard invoice the issuing participant is the seller. For
self-billing it is the buyer;
SELLER_NOT_REGISTEREDremains the error code, withinvoice.buyer.endpointidentifying that case. - An ACTIVE machine client with purpose
erp-ingest, its client ID and its one-time secret, delivered through an approved secure channel. Store the secret in your secret manager. Do not put it in source control, tickets, screenshots, browser storage or logs. - The exact API base URL, token URL, expected issuer and API audience, plus the tenant and permitted scope. These are environment parameters, not values to infer from another deployment.
- The test seller/buyer TINs and TRNs, and confirmation that the test receiver is
appropriate. The
0235endpoint value is the 10-digit TIN, not the 15-digit VAT TRN. The TRN must agree with that party's TIN. - Bash, curl, jq and uuidgen. Download the
sample invoice and keep its absolute
filename. In a repository checkout this is
examples/ingest/valid-invoice.json.
Creation of tenants and participant onboarding are operator responsibilities. A tenant administrator manages machine clients in AXS eInvoice's API-client screen — Manage → API clients, which the Get credentials in AXS eInvoice button in this page's header opens directly when the portal address is configured for this deployment. An ERP credential cannot create, rotate or revoke other credentials. If provisioning is unavailable, AXS must complete the handover before you start; a stub-generated secret is not a usable Keycloak credential.
Environment and authentication
Set these non-secret parameters from your handover before running the code:
| Variable | Value to supply |
|---|---|
API_BASE_URL |
Tenant API URL, including any deployment base path, without trailing / |
TOKEN_URL |
Exact token endpoint: <issuer>/protocol/openid-connect/token; preserve any /auth prefix |
CLIENT_ID |
Your confidential machine client ID |
TENANT_ID |
The tenant bound to that client |
SELLER_TIN, SELLER_TRN |
Your registered test issuer's TIN and TRN |
BUYER_TIN, BUYER_TRN |
Agreed test receiver's TIN and TRN |
SAMPLE_FILE |
Absolute local path to the downloaded sample JSON |
Use HTTPS on deployed environments. Loopback HTTP is only for local testing.
The API verifies the JWT's RS256 signature, issuer, audience and expiry.
The client's audience mapper must include the API audience supplied by AXS
(the API's KEYCLOAK_CLIENT_ID), which need not equal your machine CLIENT_ID.
Sending an audience parameter yourself does not configure that mapper.
A machine token has axs_principal_kind: machine, azp: <CLIENT_ID>, a single
tenant: <TENANT_ID> and the space-separated scope axs:ingest. The four
submission/status routes below all require that scope and tenant
entitlement. Requesting a scope cannot grant one the client was not assigned.
x-tenant-id selects the tenant; it never grants access to another tenant.
GET /info is a public issuer/realm diagnostic, not a credential service or a
complete audience/provisioning handover.
The following requests client_credentials. The secret is read without echo;
it is sent to curl through standard input, not as a process argument. Tokens
and headers are held in a private temporary directory. Keep shell tracing off.
set -euo pipefail
set +x
: "${API_BASE_URL:?}" "${TOKEN_URL:?}" "${CLIENT_ID:?}" "${TENANT_ID:?}"
: "${SELLER_TIN:?}" "${SELLER_TRN:?}" "${BUYER_TIN:?}" "${BUYER_TRN:?}"
: "${SAMPLE_FILE:?}"
umask 077
WORK_DIR="$(mktemp -d)"
trap 'rm -f "$WORK_DIR/token.json" "$WORK_DIR/headers"' EXIT
printf 'Private working directory: %s\n' "$WORK_DIR"
cp "$SAMPLE_FILE" "$WORK_DIR/template.json"
if [ -z "${CLIENT_SECRET:-}" ]; then
read -r -s -p 'Machine client secret: ' CLIENT_SECRET
printf '\n'
fi
printf '%s' "$CLIENT_SECRET" | curl --silent --show-error --fail-with-body \
"$TOKEN_URL" -H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode 'scope=axs:ingest' \
--data-urlencode 'client_secret@-' > "$WORK_DIR/token.json"
unset CLIENT_SECRET
jq -er '.access_token | select(type == "string" and length > 0)' \
"$WORK_DIR/token.json" | while IFS= read -r token; do
printf 'Authorization: Bearer %s\nx-tenant-id: %s\n' "$token" "$TENANT_ID"
done > "$WORK_DIR/headers"
curl --silent --show-error --fail-with-body "$API_BASE_URL/me" \
-H @"$WORK_DIR/headers" > "$WORK_DIR/me.json"
jq -e --arg tenant "$TENANT_ID" \
'.kind == "machine" and .tenantId == $tenant and (.scopes | index("axs:ingest") != null)' \
"$WORK_DIR/me.json"
/me must return HTTP 200 and the assertion prints true. A token response
contains access_token, token_type and expires_in; do not print the token.
Cache it only for its lifetime, renew shortly before expiry, and use a fresh
client-credentials request when needed. This flow does not depend on a refresh
token. Rebuild the header file after renewal. On shell exit the credential files are
removed, while invoice and result files remain in the printed private directory.
Keep those files and IDs for retries/reconciliation, then remove the directory
when no longer needed. Do not rerun document preparation to retry an existing
transaction: reuse its saved JSON. Do not enable automatic POST retries with
a freshly generated ID.
First synchronous invoice
Prepare one document, then keep its ID, UUID and content unchanged for retries. A new transaction needs both a new invoice ID and UUID. The fixed identifiers and July dates in the downloaded template are replaced below. All SBDH and party endpoint values remain aligned.
DOC_UUID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
DOC_ID="AXS-FIRST-$DOC_UUID"
ISSUE_DATE="$(date -u +%F)"
jq --arg id "$DOC_ID" --arg uuid "$DOC_UUID" --arg day "$ISSUE_DATE" \
--arg seller "$SELLER_TIN" --arg sellerTrn "$SELLER_TRN" \
--arg buyer "$BUYER_TIN" --arg buyerTrn "$BUYER_TRN" \
'.invoice.id=$id | .invoice.uuid=$uuid | .invoice.issueDate=$day |
.invoice.paymentDueDate=$day |
.sbdh.sender=("0235:"+$seller) | .sbdh.receiver=("0235:"+$buyer) |
.invoice.seller.endpoint.value=$seller | .invoice.seller.taxRegistrationId=$sellerTrn |
.invoice.buyer.endpoint.value=$buyer | .invoice.buyer.taxRegistrationId=$buyerTrn' \
"$WORK_DIR/template.json" > "$WORK_DIR/invoice.json"
HTTP="$(curl --silent --show-error -o "$WORK_DIR/sync.json" -w '%{http_code}' \
"$API_BASE_URL/ingest" -H @"$WORK_DIR/headers" \
-H 'Content-Type: application/json' --data-binary @"$WORK_DIR/invoice.json")"
printf 'Sync HTTP %s\n' "$HTTP"
jq . "$WORK_DIR/sync.json"
case "$HTTP" in 200|201) ;; *) exit 1 ;; esac
INTEGRATION_ID="$(jq -er '.integrationId' "$WORK_DIR/sync.json")"
DOCUMENT_PATH="$(jq -rn --arg id "$INTEGRATION_ID" '$id|@uri')"
curl --silent --show-error --fail-with-body \
"$API_BASE_URL/documents/$DOCUMENT_PATH" -H @"$WORK_DIR/headers" \
> "$WORK_DIR/document.json"
jq . "$WORK_DIR/document.json"
A first admission returns 201; an identical replay returns 200 with
created: false. Example response (IDs vary):
{"integrationId":"AXS-FIRST-<uuid>","outcome":"partial","created":true,"routeClass":"normal","rulePackId":"draft-uae-route-class-2026-07@provisional"}
Admission validates the canonical envelope, structural and endpoint rules,
issuer registration, and the installed PINT-AE XSD/Schematron pack. It records
TRANSPORT/PENDING. 201 is not a delivery receipt. outcome describes the
currently recorded legs: partial while pending, failed if a leg failed,
compliant if every present leg succeeded. It can change later and does not
prove that every externally required receipt has arrived. rulePackId here is
the route-classification stamp, not the version of the PINT-AE validation pack.
The document response contains integrationId, legs, routeClass,
rulePackId, direction, header, dispatch and artifact. For example,
its initial legs array is:
[{"leg":"TRANSPORT","state":"PENDING","rank":0,"reopenEpoch":0,"appliedReopenIds":[]}]
Read each leg and the dispatch/receipt fields to follow progress. artifact.stored
means that an artifact is stored, not that the receiver accepted it. The detail
response does not currently contain an aggregate outcome field.
For an unknown document ID this endpoint currently returns 200 with empty
legs and null header/route, not 404. Do not interpret an empty result as success.
Asynchronous submission and polling
Use a second ID and UUID for this second transaction. The server returns 202 after accepting the job. Validation occurs in the worker. A parseable but invalid invoice can therefore receive 202. Malformed JSON and authentication errors can still be rejected on the HTTP request path.
ASYNC_UUID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
jq --arg id "AXS-ASYNC-$ASYNC_UUID" --arg uuid "$ASYNC_UUID" \
'.invoice.id=$id | .invoice.uuid=$uuid' "$WORK_DIR/invoice.json" \
> "$WORK_DIR/async-invoice.json"
HTTP="$(curl --silent --show-error -o "$WORK_DIR/accepted.json" -w '%{http_code}' \
"$API_BASE_URL/ingest/async" -H @"$WORK_DIR/headers" \
-H 'Content-Type: application/json' --data-binary @"$WORK_DIR/async-invoice.json")"
printf 'Async HTTP %s\n' "$HTTP"
jq . "$WORK_DIR/accepted.json"
[ "$HTTP" = 202 ]
MESSAGE_ID="$(jq -er '.messageId' "$WORK_DIR/accepted.json")"
STATUS_PATH="$(jq -er '.statusUrl' "$WORK_DIR/accepted.json")"
[ "$STATUS_PATH" = "/ingest/$MESSAGE_ID" ]
for attempt in $(seq 1 30); do
curl --silent --show-error --fail-with-body "$API_BASE_URL$STATUS_PATH" \
-H @"$WORK_DIR/headers" > "$WORK_DIR/job.json"
STATUS="$(jq -er '.status' "$WORK_DIR/job.json")"
case "$STATUS" in
done|invalid|failed) break ;;
queued|processing) sleep 2 ;;
*) printf 'Unexpected status: %s\n' "$STATUS"; exit 1 ;;
esac
done
jq . "$WORK_DIR/job.json"
[ "$STATUS" = done ]
jq -e '.result.kind == "created" or .result.kind == "duplicate"' "$WORK_DIR/job.json"
ASYNC_PATH="$(jq -er '.integrationId | @uri' "$WORK_DIR/job.json")"
curl --silent --show-error --fail-with-body "$API_BASE_URL/documents/$ASYNC_PATH" \
-H @"$WORK_DIR/headers" > "$WORK_DIR/async-document.json"
jq . "$WORK_DIR/async-document.json"
The loop is bounded to about one minute. A timeout means unresolved; retain
the messageId and continue polling later, with a fresh token if needed. Do not
create another invoice to hide a timeout. Unknown message IDs return 404 for the
selected tenant. Example completed job, with a shortened placeholder message ID:
{"messageId":"<64 hex characters>","status":"done","integrationId":"AXS-ASYNC-<uuid>","result":{"kind":"created","outcome":"partial","routeClass":"normal","rulePackId":"draft-uae-route-class-2026-07@provisional"},"error":null}
| Job status | What to do |
|---|---|
queued, processing |
Keep polling within your retry budget. |
done, result.kind: created or duplicate |
Read integrationId, then the document status. This is an ingest result, not final compliance or delivery. |
done, result.kind: conflict |
Same business-ID conflict as synchronous 409. result is exactly {"kind":"conflict"}; do not count it as a successful new invoice. |
invalid |
Terminal validation rejection. result.violations has the synchronous 422 list; integrationId is null. Correct the input/registration before resubmitting. |
failed |
Infrastructure retries exhausted; error carries the reason. Give support the message ID; identical resubmission does not restart it. |
Errors and retry decisions
| HTTP status | Meaning and action |
|---|---|
| 200 / 201 | Sync duplicate / new admission respectively. For GET, inspect the response content. |
| 202 | Async acceptance only; poll and inspect status and result.kind. |
| 400 | Malformed request or missing/invalid x-tenant-id (after authentication). |
| 401 | Missing, invalid or expired bearer; also wrong signature, issuer or audience. Renew once with correct configuration. |
| 403 | Token is valid but tenant entitlement or machine axs:ingest scope is missing. Fix the handover; retrying unchanged cannot grant access. |
| 409 | Same tenant + invoice ID, different canonical invoice. Investigate the prior submission; never silently overwrite or change the ID to bypass it. |
| 422 | Envelope, structural, registration, UUID uniqueness or PINT-AE validation rejection. Read every rule, path, message. |
| 429 | Rate limited; honor Retry-After when supplied and back off. |
| 5xx / network timeout | Outcome may be unknown. Query known IDs and retry identical input with bounded exponential backoff and jitter. Escalate persistent failure. |
The actual response shapes differ. 401/403 and other thrown HTTP errors use an envelope like this (request ID, time and path vary):
{"statusCode":401,"error":"Unauthorized","message":"missing bearer token","requestId":"<request-id>","path":"/ingest","timestamp":"<ISO timestamp>"}
403 uses error: "Forbidden" and message: "principal is not entitled to this tenant"
or "missing required scope: axs:ingest". An invalid bearer uses
message: "invalid token". Keep requestId / x-request-id for support, without
sending your Authorization header or secret. The 409 and 422 bodies are instead:
{"error":"document already ingested with different content","integrationId":"AXS-FIRST-<uuid>"}
{"violations":[{"rule":"SELLER_NOT_REGISTERED","path":"invoice.seller.endpoint","message":"issuing participant is not registered to the submitting tenant"}]}
For envelope violations path is a dotted canonical path. PINT-AE findings
can use a Schematron rule ID (for example ibr-132-ae) and an XPath location.
Fatal/error findings reject; warnings alone do not. Do not build a parser that
assumes every path is a dot-separated JSON field.
Exercise replay, conflict and rejection on the same test invoice:
for variant in replay conflict invalid; do
case "$variant" in
replay) cp "$WORK_DIR/invoice.json" "$WORK_DIR/check.json"; EXPECTED=200 ;;
conflict) jq '.invoice.lines[0].itemName="Changed widget"' \
"$WORK_DIR/invoice.json" > "$WORK_DIR/check.json"; EXPECTED=409 ;;
invalid) jq '.invoice.id=""' "$WORK_DIR/invoice.json" \
> "$WORK_DIR/check.json"; EXPECTED=422 ;;
esac
HTTP="$(curl --silent --show-error -o "$WORK_DIR/check-result.json" -w '%{http_code}' \
"$API_BASE_URL/ingest" -H @"$WORK_DIR/headers" \
-H 'Content-Type: application/json' --data-binary @"$WORK_DIR/check.json")"
printf '%s: HTTP %s (expected %s)\n' "$variant" "$HTTP" "$EXPECTED"
jq . "$WORK_DIR/check-result.json"
[ "$HTTP" = "$EXPECTED" ]
done
Synchronous idempotency is keyed by (tenant, invoice.id) and hashes the
parsed canonical invoice, with recursively sorted object keys. Formatting
and object-key order do not create a new invoice; arrays and field values still
matter. SBDH is validated but is not part of that content hash. Validation runs
before duplicate detection, so a retry may reject if registration or rules have
changed. A UUID reused by a different invoice ID in the same tenant yields
422 UUID_NOT_UNIQUE, not 409. There is no caller-supplied Idempotency-Key
header in this implementation.
Async message IDs hash (tenant, complete parsed request body), including
SBDH, with stable object-key order. Identical resubmission returns the same
message ID and does not enqueue again while its durable job row exists—even
when invalid, failed, or stuck queued. A changed body produces a new job,
but that job still meets the synchronous business-ID/UUID rules. In particular,
if persistence succeeded and queue delivery failed, a replay alone does not
repair the gap: poll and contact support. Retry budgets and recovery are not an
exactly-once delivery guarantee.
Secret rotation and revocation
A human tenant administrator, using the existing API-client screen, creates,
rotates and revokes clients. Its underlying tenant APIs (/api-clients,
/api-clients/:clientId/rotate, DELETE /api-clients/:clientId) require a human
tenant-admin principal; they are not an ERP client-credentials workflow.
A pending creation must be resumed, not recreated blindly. Secrets appear only
once on successful create/rotate/resume responses; list and revoke never reveal
one. A revoked client cannot be rotated back into service.
For planned rotation, agree a cutover with the administrator, receive the fresh
secret securely, replace the secret-manager value and obtain a new token using
the token command above. Rotation regenerates the Keycloak client secret; do
not assume a dual-secret overlap window. Verify a fresh /me and submission
with the new credential. On revoke, Keycloak disables the client before AXS
records REVOKED, preventing new token issuance by that client.
Already issued JWTs remain usable at the AXS API until their expiry, provided
the signature/issuer/audience/claims still verify. The API does not introspect
tokens or read the client's ACTIVE/REVOKED database state on each request.
Secret rotation or client disable does not itself invalidate those JWTs. The
exact remaining exposure follows their exp, not the time of the administrative
operation. In a credential incident, tell AXS about both the secret and any
issued tokens; do not assume revocation is immediate access-token cancellation.
API reference and boundaries
On your supplied tenant API base URL, open /reference for the existing
self-hosted Scalar reference, or /docs for Swagger. /docs-json serves the live
contract; the downloadable OpenAPI contract is versioned with
this guide. In Scalar, select the correct environment/server URL and enter your
bearer token and tenant header before trying a request. Generated local-server
examples are not a deployment URL. This portal never asks you to store a secret.
The contract covers more than these four machine routes: some tenant routes
require a human session/role or another scope. Operator APIs (/ops/**) run
on a separate protected listener and are not part of the integrator handover.
Do not use an internal admin URL, operator credentials or a provisioning
service-account secret in ERP configuration.
Polling remains available. A human tenant administrator can configure the optional HTTPS callback described below when the deployment enables it. A self-service sandbox is not included. Documentation hosting/access and additional languages/themes are tracked in AXS-96; the full customer onboarding, external delivery receipts and usage-count rehearsal are tracked in AXS-144. This guide does not claim those deployment or onboarding checks have passed.
Tenant HTTPS callbacks
A human tenant-admin configures Manage → Callbacks, or the matching
GET /callbacks and PUT /callbacks tenant API. These are human administration
routes; ERP client-credentials tokens cannot change the destination or secret.
Read the current version and send it as expectedVersion when saving. A 409
means another administrator changed the configuration: reload before deciding.
available:false means the deployment is not ready to deliver callbacks.
Use a receiver you control, with valid HTTPS on port 443 and public IPv4 DNS. Credentials in the URL, query strings, fragments, IP literals, internal/local addresses and redirects are rejected. Every DNS answer must be public; the resolved address is pinned for each connection while TLS still verifies the original hostname. The request deadline is five seconds after DNS resolution.
Creation or explicit signing-secret rotation returns secret once. Store
it securely on the receiver; configuration reads never reveal it. Every save
creates a new version and cancels pending deliveries from the old version.
Prepare the receiver before enabling or rotating, and use pull access to recover
cancelled documents. Historical events are never sent to a newly chosen target.
AXS POSTs JSON with schemaVersion:1, id, tenantId, integrationId, type
and occurredAt. An inbound-document event also contains document, the
canonical invoice JSON. Receipt events use the established notification types:
outbound-delivered, outbound-failed, reporting-reported, reporting-failed
and fta-outage. The envelope and document are immutable for that event ID.
Validate the following headers before processing the body:
x-axs-event-id: must match the JSONid; durably deduplicate by tenant and ID.x-axs-timestamp: Unix seconds; reject timestamps outside your agreed freshness window (the controlled receiver uses five minutes).x-axs-signature:sha256=followed by the lowercase hex HMAC-SHA256 oftimestamp + "." + exact request-body bytes, using the one-time signing secret as a UTF-8 string. Compare signatures in constant time, then parse the JSON.
Return 2xx only after durably accepting the event; an already accepted duplicate
should also return 2xx without repeating the business action. Delivery is
at least once: a lost response can cause the same event to arrive again.
Only a received 2xx records DELIVERED; enqueue alone never does. Network errors,
408, 429 and 5xx retry with exponential backoff, at most six attempts per budget.
Other 3xx/4xx responses are permanent failures; AXS never follows a redirect.
GET /callbacks/deliveries and the portal show the latest 100 delivery results,
attempt counts and HTTP/error status. A human administrator can retry a DEAD
event with POST /callbacks/deliveries/:id/retry (URL-encode the ID). It preserves
the event ID/body and needs the same target configuration; pending, delivered
or cancelled events are not reopened by duplicate clicks. The receiver must
retain deduplication records across these operator retries. A 2xx proves HTTP
acceptance, not the receiver's subsequent accounting or ERP processing.