Skip to main content
The Volunteer API’s error documentation is the sparsest of the three Virtuous APIs. The OpenAPI spec documents error responses on only three of the twenty endpoints (POST /groups, PUT /groups/{id}, PUT /groups/{id}/members — all with a single 422 validation errors). No endpoint formally documents 401, 403, 404, or 500. This gap doesn’t mean those errors don’t occur — they do. It means partner integrations must build defensive error handling without spec guidance about exact response shapes or status codes. This page covers what to expect from the live API, the classification framework that handles each case appropriately, and the patterns that produce reliable production code. ⚠️ Spec gap: The Volunteer OpenAPI spec documents error responses on only three endpoints (all 422 validation errors). The rest of the spec is silent on error responses entirely. The patterns on this page reflect what the live API likely returns based on common REST conventions; confirm against actual responses for production-critical workflows.

What the spec confirms

The three endpoints that do document error responses:
EndpointDocumented error
POST /groups422 validation errors
PUT /groups/{id}422 validation errors
PUT /groups/{id}/members422 validation errors
Everything else is undocumented. No endpoint exposes the shape of an error response, the fields available on a problem object, or the headers returned with error responses.

What the live API likely returns

Based on common conventions for Laravel-style APIs (which Volunteer appears to be):
HTTP statusWhen likely returnedBody likely shape
401 UnauthorizedMissing, invalid, or expired Bearer token{ "message": "Unauthenticated." } or similar
403 ForbiddenToken valid but lacks permission for the resource{ "message": "This action is unauthorized." }
404 Not FoundResource ID doesn’t exist or isn’t accessible{ "message": "Resource not found." }
422 Unprocessable EntityRequest body validation failure{ "message": "The given data was invalid.", "errors": { "field": ["error message"] } }
429 Too Many RequestsRate limit exceeded (not documented but possible)Possibly empty or { "message": "Too Many Requests" }
500 Internal Server ErrorServer-side bug or unexpected failurePossibly empty or { "message": "Server Error" }
503 Service UnavailableMaintenance or overloadPossibly empty
These shapes are common in Laravel-based APIs but aren’t formally documented for Volunteer. Don’t switch business logic on specific error message strings — they’re not a contract.

The defensive classification framework

The classifier turns any error response into a category that determines how to handle it:
function classifyError(status, body) {
  // Network or TLS errors (no status)
  if (!status) return 'transient';

  // Success
  if (status >= 200 && status < 300) return 'success';

  // 3xx — typically redirects; treat as transient
  if (status >= 300 && status < 400) return 'transient';

  // Specific 4xx codes
  if (status === 401) return 'auth_failed';
  if (status === 403) return 'forbidden';
  if (status === 404) return 'not_found';
  if (status === 422) return 'validation_failed';
  if (status === 429) return 'rate_limited';

  // Other 4xx — generally not retryable
  if (status >= 400 && status < 500) return 'permanent_client';

  // 5xx — transient server issues
  if (status >= 500) return 'transient';

  return 'unknown';
}
Each classification determines the right response:
ClassificationRetry?Action
successN/AUse the response.
auth_failedNoToken is bad — pause integration for this customer; alert ops.
forbiddenNoPermission issue — alert ops; don’t keep trying.
not_foundNoResource doesn’t exist — return null to caller; let them decide.
validation_failedNoRequest body has issues — surface field-level errors to user.
rate_limitedYesHonor Retry-After if present; otherwise back off.
transientYesExponential backoff with jitter; bounded attempts.
permanent_clientNoUnknown 4xx — log and surface; don’t retry.
unknownNoLog loudly; surface for manual review.

Per-status handling in depth

401 Unauthorized: don’t retry

A 401 means the token isn’t being accepted. Common causes:
CauseResolution
Token expired (if Volunteer enforces expiration)Customer’s administrator generates a new token
Token was revoked in the admin portalSame
Token is malformed in the request (whitespace, missing Bearer prefix)Inspect the actual request — typically a partner-side bug
API access was disabled for the customer’s VOMO accountCustomer contacts their VOMO concierge
The response:
if (response.status === 401) {
  await pauseIntegrationForCustomer(customerId, 'auth_failed');
  await alertOps({
    severity: 'high',
    customerId,
    message: 'VOMO token rejected — credential needs refresh',
  });
  throw new AuthError('VOMO token is invalid');
}
Critical: retries against an invalid token just produce more 401s and noise. Pause the customer’s work; alert ops. See Authentication: Handling auth failures.

403 Forbidden: also don’t retry

A 403 means the token is valid but doesn’t have permission for the specific endpoint. Causes:
CauseResolution
The token’s organization doesn’t have access to the requested resourceThe resource belongs to another organization — partner has the wrong token loaded
The customer’s plan doesn’t include the featureCoordinate with VOMO concierge
The endpoint requires a higher permission tierSame
if (response.status === 403) {
  await alertOps({
    severity: 'medium',
    customerId,
    endpoint: url,
    message: 'VOMO token lacks permission for this endpoint',
  });
  throw new ForbiddenError(`No permission for ${url}`);
}
Like 401, don’t retry — the permission isn’t going to change in seconds.

