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.