Skip to content

Endpoints and contracts

Audience: partners & internalStatus: reference

The canonical reference for the request/response shapes at the boundary. Auth for each hop is in Authentication; the retry and idempotency rules the status codes imply are in Errors and retries.

Every vendor app mounts at /v1/<vendor-name>/. The vendor name is visible in the path — this is deliberate, so nobody has to guess which system a request belongs to. Underscores become dashes: business_central → /v1/business-central/. The examples below use a fictional vendor acme integrating into employees.

We pull from you — sync trigger

POST /v1/acme/sync/ HTTP/1.1
Host: employees.esz.ag
Authorization: Bearer <ACME_ACCESS_TOKEN>
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "message": "Sync enqueued",
  "entity": "employees"
}

The response is 202 as soon as the poll is queued: the run itself happens in a background worker, so a full vendor read never holds the request open. Per-row results are reported in our logs and in the mirror rows, not in this response.

The operation is idempotent and safe to repeat. The HTTP trigger, the admin button, and the schedule share one execution and queueing lock, so a trigger received while that poll is queued coalesces with it, and one received during execution creates a single trailing run. Where the scheduled poll only reads what changed, the HTTP trigger asks for a full re-read and queues on its own key, so it is never collapsed into a pending delta run; the two still never execute at the same time. The trigger may be called by our scheduler, by an operator, or by you as a "something changed, come look" signal instead of pushing a payload.

We read through to you — passthrough (optional)

GET /v1/acme/employees/ proxies your API live, unmirrored, as a debugging aid — "what does the vendor return right now?" without leaving our platform. Cheap to add, not required, and nothing depends on it.

You push to us — webhook

Signature verification for this hop is in Authentication.

POST /v1/webhooks/acme/order-created HTTP/1.1
Host: integrations.esz.ag
Content-Type: application/json
webhook-id: msg_2c1f…
webhook-timestamp: 1731582000
webhook-signature: v1,g0hM9SsE…

{ "id": "ord_88213", "status": "approved", … }
HTTP/1.1 202 Accepted
Status Meaning Your retry behaviour
202 Accepted, queued. Not "processed successfully." Do not retry.
400 Bad signature, or payload we cannot parse. Do not retry — retrying will fail identically. Alert a human.
5xx We are broken. Retry with backoff.

The 202 is the contract that matters. We acknowledge receipt, then process asynchronously. If processing fails afterwards, that is our problem to retry — not something you will learn about from the HTTP response.

We push to you — outbound

Driven by the scheduled sync: each run reads your records, compares them with our canonical data, and sends what differs. A record you do not hold yet is created by a queued job that commits in the same database transaction as the change that requires it.

PATCH /v2/employees/EMP-4471 HTTP/1.1
Host: api.acme.example
Authorization: Bearer <access_token obtained via client_credentials>
Content-Type: application/json
Idempotency-Key: example-request-id

{ "supervisor_id": "EMP-1102" }
Your response We do
2xx Mark delivered; the next read confirms it.
4xx Pause the record and surface it for a human. We do not retry until someone resumes it — a stale cross-reference id will never succeed on replay.
5xx, timeout Retry on the next scheduled run.

You will not lose an update because our process crashed. You may receive one twice. Your endpoint must be idempotent; if you support an idempotency key header, tell us its name and we will send it.

You read from us — published read endpoint

Where your system needs to fetch ESZ data on demand rather than receive our pushes:

GET /v1/acme/export/employees/?updated_since=2026-07-01T00:00:00Z HTTP/1.1
Host: employees.esz.ag
Authorization: Bearer <ACME_ACCESS_TOKEN>
HTTP/1.1 200 OK
Content-Type: application/json

{ "next": null, "previous": null, "results": [ { "id": "…", "given_name": "…", … } ] }

Cursor-paged (see Paging), filterable by updated_since. The response shape is an explicit, agreed field list — never our raw internal model.

The published-field contract

The field list is fixed during discovery and is a contract:

  • Fields can be added at any time; your parser must tolerate unknown keys.
  • A field you already consume is never removed or renamed without at least 90 days' notice and a coordinated date.
  • You get the fields your system needs for its own function. Fields whose only purpose is forwarding to another system are not published — see the hub rule in Platform and services; that routing is ours.
  • The list starts minimal: each field needs a named consumer and a stated purpose. Adding a field later is a small change; withdrawing one is a 90-day coordination, so we do not publish speculatively.

Certificates, as a worked example

The certificates service is the read endpoint most partners meet first, and it shows both halves of the pattern:

  • GET /v1/certificates and GET /v1/certificates/{number} are the ordinary scoped, cursor-paged read, behind certificates:records:read.
  • GET /v1/certificates/lookup is credential-free on purpose, for a recipient who holds a certificate but has no account. It answers only when the certificate number is paired with a second matching number from the same record, and it is rate-limited per client.

Field lists, filters and limits are in the certificates data map.

Prefer our push (outbound, above) where your system can accept it: delivery is retryable and neither side's availability affects the other's latency. A read endpoint suits on-demand lookups; it is the wrong tool when the real requirement is "tell me when something changed" — that is our push, or a webhook.

Paging

Every list endpoint is cursor-paged. The envelope is the same everywhere:

HTTP/1.1 200 OK
Content-Type: application/json

{ "next": "https://…?cursor=cD0yMDI2…", "previous": null, "results": [ … ] }
  • cursor is opaque: follow next until it is null rather than building it.
  • page_size is a client option, default 50, hard maximum 200.
  • Rows inserted while you page do not shift the pages you have already read, which offset paging cannot promise. There is no total count for the same reason.

Where the machine-readable spec lives

Every service publishes OpenAPI:

https://<service>.esz.ag/api/schema/          # the raw spec
https://<service>.esz.ag/api/schema/swagger/  # browsable
https://<service>.esz.ag/api/schema/redoc/    # browsable

The spec is generated from the code and checked in CI, so it does not drift. Base URLs for staging vs production are in Environments.

A served spec names the host it is served from, as a full URL, as its only server, so Swagger UI's "Try it out" always calls the environment you are looking at and a downloaded spec keeps its base URL. Swagger UI opens with "Try it out" active: query parameters are form fields, fixed vocabularies are drop-downs, and certificate uploads are a form with a file picker. JSON request bodies start from an example payload. Swagger UI and ReDoc load their assets from the service itself, not from a CDN. Each service's Swagger UI and ReDoc link its data map on this site.

Service landing pages

Core domain services use their root URL as an operator and integrator entry point. It links to the service's Django admin, Swagger UI, ReDoc API reference, and the platform documentation. The page shows identity and access claims only for signed-in users; it does not display empty role or permission sections.