> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate Limits

> The Volunteer API enforces 30 requests per minute and the throttling patterns that keep partner integrations inside that budget.

The Volunteer API enforces a rate limit of **30 requests per minute** per API token. This is a tight budget by API standards (0.5 requests per second sustained, or one request every 2 seconds), so partner integrations must pace deliberately rather than relying on generous headroom.

This page covers what's confirmed (the 30/minute threshold), what's still undocumented in the OpenAPI spec (the response shape and headers), and the defensive patterns that keep integrations comfortably inside the budget.

***

## Overview

| Concern                                | Behavior                                                     |
| :------------------------------------- | :----------------------------------------------------------- |
| Sustained limit                        | 30 requests per minute per API token                         |
| Sustained rate                         | 0.5 requests/second (one request every 2 seconds)            |
| Scope                                  | Unconfirmed (likely per token, see flag above)               |
| `429` response when exceeded           | Expected per Virtuous standard, unconfirmed for Volunteer    |
| `Retry-After` header on `429`          | Expected per Virtuous standard, unconfirmed for Volunteer    |
| Rate-limit headers on normal responses | None documented (unlike CRM+, which exposes `X-RateLimit-*`) |
| Window type (fixed vs rolling)         | Unconfirmed                                                  |
| Burst allowance above 30/minute        | Unconfirmed; assume none                                     |

Because so much of the enforcement shape is unconfirmed, the safe assumption is the strictest one: a hard 30/minute ceiling with no burst headroom and no progress headers to read.

***

## The defensive baseline

With only 30 requests per minute, the integration must treat the budget as scarce. Three principles:

| Principle                  | Description                                                                                                                                             |
| :------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Pace below the ceiling     | Target roughly 24 requests/minute (one every 2.5 seconds) to leave headroom for fixed-window edge effects and any concurrent traffic on the same token. |
| Honor `429` with backoff   | When the API says slow down, wait. Use `Retry-After` if present; otherwise back off a full window (60 seconds).                                         |
| Treat the budget as shared | All workloads on one token draw from the same 30/minute pool. Sum their rates and keep the total under the ceiling.                                     |

The single most important shift from a generous API: you cannot run multiple high-volume workloads concurrently against the same token. Sequence them, or allocate explicit slices of the 30/minute budget.

***

## A throttled HTTP client

For any non-trivial workload, route every request through a throttled client. Note the rate is expressed per minute, not per second, and defaults below the 30/minute ceiling for headroom:

```javascript theme={null}
class ThrottledVomoClient {
  constructor({ token, requestsPerMinute = 24 }) {
    this.token = token;
    this.requestsPerMinute = requestsPerMinute;
    this.lastRequestAt = 0;
  }

  async request(url, options = {}) {
    await this._throttle();

    const response = await fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });

    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      // Default to a full 60s window when no header is present,
      // since the limit is measured per minute.
      const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60 * 1000;
      await sleep(delayMs);
      return this.request(url, options); // Retry after backoff
    }

    return response;
  }

  async _throttle() {
    const minIntervalMs = 60000 / this.requestsPerMinute;
    const elapsed = Date.now() - this.lastRequestAt;
    if (elapsed < minIntervalMs) {
      await sleep(minIntervalMs - elapsed);
    }
    this.lastRequestAt = Date.now();
  }
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
```

Design decisions:

| Decision                      | Why                                                                                                 |
| :---------------------------- | :-------------------------------------------------------------------------------------------------- |
| Default to 24 requests/minute | Leaves a 6/minute buffer under the 30 ceiling for window edges and stray traffic.                   |
| Per-minute parameterization   | Matches how the limit is actually measured; avoids per-second math that hides the real budget.      |
| Sequential throttle           | One request at a time is plenty: at 24/minute there is no benefit to parallelism on a single token. |
| 60-second default backoff     | A full window guarantees the budget has reset if the window is fixed.                               |

<Note>
  At this budget, parallel requests on one token are almost always counterproductive: two workers each pacing at 24/minute would together hit 48/minute and trip the limit. Keep one throttled client per token, or share a single client instance across workers.
</Note>

***

## A token bucket (use sparingly)

A token bucket is only worthwhile here if you have a verified rolling window and a confirmed burst allowance. Absent that confirmation, a small bucket that still averages under 30/minute is the safest form:

