Signup validation answers whether an address looks usable right now. Pre-send risk answers a different question: given account-local history for this recipient, should this message wait, stop, or proceed under your policy? Mixing the two contracts is how teams either block registrations on a DNS blip or treat an empty history as a delivery guarantee.
nobounce keeps them separate. POST /v1/check still owns the frozen signup verdict taxonomy. POST /v1/send-decision returns an allow, hold, or block recommendation from account-local history. Your routine still owns urgency, consent, and the final dispatch action.
Hash first; never send the address again
Hash the address your application already confirmed at signup: SHA-256 of trim().toLowerCase(). Send only that 64-hex digest, the exact sending_domain on the From line, and an explicit purpose of transactional or marketing.
HASH=$(printf '%s' 'user@gmail.com' | tr '[:upper:]' '[:lower:]' | sha256sum | cut -d' ' -f1)
curl -sS https://nobounce.dev/v1/send-decision \
-H "Authorization: Bearer $NOBOUNCE_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $SEND_INTENT_ID" \
-d "{\"email_hash\":\"$HASH\",\"sending_domain\":\"mail.example.app\",\"purpose\":\"transactional\"}"
A successful body looks like:
{"email_hash":"...","decision":"hold","reason":"mailbox_full","retry_at":1760313600}
retry_at is a Unix-second timestamp on hold, otherwise null. Subdomains match only their exact name: mail.example.app is not example.app. Marketing after an unsubscribe recommends block; complaints and active provider suppressions recommend block for both purposes. That recommendation still does not replace your own unsubscribe or consent store.
Live calls need a paid key. There is no free tier: hobby is $1/mo for 1,000 checks/mo, pro $19/mo for 100,000, and scale $99/mo for 1,500,000. Obtain a key by redeeming an operator-minted access code or paying self-serve through Stripe cards or usevig stablecoins. Put the key in the Authorization header, never a URL or query parameter. Decision units are a separate monthly allowance of ten times included checks; they do not consume /v1/check quota. GET /v1/me reports send_decisions separately.
Treat the three decisions as risk facts
decision |
What it means | What it does not mean |
|---|---|---|
allow |
No active restriction in this account's history, or history is unknown (history_unknown / no_active_restriction) |
Consent, mailbox existence, or proof of delivery |
hold |
Delay until retry_at (full mailbox, throttle, stale/incomplete feed, temporary failure) |
A permanent recipient classification |
block |
An active restriction (complaint, unsubscribe for marketing, hard failure, provider suppression) | An instruction that overrides your legal or product rules |
Cold history returns allow with history_unknown. That is honest emptiness, not a clean bill of health. Known history without a restriction returns allow with no_active_restriction; still not mailbox proof. A stale import feed holds as feedback_stale; an incomplete feed holds as feedback_incomplete. Known blocks outrank those holds. Refresh the feed; do not delete local suppressions just to clear a hold.
Batch the same contract with POST /v1/send-decision/batch: email_hashes of 1–100 digests, same purpose and sending_domain. Results keep input order. Counts are evaluations, not avoided sends — the server never receives an acknowledgment that you skipped a message.
Unavailable is not allow
Signup validation can fail open: when /v1/check times out, accepting the user is the documented contract in Fail Open: Email Validation Must Never Block a Signup. Pre-send history does not inherit that rule.
A timeout, 429, 503, transport error, or unreadable body means the check is unavailable. That is not allow, not absence of restrictions, and not permission to clear a local cooldown. Preserve local facts. Each caller routine chooses whether to queue, defer, or apply another local policy; nobounce mandates neither send-on-error nor queue-on-error.
import { createHash, randomUUID } from "node:crypto";
const addressHash = (address) =>
createHash("sha256").update(address.trim().toLowerCase()).digest("hex");
export async function checkSendRisk({ address, sendingDomain, purpose }) {
const email_hash = addressHash(address);
const body = { email_hash, sending_domain: sendingDomain, purpose };
const retryKey = randomUUID(); // persist per send-intent in real jobs
try {
const response = await fetch("https://nobounce.dev/v1/send-decision", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOBOUNCE_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": retryKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return { state: "unavailable" };
const result = await response.json();
if (
result.email_hash !== email_hash ||
!["allow", "hold", "block"].includes(result.decision) ||
typeof result.reason !== "string" ||
(result.decision === "hold"
? !Number.isSafeInteger(result.retry_at)
: result.retry_at !== null)
) {
return { state: "unavailable" };
}
return { state: "available", result };
} catch {
return { state: "unavailable" };
}
}
Pass { state, result } into your own policy hook together with message kind, urgency, and local facts. The hook decides the dispatch action. nobounce supplies the recommendation and retry_at; it does not choose urgency.
Reuse Idempotency-Key for exact retries of the same endpoint and body. A completed replay refreshes current restrictions without adding decision units, so a later attempt can move from allow to block after feedback. That is intentional: it is not an immutable cached send permission. A changed body returns 409 idempotency_key_reused. The same distinction between replay and a fresh operation is covered for signup checks in Retry Email Validation Without Spending Quota Twice.
Close the loop with hash-only feedback
History starts cold until your app reports verified outcomes. POST /v1/feedback and /v1/feedback/bulk accept hashes and bounded event names only — never the address. Provider API acceptance is not delivered. Report delivered with final: true only after verified final delivery. Legacy bounced is a temporary hold, not a permanent hard suppression. Complaints and marketing unsubscribes survive later delivered/opened reports; clearing them requires an independently verified restriction_cleared.
This surface is not SMTP RCPT TO probing and not a purchased bounce database. Both remain permanently out of scope — see Why nobounce Will Never Do SMTP RCPT TO Probing. nobounce never stores a plaintext address; anything persisted is SHA-256, with domains in clear. Keep raw provider payloads and addresses in your application, not in analytics or retry logs.
The full field vocabulary, reason tables, and owner brief live at /send.md. For signup wiring and the four-verdict map, give an agent /integrate.md.