Hrmony Embedded API — Partner (EN) (1.0.0)

Download OpenAPI specification:

License: LicenseRef-Hrmony-Proprietary

Operations that concern the whole partner scope rather than a single organization: creating and listing organizations, managing webhooks.

Authentication — backend token

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.

Credentials and scopes

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:

  • A token belongs to exactly one tier. Scopes from two tiers in one request are rejected with 400 invalid_scope, and the other two tiers' gateways reject a token of this tier with 401.
  • A token carries exactly the scopes requested. 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.
  • The required scope is stated on every operation in this specification as 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.

No organization context

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.

Webhooks

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.

API style

Write endpoints carry a verb in the path: POST …/employees/create rather than POST …/employees. This is deliberate.

The style is hybrid by design:

  • Writes are RPC. The path names the action, the body carries the data.
  • Reads are REST. 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.

Errors

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.

Throttling (429)

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.

Language

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.

Organizations

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.

Create an organization

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.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "externalCustomerId": "kunde-abc123",
  • "name": "Müller GmbH"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

Rename an organization

Sets the display name of the given organization to a new value.

  • If no organization is known under 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.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{
  • "organizationId": "2b8f4c17-9a03-42de-b6c8-5d1e7f094a22",
  • "name": "Müller Holding GmbH"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List organizations for the partner

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>
Authorizations:
clientCredentials
query Parameters
limit
integer >= 1
Default: 50

Page size: default 50, at most 1000. A larger value is rejected with 400 /errors/limit-exceeded.

cursor
string

Opaque cursor from meta.pagination.nextCursor.

externalCustomerId
string

Comma-separated for multiple values. A comma always separates values and is never part of a value.

header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Webhooks

Register, deregister, list and test webhook endpoints.

Register webhook

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

  • The 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

  • Every delivery carries three 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.
  • Verify in this order: (1) reject the delivery if 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.
  • Because the timestamp is covered by the signature, a captured delivery replayed later fails your tolerance check. A retry is signed again with a fresh timestamp, so a legitimate retry always arrives inside the window.
  • This signature is the only authentication on inbound deliveries — reject any request that does not verify.

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

  • Your 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.
  • Deliveries may be duplicated (for example on retry). Deduplicate on the 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.
  • Each event is a point-in-time notification, not a source of truth: it is sent once and not corrected later. Re-query the API if you need the current value.
  • Deliveries come from no fixed IP range, so an IP allowlist is not possible. The signature is the authentication — see Verifying deliveries above.
  • A later url change does not need a new registration: POST /v1/webhooks/update repoints this endpoint and keeps its webhookId and signing secret.
  • You may register multiple endpoints; each has its own signing secret and its own eventTypes. Registering the same URL twice gives you two registrations and therefore two deliveries per event.
  • The 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.
  • The delivery body is the flat event itself — event, occurredAt and the resource — not the standard response envelope. Dispatch on event.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
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. webhook.test cannot be unsubscribed — it only ever arrives because you triggered it.

Responses

Request samples

Content type
application/json
Example

Response samples

Content type
application/json
Example
{
  • "data": {
    }
}

Change webhook URL

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.

  • Use this for a server move, a new path, or a switch to a different host. Because the secret survives, your signature check needs no change and there is no moment without an active registration: the response is the switch-over.
  • The url must be a public HTTPS endpoint, exactly as at registration.
  • Only the URL is changeable. eventTypes, language and the signing secret are fixed for the registration lifetime — to change one of those, deregister and register again.
  • An unknown webhookId is an error, never a new registration.
  • Deliveries already in flight when you call this may still reach the old URL. Send a test delivery afterwards to confirm the new one.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
{}

Response samples

Content type
application/json
{}

Test webhook

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.

  • The delivery is signed exactly like a real one, with that endpoint's own signing secret, and is translated into the language the endpoint was registered under. Verifying it verifies your production signature check.
  • The body is a 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.
  • Delivery is attempted once, synchronously, with no retry. 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.
  • A refusing endpoint is not an API error: this operation answers 200 and reports the outcome in its data.
  • A test delivery ignores 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.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
webhookId
required
string <uuid>

Responses

Request samples

Content type
application/json
{
  • "webhookId": "7e42a9c8-1b35-4d6f-8a20-c93f5e11d874"
}

Response samples

Content type
application/json
{}

Deregister webhook

Deregisters a webhook so that it no longer receives deliveries.

  • Provide the webhookId returned by the register endpoint (or from listing webhooks). Deregistering permanently stops delivery to that endpoint and invalidates its signing secret.
  • There is no secret rotation: to rotate, deregister and register again — you receive a new webhookId and a new signing secret. A URL change needs none of this: POST /v1/webhooks/update repoints an endpoint and keeps both.
  • If the webhookId does not exist, an error is returned; treat 'not found' as already deregistered.
Authorizations:
clientCredentials
header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Request Body schema: application/json
required
webhookId
required
string <uuid>

Responses

Request samples

Content type
application/json
{
  • "webhookId": "ed71eef4-4c34-46dc-81fe-954e560454fd"
}

Response samples

Content type
application/json
{
  • "data": {
    }
}

List webhooks

Lists your registered webhook endpoints (webhookId, url, registeredAt, language, eventTypes).

  • The signingSecret is never returned here — it is only shown once, at registration.
  • Use this to discover 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.
Authorizations:
clientCredentials
query Parameters
limit
integer >= 1
Default: 50

Page size: default 50, at most 1000. A larger value is rejected with 400 /errors/limit-exceeded.

cursor
string

Opaque cursor from meta.pagination.nextCursor.

header Parameters
Accept-Language
string
Value: "en"

Optional. This deployment serves the English language surface only; the only accepted value is en.

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.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "meta": {
    }
}

