Never block a signup
:::danger The one rule
Signitize must never cost you a real user. If Signitize can't answer (outage, timeout, network error, quota used up), your signup still succeeds. You check that user again later.
:::
Read this page before you go live. It takes about 15 minutes to implement, and it is the difference between "Signitize had a hiccup" and "our signup form was down". This page is the reference for failure handling; the other pages link here.
The pattern: fail open, recheck later
- Call Signitize with a short timeout (5 seconds).
- If it answers, use the result: reject throwaway, undeliverable and duplicate addresses.
- If it doesn't answer, create the account anyway and mark it
pending. The user sees nothing unusual. - A background job rechecks
pendingusers every few minutes until Signitize answers, then marks thempassedorflagged. - Hold back what abusers want (free credits, trials, bonuses) until the user is
passed. Real users barely notice; abusers gain nothing from an outage.
Two things you should not do:
- Don't stop the signup when Signitize fails ("fail closed"). Any outage, or any network problem between your server and ours, would take your signup form down with it.
- Don't just skip the check either. Users created during an outage would never be checked, and throwaway or duplicate accounts would slip through for good.
What to do with each response
Response from /v1/check | What your user sees | What you do | Mark the user |
|---|---|---|---|
200, disposable: true | "Please use a permanent email" | Reject the signup | (not created) |
200, deliverable: false | "Please check it for typos" | Reject the signup | (not created) |
200, email already used | "You already have an account" | Reject as a duplicate | (not created) |
200, all good | Normal signup | Create the account with the canonical email | passed |
400 (not an email address) | "Enter a valid email address" | Reject the signup | (not created) |
401 or 422 | Normal signup | Create the account. Alert your team: your API key or your request is wrong (Errors) | pending |
429 QuotaExceededError | Normal signup | Create the account. Alert your team and upgrade: every signup is pending until the 1st | pending |
429 RateLimitError | Normal signup | Create the account. Too many calls this second; don't wait for a retry | pending |
5xx, timeout, network error, anything else | Normal signup | Create the account | pending |
Only 200 and 400 ever change what the user sees. Everything else is invisible to them.
Step 1: store the result
Add three columns to your users table:
| Column | Type | Meaning |
|---|---|---|
email_canonical | text, unique, nullable | The email returned by Signitize. NULL while the check is pending |
email_check | text | passed, pending or flagged |
email_flag | text, nullable | Why a user was flagged: disposable, undeliverable, invalid or duplicate |
A unique column accepts several NULLs in PostgreSQL, MySQL and SQLite, so pending users don't collide.
Step 2: the signup
- Node.js
- Python
async function checkEmail(email) {
try {
const response = await fetch('https://api.signitize.io/v1/check', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SIGNITIZE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(5000),
})
if (response.ok) return { status: 'checked', result: await response.json() }
if (response.status === 400) return { status: 'invalid' }
if (response.status === 401 || response.status === 422) alertTeam(`Signitize rejected our request: ${response.status}`)
if (response.status === 429) {
const body = await response.json().catch(() => null)
if (body?.error?.type === 'QuotaExceededError') alertTeam('Signitize monthly quota used up')
}
return { status: 'unavailable' }
} catch {
return { status: 'unavailable' } // timeout or network error
}
}
async function signup(req, res) {
const typed = req.body.email.trim()
const check = await checkEmail(typed)
if (check.status === 'invalid') return res.status(422).json({ error: 'Enter a valid email address.' })
if (check.status === 'checked') {
const { email, disposable, deliverable } = check.result
if (disposable) return res.status(422).json({ error: 'Please use a permanent email address.' })
if (!deliverable) return res.status(422).json({ error: 'This email address cannot receive mail. Please check it for typos.' })
if (await users.findByCanonicalEmail(email)) return res.status(409).json({ error: 'You already have an account.' })
await users.create({ email: typed, emailCanonical: email, emailCheck: 'passed' })
} else {
await users.create({ email: typed, emailCanonical: null, emailCheck: 'pending' })
}
await sendVerificationEmail(typed) // always to the address the user typed
return res.status(201).json({ ok: true })
}
import os
import requests
def check_email(email):
try:
response = requests.post(
"https://api.signitize.io/v1/check",
headers={"Authorization": f"Bearer {os.environ['SIGNITIZE_API_KEY']}"},
json={"email": email},
timeout=5,
)
except requests.RequestException:
return {"status": "unavailable"} # timeout or network error
if response.ok:
return {"status": "checked", "result": response.json()}
if response.status_code == 400:
return {"status": "invalid"}
if response.status_code in (401, 422):
alert_team(f"Signitize rejected our request: {response.status_code}")
if response.status_code == 429 and error_type(response) == "QuotaExceededError":
alert_team("Signitize monthly quota used up")
return {"status": "unavailable"}
def error_type(response):
try:
return response.json()["error"]["type"]
except (ValueError, KeyError, TypeError):
return None
def signup(typed_email):
typed = typed_email.strip()
check = check_email(typed)
if check["status"] == "invalid":
return 422, "Enter a valid email address."
if check["status"] == "checked":
result = check["result"]
if result["disposable"]:
return 422, "Please use a permanent email address."
if not result["deliverable"]:
return 422, "This email address cannot receive mail. Please check it for typos."
if users.find_by_canonical_email(result["email"]):
return 409, "You already have an account."
users.create(email=typed, email_canonical=result["email"], email_check="passed")
else:
users.create(email=typed, email_canonical=None, email_check="pending")
send_verification_email(typed) # always to the address the user typed
return 201, None
Step 3: recheck pending users
Run this every 10 minutes (cron, a scheduled task or your job queue). It stops as soon as Signitize still doesn't answer, so an outage doesn't waste requests, and it pauses between calls to stay under your plan's per-second limit.
- Node.js
- Python
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function recheckPendingSignups() {
const pending = await users.findPending({ limit: 50, oldestFirst: true })
for (const user of pending) {
await sleep(600) // stay under 2 requests per second (Free plan)
const check = await checkEmail(user.email)
if (check.status === 'unavailable') return // still down: try again next run
if (check.status === 'invalid') {
await users.update(user.id, { emailCheck: 'flagged', emailFlag: 'invalid' })
continue
}
const { email, disposable, deliverable } = check.result
const owner = await users.findByCanonicalEmail(email)
const flag = disposable ? 'disposable' : !deliverable ? 'undeliverable' : owner && owner.id !== user.id ? 'duplicate' : null
await users.update(user.id, {
emailCanonical: flag === 'duplicate' ? null : email,
emailCheck: flag ? 'flagged' : 'passed',
emailFlag: flag,
})
}
}
import time
def recheck_pending_signups():
for user in users.find_pending(limit=50, oldest_first=True):
time.sleep(0.6) # stay under 2 requests per second (Free plan)
check = check_email(user.email)
if check["status"] == "unavailable":
return # still down: try again next run
if check["status"] == "invalid":
users.update(user.id, email_check="flagged", email_flag="invalid")
continue
result = check["result"]
owner = users.find_by_canonical_email(result["email"])
if result["disposable"]:
flag = "disposable"
elif not result["deliverable"]:
flag = "undeliverable"
elif owner and owner.id != user.id:
flag = "duplicate"
else:
flag = None
users.update(
user.id,
email_canonical=None if flag == "duplicate" else result["email"],
email_check="flagged" if flag else "passed",
email_flag=flag,
)
Each recheck that gets a 200 or 400 counts as one check. A 503 is never counted, so rechecking during an outage costs you nothing. A call your client abandons at its own timeout may still be counted if Signitize finishes it.
Step 4: hold back the perks, not the user
A pending user can do everything a normal new user can, except what abusers sign up for. Grant those when the user becomes passed:
- free credits, trial periods, signup bonuses, referral rewards
- sending invitations or messages to other people
- anything that costs you money per account
When a recheck flags a user:
email_flag | Suggested action |
|---|---|
disposable | Keep the account but no perks. Ask the user to change to a permanent address to unlock them |
duplicate | No perks. Review it or merge it with the existing account |
undeliverable, invalid | The verification email can't arrive anyway. Delete the account if it is still unverified after a few days |
Don't delete flagged users the moment they are flagged: a person may be waiting on the other side.
Test your fallback before launch
Make Signitize unreachable on purpose and sign up a test user. The signup must succeed and the user must be pending:
- point your client at an address that refuses connections, such as
http://127.0.0.1:9/v1/check, or - set the timeout to 1 millisecond.
Then restore the URL, run the recheck job and confirm the user becomes passed.
What Signitize does on its side
- Clear failures. An outage answers
503, never a misleading200, so you always know when to mark a userpending. - No charge for outages. A
503is not counted toward your quota, so rechecks during an outage are free. - Two kinds of
429.RateLimitErrormeans "retry in a second";QuotaExceededErrormeans "your monthly quota is used up". See Rate limits. - Partial answers stay safe. If a DNS lookup fails inside a check, Signitize still answers
200and never marks a real address as undeliverable because of it. See Speed and failure handling.