Guides

Check an address at signup

Catch typos and addresses that do not exist before you save a new user.

When someone signs up, your app can ask Mailyte whether their email address has a real mailbox before it saves them. A typo like ada@gmail.con is caught while the person is still on the page, and an address that does not exist never reaches your database, so it can never bounce later.

Each answer uses one check from the account. Nothing is sent to the person: we ask their mail server whether the mailbox exists, the way a sending server would, and stop before any message goes out.

Two ways to do it

You want Call Key needs
An answer, and nothing saved at Mailyte POST /api/v1/verifications/check verification:write
The person saved as a contact, checked in the same call POST /api/v1/contacts or PUT /api/v1/contacts with "check": "wait" contacts:write

The first works on every account, whether or not Bounce protection is switched on. The second checks only while Bounce protection is switched on for the account (the owner turns it on under Sending › Bounce protection); with it off, the contact is saved as not_checked.

Call both from your server, never from the browser. The key is a secret, and a browser cannot read our response headers from another site anyway.

Check one address

curl -X POST "$MAILYTE_BASE/api/v1/verifications/check" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H "Idempotency-Key: signup-7f3a9c" \
  -H 'Content-Type: application/json' \
  -d '{ "email": "ada@gmail.con" }'
{
  "message": "Address checked",
  "data": {
    "object": "verification_result",
    "email": "ada@gmail.con",
    "result": "invalid",
    "reason": "misspelt_domain",
    "flags": [],
    "suggestion": "ada@gmail.com",
    "checks_used": 1
  },
  "success": true,
  "code": 200
}

We answer in the same call when we can. Most answers take a second or two; a mailbox we have not seen before can take up to about 8 seconds. Give your HTTP client a timeout of about 10 seconds.

The reply carries X-Mailyte-Checks-Remaining: the checks the account has left after this one.

Send an Idempotency-Key header, so a retried request is never charged twice.

What to do with each answer

result reason At signup
valid mailbox_exists Save the person.
invalid misspelt_domain Ask "Did you mean suggestion?" and let them pick.
invalid mailbox_does_not_exist, domain_cannot_receive_mail, invalid_syntax Ask for another address. Mail to this one can never arrive.
risky role_address, accepts_all_addresses, disposable_address, inbox_full Save the person. A throwaway address (disposable_address) is your call.
unknown provider_does_not_confirm, could_not_confirm Save the person. This answer is free.
pending checking Save the person. This answer is free.

checks_used says what the answer cost: 1 when it used a check, 0 when it was free. Free answers are "couldn't confirm" (unknown), "still checking" (pending), "not an email address" (invalid_syntax), and a shared address such as info@ that the provider would not confirm (risky, role_address, checks_used: 0). Read checks_used, not result, to know the cost.

suggestion is set only for misspelt_domain. Everywhere else it is null.

flags adds detail to risky: role (info@, sales@), disposable (a throwaway inbox), catch_all (the domain accepts any address) and full (the mailbox has no space).

A full inbox is a real address. risky with inbox_full means the person exists but their mailbox has no space right now, so mail to it bounces until they clear it. Let them sign up. If they become a contact, Mailyte holds marketing to them for 7 days and then checks again, for free. Your app's own emails, such as the welcome email, still go.

Never block a signup on us

A check is a guard, not a gate. Let the person sign up whenever we cannot answer:

  • No checks left. The reply is a 402 with error_code: "PLAN_LIMIT_EXCEEDED" and data.limit_type: "checks". data.quote says how many checks are needed and which pack covers them. The account owner is emailed, and the checks.ran_out webhook fires (both once per cycle: a new month or a purchase starts the next one).
  • The checker is unavailable. The reply is a 503. Nothing was used.
  • Your timeout ran out. Save the person. Nothing was used unless an answer came back.
async function checkAtSignup(email) {
  try {
    const res = await fetch(`${process.env.MAILYTE_BASE}/api/v1/verifications/check`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MAILYTE_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) return { ok: true }; // 402, 503, anything else: let them in.

    const { data } = await res.json();
    if (data.result !== 'invalid') return { ok: true, risky: data.result === 'risky' };
    return { ok: false, suggestion: data.suggestion };
  } catch {
    return { ok: true }; // Timeout or network error: let them in.
  }
}

Save the contact and check it in one call

If your app keeps its users as Mailyte contacts, add "check": "wait" to the write that creates them:

curl -i -X PUT "$MAILYTE_BASE/api/v1/contacts" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "ada@example.com",
    "name": "Ada Lovelace",
    "status_if_new": "subscribed",
    "check": "wait"
  }'

We check the address before we answer, for up to about 8 seconds, so the contact's email_status in the reply is already the answer: valid, risky or invalid. An invalid contact's state becomes bounced, and the address is suppressed, so nothing is ever sent to it.

"check": "wait" acts only when the write creates the contact, changes its address, or opts in a contact that was unconfirmed and never checked (its status becomes subscribed). It acts only while Bounce protection is switched on. Any other value of check is ignored, so sending it never makes a request fail. The write itself never fails because of a check.

The reply can still say pending. That happens when the checker is slow, when the checker could not confirm it yet, when several waits are already running for your account, or when no checks are left. The contact is then checked in the background, and the contact.checked webhook tells you the answer when it lands. If no checks were left, it becomes not_checked instead, and checks.ran_out fires once per cycle.

The contact reply has no reason and no suggestion. To offer "Did you mean…?", call POST /verifications/check first.

The reply carries X-Mailyte-Checks-Remaining, and X-Mailyte-Checks-Warning when the checks are running_low or have ran_out. See Checking contacts added through the API.

Next

Checking contacts added through the API — email_status, the checks headers, the webhooks, and what a full inbox means for your app.