404 Not Found: handle gracefully

A 404 from a single-resource endpoint (e.g., GET /users/12345) means the user with that ID doesn’t exist in the customer’s organization. Causes:
CauseResolution
The ID was wrongCaller bug
The user was deleted between the integration learning about them and the lookupRace condition — handle as “not found”
The user exists in a different organizationPartner is using the wrong token
The endpoint doesn’t exist (rare — partner constructed a wrong URL)Partner-side URL bug
async function getUserById(userId) {
  const response = await fetch(`https://api.vomo.org/v1/users/${userId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (response.status === 404) {
    return null; // Caller decides what to do
  }

  if (!response.ok) {
    throw await buildError(response);
  }

  return parseUser(await response.json());
}
Don’t retry 404 — a non-existent resource will continue to not exist on retry. Return null and let the caller handle the absence.

422 Validation Error: the documented case

The three endpoints that document 422 (POST /groups, PUT /groups/{id}, PUT /groups/{id}/members) return validation errors when the request body doesn’t pass validation. Other endpoints that accept request bodies (POST /projects, POST /users, PUT /projects/{id}) almost certainly return 422 similarly even though it’s undocumented. The body shape (assuming Laravel conventions):
{
  "message": "The given data was invalid.",
  "errors": {
    "name": ["The name field is required."],
    "email": ["The email must be a valid email address."]
  }
}
Handling:
async function createGroup(groupData) {
  const response = await fetch('https://api.vomo.org/v1/groups', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(groupData),
  });

  if (response.status === 422) {
    const problem = await response.json();
    throw new ValidationError(
      problem.message ?? 'Validation failed',
      problem.errors ?? {}
    );
  }

  if (!response.ok) {
    throw await buildError(response);
  }

  return await response.json();
}

class ValidationError extends Error {
  constructor(message, fieldErrors) {
    super(message);
    this.fieldErrors = fieldErrors; // { fieldName: ["error message", ...] }
  }
}
Don’t retry 422 — the same payload will fail validation again. Surface the field-level errors to the user (or your integration’s logs) so the bad data can be corrected.

429 Too Many Requests: rate limited

429 isn’t documented in the spec but rate limits likely exist at the platform level. When it happens:
if (response.status === 429) {
  const retryAfter = response.headers.get('Retry-After');
  const delayMs = retryAfter
    ? parseInt(retryAfter, 10) * 1000
    : 30 * 1000; // Default: 30 seconds

  await sleep(delayMs);
  // Retry the request after the delay
}
See Rate Limits for the broader rate-limit pattern.

5xx Server errors: retry with backoff

Server errors (500, 502, 503, 504) are typically transient. Retry with exponential backoff:
async function callWithRetry(fn, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const classification = classifyError(err.status, err.body);

      if (classification !== 'transient' && classification !== 'rate_limited') {
        throw err; // Non-retryable
      }

      if (attempt === maxAttempts) {
        throw err; // Exhausted retries
      }

      const baseDelay = Math.pow(2, attempt - 1) * 1000;
      const jitter = Math.random() * 1000;
      await sleep(baseDelay + jitter);
    }
  }
}
The exponential backoff (1s, 2s, 4s, 8s, 16s with jitter) is the standard pattern. Bounded retries prevent infinite loops; jitter prevents thundering-herd retries from multiple integrations simultaneously.

A complete error-handling pattern

Putting it together as a reusable client:
class VomoApiError extends Error {
  constructor(message, status, body, classification) {
    super(message);
    this.status = status;
    this.body = body;
    this.classification = classification;
  }
}

class VomoClient {
  constructor({ token, customerId, alerter, credentialPause }) {
    this.token = token;
    this.customerId = customerId;
    this.alerter = alerter;
    this.credentialPause = credentialPause;
  }

  async request(path, options = {}) {
    const url = path.startsWith('http') ? path : `https://api.vomo.org/v1${path}`;

    return this._withRetry(async () => {
      const response = await fetch(url, {
        ...options,
        headers: {
          Authorization: `Bearer ${this.token}`,
          Accept: 'application/json',
          ...(options.body ? { 'Content-Type': 'application/json' } : {}),
          ...options.headers,
        },
      });

      if (response.ok) {
        return response.json();
      }

      const body = await this._parseBody(response);
      const classification = classifyError(response.status, body);
      const message = body?.message ?? `Request failed: ${response.status}`;
      const error = new VomoApiError(message, response.status, body, classification);

      // Side effects for specific classifications
      switch (classification) {
        case 'auth_failed':
          await this.credentialPause.pause(this.customerId, 'vomo_auth_failed');
          await this.alerter.send({
            severity: 'high',
            customerId: this.customerId,
            title: 'VOMO token rejected',
            url,
          });
          break;

        case 'forbidden':
          await this.alerter.send({
            severity: 'medium',
            customerId: this.customerId,
            title: 'VOMO permission denied',
            url,
          });
          break;

        case 'rate_limited':
          const retryAfter = response.headers.get('Retry-After');
          error.retryAfter = retryAfter ? parseInt(retryAfter, 10) : null;
          break;
      }

      throw error;
    });
  }

  async _withRetry(fn, maxAttempts = 5) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        return await fn();
      } catch (err) {
        if (err instanceof VomoApiError) {
          if (err.classification !== 'transient' && err.classification !== 'rate_limited') {
            throw err;
          }
        } else if (!isNetworkError(err)) {
          throw err;
        }

        if (attempt === maxAttempts) throw err;

        const delayMs = err.retryAfter
          ? err.retryAfter * 1000
          : Math.pow(2, attempt - 1) * 1000 + Math.random() * 1000;

        await sleep(delayMs);
      }
    }
  }

  async _parseBody(response) {
    try {
      return await response.json();
    } catch {
      return null;
    }
  }
}

