Skip to main content

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), no retry / force-transition endpoints, no writable /state-transitions sub-resource.
  • Documented verb exceptions: POST /v1/documents/{id}/cancel and POST /v1/documents/{id}/void — both justified in Principles §1. New verb endpoints require an ADR.
  • Enforcement: CI lints the spec on every PR; contract-guardian agent reviews each spec change.

On this page

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)

RuleCitationOur adoption
Pluralize resource namesMUST 134/v1/documents (not /document)
Kebab-case in URL pathsMUST 129/v1/documents/{id}/events. Multi-word sub-resources use kebab-case (/issuer-profiles, /webhook-subscriptions)
Snake_case in query paramsMUST 130?issuer_tax_id=...&receiver_tax_id=...&limit=50
Verb-free URLsMUST 141Enforced by default. Documented exceptions (/cancel, /void) live in Principles §1. New exceptions require an ADR.
Normalized paths (no trailing /)MUST 136All paths are normalized
RFC 3339 date-timeMUST 169, 238created_at, updated_at, occurred_at all format: date-time
Money as {amount, currency} objectMUST 173LineItem.unit_price, Money schema. Amount is decimal-as-string (never float).
Snake_case JSON propertiesMUST 118tax_id, document_type, fiscal_id, line_items, unit_price, etc.
UPPER_SNAKE_CASE for enum valuesMUST 240DRAFT, PENDING_SUBMISSION, EMITTED, INVOICE, CREDIT_NOTE, PAYMENT_COMPLEMENT, etc.
ISO 3166 alpha-2 country / ISO 4217 currencyMUST 170Country enum, Money.currency
Bearer JWT with explicit scopesMUST 104-105invoicing.document.read, invoicing.document.write
x-api-id + x-audience in OpenAPI infoMUST 218Both set on info
RFC 7807 Problem JSON for errorsMUST 176Problem schema, application/problem+json content type on all 4xx/5xx
Official HTTP status codesMUST 243201 (POST), 200 (PATCH/GET), 204 (DELETE), 404, 409, 412, 422, 429
Cursor-based paginationSHOULD 160 + MUST 159?cursor= + response page.cursor
items as top-level array wrapperMUST 110List responses return {items: [...], page: {...}}, never bare arrays
Backward-compatible by defaultMUST 106Breaking changes require ADR + deprecation period
X-Flow-ID request header for tracingMUST 233Required (soft) on all endpoints
Functional event namingMUST 213Bus events: invoicing-service.document.<verb>. In-resource timeline events use shorter document.<verb> for the /events endpoint.
RuleCitationOur adoption
Idempotent state transitions via Idempotency-KeyMAY 230Required on POST/PATCH (we elevated from MAY to MUST internally)
ETag + If-Match for optimistic lockingSHOULD 182GET returns ETag; PATCH accepts If-Match
Limit resource types per API (≤ 8)SHOULD 146V1 has 4 first-class resources (Document, Party, IssuerProfile, WebhookSubscription). Future BCs (Cobranza, Anticipos) own their own resources
Limit nesting depth (≤ 3)SHOULD 147Max is /documents/{id}/events (depth 2)
Avoid versioning, extend compatiblySHOULD 113URL path includes /v1 but no plan to release /v2 — extension via fields with sensible defaults
Deprecation + Sunset headersMUST 189-190Will apply when deprecating any field/endpoint

MAYs we explicitly skip (for now)

RuleWhy
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 versioningMUST 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.yaml except 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