Download OpenAPI specification:
Operations that concern the whole partner scope rather than a single organization: creating and listing organizations, managing webhooks.
Backend JWT via the client_credentials grant. Tokens are not issued by
this gateway but by its own subdomain, auth.<partner>.hrmony.rest:
curl --request POST \
--url https://auth.<partner>.hrmony.rest/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data client_id=<CLIENT_ID> \
--data client_secret=<CLIENT_SECRET> \
--data scope='partner:read partner:write'
<partner> is the subdomain assigned to the partner; Hrmony provides
client_id and client_secret during onboarding.
The response carries access_token, token_type: Bearer and expires_in.
Send the token as Authorization: Bearer <access_token>. This tier has no
refresh tokens — once a token expires, request a new one through the same grant;
cache it server-side and renew it before it expires.
For this tier the authorization server has exactly two endpoints: the
POST /oauth/token above, and GET /.well-known/jwks.json carrying the public
key that issued tokens can be verified against. Both live on the auth subdomain rather than on
this gateway, so they do not appear in the endpoint list below — the example above
is their complete description.
Hrmony issues one client_id/client_secret pair per partner installation.
That pair is deliberately valid on all three tiers — it is not an access right
in itself, only the permission to have tokens issued. What a call may do is
decided by the issued access token alone:
400 invalid_scope, and the other two tiers'
gateways reject a token of this tier with 401.scope=partner:read
yields a read-only token: write endpoints then answer 403
(/errors/forbidden). Every GET is a read, every POST a write. For
use cases that only read — a back office, say — this is the intended route:
a token of its own with the smallest sufficient scope rather than one token
holding every right.x-required-scope.On request Hrmony issues several client_id/client_secret pairs for the same
installation — one per connecting system, say, so that one can be replaced
without touching the others. A credential cannot be restricted to particular
tiers or scopes; the separation happens on the token.
Requests on this tier do not run in the context of an organization and
therefore carry no X-Hrmony-Tenant-ID header. Where an organization is
relevant at all, it is named as a path parameter (GET) or in the body
(POST). Everything that happens inside one organization lives on the admin
tier.
The events documented under webhooks describe requests Hrmony sends to the
partner, not endpoints of this API. Receiver URLs are registered through this
tier's /v1/webhooks endpoints; every delivery is signed so the receiver can
verify it.
Write endpoints carry a verb in the path: POST …/employees/create rather
than POST …/employees. This is deliberate.
The style is hybrid by design:
GET …/{id}, parameters in the URL, no body.Behind every write endpoint is a command-based model, not a CRUD one. Enabling
the meal subsidy or fetching payroll data are not updates to a field on a
resource; expressing them as PUT or PATCH would force an unnatural
mapping. Many actions are also scheduled rather than immediate: a benefit is
activated for an upcoming service month, not right now, and a PATCH on the
current state cannot say that.
Reads stay on GET because GET buys something real: responses are
cacheable in CDNs and proxies, retries are safe, and a plain lookup needs no
request body.
The result is RPC for actions, REST for data.
All errors follow RFC 9457 Problem Details.
Only the type URI is stable and worth branching on — title and detail
are for humans and may change.
They follow the request language, with one exception: for /errors/unauthorized, /errors/forbidden, /errors/not-found, /errors/throttled, /errors/client-error and /errors/server-error they are always English, for /errors/internal-server-error they can be English.
Every error carries a requestId; quote it when raising something with Hrmony.
403 or 404. 403 means the token lacks the required scope, or the
current business state forbids the action — a benefit that is not enabled for
the organization, for instance.
404 means the resource addressed
does not exist or is not yours:
another employee's receipt, another partner's organization. We answer both the
same way, deliberately indistinguishable — otherwise the status codes would let
you probe which ids exist.
A 404 therefore does not tell you that the id is unknown.
One error is listed under no single endpoint: /errors/not-found (404)
for a path this API does not know, or an HTTP method an endpoint does not offer.
Framework errors and domain errors alike carry type URIs under
/errors/….
Responses the API gateway produces itself, and that are none of the cases above,
carry the catch-all types /errors/client-error (4xx) and
/errors/server-error (5xx). Neither carries a status field: they
span several status codes, and the gateway cannot read the actual code inside its
template. Branch on the HTTP status code of the response instead.
There are no application-level rate limits: no quota per client, per token, per
endpoint or per time window, and therefore no RateLimit-* headers either.
The 429 /errors/throttled documented on every operation occurs only when
the environment's capacity limit is reached. That limit sits high enough that
regular operation never touches it, and it responds to total load across all
endpoints — not to a single endpoint or a particular access pattern.
A 429 carries no Retry-After. Treat it as transient and retry with
exponentially growing delays plus jitter, not at a fixed interval. The usual
retry rules apply: GET is safe to repeat, write endpoints only with the same
Idempotency-Key as the first attempt — otherwise you create a second
operation.
A 429 says nothing about permissions and does not indicate a blocked
account: it means only that this request was not processed and that the same
request may succeed later.
For your partner account, the API serves the English variant only: English
paths, English field names, English error text. Accept-Language is optional; when
sent it must be en. This variant is supported permanently.
The benefit product names are translated like every other term: mealSubsidy
and mobility.
Create, list and rename organizations.
An organization is permanent. There is no endpoint to delete one. Its
display name, by contrast, you can change at any time via
POST /v1/organizations/updateName; the new name then also appears in
generated documents, while documents already generated keep the previous one.
A customer is wound down rather than deleted: its employees via
POST /v1/employees/deactivate, its benefits via
POST /v1/organization/<benefit>/disable (both on the admin tier). The
organization itself remains, its externalCustomerId stays taken, and its
data is retained to meet statutory retention obligations. If you
have a case where an organization genuinely needs deleting rather than winding
down, raise it with us and we will work out the process together.
Creates an organization.
name must be 1 to 200 characters long and must not contain control characters (such as \r or \n), otherwise 400 invalid-request-body.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| externalCustomerId required | string [ 1 .. 255 ] characters Stable customer ID from the partner CRM. Unique per partner account; duplicate results in 409. |
| name required | string [ 1 .. 200 ] characters ^[^\p{Cc}]+$ Display name of the new organization. 1 to 200 characters, no control characters. |
{- "externalCustomerId": "kunde-abc123",
- "name": "Müller GmbH"
}{- "data": {
- "organizationId": "2b8f4c17-9a03-42de-b6c8-5d1e7f094a22",
- "externalCustomerId": "kunde-abc123",
- "name": "Müller GmbH"
}
}Sets the display name of the given organization to a new value.
organizationId, the endpoint answers 404 organization-not-found and changes nothing.name must be 1 to 200 characters long and must not contain control characters (such as \r or \n), otherwise 400 invalid-request-body.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| organizationId required | string <uuid> Hrmony-internal UUID of the organization to rename. |
| name required | string [ 1 .. 200 ] characters ^[^\p{Cc}]+$ New display name of the organization. 1 to 200 characters, no control characters. |
{- "organizationId": "2b8f4c17-9a03-42de-b6c8-5d1e7f094a22",
- "name": "Müller Holding GmbH"
}{- "data": {
- "organizationId": "2b8f4c17-9a03-42de-b6c8-5d1e7f094a22",
- "externalCustomerId": "kunde-abc123",
- "name": "Müller Holding GmbH"
}
}Filterable fields
A comma in a value is always an enumeration separator; literal commas are not allowed.
An enumeration takes at most 100 values; more are rejected with 400 /errors/invalid-filter.
An empty value is not allowed, not even as an element of an enumeration.
| Field | Operators | Values | Examples |
|---|---|---|---|
externalCustomerId |
=, , (IN) |
string | ?externalCustomerId=<value>?externalCustomerId=<value>,<value2> |
| limit | integer >= 1 Default: 50 Page size: default 50, at most 1000. A larger value is rejected with |
| cursor | string Opaque cursor from |
| externalCustomerId | string Comma-separated for multiple values. A comma always separates values and is never part of a value. |
| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
{- "data": [
- {
- "organizationId": "2b8f4c17-9a03-42de-b6c8-5d1e7f094a22",
- "externalCustomerId": "kunde-abc123",
- "name": "Müller GmbH"
}
], - "meta": {
- "pagination": {
- "nextCursor": null,
- "hasMore": false,
- "totalCount": 1
}
}
}Registers a webhook endpoint. By default it receives every event this API sends — receipt.in_review and receipt.status_changed, both documented under Webhook Events — plus the webhook.test event you trigger yourself. Pass eventTypes to receive only the events you name; omit it and you keep receiving all of them, including ones added later. webhook.test is never filtered: it only ever arrives because you asked for it. The subscription is fixed for the endpoint's lifetime — to change it, deregister and register again.
Signing secret
signingSecret (whsec_…) is returned exactly once in this response and never again. Store it securely at registration time; if you lose it, deregister and register again to obtain a new one.Verifying deliveries
X-Hrmony- headers: X-Hrmony-Timestamp — the ISO 8601 UTC time this delivery attempt was signed —, X-Hrmony-Signature: sha256=<hex> — the HMAC-SHA256, keyed with your signingSecret, over the string <X-Hrmony-Timestamp> + . + the raw request body —, and X-Hrmony-Event-Id, the id to deduplicate on. A test delivery adds X-Hrmony-Test: true; a real one never carries that header.X-Hrmony-Timestamp differs from your own clock by more than 5 minutes, (2) recompute the HMAC over <timestamp>.<raw request bytes> before any JSON parsing or re-serialization, (3) compare using a constant-time comparison.Example delivery
A complete delivery as it arrives at your endpoint. It is a verifiable test vector: signed with the example secret whsec_EXAMPLE0000000000000000000000000000, it must reproduce exactly the signature below.
POST /hooks/hrmony HTTP/1.1
Host: partner.example.com
Content-Type: application/json
X-Hrmony-Timestamp: 2026-05-12T09:14:01.284Z
X-Hrmony-Event-Id: 0d5f9a71-6e2c-4a83-9b40-1c7d5e3f8a26
X-Hrmony-Signature: sha256=96e2b14ddfa56a5c598ffbb9882ce6c552f9d947d3aafcce84ae7c60f50800db
{"event":"receipt.status_changed","occurredAt":"2026-05-12T09:14:00Z","cause":"initial_review","receipt":{"id":"9f3c1a52-7d4e-4b8a-9c11-2f6b8d0a41e7","organizationId":"a1e5c9d0-6b3f-4c2a-8e17-5d9f2b4a7c60","employeeId":"3b7e9f4d-2c1a-4e6b-9d8f-1a5c3e7b9d24","benefitType":"meal_subsidy","status":"recorded","reimbursementAmount":7.5}}
The signed string is the X-Hrmony-Timestamp value, a ., then the body bytes verbatim — no whitespace added, no re-serialization:
2026-05-12T09:14:01.284Z.{"event":"receipt.status_changed","occurredAt":"2026-05-12T09:14:00Z","cause":"initial_review","receipt":{"id":"9f3c1a52-7d4e-4b8a-9c11-2f6b8d0a41e7","organizationId":"a1e5c9d0-6b3f-4c2a-8e17-5d9f2b4a7c60","employeeId":"3b7e9f4d-2c1a-4e6b-9d8f-1a5c3e7b9d24","benefitType":"meal_subsidy","status":"recorded","reimbursementAmount":7.5}}
Note that X-Hrmony-Timestamp is the delivery attempt and differs from the body's occurredAt, which is when the event itself occurred — a status change for receipt.status_changed, the start of a review for receipt.in_review.
Delivery behaviour
url must be a public HTTPS endpoint that accepts POST and returns a 2xx status quickly; deliveries time out after 10 seconds, and any non-2xx response or timeout is retried with backoff. Retries are not unlimited: after five failed attempts the event is parked on our side and is not delivered again unless Hrmony replays it. Re-query the API for anything you may have missed.X-Hrmony-Event-Id header and keep your handler idempotent — the timestamp check bounds replay, it does not remove duplicates; it does bound how long you need to retain event ids.url change does not need a new registration: POST /v1/webhooks/update repoints this endpoint and keeps its webhookId and signing secret.eventTypes. Registering the same URL twice gives you two registrations and therefore two deliveries per event.Accept-Language of this request fixes the language of every delivery to this endpoint, for its whole lifetime. To change it, deregister and register again.event, occurredAt and the resource — not the standard response envelope. Dispatch on event.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| url required | string <uri> ^https:// |
| eventTypes | Array of strings non-empty Items Enum: "receipt.status_changed" "receipt.in_review" Events this endpoint receives. Omit the field and it receives all of them, including ones added later. An empty list is an error. It cannot be changed afterwards: deregister and register again. |
{
}{- "data": {
- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874",
- "signingSecret": "whsec_EXAMPLE0000000000000000000000000000",
- "registeredAt": "2026-05-12T09:14:00Z",
- "language": "de"
}
}Points an existing registration at a new url. Everything else about the webhook stays as it is — same webhookId, same signingSecret, same language, same eventTypes.
url must be a public HTTPS endpoint, exactly as at registration.eventTypes, language and the signing secret are fixed for the registration lifetime — to change one of those, deregister and register again.webhookId is an error, never a new registration.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| webhookId required | string <uuid> |
| url required | string <uri> ^https:// The new delivery URL. Replaces the previous one entirely; from the response on, deliveries go only there. |
{- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874",
}{- "data": {
- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874",
}
}Delivers a webhook.test event to one of your registered endpoints, so you can check your registration end to end before a real event depends on it.
webhook.test event and carries no resource — only the webhookId under test. Every test delivery additionally carries the header X-Hrmony-Test: true. Never let a test event reach your business logic.delivered reports whether your endpoint answered 2xx, statusCode what it answered, and error why no answer was reached at all — a connection failure or timeout in most cases, but any error that prevented the attempt.200 and reports the outcome in its data.eventTypes entirely, so a green result proves your URL, reachability and signature — not that this endpoint is subscribed to the events you care about. The response echoes eventTypes so you can check both at once.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| webhookId required | string <uuid> |
{- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874"
}{- "data": {
- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874",
- "delivered": true,
- "statusCode": 200
}
}Deregisters a webhook so that it no longer receives deliveries.
webhookId returned by the register endpoint (or from listing webhooks). Deregistering permanently stops delivery to that endpoint and invalidates its signing secret.webhookId and a new signing secret. A URL change needs none of this: POST /v1/webhooks/update repoints an endpoint and keeps both.webhookId does not exist, an error is returned; treat 'not found' as already deregistered.| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
| webhookId required | string <uuid> |
{- "webhookId": "ed71eef4-4c34-46dc-81fe-954e560454fd"
}{- "data": {
- "webhookId": "ed71eef4-4c34-46dc-81fe-954e560454fd"
}
}Lists your registered webhook endpoints (webhookId, url, registeredAt, language, eventTypes).
signingSecret is never returned here — it is only shown once, at registration.webhookIds (for example to deregister) and to audit which endpoints are currently registered.eventTypes is what that endpoint is subscribed to. Absent means it receives every event.| limit | integer >= 1 Default: 50 Page size: default 50, at most 1000. A larger value is rejected with |
| cursor | string Opaque cursor from |
| Accept-Language | string Value: "en" Optional. This deployment serves the English language surface only; the only accepted value is |
| X-Request-Id | string Correlation id; echoed on the response. |
| traceparent | string W3C trace context — echoed back when the request supplied it. The API never generates one of its own. |
| tracestate | string W3C trace state — echoed back when the request supplied it. |
{- "data": [
- {
- "webhookId": "ed71eef4-4c34-46dc-81fe-954e560454fd",
- "registeredAt": "2019-08-24T14:15:22Z",
- "language": "de",
- "eventTypes": [
- "receipt.status_changed"
]
}
], - "meta": {
- "pagination": {
- "nextCursor": "string",
- "hasMore": true,
- "totalCount": 0
}
}
}Events delivered to registered webhook endpoints.
Both receipt events describe the same lifecycle from different angles. Which transition triggers which event is stated here once, in full; each event's own description then only says what its body carries.
Transitions and webhooks
After the upload the receipt is in_review; receipt.in_review says so,
exactly once. Every review outcome arrives as receipt.status_changed, the
arrow is the cause:
cause: initial_review — the first review: recorded with
reimbursementAmount, or rejected with rejection.reason.cause: dispute_upheld — the re-review of a disputed receipt changed the
outcome; the body carries the new values.cause: dispute_dismissed — the re-review confirmed the outcome; the status
stays and the body carries neither reimbursementAmount nor rejection.Rules
recorded and rejected are not final states. A receipt's status always
reflects its latest review, so both recorded → rejected and
rejected → recorded are possible.POST /v1/receipts/dispute) has no status of its own: the receipt
keeps the outcome it has until the new one is decided.in_review produces no receipt.status_changed. That the review
has started is what receipt.in_review says, exactly once per receipt —
how many review steps it takes stays internal.reimbursementAmount in a webhook holds as of the delivery time and is
never back-filled. It is a share of an allocation period — the day for the
meal subsidy, the month for mobility — and changes with the other receipts
in that period:GET /v1/receipts/{receiptId} gives you the current value.X-Hrmony-Event-Id. Within one receipt, occurredAt orders the events:
discard a delivery whose occurredAt is older than the state you have
already applied for that receipt. There is no sequence number; occurredAt
does the same job.POST /v1/receipts/delete) has no status any more, and
the deletion produces no webhook.Delivered once a review outcome is decided. On cause: "dispute_dismissed" nothing changed: the body carries neither reimbursementAmount nor rejection.
| event required | string Value: "receipt.status_changed" |
| occurredAt required | string <date-time> |
| cause required | string What triggered this delivery: Value: "dispute_dismissed" |
required | Receipt stays recorded (object) or Receipt stays rejected (object) |
{- "event": "receipt.status_changed",
- "occurredAt": "2026-05-12T09:14:00Z",
- "cause": "initial_review",
- "receipt": {
- "id": "9f3c1a52-7d4e-4b8a-9c11-2f6b8d0a41e7",
- "organizationId": "a1e5c9d0-6b3f-4c2a-8e17-5d9f2b4a7c60",
- "employeeId": "3b7e9f4d-2c1a-4e6b-9d8f-1a5c3e7b9d24",
- "benefitType": "meal_subsidy",
- "status": "recorded",
- "reimbursementAmount": 7.5
}
}Delivered exactly once per receipt, as soon as it is registered after the upload and therefore in review. The body carries neither status nor reimbursementAmount — the event says that the receipt is being reviewed, not what comes out of it. Not even a dispute repeats it; its outcome arrives as receipt.status_changed.
| event required | string Value: "receipt.in_review" |
| occurredAt required | string <date-time> |
required | object (In review) The receipt is in review. Its status stays |
{- "event": "receipt.in_review",
- "occurredAt": "2026-05-12T09:14:00Z",
- "receipt": {
- "id": "9f3c1a52-7d4e-4b8a-9c11-2f6b8d0a41e7",
- "organizationId": "a1e5c9d0-6b3f-4c2a-8e17-5d9f2b4a7c60",
- "employeeId": "3b7e9f4d-2c1a-4e6b-9d8f-1a5c3e7b9d24",
- "benefitType": "meal_subsidy"
}
}Delivered only when you call POST /v1/webhooks/test — never by anything happening on the platform. The body carries no resource, just the webhookId of the endpoint under test, and every delivery additionally carries the header X-Hrmony-Test: true. It is signed like any other delivery, so you can verify your signature check against it. Never process this event as a business event.
| event required | string Value: "webhook.test" |
| occurredAt required | string <date-time> |
required | object |
{- "event": "webhook.test",
- "occurredAt": "2026-05-12T09:14:00Z",
- "test": {
- "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874"
}
}