Add email verification to a signup form
Verify an email on signup with one server-side POST /verify call. Timeout and fail-open handling, what to do with catch-all, unknown and role addresses, and a Next.js route handler.
The short answer
To verify an email on signup, call POST https://www.emailpal.io/api/public/v1/verify from your server with {"email": "..."} and a write-scope key. It costs one of your daily checks (500 on Starter, 1,500 on Growth, 4,500 on Scale) and returns one of eight statuses. Block invalid and disabled addresses, accept safe, and for catch_all, unknown and role_account fail open and let the confirmation email decide. The worker takes up to 45 seconds, so set your own shorter timeout.
Verify on the server, never in the browser, because the key has write scope. Set a client timeout of a few seconds and treat every non-200 response, timeout or unknown result as "no answer" so signup never fails because verification did. Reject only addresses the server rejected (invalid, disabled), warn on inbox_full and disposable, and send a confirmation email to everyone else.
How it works, step by step
Step 1
Create a write-scope key and keep it on the server
Create the key on the API keys page and store it as a server-side environment variable. Verification needs write scope, so a key in client code could be used to spend your daily allowance.
Step 2
Call POST /verify from your signup handler
Send {"email": "..."} with the key as a Bearer token. Run your own syntax check first so obvious typos do not spend a check, because every call burns one credit before the address is probed.
POST /api/public/v1/verifybashcurl -X POST https://www.emailpal.io/api/public/v1/verify \ -H "Authorization: Bearer ep_live_..." \ -H "Content-Type: application/json" \ -d '{"email":"dana@foldguide.example"}'Step 3
Set a timeout and fail open
The worker budget for one check is 45 seconds and the route allows 60. Use a much shorter client timeout and continue the signup when it fires. Treat 402, 429, 503 and 5xx the same way.
Step 4
Map each status to one action
Reject invalid and disabled. Ask the user to confirm when status is inbox_full or disposable. Accept safe, catch_all, role_account and unknown, and let the confirmation email do the real work.
Step 5
Send a confirmation email anyway
Verification tells you the server will take mail for the address. Only a confirmation link proves the person reads it, so keep that step for everyone, and use the status to decide how loudly to ask.
01
Why the call has to be server-side
POST /verify requires a key with write scope. It spends a metered daily credit and opens a live SMTP connection on EmailPal’s behalf, so a read-only key is deliberately refused. A browser that holds that key can run your allowance down or call anything else a write key can do on the account, so the call belongs in your signup route handler and the browser only talks to your own endpoint.
Every call also counts toward your hourly API request limit, which is 1,000 requests an hour on Starter, Growth and Scale. That is a ceiling of 1,000 signup checks an hour from one account, and your daily allowance is the lower limit on every plan: 500 a day on Starter, 1,500 on Growth, 4,500 on Scale, with extra blocks of 500 a day at $9.95/month.
02
Timeouts: the worker waits up to 45 seconds
A synchronous check opens a real SMTP conversation with the recipient’s mail server through EmailPal’s verification IP pool. We do not publish a latency figure, and the worker will wait up to 45 seconds before giving up. The route itself allows 60 seconds so a slow probe returns a clean error rather than a platform timeout.
A signup form cannot wait that long. Our recommendation is a client timeout of a few seconds, and fail open when it fires. The credit is taken before the probe, so aborting your request does not refund it. If a timeout fires often for one domain, cache the "no answer" for that domain for a few minutes rather than retrying inline.
Greylisting is the common reason for a slow or empty answer. The API does not wait out a greylist delay inline: it returns unknown with sub_status antispam_system immediately, which is the right trade for a signup form and the reason treating unknown as acceptable is the safe default.
03
What to do with each status
safe: accept. The server rejected an invented mailbox and accepted this one, so the address is deliverable. invalid: reject with a message asking the user to check for a typo. This covers malformed addresses and mailboxes the server rejected outright. disabled: reject. The account has been closed or suspended and it will bounce.
catch_all: accept, and rely on the confirmation email. The domain accepts mail for every name, including invented ones, so the check cannot say whether this mailbox exists. Rejecting these would turn away real people at small companies that run a catch-all. unknown: accept and rely on the confirmation email. It means no definitive answer (greylisting, timeout, unreachable server), not that the address is bad, and a result you cannot trust is a bad reason to refuse a signup.
role_account: your call, and it depends on your product. info@ and support@ reach a shared mailbox, which is fine for a company account and a poor sign of an individual user. We would accept it and flag it in your own data. inbox_full: accept with a soft warning. The mailbox exists but is over quota, so your confirmation email is likely to bounce today. disposable: block or warn, depending on whether throwaway signups cost you something.
04
Fail open, and say what failed open means
Every non-200 response should become "no answer", never "invalid". A 402 subscription_required or verification_capacity_required means a billing state to fix. A 429 verification_limit_reached means today’s allowance is spent and resets at midnight UTC. A 429 rate_limited carries a Retry-After header. A 503 capacity_exhausted is not counted against your allowance and carries a Retry-After header. A timeout or any other 5xx is a missing answer.
In each of those cases, create the account, send the confirmation email, and log that verification was skipped so you can see how often it happens. Two other protections matter more than the check itself: rate-limit your own signup endpoint per IP, because every bot submission spends one of your daily checks, and cache a status for an address for as long as you consider it fresh so a user fixing a typo does not cost two checks.
05
Example route handler (Next.js)
A minimal App Router handler that fails open on any problem and only blocks addresses the server rejected. The helper functions at the bottom are yours to supply.
type VerifyResult = { status: string; sub_status: string | null };
async function checkEmail(email: string): Promise<VerifyResult | null> {
try {
const res = await fetch('https://www.emailpal.io/api/public/v1/verify', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.EMAILPAL_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(6_000),
});
if (!res.ok) return null; // 402, 429, 503, 5xx: no answer, fail open
return (await res.json()) as VerifyResult;
} catch {
return null; // timeout or network error: fail open
}
}
export async function POST(request: Request) {
const { email } = await request.json();
const result = await checkEmail(email);
if (result?.status === 'invalid' || result?.status === 'disabled') {
return Response.json(
{ ok: false, field: 'email', message: 'That address cannot receive mail. Check it for typos.' },
{ status: 422 },
);
}
if (result?.status === 'disposable') {
return Response.json(
{ ok: false, field: 'email', message: 'Please use a work or personal address.' },
{ status: 422 },
);
}
await createUser({ email, emailVerdict: result?.status ?? 'skipped' });
await sendConfirmationEmail(email);
return Response.json({
ok: true,
warn: result?.status === 'inbox_full' ? 'That mailbox looks full. Check your inbox for our email.' : undefined,
});
}
// Yours to supply.
declare function createUser(input: { email: string; emailVerdict: string }): Promise<void>;
declare function sendConfirmationEmail(email: string): Promise<void>;06
What the check does not tell you
A safe result means the receiving server accepted the address at RCPT TO. It does not prove the person owns it, reads it, or typed their own address. Yahoo, AOL, ymail and rocketmail accept every address at that stage, so for those four domains EmailPal probes the address itself but the accept carries less information than it does at a server that rejects strangers.
The check also costs a daily credit per call, including for role_account and disposable results that are settled without opening a connection. If signup volume is above your allowance, your options are blocks of 500 a day at $9.95/month, or running the check only for domains you have not seen before.
Limits, prices and names, in one table
The figures this page relies on, so you do not have to hunt for them.
| Endpoint | POST /api/public/v1/verifyBody: {"email": "..."}. Write scope required. |
| Worker budget for one check | 45 seconds |
| Route maxDuration | 60 seconds |
| Cost per call | 1 creditTaken before the probe. Only a 503 refunds it. |
| Daily allowance | 500 / 1,500 / 4,500Starter, Growth, Scale. Resets at 00:00 UTC. |
| Extra capacity | $9.95/month per 500/day |
| API request limit | 1,000 per hourApplies to every call, on every plan. |
| Statuses returned | 8safe, catch_all, role_account, disposable, inbox_full, disabled, invalid, unknown. |
| Reject on | invalid, disabledRecommended. These are the only two where the server said no. |
| Fail open on | unknown, any non-200, any timeout |
Checked against the product and the API on . Plans and limits change, so confirm in the docs before you build on them.
This is not for you if
- You need a guaranteed response in under a second. The worker budget is 45 seconds and we publish no latency guarantee, so you must be able to fail open.
- You have more signups a day than your allowance. The call returns 429 after the allowance is spent, so you would be failing open for the rest of the day unless you buy blocks.
- You want to run the key in client-side JavaScript. Write scope means it must stay on your server.
- You treat a verification result as proof of ownership. Only a confirmation email proves that, and verification does not replace it.
Common questions
How long does a verification call take?
It depends on the recipient’s mail server. The EmailPal worker waits up to 45 seconds for one check and the route allows 60 seconds, and we do not publish a typical latency. Set your own client timeout of a few seconds and continue the signup when it fires.
Should I block catch-all addresses on signup?
No. A catch-all domain accepts every address, so the result says nothing about this mailbox and does not mean it is bad. Accept the signup and send a confirmation email. Block only invalid and disabled, where the server rejected the mailbox.
What should I do with unknown results?
Accept them. Unknown means the server gave no definitive answer, usually because of greylisting or a timeout, and it is never rounded up to safe. The API returns it immediately with sub_status antispam_system rather than waiting out a greylist delay.
Should I verify on every signup or only some?
Each call spends one daily credit, so you can skip addresses you have already verified, addresses you reject with your own syntax check, and domains you trust. Starter includes 500 checks a day, which covers 500 signups a day with no retries.
Is it safe to call the API from the browser?
No. The endpoint needs a key with write scope, and a key in client code can be copied and used to spend your allowance. Call it from your server and expose only your own signup endpoint to the browser.
What happens to signup if the verification service is down or my allowance is spent?
Nothing, if you fail open. A 402, 429, 503, other 5xx or timeout becomes "no answer" in your handler, the account is created, and the confirmation email goes out as normal. Log those skipped checks so you can see how often they happen.
Build it on infrastructure that holds up
Domains, mailboxes, warming and verification over one REST API and MCP server, with the sequencer, Unibox and CRM on every plan.
No card to start — cancel any time.