Webhook Events

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

State diagram of a receipt

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.
  • A dispute (POST /v1/receipts/dispute) has no status of its own: the receipt keeps the outcome it has until the new one is decided.
  • Entering 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.
  • Every re-review produces another event for the same receipt id, even when the outcome is unchanged.
  • A 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:
    • When a further receipt for the same period arrives, the amount on the receipts already recorded might go down.
    • When one falls away — deleted, or rejected by a re-review — the amount on the remaining ones might go up.
    • No webhook follows for the receipts changed that way; only the receipt that was itself re-reviewed triggers one, and there are no correction events.
    • Only GET /v1/receipts/{receiptId} gives you the current value.
  • The order in which deliveries arrive is not guaranteed; deduplicate on 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.
  • A deleted receipt (POST /v1/receipts/delete) has no status any more, and the deletion produces no webhook.

Receipt status has changed Webhook

Delivered once a review outcome is decided. On cause: "dispute_dismissed" nothing changed: the body carries neither reimbursementAmount nor rejection.

Authorizations:
clientCredentials
Request Body schema: application/json
required
One of
event
required
string
Value: "receipt.status_changed"
occurredAt
required
string <date-time>
cause
required
string

What triggered this delivery: initial_review the receipt's first review, dispute_upheld a re-review that changed the disputed outcome, dispute_dismissed a re-review that confirmed it.

Value: "dispute_dismissed"
required
Receipt stays recorded (object) or Receipt stays rejected (object)

Responses

Request samples

Content type
application/json
Example
{
  • "event": "receipt.status_changed",
  • "occurredAt": "2026-05-12T09:14:00Z",
  • "cause": "initial_review",
  • "receipt": {
    }
}

In review Webhook

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.

Authorizations:
clientCredentials
Request Body schema: application/json
required
event
required
string
Value: "receipt.in_review"
occurredAt
required
string <date-time>
required
object (In review)

The receipt is in review. Its status stays in_review until the outcome is decided.

Responses

Request samples

Content type
application/json
{
  • "event": "receipt.in_review",
  • "occurredAt": "2026-05-12T09:14:00Z",
  • "receipt": {
    }
}

Test event Webhook

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.

Authorizations:
clientCredentials
Request Body schema: application/json
required
event
required
string
Value: "webhook.test"
occurredAt
required
string <date-time>
required
object

Responses

Request samples

Content type
application/json
{
  • "event": "webhook.test",
  • "occurredAt": "2026-05-12T09:14:00Z",
  • "test": {
    }
}