Errors
Write your error handling against the status code, not against the shape of the body.
Most failures are RFC 7807 Problem Details, but not all of them: an unauthenticated request returns
no body at all, and a 409 on cost centres carries an operation-specific body. A parser that assumes
a single shape will throw on a failure it was written to handle.
The two standard bodies
Section titled “The two standard bodies”RFC 7807 Problem Details
The API's standard error shape. Emitted by FastEndpoints validation failures (c.Errors.UseProblemDetails()) and by the unhandled-exception handler. This is the shape to write your error handling against.
The status field always repeats the HTTP status code.
| Field | What it carries |
|---|---|
type | A URI reference identifying the problem type. |
title | A short, human-readable summary. Stable for a given problem type. |
status | The HTTP status code. |
detail | A human-readable explanation. On 500 this is populated only in Development; in production it is null so internal messages are never disclosed. |
instance | The request path that produced the error. |
errors | On validation failures, a map of field name to an array of messages. Absent on non-validation errors. |
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"instance": "/branches",
"errors": {
"branchLocation": ["Branch location is required."]
}
}Bare 401
Every 401 has an empty body — there is no JSON payload to parse, so branch on the status code. A business operation rejecting a missing or expired token also sends WWW-Authenticate: Bearer, because that 401 comes from the JWT handler's challenge. POST /apiauth is anonymous, so nothing challenges and the header is absent. Do not branch on the header.
HTTP/1.1 401 Unauthorized
Content-Length: 0
WWW-Authenticate: Bearer # business operations only, not POST /apiauthStatus codes at a glance
Section titled “Status codes at a glance”| Status | Body | What it means | What to do | Retry? |
|---|---|---|---|---|
400 | RFC 7807 Problem Details | The request was rejected before anything changed. | Read errors for the offending field names. These are deterministic — retrying the identical request produces the identical failure. Most come from the payload, so fix it rather than retrying; some come from data already stored, such as an entry's date or status or your agency's settings, which no change to the request fixes: the message says which. | No — fix the request |
401 | Bare 401 | No token, an expired token, or a token the endpoint will not accept. | Obtain a fresh token with POST /apiauth. Request a new token when the current one expires rather than on every call. | After re-authenticating or backing off |
429 | RFC 7807 Problem Details | Too many token exchanges from the same source address in a short window. | Cache the access token and re-exchange only when it expires — read expiresIn for the lifetime. Only POST /apiauth is rate limited; business operations are not. | After re-authenticating or backing off |
403 | RFC 7807 Problem Details | The token is valid but does not carry the permission the endpoint requires. | This is a configuration matter, not a request matter. Contact Recruso support; changing the request will not help. | No — fix the request |
404 | RFC 7807 Problem Details | No such record that your credentials can see. Deliberately indistinguishable from "exists but belongs to another agency" — the API never discloses the existence of another tenant's data. | Confirm the id came from a list call made with the same credentials. If you expect access, check that user's branch access in the Recruso UI. | No — fix the request |
409 | Operation-specific | The name you asked for is held by a soft-deleted record, which still occupies the underlying unique constraint. | The response body carries that record's costCentreId. Either call POST /branches/{branchId}/cost-centres/{id}/restore to bring it back, or choose a different name. | No — fix the request |
500 | RFC 7807 Problem Details | An unhandled exception. Always a defect on the Recruso side. | Retry once with backoff. If it persists, contact Recruso support with the instance path and the approximate time. detail is null in production by design. | After re-authenticating or backing off |
What each status means
Section titled “What each status means”400 — The request was rejected before anything changed.
Why it happens
- A required field was missing or empty.
- A field exceeded the length of its underlying column.
- A uniqueness rule was violated by an active record — for example a branch name already in use, or a cost centre name already used in the same branch.
- Restoring a cost centre whose name has since been taken by an active one.
- For a worker's availability list,
fromortois missing,fromis afterto, or the range covers more days than your agency shows at once in Recruso CRM, at most 30. - An availability date, new or already stored, is more than 20 days before today or more than a year after it.
- An availability status that affects a worker's AWR parity is chosen, or is already held by the entry being changed or removed.
- The entry being changed or removed holds an availability status that is not one of your agency's.
- A worker already has as many availability entries on a date as your agency allows, which is one, or two when it allows more than one status a day.
- Your agency's holiday accrual settings hold more than one entry for the worker's type, so an availability entry cannot be created until they are corrected in Recruso CRM.
- Renaming, or changing the AWR flag of, an availability status that any availability entry in your agency uses.
- For
POST /apiauth, the client id is malformed — it must be the whole value issued by the Developer Console —clientSecretis missing or empty, orgrantTypeis anything other thanclient_credentials. These are request-shape failures, caught before any credential is looked up — which is why they are 400 and not the 401 a wrong-but-well-formed credential gets.
What to do
Read errors for the offending field names. These are deterministic — retrying the identical request produces the identical failure. Most come from the payload, so fix it rather than retrying; some come from data already stored, such as an entry's date or status or your agency's settings, which no change to the request fixes: the message says which.
401 — No token, an expired token, or a token the endpoint will not accept.
Why it happens
- No
Authorization: Bearer {token}header was sent. - The access token has expired.
- For
POST /apiauth, the client id or secret is wrong, the credential has been revoked or has expired, the API app is inactive, the caller is outside the app's configured IP allow-list, or the Recruso user the app belongs to has been disabled. These are deliberately not distinguished from one another.
What to do
Obtain a fresh token with POST /apiauth. Request a new token when the current one expires rather than on every call.
Retrying with the same token will fail identically. Retry only after re-authenticating.
429 — Too many token exchanges from the same source address in a short window.
Why it happens
- Calling
POST /apiauthbefore every request instead of caching the token for its lifetime. - Several instances of an integration authenticating independently from one address.
What to do
Cache the access token and re-exchange only when it expires — read expiresIn for the lifetime. Only POST /apiauth is rate limited; business operations are not.
Back off before retrying. A client that retries immediately stays throttled. The response carries a Retry-After header and a retryAfterSeconds member in the body — wait that long rather than guessing.
403 — The token is valid but does not carry the permission the endpoint requires.
Why it happens
- The token lacks the permission claim the policy demands.
- The endpoint is restricted to Recruso staff and is not part of the integrator surface.
What to do
This is a configuration matter, not a request matter. Contact Recruso support; changing the request will not help.
404 — No such record that your credentials can see. Deliberately indistinguishable from "exists but belongs to another agency" — the API never discloses the existence of another tenant's data.
Why it happens
- The id does not exist.
- The record belongs to a different agency.
- The record has been soft-deleted.
- The worker has been deleted under data-protection law — and so, with them, is every record they held, from a compliance document to an activity.
- The record is under a branch outside the accessible-branch list of the Recruso user behind your API app.
- Nested ids do not line up — for example a real cost centre id under the wrong branch id.
What to do
Confirm the id came from a list call made with the same credentials. If you expect access, check that user's branch access in the Recruso UI.
409 — The name you asked for is held by a soft-deleted record, which still occupies the underlying unique constraint.
Why it happens
- Creating or renaming a cost centre to a name a deleted cost centre in the same branch holds.
What to do
The response body carries that record's costCentreId. Either call POST /branches/{branchId}/cost-centres/{id}/restore to bring it back, or choose a different name.
This status carries an operation-specific body, not Problem Details. See the SoftDeletedCostCentreConflictResponse schema in the reference.
500 — An unhandled exception. Always a defect on the Recruso side.
Why it happens
- Nothing you can correct in the request.
What to do
Retry once with backoff. If it persists, contact Recruso support with the instance path and the approximate time. detail is null in production by design.
404 does not mean “does not exist”
Section titled “404 does not mean “does not exist””Worth restating on its own, because it is the single most common misreading of this API. A 404 means no such record that your credentials can see. It is deliberately indistinguishable from “exists, but belongs to another agency” — the API never discloses the existence of another tenant’s data, so a probe cannot be used to enumerate it.
The consequence for your integration is that a 404 is not proof of absence. It is equally consistent with a record you own that has been soft-deleted, a worker deleted under data-protection law, or a record sitting under a branch outside the accessible-branch list of the Recruso user behind your API app. Confirm the id came from a list call made with the same credentials before treating it as gone.
Retrying
Section titled “Retrying”Only three of these statuses are worth a retry, and none is worth an immediate one.
- 401 — retry once, after obtaining a fresh token. Retrying with the same token fails identically.
- 429 — back off, then retry. This one is nearly always self-inflicted: cache the access token for its full lifetime instead of exchanging credentials before every call.
- 500 — retry once with backoff. If it persists, it is a defect on the Recruso side; contact
support with the
instancepath from the body and the approximate time.
Everything else is deterministic. A 400 produced by the same payload will be produced again by the same payload, so fix the request rather than queueing it for retry.
Responses and pagination — the conventions that apply when a call succeeds.

