Errors
The Wingspan V3 error format, every error code and HTTP status, and which errors to retry, with what backoff.
Handle Wingspan V3 errors consistently by checking the HTTP status and code, then retrying only the responses that support retries. Every V3 endpoint returns errors in the same format, so one error handler covers the whole API.
The error format
Errors come back with an HTTP status of 400 or higher and a body of type application/problem+json, following RFC 9457:
{
"type": "https://api.wingspan.app/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "Request failed validation.",
"code": "ValidationError",
"requestId": "3f2c9a7e1b4d4e0f9a8c6b5d2e1f0a93",
"errors": [
{ "field": "dueDate", "code": "Required" },
{ "field": "lineItems", "code": "TooShort" }
]
}| Field | What it's for |
|---|---|
type | A stable URI for the kind of error. |
title | A short, human-readable name for the error. |
status | The HTTP status, repeated in the body. |
detail | An explanation for a person reading logs. Its wording can change, so don't parse it. |
code | The machine-readable error code. Branch your code on this field. |
requestId | The ID of this request. Log it, and quote it when you contact support. |
errors | Present on 422 only. One entry per field that failed, each with field, a code, and sometimes a message. |
detailCode | Optional. A finer reason from the service that handled the request, such as payments.PayableCurrencyNotAllowed. Useful for logs and support. |
extensions | Optional. Structured data for specific codes, such as the bucket that was exhausted on a 429. |
Branch on code, not detailCode
code, not detailCodecode comes from a fixed list (below) and keeps its meaning. Services can add detailCode values without notice, so your code can encounter values it does not recognize. Use detailCode for logging, dashboards, and support tickets. Make decisions on code and status. If you inspect detailCode, handle unknown values by falling back to code.
detailCode always has the form {service}.{Reason}. The service part tells you which area handled the request: payments, finance, onboarding, compliance, users (identity and Accounts), integrations (accounting connections), operations (async operations), or revenue (plans and subscriptions). Quote requests use the older prefixes currency and amount.
Nothing is dropped silently
If a request contains a field, query parameter, filter, or expand value that the operation doesn't accept, the request fails with 422 ValidationError and the problem is listed in errors[]. Wingspan doesn't ignore the bad part and return success. When a request succeeds, every field you sent was accepted.
HTTP status codes
| Status | Meaning |
|---|---|
400 | The body isn't valid JSON (code: ValidationError, no errors[]). Also AccountScopeNotApplicable when you send X-Wingspan-Account to an endpoint that doesn't accept it, and BadRequest on a few operations that require a header. |
401 | The token is missing, invalid, or expired. |
402 | A payment rail returned a failure (PaymentFailed). |
403 | You're authenticated but not allowed to do this. |
404 | The resource doesn't exist, or you can't see it. Wingspan returns 404 rather than 403 so that IDs can't be used to discover other customers' records. |
405 | The path exists but doesn't support this HTTP method. The Allow header lists the methods it does support. |
409 | The request conflicts with the current state: a duplicate, a transition the resource can't make, or an Idempotency-Key in use. |
410 | An invite token has expired or has already been used. |
412 | The If-Match value no longer matches the resource. See Concurrency and ETags. |
413 | The request body is larger than 1 MB (code: ValidationError). |
422 | The request is well formed but a value is invalid. See errors[] when it's present. |
423 | The Account is temporarily not accepting V3 writes. |
429 | You've hit a rate limit. See Rate limiting. |
500 | Rare. An internal consistency check failed. Most unexpected failures return 503. |
501 | The operation is listed in the API but is currently unavailable. |
503 | Wingspan or one of its dependencies failed, usually temporarily. Most unexpected failures return this status. Safe to retry. |
A missing or wrong Content-Type returns 422 ValidationError. Send Content-Type: application/json on every request with a body.
Error codes
code | Status | When you'll see it |
|---|---|---|
BadRequest | 400 | Returned by a few operations when a required header is missing. |
AccountScopeNotApplicable | 400 | You sent X-Wingspan-Account to an endpoint that doesn't use it, such as a Person-only or global endpoint. |
Unauthenticated | 401 | The bearer token is missing or invalid. |
TokenExpired | 401 | The token was valid but has expired. |
ReplicationLagAccountUnready | 401 | Reserved. It's part of the API's code list, but the API doesn't currently return it. Treat it like Unauthenticated if you ever see it. |
PaymentFailed | 402 | The payment rail rejected the funds movement. |
Forbidden | 403 | You don't have the role or permission for this action, or the Account doesn't have the product the operation needs. |
ScopeInsufficient | 403 | Your token or API key doesn't include a scope this operation requires. |
AccountMismatch | 403 | The X-Wingspan-Account value, or the Account in the path, is an Account you can't act on. |
StepUpMfaRequired | 403 | This action needs a recent multi-factor check. Complete the challenge named in extensions and retry. |
ResourceNotFound | 404 | The resource doesn't exist or isn't visible to you. |
MethodNotAllowed | 405 | The path doesn't support this method. See the Allow header. |
InvalidStateTransition | 409 | The action needs the resource to be in a different status, such as paying an invoice that's still a draft. |
ResourceConflict | 409 | A duplicate, such as a second record with the same externalId, or another uniqueness conflict. |
ResourceLocked | 409 | The resource is in the middle of a transition. Retry shortly. |
IdempotencyKeyConflict | 409 | The Idempotency-Key was used with a different body, or the first request with that key is still running. See Idempotency. |
EligibilityBlocked | 409 | The payee or engagement has unmet blocking requirements. |
RequirementNotFulfilled | 409 | A specific requirement the action needs hasn't been completed. |
PrincipalLockedByActiveEngagement | 409 | The Account's principal can't change while an employee engagement is active. |
InsufficientFunds | 409 | The funding source doesn't cover the amount. |
EventCursorExpired | 409 | An event-log cursor is too old or doesn't match your query. See Recover missed events. |
WebhookSecretRotationInProgress | 409 | A webhook secret rotation is already in its grace period. |
InviteTokenExpired | 410 | The invite token has expired or has already been redeemed. Send a new invite. |
PreconditionFailed | 412 | Your If-Match value doesn't match the resource's current ETag. |
ValidationError | 422 | One or more values are invalid. See errors[]. |
MissingRequiredField | 422 | A single required field is missing. |
InvalidFormat | 422 | A single field has the wrong format. |
PrincipalRequired | 422 | An employee engagement needs the Account to have a principal Person first. |
OwnerRequired | 422 | An API key create needs an explicit owner. |
AccountQuiescedForV3 | 423 | The Account is temporarily read-only in V3. Reads still work. Contact support. |
RateLimitExceeded | 429 | A rate-limit bucket is empty. Wait for Retry-After. |
InternalError | 500 | An internal consistency check failed. Quote requestId to support if it keeps happening. |
NotImplemented | 501 | The operation is currently unavailable. Retrying won't help. |
ServiceUnavailable | 503 | A dependency is temporarily down. Retry with backoff. |
PaymentRailUnavailable | 503 | A payment rail is temporarily unavailable. Retry with backoff. |
Field error codes
Each entry in errors[] has one of these codes:
errors[].code | Meaning |
|---|---|
Required | The field is missing. |
InvalidFormat | The value has the wrong type or format. |
OutOfRange | A number is too small or too large. |
TooShort, TooLong | A string or list is too short or too long. |
DuplicateValue | A value that must be unique is repeated. |
UnsupportedValue | The value isn't one of the allowed values, or the field isn't accepted by this operation. |
ConflictingFields | Two fields that can't be used together were both sent. |
EligibilityBlocked | The field refers to something that isn't eligible yet. |
NotApplicableForMemberType | The field doesn't apply to this kind of record. |
RoundingResidual | Split percentages don't add up to 100% and no remainder is set. |
CurrencyMismatch | Amounts on one document use more than one currency. |
Mismatch | A value doesn't match its verified source. |
ResourceNotFound | The field refers to a resource that doesn't exist. |
Handling errors well
Which errors to retry
| Response | Retry? | How |
|---|---|---|
429 RateLimitExceeded | Yes | Wait the number of seconds in Retry-After, then retry. See Rate limiting. |
503 ServiceUnavailable, 503 PaymentRailUnavailable | Yes | Exponential backoff with jitter. Use extensions.retryAfter or Retry-After if present. |
500 InternalError | A few times | Exponential backoff with jitter. If it keeps failing, stop and contact support with the requestId. |
| Network timeout, connection reset | Yes | Retry with the same Idempotency-Key so the work isn't repeated. |
409 IdempotencyKeyConflict with extensions.retryAfter | Yes | The first request with this key is still running. Wait, then retry with the same key and body. |
409 ResourceLocked | Yes | Retry after a short delay. |
401 TokenExpired | After refreshing | Get a new token, then retry. |
412 PreconditionFailed | After re-reading | GET the resource, reapply your change to the current version, and retry with the new ETag. |
409 IdempotencyKeyConflict without retryAfter | No | You reused a key with a different body. Send a new request with a new key. |
409 InvalidStateTransition, ResourceConflict, EligibilityBlocked, RequirementNotFulfilled, InsufficientFunds | No | Read the resource, fix the underlying state, then send a new request. |
400, 413, 422 | No | Fix the request using errors[] and detail. For a 413, send a smaller body. |
401 Unauthenticated, 403, 404 | No | Fix the token, permissions, X-Wingspan-Account, or ID. |
423 AccountQuiescedForV3 | No | Contact support. |
501 NotImplemented | No | The operation isn't available. |
Start exponential backoff at about one second and double the delay after each attempt. Add random jitter, cap the delay at about 30 seconds, and stop after a limited number of attempts. Always honor Retry-After when it is present.
Retrying creates and money movement
Send an Idempotency-Key on every create and every money-moving request, and reuse the same key when you retry. Wingspan stores the response and replays it, so a retry doesn't create a second record or move money twice.
Responses with a 5xx status aren't stored, so a retry after a 5xx runs the request again. The first attempt may have finished before the error. Before you retry a create after a 5xx, look the record up by its externalId if you set one, or re-read the resource you were changing. See Idempotency.
429 compared with 503
A 429 means you sent more requests than your quota allows. Slow down and follow Retry-After. A 503 means Wingspan or one of its providers is temporarily unavailable, and it has nothing to do with your request volume. Back off and retry. Wingspan never returns 429 for its own outages, so a 429 always means you should reduce your request rate.
409 compared with 412
A 409 means the request conflicts with the resource's state: the resource is in the wrong status, or a record like it already exists. Resending the same request won't help until the state changes. A 412 means someone changed the resource after you read it. Your request may still be valid, but it was written against an old version. Re-read the resource, check that your change still makes sense, and retry with the new ETag.
Use requestId with support
requestId with supportLog requestId with every error, alongside the method, path, and status. When you contact support, include the requestId and the time of the request. It lets us find the exact request in our logs without you sending payloads that may contain personal or financial data.
Check every item in a bulk operation
A batch can finish while individual items fail. After a batch completes, list its items with filter[status][anyOf][]=Failed and read each item's statusReason. See Batches and bulk operations.
From V1
- V1 errors had a numeric
codeand amessage. V3 errors have an HTTPstatus, a stringcodefrom the table above, and per-fielderrors[]on422. - V1 returned
400for most validation failures. V3 uses400only for requests that can't be read, and422for invalid values. - V1 returned
208 Already Reportedfor a duplicate bulk payable item. V3 returns the original response when you retry with the sameIdempotency-Key, and409 ResourceConflictfor a duplicateexternalId. - V1 put bulk item errors in
metadata.errorMessage. V3 puts them in each batch item'sstatusReason.
Related pages
Updated 10 days ago