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.