Error Handling

A practical guide to handling errors from the SpeyBooks API.

All error responses follow a consistent envelope. Error codes are machine-readable (snake_case strings), messages are human-readable, and an optional field property identifies the offending input for validation errors.


Error response envelope

Every error response has the same structure:

{
  "success": false,
  "error": {
    "code": "error_code",
    "message": "Human-readable explanation",
    "field": "fieldName"
  }
}

The code field is stable and safe to match against in your code. The message field may change between releases and is intended for logging or display. The field property is only present on validation errors.


HTTP status codes

StatusMeaningWhen it happens
400Bad RequestMalformed JSON, missing required fields, invalid field values
401UnauthorisedMissing or invalid API key, expired JWT session
403ForbiddenAPI key lacks the required scope for this operation
404Not FoundResource does not exist, or belongs to a different organisation
409ConflictResource is in a state that prevents the operation
413Payload Too LargeRequest body exceeds the size limit (e.g. CSV import)
422Unprocessable EntityRequest is valid JSON but fails business logic validation
429Too Many RequestsRate limit exceeded - check Retry-After header
500Internal Server ErrorUnexpected failure on our side - safe to retry

Common validation errors

Validation errors (400) include the field property so you can programmatically highlight the problem input.

Error codeFieldCause
validation_errornameRequired field is missing or empty
validation_errorcontactTypeValue must be customer or supplier
validation_erroremailInvalid email format
validation_errortotalAmount must be a positive integer (minor units)
invalid_typepaymentTermsExpected integer, received string
invalid_datedueDateDate must be in YYYY-MM-DD format

All monetary amounts are integers in minor units. A validation error on total with value 5000.00 means you should send 500000 instead (GBP 5,000.00 = 500000 pence).


Conflict errors (409)

Conflict errors occur when a resource is in a state that prevents the requested operation. These are not retryable - the underlying condition must be resolved first.

Error codeCause
already_reconciledTransaction has been reconciled and cannot be modified or deleted
already_paidInvoice is fully paid and cannot be edited
already_voidedResource has been voided and cannot be modified
duplicate_entryA resource with the same unique key already exists

Business logic errors (422)

The 422 status indicates a request that is syntactically valid but violates a business rule.

Error codeContextCause
balance_mismatchBank importsStatement rows do not reconcile to the expected balance
unresolved_conflictsBank importsImport has unresolved duplicate or mapping conflicts
incomplete_ledgerVAT returnsTransactions are missing categorisation for the VAT period
insufficient_profitDividendsAvailable profit is insufficient for the declared dividend amount

Rate limiting

When you exceed the request limit, the API returns 429 Too Many Requests with a Retry-After header. The header value is the number of seconds to wait before retrying.

Do not retry immediately. Read the Retry-After value and wait at least that long. Clients that ignore the header and retry aggressively may be temporarily blocked.


Idempotency keys and failures

Idempotency keys let you safely retry POST requests without creating duplicates. Include an Idempotency-Key header with a unique string (up to 255 characters). Within 24 hours, repeating the same key returns the original response.

How the key behaves depends on the outcome of the first request:

First resultKey behaviourYour action
2xx successStored - replays the original responseSafe to retry with the same key
4xx client errorStored - replays the error responseFix the request body, then use a new key
5xx server errorReleased - key is available for retryRetry with the same key

If a request fails with 5xx, the key is released so you can retry the exact same request. If it fails with 4xx, the error is stored against the key - retrying with the same key and a corrected body will still return the original error. Generate a new key after fixing a 4xx.


Retry strategy

Not all failures should be retried. Use this as a guide:

StatusRetry?Strategy
400, 401, 403, 404NoFix the request - these are deterministic
409NoResolve the conflict, then try the operation again
413NoReduce the payload size
422NoFix the business logic issue (e.g. categorise transactions before filing VAT)
429YesWait for Retry-After seconds, then retry
500YesExponential backoff: 1s, 2s, 4s, 8s, 16s with jitter

For automated workflows, use exponential backoff with jitter on 5xx errors. Start with a 1-second delay and double it on each attempt, adding a random offset of 0-500ms to avoid thundering herd problems. Cap retries at 5 attempts.

For rate limit errors (429), always respect the Retry-After header rather than using your own backoff schedule.

Which operations are idempotent?

GET, PUT, and DELETE requests are inherently idempotent - repeating them produces the same result. POST and PATCH requests are not idempotent by default. Use the Idempotency-Key header for any POST request in an automated workflow.


Webhook delivery failures

When SpeyBooks cannot deliver a webhook event, it retries with increasing delays. The retry schedule uses exponential backoff: roughly 1 minute, 5 minutes, 30 minutes, 2 hours, and 24 hours. After 5 failed delivery attempts, the event is marked as failed. The endpoint stays active and continues to receive new events.

Monitor delivery health via the Webhook Endpoints API. The GET /webhook-endpoints/{id}/deliveries endpoint returns delivery logs including status codes, response times, and retry counts.


Best practices

Write your error handling to match on error.code, not error.message. Codes are stable and machine-readable; messages may be reworded in future releases.

For validation errors, use the field property to highlight the specific input. When building a form that submits to the API, map error.field directly to the form field name.

Log the full error response body on failures. The code and message together provide enough context for debugging without needing to reproduce the request.