Concepts

Error Handling

Every API error returns a consistent JSON structure with a machine-readable code, human-readable message, and optional details. Learn the full error taxonomy and how to handle each category.

Error Response Format

All error responses use a consistent envelope. The top-level error object contains three fields:

code — A unique, machine-readable string you can switch on programmatically (e.g. INSUFFICIENT_CREDITS).

message — A human-readable explanation safe to log or display.

details — An optional object with structured context such as missing fields, invalid values, or retry timing.

Response200 OK
// Route error (most endpoints)
{
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits to submit this application"
  }
}

// Validation error (422)
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "field 'email' is required",
    "details": [
      { "loc": ["body", "candidate", "personalInformation", "email"], "msg": "field required", "type": "value_error.missing" }
    ]
  }
}

// Rate limit error (429) — includes retryAfter
{
  "error": {
    "code": "RATE_LIMIT_CONCURRENT_EXCEEDED",
    "message": "Too many concurrent requests. Maximum 25 allowed.",
    "retryAfter": 30
  }
}

HTTP Status Codes

The API uses standard HTTP status codes.

As a rule of thumb: 4xx errors (except 429) should not be retried without changing the request. 429 and 5xx errors are safe to retry with backoff.

Authentication Errors

These errors occur when the request cannot be authenticated. Check your API key or admin secret.

NameTypePresenceDescription
INVALID_API_KEY401AlwaysThe Bearer token is missing, malformed, or does not match any active client. Verify your API key starts with bp_live_ and is 40 characters.
UNAUTHORIZED401AlwaysThe X-Admin-Secret header is missing or does not match the server's configured secret. Only applies to /internal/ endpoints.
CLIENT_INACTIVE403AlwaysThe API key is valid but the client account has been suspended. Contact support or use PUT /internal/clients/:clientId to reactivate.
ADMIN_NOT_CONFIGURED500AlwaysThe server's admin secret is not configured. This is a server-side configuration issue — contact the platform administrator.

Validation Errors

Returned when request parameters or body fields fail validation. The details array in the response pinpoints every invalid field with its location, message, and error type.

NameTypePresenceDescription
VALIDATION_ERROR422AlwaysOne or more request body fields failed schema validation. Check the details array for specific fields and error messages.
INVALID_STATUS400AlwaysAn invalid status value was provided. For users and clients, status must be 'active' or 'inactive'. For applications, see the valid status list in the error message.
INVALID_MANDATORY_FIELDS400AlwaysAttempted to remove a system-required field from the mandatory fields configuration. The error message lists which fields cannot be removed.
INVALID_IDEMPOTENCY_KEY400AlwaysThe Idempotency-Key header is not a valid UUID v4. Generate keys using a proper UUID library.
Response200 OK
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "field 'email' is required",
    "details": [
      {
        "loc": ["body", "personalInformation", "email"],
        "msg": "field required",
        "type": "value_error.missing"
      },
      {
        "loc": ["body", "resumeUrl"],
        "msg": "invalid url format",
        "type": "value_error.url"
      }
    ]
  }
}

Resource Errors

Returned when a referenced resource does not exist or belongs to a different client. The API enforces ownership — you can only access resources that belong to your authenticated client.

NameTypePresenceDescription
USER_NOT_FOUND404AlwaysNo user exists with the given ID, or the user belongs to a different client.
PROFILE_NOT_FOUND404AlwaysNo candidate profile exists with the given ID, or it belongs to a different client.
PROFILE_INCOMPLETE400AlwaysThe candidate profile is missing mandatory fields required for application submission. Validate the profile first with GET /profiles/:profileId/validate.
SESSION_NOT_FOUND404AlwaysNo session exists with the given ID, or it belongs to a different client.
RESUME_NOT_FOUND404AlwaysNo resume exists with the given ID, or it belongs to a different client.
APPLICATION_NOT_FOUND404AlwaysNo application exists with the given ID, or it belongs to a different client.
CLIENT_NOT_FOUND404AlwaysNo client exists with the given ID. Only returned by /internal/ admin endpoints.
NOT_FOUND404AlwaysGeneric not-found for search records, demo users, or demo profiles.