```javascript theme={null}
class TokenBucket {
  constructor({ capacity, refillPerMinute }) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillPerSecond = refillPerMinute / 60;
    this.lastRefillAt = Date.now();
  }

  async acquire() {
    while (true) {
      this._refill();
      if (this.tokens >= 1) {
        this.tokens -= 1;
        return;
      }
      const waitMs = ((1 - this.tokens) / this.refillPerSecond) * 1000;
      await sleep(waitMs);
    }
  }

  _refill() {
    const now = Date.now();
    const elapsedSec = (now - this.lastRefillAt) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSecond);
    this.lastRefillAt = now;
  }
}

// Small burst of 5, sustaining an average of 24 requests/minute.
const bucket = new TokenBucket({ capacity: 5, refillPerMinute: 24 });

async function rateLimitedRequest(url, options) {
  await bucket.acquire();
  return fetch(url, options);
}
```

<Warning>
  A burst is risky under a fixed clock-minute window: five quick requests near a minute boundary could land alongside the next window's traffic and exceed 30 in a 60-second span. Only use a burst-capable bucket once engineering confirms the window is rolling. Until then, prefer the sequential throttled client above.
</Warning>

***

## Allocating the 30/minute budget across workloads

Unlike a generous API, there is no per-workload allowance to hand out freely. There is one pool of 30 requests/minute (per token, pending confirmation), and every workload draws from it. The table below is a starting allocation when workloads run concurrently on the same token; the slices sum to 24/minute to preserve headroom:

| Workload                      | Suggested slice                | Rationale                                                                         |
| :---------------------------- | :----------------------------- | :-------------------------------------------------------------------------------- |
| Interactive UI lookups        | Reserve up to 8/minute         | A user is waiting; protect a slice so lookups are not starved by background jobs. |
| Steady-state sync (polling)   | 8-12/minute                    | Latency tolerant; the bulk of routine work.                                       |
| Backfill (one-time bulk read) | Up to 20/minute when run alone | Pause other workloads during a backfill rather than splitting the budget.         |
| Daily reconciliation          | 8-10/minute, off-hours         | No need to rush; schedule when interactive traffic is quiet.                      |

<Tip>
  The cleanest design at this budget is to run one workload at a time per token. A backfill that owns the whole 24/minute finishes far faster than one squeezed into a 10/minute slice, and you avoid the math of summing concurrent rates.
</Tip>

For partner integrations serving many customers, what matters is whether the limit is per token or per source. If each customer has their own token, each gets 30/minute. If the partner shares one token or the platform enforces a per-IP cap, 100 customers at 24/minute each would mean 2,400 requests/minute against a shared ceiling. Confirm the scope before scaling (see the flag at the top of this page).

***

## When you hit the limit

A `429` means your traffic exceeded the 30/minute budget. Common causes and fixes:

| Cause                                     | Solution                                                                          |
| :---------------------------------------- | :-------------------------------------------------------------------------------- |
| Multiple workloads on one token           | Sequence them, or sum their rates under 24/minute.                                |
| Polling too frequently                    | Increase the poll interval; widen the time window per poll.                       |
| Inefficient pagination (re-reading pages) | Follow `links.next` and stop at `null`.                                           |
| Lookups in a tight loop                   | Batch or cache the lookups; a per-record loop burns the budget fast at 30/minute. |
| Reference data re-fetched per request     | Cache reference data with an appropriate TTL.                                     |

At 30/minute, the fixes are usually structural rather than "just slow down." A loop that does one `GET` per record can only process 30 records per minute, so caching and batching matter far more here than on a generous API.

***

## Polling without webhooks

Volunteer has no webhooks, so change detection means polling, and polling competes directly for the 30/minute budget. Poll no faster than the business need actually requires:

| Workload type           | Reasonable poll frequency                                                 |
| :---------------------- | :------------------------------------------------------------------------ |
| Daily reporting refresh | Once daily                                                                |
| Same-day data freshness | Once every 1-4 hours                                                      |
| Near-real-time sync     | Once every 5-15 minutes                                                   |
| Real-time required      | Reconsider the design; sub-minute polling is not sustainable at 30/minute |

<Warning>
  Watch the per-poll request count, not just the interval. A 5-minute poll that paginates through 10 pages spends 10 of your 30 requests in that minute. Pace the pagination within each poll cycle so a single poll never consumes the whole budget.
</Warning>

***

## Monitoring rate-limit pressure

Because Volunteer exposes no `X-RateLimit-*` headers to read ahead of a `429`, you are flying without a fuel gauge. Track these metrics so pressure surfaces before it reaches customers:

| Metric                              | Healthy baseline              | Alert threshold                        |
| :---------------------------------- | :---------------------------- | :------------------------------------- |
| `429` rate per customer             | 0                             | Any sustained non-zero                 |
| Requests per minute per token       | At or below configured pacing | Approaching 30                         |
| Average request latency             | Stable                        | Sudden spike (often precedes a `429`)  |
| Total requests per customer per day | Steady                        | Sudden growth without a traffic change |

