Skip to content

Global Quota

The global quota is the shared SES sending cap for the trailing 24 hours, aggregated across all tenants and all send sources under the account. Use it to route high-volume campaigns through Outbound up to the shared cap, then fall back to another vendor (e.g. SendGrid) once you're near it.

This is distinct from dashboard.quota(), which is per-tenant and for reporting only.

Sends are NOT gated. email.send and email.bulk always submit — there is no global-quota gate on the send path. Respecting the shared cap is opt-in: call quota.check(count) before you send and route on the result.

The window is rolling 24h (matching AWS SES): there is no reset event — capacity returns continuously as individual sends age past 24h. Route on available only; never wait on a reset time.

All examples assume you've set the API key in the constructor. Any valid tenant key may read or check — the pool is account-wide. See Configuration.


Check the Quota (opt-in)

quota.check(count) is the primitive you want. It atomically decides whether count fits in the shared pool and holds it, so concurrent callers can't both grab the same headroom. A plain read can't do this — two campaigns reading the same value both decide "it fits" and overshoot.

ts
const recipientCount = 300;
const { ok, available, shortfall } = await outbound.quota.check(recipientCount);

if (ok) {
  await outbound.email.bulk({ fromEmail, emailSubject, emails: batch });
} else {
  await sendViaSendGrid(batch); // not enough shared headroom; `shortfall` = how much over
}
// Nothing to release — the hold auto-expires.

Why a hold (the concurrency story)

Remaining = 500
Req A: check(300) → 500 ≥ 300 → HOLD 300 → { ok: true,  available: 200 }
Req B: check(300) → 200 < 300 → no hold → { ok: false, available: 200, shortfall: 100 }

The check runs as a single atomic server operation, so A's hold is visible to B with no race. The hold self-expires after ttlSeconds (the SES propagation window) — by then the send has shown up in SES's own counter, so there is no reservationId and nothing to release.

Response

ts
{
  ok: true,        // true = `count` was held; safe to send up to `count`
  available: 200,  // headroom remaining AFTER this call's hold
  ttlSeconds: 180, // how long the hold lasts if you do nothing
  shortfall: 100,  // present only when ok:false — count - available
  degraded: true   // present only on a fail-closed response (see below)
}
FieldTypeDescription
okbooleantrue = count held against the pool; send up to count. false = route to fallback.
availablenumberShared headroom remaining after this call's hold.
ttlSecondsnumberHold lifetime if you do nothing.
shortfallnumber?Present when ok:false: count - available.
degradedboolean?Present (true) when the quota service was degraded and failed closed.

Fail-closed. If the quota service can't compute headroom (Redis/SES error), check returns { ok: false, degraded: true } (HTTP 200, not a 5xx) so a degraded service makes you fall back rather than overshoot. A 400 is only for a bad count.

Call check once per intended send — each ok:true places a fresh hold.


Read the Global Quota

A cheap, side-effect-free snapshot — good for dashboards or a rough enqueue-time hint. For anything concurrency-sensitive use check(), which holds.

ts
const q = await outbound.quota.global();
console.log(`Available: ${q.available} of ${q.limit} reservable (${q.percentageUsed}% of ${q.hardLimit} used)`);

Response Fields

FieldTypeDescription
hardLimitnumberReal SES global cap for the window. Server-driven.
systemReservePercentnumber% of hardLimit held back for ungated system sends.
limitnumberEffective reservable cap = floor(hardLimit * (1 - systemReservePercent/100)).
usednumberAccepted/sent plus in-flight (queued + processing) recipients, all sources.
reservednumberSum of live (un-consumed, un-expired) holds.
inFlightnumber?Queued + processing recipients folded into used.
availablenumbermax(0, limit - used - reserved). Route on this.
remainingnumber?max(0, hardLimit - used). Informational only — do not route on it.
percentageUsednumber?used / hardLimit * 100.
windowstringrolling_24h (SES) or calendar_day_utc.
nextCapacityFreesAtstring | nullInformational only; null under rolling-24h.
perSecondLimitnumber?Current global send-rate ceiling (emails/sec).
asOfstringISO 8601 snapshot time.

Migrating from removed send-time gating

Earlier versions exposed quota.reserve() / quota.release() and a reserveFromGlobalQuota flag / reservationId param on email.send / email.bulk. These have all been removed — sends were never actually gated by them. Use an explicit check() before sending instead:

ts
// OLD (no longer gates):
// await outbound.email.bulk({ ...batch, reserveFromGlobalQuota: true });

// NEW:
if ((await outbound.quota.check(batch.length)).ok) {
  await outbound.email.bulk({ ...payload, emails: batch });
} else {
  await sendViaSendGrid(batch);
}

Possible Errors

ErrorStatusCause
BadRequestError400count missing / not a positive integer
AuthenticationError401Invalid or missing API key
RateLimitError429Read/check rate limit exceeded

Released under the MIT License.