Errors
Failures return an HTTP status and a JSON error object. Branch on the status and code; message is for people and can change.
Error object
{
"error": {
"code": "VALIDATION_ERROR",
"message": "role is required",
"details": [
{ "field": "role", "message": "role is required" }
],
"errorId": "err_3f9a1c7e5b2d4a60"
}
}
| Field | Present | |
|---|---|---|
code | Always | See Codes. |
message | Always | Human-readable. For validation errors, the first entry in details. |
details | Body and query validation errors | { field, message } per problem. field is a dot path, such as salary.currencyCode or readings.3.periodEnd. |
errorId | 400, 403, 404, 500 | Quote it to Flowstate support. |
401, 409 and 501 bodies have code and message only.
Codes
| Status | code | When |
|---|---|---|
400 | VALIDATION_ERROR | A body or query parameter fails validation; an include value isn’t allowed; a unique value (externalId, or an employee email) is already taken; or a teamId, projectId, employeeId, contractorId or vacancyId in an assignment body, or in a nested teamAssignment or projectAssignment, doesn’t exist. |
401 | UNAUTHORIZED | Missing, malformed, unknown, revoked or expired key. See Authentication. |
403 | FORBIDDEN | The key lacks the permission, or orgId isn’t the key’s organisation. |
404 | NOT_FOUND | The record in the path doesn’t exist in your organisation; a managerId or jobRole.externalId in the body doesn’t resolve; or scenarioId isn’t one of your scenarios. |
409 | CONFLICT | Filling a vacancy that’s already filled. |
429 | — | Rate limited. See Rate limits. |
500 | INTERNAL_ERROR | Server error. |
501 | NOT_IMPLEMENTED | A create, update, delete or vacancy fill sent with scenarioId. See Scenarios. |
Functional groups use their own codes (INVALID_INPUT, FEATURE_DISABLED, PLAN_NOT_EDITABLE and others) in the same { "error": { "code", "message" } } shape, without errorId.
Rate limits
POST /business-metrics/readings allows 120 requests a minute per key. Over the limit it returns 429, a Retry-After header in seconds, and a different body:
{
"error": "too_many_requests",
"error_description": "Rate limit exceeded. Please retry later."
}
Handle 429 the same way on any endpoint. The MCP server’s limits are on MCP server.
Retrying
| Response | Retry |
|---|---|
429 | After Retry-After seconds. |
500 | With exponential backoff and a cap on attempts. |
400 401 403 404 409 501 | No. Fix the request, key or permissions. |
Creates aren’t idempotent. Before retrying a POST that may have succeeded, look the record up by its externalId (GET /employees/{externalId}); a duplicate externalId returns 400.
async function request(url, init, maxAttempts = 4) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, init);
const retryable = res.status === 429 || res.status === 500;
if (!retryable || attempt === maxAttempts) return res;
const retryAfter = Number(res.headers.get("Retry-After"));
const delayMs = res.status === 429 && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}