Skip to guide

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_REGISTERED remains the error code, with invoice.buyer.endpoint identifying 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 0235 endpoint 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 JSON id; 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 of timestamp + "." + 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.