Endpoints and contracts¶
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/certificatesandGET /v1/certificates/{number}are the ordinary scoped, cursor-paged read, behindcertificates:records:read.GET /v1/certificates/lookupis 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": [ … ] }
cursoris opaque: follownextuntil it isnullrather than building it.page_sizeis 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.