# 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](../../examples/ingest/valid-invoice.json) 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.

```bash
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.

```bash
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):

```json
{"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:

```json
[{"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.

```bash
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:

```json
{"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):

```json
{"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:

```json
{"error":"document already ingested with different content","integrationId":"AXS-FIRST-<uuid>"}
```

```json
{"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:

```bash
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](openapi.json) 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.
