API Style Guide
Quetzal adopts the Zalando RESTful API Guidelines as the canonical style reference. https://opensource.zalando.com/restful-api-guidelines/
This doc maps the Zalando MUSTs we adopt to our specific spec choices, and flags rules where we diverge (and why).
TL;DR
- Adopt Zalando MUSTs for naming, HTTP semantics, errors, pagination, idempotency, events.
- Stricter than Zalando on three points: opaque
metadata(executor stages can't read it), noretry/force-transitionendpoints, no writable/state-transitionssub-resource. - Documented verb exceptions:
POST /v1/documents/{id}/cancelandPOST /v1/documents/{id}/void— both justified in Principles §1. New verb endpoints require an ADR. - Enforcement: CI lints the spec on every PR;
contract-guardianagent reviews each spec change.
On this page
- Why Zalando
- MUSTs adopted
- SHOULDs adopted
- MAYs we skip
- Stricter than Zalando
- Deliberate divergences
- CI enforcement
- Quick references
Why Zalando
- Mature, widely cited, production-tested at scale
- Explicit about MUSTs vs SHOULDs vs MAYs (easy to enforce automatically)
- Aligns with the design principles already adopted (
SPEC/principles.md) — resource-oriented, documented verb exceptions only, RFC 7807 errors, cursor pagination - Provides naming conventions, error formats, idempotency, and event conventions we'd otherwise invent from scratch
The Zalando guide is the default reference for unspecified concerns. If a question is not
answered by SPEC/principles.md, look at Zalando.
MUSTs adopted (enforced by CI + contract-guardian)
| Rule | Citation | Our adoption |
|---|---|---|
| Pluralize resource names | MUST 134 | /v1/documents (not /document) |
| Kebab-case in URL paths | MUST 129 | /v1/documents/{id}/events. Multi-word sub-resources use kebab-case (/issuer-profiles, /webhook-subscriptions) |
| Snake_case in query params | MUST 130 | ?issuer_tax_id=...&receiver_tax_id=...&limit=50 |
| Verb-free URLs | MUST 141 | Enforced by default. Documented exceptions (/cancel, /void) live in Principles §1. New exceptions require an ADR. |
| Normalized paths (no trailing /) | MUST 136 | All paths are normalized |
| RFC 3339 date-time | MUST 169, 238 | created_at, updated_at, occurred_at all format: date-time |
Money as {amount, currency} object | MUST 173 | LineItem.unit_price, Money schema. Amount is decimal-as-string (never float). |
| Snake_case JSON properties | MUST 118 | tax_id, document_type, fiscal_id, line_items, unit_price, etc. |
| UPPER_SNAKE_CASE for enum values | MUST 240 | DRAFT, PENDING_SUBMISSION, EMITTED, INVOICE, CREDIT_NOTE, PAYMENT_COMPLEMENT, etc. |
| ISO 3166 alpha-2 country / ISO 4217 currency | MUST 170 | Country enum, Money.currency |
| Bearer JWT with explicit scopes | MUST 104-105 | invoicing.document.read, invoicing.document.write |
x-api-id + x-audience in OpenAPI info | MUST 218 | Both set on info |
| RFC 7807 Problem JSON for errors | MUST 176 | Problem schema, application/problem+json content type on all 4xx/5xx |
| Official HTTP status codes | MUST 243 | 201 (POST), 200 (PATCH/GET), 204 (DELETE), 404, 409, 412, 422, 429 |
| Cursor-based pagination | SHOULD 160 + MUST 159 | ?cursor= + response page.cursor |
items as top-level array wrapper | MUST 110 | List responses return {items: [...], page: {...}}, never bare arrays |
| Backward-compatible by default | MUST 106 | Breaking changes require ADR + deprecation period |
X-Flow-ID request header for tracing | MUST 233 | Required (soft) on all endpoints |
| Functional event naming | MUST 213 | Bus events: invoicing-service.document.<verb>. In-resource timeline events use shorter document.<verb> for the /events endpoint. |
SHOULDs adopted (recommended, enforced via reviewer + agent)
| Rule | Citation | Our adoption |
|---|---|---|
Idempotent state transitions via Idempotency-Key | MAY 230 | Required on POST/PATCH (we elevated from MAY to MUST internally) |
| ETag + If-Match for optimistic locking | SHOULD 182 | GET returns ETag; PATCH accepts If-Match |
| Limit resource types per API (≤ 8) | SHOULD 146 | V1 has 4 first-class resources (Document, Party, IssuerProfile, WebhookSubscription). Future BCs (Cobranza, Anticipos) own their own resources |
| Limit nesting depth (≤ 3) | SHOULD 147 | Max is /documents/{id}/events (depth 2) |
| Avoid versioning, extend compatibly | SHOULD 113 | URL path includes /v1 but no plan to release /v2 — extension via fields with sensible defaults |
| Deprecation + Sunset headers | MUST 189-190 | Will apply when deprecating any field/endpoint |
MAYs we explicitly skip (for now)
| Rule | Why |
|---|---|
| Full HATEOAS (Level 3) | MAY 163. Adds complexity without proven need in our consumer pattern (Cobranza, Anticipos are programmatic, not browsable). Level 2 (resource-oriented + HTTP verbs) is sufficient. |
| Media-type versioning | MUST 114. Reserved for true breaking changes; not used in v0.1.x |
Where we are STRICTER than Zalando
1. No writable /state-transitions sub-resource
Zalando allows POST /documents/{id}/state-transitions as a compromise for auditable state
changes (MUST 138 vs 141 trade-off). We do not. State transitions are pure PATCH on the
resource. The audit trail lives in /documents/{id}/events as a read-only timeline projection,
not as a writable sub-resource.
The exceptions in Principles §1 (cancel, void)
are not state transitions — they are regulated operations with tax-authority side effects and
operation-specific bodies.
2. metadata is opaque to lower stages
Zalando does not prescribe rules on metadata field reads. We add a hard rule
(Principles §4): executor/confirmer/side_effects/post_processing stages
must not read metadata. Enforced by the leak-auditor agent.
3. No action endpoints for retry / force-transition
Zalando does not block these. We do (Principles §2). Retry policy is internal; ops intervention goes via admin UI calling private service methods.
Where we may DIVERGE from Zalando (deliberate choices)
Event timeline names
Zalando MUST 213 prescribes invoicing-service.document.created for bus event names. We follow
that for events published to Kafka/SNS. But the /v1/documents/{id}/events endpoint returns
in-resource timeline events with shorter names (document.created, document.emitted) because
they are scoped to a single resource view, not a cross-service bus payload. This is a deliberate
design choice — documented here so future reviewers understand it is not an oversight.
Decimal-as-string for Money.amount
Zalando MUST 173 says amount as decimal format. Some interpretations allow JSON number. We
mandate string (regex ^-?[0-9]+(\.[0-9]+)?$) to eliminate any float ambiguity — fiscal
amounts cannot tolerate IEEE 754 rounding. This is stricter than the letter of Zalando but
aligns with the spirit.
CI Enforcement (Day 1)
The on-pr.yml workflow enforces:
- Forbidden URL verbs in
SPEC/openapi.yamlexcept the allowlisted exceptions (/cancel,/void); new verbs require an ADR before the lint allowlist is updated - Forbidden Mattilda terms in spec + scenario tests
- YAML structure validity for scenario tests
- Cross-country shape parity for scenario tests
- Source-level Mattilda leak audit in
src/modules/invoicing/
Phase 2 (when Spectral or equivalent is configured): adopt Zalando's zally ruleset directly. Until then, the checks above are the enforceable subset.
Quick references
- Zalando guidelines: https://opensource.zalando.com/restful-api-guidelines/
- Zally linter (Zalando's own enforcement tool): https://github.com/zalando/zally
- RFC 3339 (date-time): https://datatracker.ietf.org/doc/html/rfc3339
- RFC 7807 (Problem JSON): https://datatracker.ietf.org/doc/html/rfc7807
- RFC 7396 (JSON Merge Patch): https://datatracker.ietf.org/doc/html/rfc7396
- ISO 4217 (currency codes): https://www.iso.org/iso-4217-currency-codes.html
- ISO 3166-1 alpha-2 (country codes): https://www.iso.org/iso-3166-country-codes.html