Checking contacts added through the API
email_status, the checks headers, the webhooks, and what a full inbox means.
While Bounce protection is switched on for an account, every contact your app adds is checked: does
the mailbox exist? This guide covers what your app sees: the contact's email_status, two response
headers, three webhooks, and what happens when a person's inbox is full.
What gets checked
Each of these uses one check, in the background:
- a new contact from
POST /contacts,PUT /contactsorPOST /contacts/import; - a contact whose address changes through
PUTorPATCH /contacts/{id}. The old answer belonged to the old address, so it is dropped.
The contact reads pending until the answer lands, usually within a minute. Gmail addresses are
checked at a steady pace, so a large import can take longer. To have the answer in the reply instead,
send "check": "wait" (see Check an address at signup).
Some contacts are not checked, and use no check:
- A contact we would not email anyway:
unsubscribed,bounced,complained, orunconfirmed. A contact from the Events API startsunconfirmedand is checked when it becomessubscribed. - An address that is already a contact. An import counts it as a duplicate.
If Bounce protection is switched off, or the account has no checks left, the contact is still saved,
as not_checked. A write never fails because of a check.
email_status
Every contact carries email_status. It is read-only.
| Value | Means |
|---|---|
null |
Never checked. Contacts added before Bounce protection launched, and contacts we would not email (above). |
not_checked |
Added after launch and not checked: Bounce protection was off, or no checks were left. |
pending |
A check is on its way. |
valid |
The mailbox exists. |
risky |
It may not take mail: the domain accepts every address, a shared address like info@, a throwaway inbox, or an inbox that is full (see below). |
invalid |
It does not exist. The contact's state becomes bounced and the address is suppressed. |
unknown |
The checker could not confirm it. No check is used. |
null and not_checked are different on purpose: null is from before, or someone you do not
email; not_checked is a contact that should have been checked and was not.
Find what has not been checked. Filter the list with one value or a comma list:
curl "$MAILYTE_BASE/api/v1/contacts?email_status=not_checked,never_checked&created_via=api,upsert" \
-H "Authorization: Bearer $MAILYTE_API_KEY"never_checked selects the contacts whose email_status is null. created_via=api,upsert keeps
the contacts your app added (POST /contacts and PUT /contacts); the other values are dashboard,
import and event. Contacts from before launch have no created_via, so they match no value.
Check them. POST /verifications/contacts checks the contacts that have not been checked yet,
newest first, one check each:
curl -X POST "$MAILYTE_BASE/api/v1/verifications/contacts" \
-H "Authorization: Bearer $MAILYTE_API_KEY" \
-H "Idempotency-Key: recheck-2026-10-01" \
-H 'Content-Type: application/json' \
-d '{ "max": 2000 }'It answers 202 and runs in the background. max is at most 50,000. If the account has fewer checks
than it needs, nothing starts and nothing is used: a 402 whose data.quote says how many checks are
needed. One such run at a time: a second is a 409. The key needs verification:write.
The checks headers
The contact writes that can use checks (POST and PUT /contacts, PUT and PATCH /contacts/{id},
POST /contacts/import and POST /verifications/contacts) carry two headers:
X-Mailyte-Checks-Remaining: 3760
X-Mailyte-Checks-Warning: running_lowX-Mailyte-Checks-Remaining is the checks the account has left after the request: this month's
plan checks, then bought checks, then send credits bought or given before checks launched, all
counted in checks. If it is missing, we could not read the balance. Do not treat a missing header as
zero.
X-Mailyte-Checks-Warning is there only when it applies:
running_low: at the pace of the last 7 days the checks last under a week, or 80% of this month's plan checks are used and none are bought.ran_out: no checks are left, and this month a check could not run for lack of them: a new contact was added asnot_checked, orPOST /verifications/checkanswered402.
The warning can lag a few minutes behind the balance. Browsers cannot read these headers from another site; they are for your server.
POST /verifications/check carries X-Mailyte-Checks-Remaining only, and says what the answer cost
in its body (checks_used).
The webhooks
Three events are about the account, not about a message. Subscribe to them by name, like any other
event. A webhook's domain, sender and subject filters never hold them back, because they have no
domain, sender or subject. The envelope is the usual one;
the fields below are its data.
contact.checked: a contact's address was checked and the answer changed. Once per change.
{
"contact_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"email": "ada@example.com",
"email_status": "risky",
"reason": "inbox_full",
"suggestion": null,
"checked_at": "2026-10-01T09:12:44+00:00",
"email_full_until": "2026-10-08T09:12:40+00:00"
}email_status is the contact's new value. reason and suggestion read as on a verification
result: reason is one of mailbox_exists, mailbox_does_not_exist, domain_cannot_receive_mail,
misspelt_domain, invalid_syntax, disposable_address, accepts_all_addresses, role_address,
provider_does_not_confirm, inbox_full or could_not_confirm, and suggestion is the corrected
address for a misspelt domain, else null. An unknown answer that the checker could not confirm
yet is asked again later, so the same contact can be announced again.
checks.running_low: the checks will run out soon.
{
"trigger": "forecast",
"plan_left": 120,
"bought": 0,
"credit_fallback": 0,
"forecast_days": 3
}trigger is forecast (under 7 days left at the pace of the last 7 days) or plan_80 (80% of this
month's plan checks used, and none bought). The counts are in checks. forecast_days is null when
there is no recent use to forecast from (it can happen with plan_80).
checks.ran_out: a check could not run because no checks were left. Either a new contact was
added as not_checked, or a one-address check (POST /verifications/check) answered 402.
{
"since": "2026-10-01T09:12:44+00:00",
"buy_path": "/dashboard/billing/checkout?checks_1k=1&from=api_warning"
}buy_path is the dashboard page where the owner buys the checks we suggest, or
/dashboard/billing?from=api_warning when no pack is on sale or the amount needs a quote. Join it to
your dashboard address.
The two checks.* events are sent at most once per cycle. A new month, a pack bought, or checks added
by Mailyte start the next cycle. The account owner is emailed the same news.
Inbox full
A check can find that a person's mailbox has no space. That is a real answer, and it uses one check.
- The contact's
email_statusisrisky, andcontact.checkedcarriesreason: "inbox_full". email_full_untilis set on the contact and incontact.checked: 7 days after the checker saw the full inbox. Until then, Mailyte holds marketing to the person. A full inbox staysrisky, so this field is how your app tells it apart.- Find the people on hold with
GET /contacts?email_status=inbox_full. They also matchrisky. - Your app's other email, such as receipts and password resets, still goes.
In the hold's last hour we check the address again, for free. Still full: a new date, and a new
contact.checked. Space again: email_full_until goes back to null, and contact.checked carries
the new answer. If no answer comes in time, the hold just ends on its date: email_full_until reads
null from then on, and the contact stays risky.
email_full_until is null whenever no hold is running.
A marketing key and a full inbox
A marketing SMTP key sends only to the account's contacts, and only to contacts whose address has been checked. While a contact's inbox is on hold, a marketing key's message to them is refused before it is queued, with this reply:
452 4.2.2 <ada@example.com>: Recipient address rejected: <ada@example.com> inbox is full (checked before sending). Nothing was sent; try again after 2026-10-08 09:12 UTC.The date is the hold's end, in UTC, written YYYY-MM-DD HH:MM UTC. It is the contact's
email_full_until, rounded down to the minute, so the hold can still run for up to a minute after
it.
It is a temporary refusal, and it is yours to retry, not ours. Mailyte never queued the message, so we do not send it later. Your app still has it: send it again a minute after the date. Trying sooner gets the same answer.
The refusal is logged as deferred and sent as an email.deferred webhook, with the reply text in
data.detail. That is the one email.deferred you act on. Match on
inbox is full (checked before sending) and read the date from try again after:
const m = /inbox is full \(checked before sending\).*try again after (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) UTC/
.exec(event.data.detail ?? '');
// The date is rounded down to the minute: add one.
if (m) retryAfter(new Date(Date.parse(`${m[1].replace(' ', 'T')}:00Z`) + 60_000));Your SMTP library sees the 452 for each refused recipient, at the moment you send. That is the
signal to rely on. The webhook comes only for a recipient refused before any other recipient of the
same message was accepted.
A marketing key gets two other refusals of its own, both permanent:
550 5.7.1 <ada@example.com>: Recipient address rejected: <ada@example.com> is not one of your contacts. Add them first with PUT /v1/contacts (checked before sending). Nothing was sent.
550 5.7.1 <ada@example.com>: Recipient address rejected: <ada@example.com> hasn't been checked. Your account has no checks left: buy checks in Billing (checked before sending). Nothing was sent.A contact still being checked (pending) is let through. Transactional keys are never refused for
any of these.
Refused after we accepted it
We check a marketing message's recipients once more as we send it. If none of them can take it
(each one's inbox is full, or the address does not exist, or it is a throwaway inbox), we drop the
message instead of keeping it in a queue for days. Each full-inbox recipient is recorded as
rejected and sent as an email.rejected webhook, with dsn 5.2.2 and this text in
data.detail:
Recipient address <ada@example.com>: inbox is full (checked before sending). Nothing was sent. No bounce notice sent.Match on inbox is full (checked before sending), not on the whole text. This one names no date. If the person is your contact, their email_full_until says when the hold
ends.
Next
Handling delivery events — what every other event means, and which ones you act on.