Authentication
Every request to Quetzal Invoicing carries four pieces of identity. Three are headers, one is the body. Get any wrong and the request fails closed.
Required headers
| Header | Required on | What it does |
|---|---|---|
Authorization: Bearer <token> | All endpoints | OAuth2 / JWT access token. Carries the caller identity, the tenant the caller can act for, and the scopes they hold. |
X-Tenant-Id: <slug> | All endpoints | Explicit tenant context for the request. Must match the tenant claim in your token. Sent separately so multi-tenant tokens (rare) can still be unambiguous per request. |
Idempotency-Key: <uuid> | POST, PATCH | A UUID you generate. Quetzal stores the request fingerprint under this key for 24h. Retrying the same (method, path, key) returns the original response. |
X-Flow-Id: <id> (optional) | All | A correlation ID. If you send one, Quetzal logs it on every event for that request — useful for tracing across systems. |
Token scopes
The bearer token carries scopes. Each endpoint requires one or more of:
| Scope | Grants |
|---|---|
invoicing.document.read | GET /v1/documents, GET /v1/documents/{id}, GET /v1/documents/{id}/events, GET /v1/documents/{id}/evidence |
invoicing.document.write | POST /v1/documents, PATCH /v1/documents/{id}, POST /v1/documents/{id}/cancel, POST /v1/documents/{id}/void |
invoicing.party.read / .write | The corresponding operations under /v1/parties |
invoicing.issuer_profile.read / .write | The corresponding operations under /v1/issuer_profiles |
invoicing.webhook.read / .write | The corresponding operations under /v1/webhook_subscriptions |
A token can hold multiple scopes. Ask for the narrowest set you need.
How to get a token
Quetzal does not issue tokens itself — it relies on your organization's identity provider. Talk to your Quetzal admin to:
- Register an OAuth2 client (or service account) for your integration.
- Get a
client_idandclient_secret. - Request scopes appropriate to your workload.
The exact provider endpoint and token format depend on your deployment. The runtime contract is fixed though: any RFC 8725-compliant JWT with the right scopes and a tenant claim will validate.
Why Idempotency-Key is mandatory on writes
Network retries, queue redelivery, worker restarts — every production system retries. If retries each created a new document, you'd end up with duplicate fiscal emissions, which means duplicate tax filings, which means an audit conversation you don't want.
Idempotency-Key fixes this. Generate a UUID per intent (one document you want to emit), include it on the request, and:
- The first request that arrives wins. Quetzal creates the document and remembers the key.
- Any later request with the same key — even with a different body — returns the original document. The body is not re-processed.
- The key is scoped to your tenant + the endpoint. Sending the same key on
POST /v1/partiesdoes not collide with one onPOST /v1/documents. - Keys are remembered for 24 hours. After that the slot is free.
Always generate a fresh UUID per new document. Reusing a key across distinct documents will silently return the wrong one.
Common authentication errors
| Status | When | What to check |
|---|---|---|
401 Unauthorized | No Authorization header, or token is malformed / expired / signed by the wrong issuer. | The token's exp claim. The signing key on Quetzal's side. |
403 Forbidden | Token is valid but doesn't carry the scope this endpoint needs. | Compare your token's scope claim with the scope table above. |
403 Forbidden (tenant mismatch) | X-Tenant-Id doesn't match the token's tenant claim. | The header and the claim must be the same tenant slug. |
409 Conflict | The Idempotency-Key is reused with a different request body. | Either reuse the key with the exact same body (intended replay), or generate a new UUID. |
422 Unprocessable Entity | Idempotency-Key is not a valid UUID. | Generate one with uuidgen, crypto.randomUUID(), etc. |
All error responses are RFC 7807 Problem JSON — application/problem+json with a machine-readable type, a human title, a detail, and an instance.
Putting it together
A complete POST looks like this:
POST /v1/documents HTTP/1.1
Host: quetzal-api.mattilda.io
Authorization: Bearer eyJhbGciOiJIUzI1NiI...
X-Tenant-Id: mattilda
Idempotency-Key: 9f8a1c4e-2e6a-4f9c-b3a5-1d4e5f6a7b8c
X-Flow-Id: req-0193a8c2-f4d1
Content-Type: application/json
{ ... }
Get this right once in your HTTP client setup; from then on it's invisible.
Next
- Quickstart — try a complete request end-to-end.
- API Reference — every endpoint with its required scopes.
- Principles — why the contract looks the way it does.