Smart Routing with Global Quota
If you route outbound email across more than one vendor, you can push as much volume as possible through Outbound up to the shared account cap, then fall back to your other vendor once you're near it — without ever overshooting the cap, even under heavy concurrency.
This guide shows the end-to-end workflow. For the field-by-field API, see the Global Quota reference.
The problem
A plain quota read is a snapshot. If two large batches read the same cached "80,000 available" at the same time, both decide "my 50,000 fits," and you overshoot the cap. Caching makes this worse — both reads return the identical stale number.
The fix is an atomic check-and-hold: the headroom check and the hold happen in one server-side operation, so concurrent callers serialize correctly. The SDK exposes this as quota.check(count).
Sends are never gated.
email.send/email.bulkalways submit — there is no global-quota gate on the send path. Respecting the shared cap is opt-in and lives entirely on your side: callcheck()first and route on itsok.
The two-step pattern
- At enqueue — read
quota.global()(cache it ~30–60s) for a cheap routing hint. - Before each batch — call
quota.check(count). It atomically holdscountagainst the shared pool and returnsok. Ifok:false, route that batch to your fallback vendor.
The golden rule
Route on available as a hint, but let the atomic check() be the real decision. Never gate on remaining — it's informational only.
Step 1 — enqueue-time hint (cached)
// Cache this across your worker fleet with a short TTL.
async function getCachedGlobalQuota() {
const cached = await redis.get('outbound:global-quota');
if (cached) return JSON.parse(cached);
const q = await outbound.quota.global();
await redis.set('outbound:global-quota', JSON.stringify(q), 'EX', 45);
return q;
}
const { available } = await getCachedGlobalQuota();
const routeHint = available >= recipientCount ? 'outbound' : 'sendgrid';available is already net of the system reserve buffer and live holds — don't apply your own safety multiplier on top.
Step 2 — per-batch atomic check (the real decision)
Each batch makes one check() call, then sends only if it fit. There's no reservationId and nothing to release — the hold auto-expires after the SES propagation window.
async function sendBatch(batch, campaignId) {
const { ok } = await outbound.quota.check(batch.recipients.length);
if (!ok) {
await sendViaFallbackVendor(batch); // not enough shared headroom — fall back
return;
}
await outbound.email.bulk({
fromEmail: batch.fromEmail,
emailSubject: batch.subject,
emails: batch.recipients, // e.g. ~300 recipients
campaignId,
idempotencyKey: batch.idempotencyKey,
});
}Campaigns split across vendors
A campaign near the cap will send early batches via Outbound and later batches via your fallback as available drops. That's expected and correct — record the actual vendor per batch rather than assuming the whole campaign went one way.
Concurrency: why this can't overshoot
The cached read affects only the baseline. Contention is resolved by the live, atomic hold inside check(). Two concurrent batches against available: 500:
Batch A: check(300) → 500 ≥ 300 → HOLD 300 → { ok: true, available: 200 }
Batch B: check(300) → 200 < 300 → no hold → { ok: false, available: 200, shortfall: 100 }The check and the hold happen in one server-side step, so B always sees A's hold. Exactly one wins.
Idempotency: just retry
Pass an idempotencyKey per batch and retry the same email.bulk call on network failure — a send deduplicated by idempotencyKey consumes zero quota (nothing new is sent). Call check() once per intended batch; a pure network retry of an already-accepted batch doesn't need a fresh check.
Rolling-24h window
The window matches AWS SES: rolling 24h, no reset event. Capacity returns continuously as individual sends age past 24h. After a burst, available can sit near zero for a while and then free up in chunks mirroring your sending from 24h ago. Keep gating on available — don't wait on nextCapacityFreesAt (it's informational and null under rolling-24h).
What counts
used reflects accepted recipients (post-suppression, post-dedup) across all tenants and sources, plus in-flight (queued + processing). Suppressed recipients and idempotent retries are never counted — so a 300-recipient batch with 12 suppressed consumes only 288.
See the Global Quota API reference for every field and response shape.