Inboxes · September 23, 2026
What Happens When an Agent's Email Allowance Runs Out
Sending returns HTTP 402, not 429. What keeps working, how a unit is counted, which reservations stay held, and the upstream quota you do not control.
An agent that runs a mailbox will eventually reach the end of its monthly allowance, usually mid-task, usually while something else is waiting on the reply. Most providers publish the quota number and stop there. The number is the least useful part. What matters is which capabilities survive the moment the counter hits zero, which units are still held rather than spent, and whether your retry loop makes the situation worse.
This page sets out exactly what happens when an agent’s email allowance runs out here: which call fails, with which status code, what keeps working, how the counter is spent, and the second allowance sitting above yours that no dashboard shows you. If agent mailboxes are new to you, start with email for AI agents. If you only want plan numbers, the pricing page has them.
What happens when an agent’s email allowance runs out
Sending stops. Nothing else does.
A live send whose recipients would take the workspace past its allowance is refused
before anything is submitted to a provider. The API returns HTTP 402 with the error
code quota_exceeded and the message “This workspace has exhausted its delivery
allowance.” No row reaches a provider, there is no partial send, and no overage is
billed, because there is no overage path to bill through. The refusal is a single
conditional UPDATE in reserve() in packages/domain/src/billing.ts, guarded by
used + reserved + quantity <= allowance + additional_allowance. If that statement
updates no row, the request throws. quota_exceeded maps to 402 in
packages/contracts/src/errors.ts.
Everything the agent can do without transmitting keeps working:
| Capability | After the allowance is exhausted |
|---|---|
| Reading threads and messages | works |
| Receiving new inbound mail | works, and is still recorded |
| Creating drafts | works |
| Submitting a draft for human approval | works |
| Polling the project event stream | works |
| Webhook deliveries | work |
| Exporting mail as JSON | works |
| Sending, or approving a send | HTTP 402 quota_exceeded |
The agent degrades to read-only rather than going dark. A triage agent that classifies, labels and drafts continues to do all three, and only the final transmission is refused. That shape is a consequence of where the check lives: the quota is enforced at the send path, not at the session, the credential or the inbox. Nothing about an exhausted workspace changes what a token is allowed to do.
An email unit is one message in, or one recipient out
The unit is not “an email”. One message received is one unit, and one recipient sent to is one unit. A single send addressed to three people costs three.
The recipient count is taken across To, Cc and Bcc together, each address normalised,
then deduplicated. Addressing the same person in To and again in Cc costs one unit, not
two. The per-message ceiling is 25 unique recipients (LIMITS.uniqueRecipients in
packages/contracts/src/events.ts), so the most a single send can cost is 25.
There is a trap in “normalised” that will surprise you, and it is worth knowing before
it shows up in a bill. normalizeAddress in packages/contracts/src/canonical.ts
trims the address and lower-cases the domain only. The local part is left exactly as
you sent it, because the local part is case-sensitive under RFC 5321 and collapsing it
would merge two addresses that a receiving server may treat as different people. The
billing consequence follows directly:
| Recipients on one send | Unique addresses | Units |
|---|---|---|
ops@acme.com, ops@ACME.com | 1 | 1 |
ops@acme.com, Ops@acme.com | 2 | 2 |
ops@acme.com in To, ops@acme.com in Cc | 1 | 1 |
So an agent that copies a name out of a signature block and sends to Jane@corp.com
while also sending to jane@corp.com pays twice, and the recipient sees two copies.
This is not a rounding artefact that gets corrected later. On acceptance the ledger
writes one debit row per distinct canonical address, and the settlement asserts that the
count matches the number reserved, so the two-unit reservation becomes a two-unit
charge. Lower-case the local part yourself before you build the recipient list if you
want the collapse.
Test mail is never charged. The reservation call is wrapped in an
inbox.environment === "live" check, so a send from a test inbox never touches the
quota path at all, and injected inbound costs nothing either. That makes the whole of
testing an agent’s email loop without sending
free of quota consequences, which is most of the point of having a test environment.
Three consequences worth planning around:
- A reply-all agent is expensive per action. Twelve people on a thread is twelve units for one reply.
- An inbound-heavy workload spends the allowance without the agent ever sending. One mailing-list subscription can drain 100 units on its own.
- A digest to one address beats twelve individual notes by a factor of twelve, and it is usually the better product decision anyway.
Units are reserved before the send and settled afterwards
The counter moves in two steps, and the gap between them is where the confusing behaviour lives.
When a live send is queued, the unique recipient count is reserved against the current period. The reservation is what fails when there is no room, which is why the 402 arrives on the send call rather than minutes later. Once the provider outcome is known, the reservation settles:
| Outcome | Reservation state | Units consumed | Why |
|---|---|---|---|
| accepted | consumed | one per recipient | The provider took the message. |
| failed | released | none | Nothing was transmitted. |
| submission unknown | held_unknown | none yet | Acceptance could not be confirmed. |
| Recipient suppressed | reversed | one refunded | A later provider event credits it back. |
Two of those rows are load-bearing.
A failed send costs nothing. The reservation is released and the period is unchanged. You are billed for provider acceptance, not for attempts.
An unknown submission holds its units instead of releasing them. When a submission
is interrupted after the request goes out but before an acknowledgement comes back, the
intent lands in submission_unknown and the reservation moves to held_unknown rather
than being decremented. That is deliberately conservative: the mail may well have gone
out, and releasing the reservation would let the same allowance be spent twice. It has a
consequence people miss. remaining is computed as allowance minus used minus
reserved, so held units keep suppressing your headroom even though nothing has been
charged. An inbox that accumulates a run of unknown submissions can report zero
remaining with used still well under the allowance. Settlement is not permanently
closed for those reservations, since the settle path only short-circuits on consumed
and released, so a later confirmed outcome still resolves them.
What acceptance actually proves covers how to
diagnose that state.
A retried send carrying the same Idempotency-Key and the same payload replays the
stored response and does not reserve a second time, so a retry loop cannot multiply your
bill. It can still multiply your 402s. The rules for deriving keys are in
retrying an agent’s email safely.
402, not 429: the distinction your retry loop depends on
Most email APIs answer an exhausted quota with HTTP 429, and that is the single most expensive assumption an agent can carry into this failure. Verified on vendor docs on 22 September 2026:
- Resend returns 429 for quota exhaustion and distinguishes it in the body, with
nameset todaily_quota_exceededormonthly_quota_exceeded. The daily quota applies to the Free plan only and resets at 00:00 UTC. - Nylas documents that on Agent Accounts, reaching a limit stops that activity until the period resets, and sends return HTTP 429.
- AgentMail documents 429 with a
Retry-Afterheader, usually one second, for API rate limits. Its rate-limits page does not document a separate response for monthly volume exhaustion, so a client cannot tell the two apart from the status code.
All defensible choices, and all of them collide with the default behaviour of nearly every HTTP client an agent framework ships with, because 429 means “try again later” and the standard handler obeys it. An agent that treats a monthly quota refusal as a rate limit backs off, retries, backs off further and retries again, for the rest of the calendar month, burning its own task budget against a condition that no amount of waiting inside the current run will clear.
This platform returns 402 for allowance exhaustion and reserves 429 for genuine rate limiting. Four different conditions stop a send, they have four different remedies, and only one of them is the monthly allowance. The classification you want in the agent:
| Response | Meaning | Retryable |
|---|---|---|
429 rate_limited | Too many requests right now. | Yes. Back off. |
402 quota_exceeded | The delivery allowance is spent. | No. Wait for the period reset. |
402 payment_required “A current paid subscription is required to resume sending.” | The workspace is not entitled to send at all. | No. Different remedy entirely. |
403 permission_denied “Sending is paused or the resource is inactive.” | Workspace paused, or the inbox or project inactive. | No. Checked before the quota. |
403 policy_denied “The daily recipient limit has been reached.” | A daily cap you configured, not the plan allowance. | Yes, after the UTC day rolls. |
The last two matter for diagnosis. The send authorization check refuses a workspace
whose lifecycle, sending or abuse status is not clear before billing is consulted at
all, so a paused workspace never reports as out of allowance. And the daily recipient
limit is your own policy, bucketed by UTC calendar day, so it clears at 00:00 UTC
regardless of where your billing period sits. Every ApiError carries a retryable boolean in the
response body, and it is true only for rate_limited, provider_unavailable and
temporarily_unavailable. Read that field rather than pattern-matching status codes.
There are two allowances, and the second one is not yours
Your workspace allowance is the one you can see. Above it sits the upstream sending provider’s own quota, on an account you do not hold, and the two fail in completely different ways. This is the failure mode nobody writes about, and it looks nothing like a 402.
When the upstream provider refuses a submission with 429 and a quota name in the body, the send is classified as retryable-before-submission rather than failed, because nothing was accepted. What follows is worth reading slowly:
- The intent goes back to
queued, not tofailed. - Your reservation stays reserved. It is not released and not consumed, so those units remain unavailable to any other send.
- No event is emitted.
message.queuedalready fired when you submitted, and the retry path deliberately skips event emission, so nothing on the webhook or the event stream tells you this happened. - The retry waits for the provider’s
Retry-After(orratelimit-reset) when it sends one, whether that is shorter or longer than the defaults. Without either header, the wait is fifteen minutes for a quota refusal and one second for ordinary throttling. The job’s own exponential backoff, capped at five minutes, is the floor under both, and up to a second of jitter is added. - The intent carries a provider idempotency deadline set to 23 hours after its first
submission. When a still-queued intent passes that deadline, it is marked
failedwithlast_error_codeofprovider_retry_window_expired, the reservation is released, andmessage.failedis emitted with that reason. - Every retry is re-authorized from scratch. Before each attempt the send job
re-runs the workspace, grant and policy checks and re-reserves the daily recipient
limit against the current UTC day. A send that waited past 00:00 UTC moves its
daily reservation into the new day’s bucket, and if that bucket is already full, or
sending was paused or a policy changed in the meantime, the intent becomes
canceled. The allowance reservation is released, and no event fires, becausemessage.canceledis declared but not emitted in this build.
So a provider-side quota problem presents to your agent as a message that sits in
queued for up to 23 hours with no event and no error, while holding units that your
own counter reports as unavailable. The symptom you will actually observe is remaining
dropping with used flat, and a message whose state never advances. That is the
signature. Check remaining against used before you assume your own allowance is the
problem, because if used + reserved is below the allowance, the block is not yours.
And poll the message itself, not only the event stream: a send that ends this way in
canceled looks, from the events alone, exactly like one still waiting.
Inbound mail is never dropped to protect the meter
Received mail is committed even when the workspace is past its allowance. Inbound is
recorded against the usage ledger and increments the counter, and there is no branch
anywhere in recordInbound() that refuses the message. The comment above that code says
it plainly: provider-accepted mail is committed even beyond allowance, and is never
dropped for billing.
Two things follow. First, used can exceed allowance, and the entitlement response
exposes an over_limit boolean, true whenever used + reserved is greater than the
allowance, for exactly that case. Second, the overshoot is real accounting rather than a
free pass: mail received past zero has still spent units in the current period. No debt
carries into the next one, but the deficit is not imaginary, it is simply not enforced
against inbound.
This is a real divergence from at least one competitor. Nylas documents that on its Free and Essentials plans, exhausting the bandwidth budget pauses inbound delivery until the month resets, with overage billing instead of blocking on higher tiers. That is a coherent decision for a metered platform. It is not the one taken here, because an agent mailbox that stops accepting mail is a mailbox that loses somebody’s reply, and a lost reply is not recoverable by upgrading later.
An approval refused on quota is not spent
Approving a held draft runs the same send path as a direct send, with the authorisation
kind set to human approval, which means it runs the same reservation and can throw the
same 402. A reviewer who clicks Approve with an empty allowance gets
quota_exceeded, and because the reservation and the approval state change happen in
one transaction, the whole thing rolls back. The approval is not consumed, the draft is
not marked sent, and the same approval can be used again once there is room.
There is a deadline on that, and it is shorter than a billing period. Approvals are
created with expires_at seven days out, and the send path rejects an expired approval
with permission_denied and the message “Approval is expired, consumed, or no longer
valid.” If your allowance resets more than seven days after the approval was raised,
the approval dies first and has to be requested again. An agent that parks approved
work until the counter rolls over needs to re-submit the draft rather than re-use the
approval. The mechanics of the approval surface itself are in
holding agent email for human approval.
Check the counter before you send, not after
The entitlement endpoint returns the live period, so an agent can look before it leaps.
The credential needs usage:read or billing:manage, and a credential with neither
gets permission_denied rather than a partial response:
curl "https://api.emailforagents.ai/v1/billing/entitlement" \
-H "Authorization: Bearer $EFA_API_KEY"
The usage block is the part you want:
{
"usage": {
"period_id": "go-live:2026-09",
"period_start": "2026-09-01T00:00:00.000Z",
"period_end": "2026-10-01T00:00:00.000Z",
"used": 94,
"reserved": 3,
"allowance": 100,
"remaining": 3,
"over_limit": false
}
}
remaining is allowance minus used minus reserved, floored at zero, so it already
accounts for sends in flight and for anything held unknown. allowance already includes
any additional grant on the period, so you do not have to add anything to it. One edge
case to handle: usage is null when no period row exists yet, which is the state of a
workspace that has never sent or received live mail. Treat null as unknown, not as zero:
import os, requests
BASE = "https://api.emailforagents.ai/v1"
H = {"Authorization": f"Bearer {os.environ['EFA_API_KEY']}"}
def remaining() -> int | None:
usage = requests.get(f"{BASE}/billing/entitlement", headers=H).json()["usage"]
return usage["remaining"] if usage else None
def affordable(recipients: list[str]) -> bool:
left = remaining()
if left is None:
return True # No period yet. Let the reservation decide.
return len({r.strip() for r in recipients}) <= left
Do not treat that check as a guarantee. It is advisory, the authoritative decision is the
reservation, and another agent in the same workspace can spend the difference between
your check and your send. Use it to choose a behaviour, not to skip error handling. When
remaining is small, a well-built agent switches to composing drafts and letting a human
release them, which is the same degradation the 402 forces anyway, arrived at on purpose
and with the human already in the loop.
What the free allowance covers, and when it resets
One complimentary live inbox and 100 email units a month. Both attach to a verified owner identity, not to a workspace, and they are claimed by that identity’s first eligible workspace. That binding is the answer to the obvious workaround: creating a second workspace does not renew the allowance, because the claim is recorded against the owner identity and the second workspace finds it already taken. The claim is a uniqueness constraint on the identity, which also means two concurrent activation attempts resolve to exactly one winner.
The free period is the calendar month in UTC. The period identifier is
go-live:YYYY-MM, it runs from 00:00 UTC on the first to 00:00 UTC on the first of the
next month, and the row is created on first use rather than on a schedule. There is no
rollover, so unspent units do not carry forward. A workspace on a paid subscription
follows that subscription’s own period instead, identified by the subscription rather
than by the month, so its reset is not tied to the calendar. Nothing about a reset is
retroactive: a send refused with quota_exceeded is not queued for the new period and is
not retried for you. Submit it again.
Alongside the live inbox, test mode allows up to 3 active test inboxes per workspace,
retaining the last 100 messages, up to 10 MB, with content expiring after 7 days. None of
it is charged, and none of it can reach an internet recipient, because a test inbox is a
non-routable .mail.invalid address on a simulated transport.
Builder at $19 a month and Team at $79 a month are proposed plans at proposed launch prices. Stripe checkout is disabled, so neither can be bought today, and final terms will be published before anything is charged. Custom domains require one of those plans, so every live address today is on the platform domain. If your workload needs more than 100 units a month right now, the honest answer is that this platform cannot sell you that yet. AgentMail’s free tier of 3 inboxes and 3,000 emails a month is more generous than ours on both axes, and for a high-volume workload today it is the better choice.
Three habits make 100 units last longer than you would expect. Develop against test inboxes, since the entire read, draft, approve and send loop runs in test mode at zero cost with identical call shapes, and only the final live transmission needs the live inbox. Collapse recipients, because one message to a shared alias is one unit where the same content to eight individuals is eight. And filter inbound at the source: every newsletter, bounce notification and automated receipt landing in an agent mailbox is a unit spent on something the agent will discard, which is a strong argument for keeping the agent’s address off distribution lists entirely, as described in how an agent inbox differs from a Gmail account.
If you hit the ceiling and want your data out, the export path still works while sending
is refused. Exports are capped at two requests per hour per member, which returns 429
rate_limited rather than a quota error, and at 500 inboxes and 5,000 messages per
export, and they produce JSON you keep. The inbox model everything above operates on is
described on the agent inbox product page.
Keep reading
Email for AI Agents: How Agent Inboxes Actually Work
What an email inbox for an AI agent is, how mail arrives and leaves, which permissions matter, and when an agent needs its own address instead of yours.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown: what each email status actually proves, what it does not, and the correct next action for every one of them.