```javascript theme={null}
async function request(url, options) {
  const start = Date.now();
  const response = await fetch(url, options);
  const latencyMs = Date.now() - start;

  metrics.timing('vomo.request.latency', latencyMs, { endpoint: simplifyUrl(url) });

  if (response.status === 429) {
    metrics.increment('vomo.request.rate_limited', { customerId });
    // Alert if this is sustained.
  }

  return response;
}
```

Per-customer `429` rate is the canary. Since there are no headers to warn you, the count-per-minute metric is your only proactive signal; alert as it approaches 30.

***

## When the platform is the culprit

Sometimes 30/minute is genuinely too low for a customer's workload. The path forward:

| Step                                          | Action                                                  |
| :-------------------------------------------- | :------------------------------------------------------ |
| Document the workload requirements            | How many records, how often, by which integration path. |
| Confirm the limit is hit consistently         | Show the data; not just occasional spikes.              |
| Coordinate with the customer's VOMO concierge | Request a higher rate tier for that customer's account. |
| Confirm the new limit works                   | After any upgrade, monitor to verify.                   |

<Note>
  Whether higher rate tiers exist for Volunteer is not confirmed. **Human input required:** check with the team whether the 30/minute limit can be raised per customer, and document the tiers if so.
</Note>

***

## A rate-limit checklist

Walk through this when designing or auditing a Volunteer integration:

* All API requests go through a throttled client (per-minute paced)
* The client targets at most 24 requests/minute, not 30, to leave headroom
* Only one high-volume workload runs at a time per token, or concurrent rates sum under the ceiling
* `429` responses honor `Retry-After` when present
* Default backoff (no `Retry-After`) is a full 60-second window
* Per-token requests-per-minute is monitored and alerted as it approaches 30
* `429` rate is alerted on (any sustained non-zero)
* Polling intervals and per-poll page counts both fit the budget
* Reference data is cached to avoid repeated lookups
* Per-record loops are replaced with batched or cached reads where possible
* No retry-forever loops; bounded attempts only

***

## Common-cases reference

### Interactive lookup (user waiting)

At 30/minute, a `429` on a user-facing lookup means a wait of up to a full window, which is a poor experience. The real protection is reserving budget for interactive traffic (see allocation table above) so these rarely get rate-limited in the first place. A single retry covers the rare hit:

```javascript theme={null}
async function lookupUser(userId) {
  const response = await fetch(`https://api.vomo.org/v1/users/${userId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (response.status === 429) {
    const retryAfter = parseInt(response.headers.get('Retry-After') ?? '60', 10);
    await sleep(retryAfter * 1000);
    return lookupUser(userId); // Single retry
  }

  if (!response.ok) throw new Error(`Lookup failed: ${response.status}`);
  return response.json();
}
```

### Steady-state polling (every 15 minutes)

```javascript theme={null}
async function pollUsersSync(customerId) {
  const client = new ThrottledVomoClient({ token, requestsPerMinute: 12 });
  const lastSync = await getCheckpoint(customerId);

  let url = `https://api.vomo.org/v1/users?updated_after=${encodeURIComponent(lastSync)}`;

  while (url) {
    const response = await client.request(url);
    const page = await response.json();
    await processUsers(customerId, page.data);
    url = page.links.next;
  }

  await advanceCheckpoint(customerId);
}

// Run on a 15-minute interval.
setInterval(() => pollUsersSync(customerId).catch(console.error), 15 * 60 * 1000);
```

### Backfill (one-time bulk read)

```javascript theme={null}
async function backfillUsers(customerId) {
  // Owns most of the budget; pause other workloads while this runs.
  const client = new ThrottledVomoClient({ token, requestsPerMinute: 20 });
  let url = 'https://api.vomo.org/v1/users';

  while (url) {
    const response = await client.request(url);
    const page = await response.json();
    await processUsers(customerId, page.data);

    console.log(`Backfill: ${page.meta.to}/${page.meta.total}`);
    url = page.links.next;
  }
}
```

A backfill at 20/minute reads roughly 20 pages per minute, so a backfill of N pages takes about N/20 minutes. Plan accordingly and run it when no other workload needs the token.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Pagination" icon="list" href="/volunteer/pagination">
    The page-following pattern that keeps bulk reads inside the request budget.
  </Card>

  <Card title="Errors" icon="circle-exclamation" href="/volunteer/errors">
    How `429` and other error responses are classified and handled.
  </Card>

  <Card title="Integration Overview" icon="plug" href="/volunteer/guides/integration-overview">
    The broader patterns for building a well-behaved Volunteer integration.
  </Card>

  <Card title="Authentication" icon="key" href="/volunteer/authentication">
    Token setup, which determines the scope your 30/minute budget applies to.
  </Card>
</CardGroup>
