---
title: "How to Check Pre-Send Email Risk Before Dispatch"
description: "Call POST /v1/send-decision with a SHA-256 hash, purpose, and sending domain; treat allow, hold, and block as recommendations, and never read an unavailable check as permission to send."
slug: "pre-send-email-risk-decision"
date: 2026-10-12
updated: 2026-10-12
last_tested: 2026-10-12
summary: "Signup validation and pre-send history are separate contracts: hash the confirmed address, ask /v1/send-decision for an allow/hold/block recommendation, and keep final dispatch policy in your application."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "nobounce.dev sender integration contract"
    url: "https://nobounce.dev/send.md"
  - title: "nobounce.dev agent reference"
    url: "https://nobounce.dev/llms.txt"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy and recipient history"
    url: "https://nobounce.dev/v1/config"
  - title: "Node.js crypto — createHash"
    url: "https://nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
---

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`.

```bash
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:

```json
{"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](https://nobounce.dev/blog/fail-open-email-validation-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.

```js
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](https://nobounce.dev/blog/retry-email-validation-with-idempotency-key/).

## 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](https://nobounce.dev/blog/why-no-smtp-rcpt-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](https://nobounce.dev/send.md). For signup wiring and the four-verdict map, give an agent [/integrate.md](https://nobounce.dev/integrate.md).
