Guides

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 /contacts or POST /contacts/import;
  • a contact whose address changes through PUT or PATCH /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, or unconfirmed. A contact from the Events API starts unconfirmed and is checked when it becomes subscribed.
  • 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_low

X-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 as not_checked, or POST /verifications/check answered 402.

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_status is risky, and contact.checked carries reason: "inbox_full".
  • email_full_until is set on the contact and in contact.checked: 7 days after the checker saw the full inbox. Until then, Mailyte holds marketing to the person. A full inbox stays risky, so this field is how your app tells it apart.
  • Find the people on hold with GET /contacts?email_status=inbox_full. They also match risky.
  • 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.