function isNetworkError(err) {
  return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED'].includes(err.code);
}
Usage:
const vomo = new VomoClient({ token, customerId, alerter, credentialPause });

try {
  const user = await vomo.request('/users/12345');
  console.log(user.data);
} catch (err) {
  if (err instanceof VomoApiError) {
    switch (err.classification) {
      case 'not_found':
        return null;
      case 'validation_failed':
        return { errors: err.body.errors };
      default:
        throw err; // Already handled side effects; let it propagate
    }
  }
  throw err;
}
The client centralizes retry, classification, and alerting — caller code only handles the cases it cares about (not_found, validation_failed, etc.).

Surfacing errors meaningfully

The error pattern produces good developer experience when integrated properly:

Don’t swallow errors silently

// ❌ Anti-pattern — caller has no idea what happened
try {
  await fetchUsers();
} catch {
  return [];
}

// ✅ Let errors propagate with context
try {
  return await fetchUsers();
} catch (err) {
  logger.error('User fetch failed', { customerId, error: err.message });
  throw err;
}
Silently returning empty arrays on errors makes problems invisible until they’re catastrophic. Let errors surface so they can be investigated.

Surface field-level validation errors

try {
  await vomo.request('/groups', {
    method: 'POST',
    body: JSON.stringify({ name: 'New Group' }),
  });
} catch (err) {
  if (err.classification === 'validation_failed') {
    // err.body.errors might look like:
    // { name: ["The name has already been taken."] }
    Object.entries(err.body.errors ?? {}).forEach(([field, messages]) => {
      messages.forEach((msg) => {
        ui.showFieldError(field, msg);
      });
    });
    return;
  }
  throw err;
}
Field-level errors are the most useful kind — they tell the user (or the calling code) exactly what’s wrong, not just “something failed.”

Distinguish user-facing from internal errors

function userFacingMessage(err) {
  if (!(err instanceof VomoApiError)) {
    return 'Something went wrong. Please try again.';
  }

  switch (err.classification) {
    case 'auth_failed':
      return 'Our connection to VOMO is having an issue. Our team has been notified.';
    case 'forbidden':
      return 'This action requires additional permission. Contact your administrator.';
    case 'not_found':
      return 'This record could not be found.';
    case 'validation_failed':
      return null; // Field-level errors shown elsewhere
    case 'rate_limited':
      return 'Please wait a moment and try again.';
    case 'transient':
      return 'A temporary issue occurred. Please try again.';
    default:
      return 'Something went wrong. Please try again.';
  }
}
User-facing messages shouldn’t expose technical details (status codes, stack traces, exact error texts from the API). The mapping function above produces friendly messages while preserving the technical details for logs.

Monitoring error rates

Track these metrics for any production Volunteer integration:
MetricHealthy baselineAlert threshold
4xx rate per customer<0.5%>2% sustained
5xx rate per customer<0.1%>0.5% sustained
401 rate (any)0Any non-zero — credential issue
429 rate0 to occasionalSustained spike — rate-limit issue
503 rate0 to occasionalSustained — VOMO platform issue
Retry success rate>95%<80% — transient errors aren’t actually transient
Per-customer breakdowns matter. A customer-specific spike in 401s indicates that customer’s token is bad; a platform-wide spike in 503s indicates a VOMO issue not specific to any one customer.

A common-error-cases checklist

For a new Volunteer integration, walk through these cases explicitly:
  • Token is invalid (401) → pause customer; alert
  • Token lacks permission (403) → alert; don’t retry
  • Resource doesn’t exist (404) → return null; caller decides
  • Request body invalid (422) → surface field-level errors
  • Rate limited (429) → honor Retry-After; back off
  • Server error (5xx) → retry with exponential backoff
  • Network error → retry as transient
  • Unexpected 4xx → log and surface; don’t retry
  • All errors logged with sufficient context (customer ID, endpoint, status, body)
  • Metrics emitted for each error class
  • Field-level validation errors surfaced to UI / caller
  • User-facing messages distinct from internal logs

Where to go next

The retry pattern for 429 responses in detail.The broader recovery patterns — circuit breakers, dead-letter queues, classification.The auth-failure handling section in depth.Error patterns specific to multi-page reads.
Last modified on May 22, 2026