Business Logic Errors

These errors indicate the request is valid but violates a business rule — duplicate resources, insufficient credits, or invalid session operations.

NameTypePresenceDescription
INSUFFICIENT_CREDITS402AlwaysYour account has zero credits remaining. Applications cannot be submitted until credits are added.
DUPLICATE_APPLICATION409AlwaysAn application with the same candidate email and job URL already exists. Each candidate can only apply to a job once.
DUPLICATE_USER409AlwaysA user (or demo user) with the same externalUserId already exists for your client.
INVALID_SESSION_TYPE400AlwaysThe operation is not valid for this session type. For example, PUT /sessions/:id/recurring only works on autopilot_recurring sessions.
INVALID_SESSION_STATE400AlwaysThe session is in a state that does not allow this operation. For example, you cannot pause a completed session or update a cancelled session.
RATE_LIMIT_CONCURRENT_EXCEEDED429AlwaysToo many concurrent requests. The response includes retryAfter (seconds) and the X-RateLimit-Limit / X-RateLimit-Remaining headers.

Handling Errors in Code

Build a centralized error handler that inspects the HTTP status code first, then the error code for specific logic. Here is a production-ready pattern for each language.

# Use -w to capture the HTTP status code alongside the response body
response=$(curl -s -w "\n%{http_code}" -X POST https://apply-api.boringproject.ai/api/v1/apply \
  -H "Authorization: Bearer bp_live_..." \
  -H "Content-Type: application/json" \
  -d '{"job_url": "https://boards.greenhouse.io/acme/jobs/12345", "candidate": {...}, "resume_url": "..."}')

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [ "$http_code" -ge 400 ]; then
  error_code=$(echo "$body" | jq -r '.detail.code // .error.code')
  error_msg=$(echo "$body" | jq -r '.detail.message // .error.message')
  echo "Error [$http_code] $error_code: $error_msg"

  case "$error_code" in
    INSUFFICIENT_CREDITS) echo "Add credits before retrying" ;;
    DUPLICATE_APPLICATION) echo "Already applied — skip" ;;
    RATE_LIMIT_CONCURRENT_EXCEEDED) echo "Waiting 30s..."; sleep 30 ;;
    VALIDATION_ERROR) echo "$body" | jq '.error.details' ;;
  esac
fi

Retry Strategy

Not all errors should be retried. Follow these guidelines:

Do not retry 400, 401, 402, 403, 404, 409, or 422 errors — these indicate a problem with the request itself. Fix the input and try again.

Retry 429 errors after waiting the number of seconds specified in the retryAfter field (default 30 seconds).

Retry 500, 502, 503, and 504 errors with exponential backoff: wait 1s, 2s, 4s, 8s, up to a maximum of 60 seconds. Add random jitter (0-1s) to prevent thundering herds.

Set a maximum retry count (recommended: 3 attempts) to avoid infinite loops.

Always use idempotency keys (Idempotency-Key header) on POST requests so that retries do not create duplicate resources.

Always include an Idempotency-Key header on retried POST requests. Without it, a retry could create a duplicate application or user if the original request succeeded but the response was lost.
async function withRetry(fn, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (!(err instanceof BoringApiError)) throw err;

      // Never retry client errors (except 429)
      if (err.status >= 400 && err.status < 500 && err.status !== 429) throw err;

      if (attempt === maxRetries) throw err;

      const delay = err.code === 'RATE_LIMIT_CONCURRENT_EXCEEDED'
        ? 30_000
        : Math.min(1000 * 2 ** attempt, 60_000) + Math.random() * 1000;

      console.log(`Retry ${attempt + 1}/${maxRetries} in ${Math.round(delay / 1000)}s...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}