# Sender integration with nobounce.dev

This contract applies to numerologo.ia.br and medium.ia.br. It does not authorize changes in either repository.
Each sender owner must select an explicit nobounce account and keep its API key server-side.
The history is account-local. No unrelated tenant can read or change it.

## Required order

1. Enforce your own consent, verification, unsubscribe, suppression, and sender cooldown policies.
2. Call POST /v1/check at signup. Any suggestion requires customer confirmation before changing the address.
3. Persist the confirmed address only in your application. Do not log it in analytics.
4. Before a send, hash the confirmed address with SHA-256 after trim().toLowerCase().
5. Call POST /v1/send-decision with that hash, the actual sending_domain, and an explicit purpose.
6. Allow means no active known restriction. It is not proof of delivery, consent, or mailbox existence.
7. Hold means queue until retry_at, then obtain a new decision. Block means do not call the sender provider.
8. Send only after both your local policy and the decision permit it.
9. Report verified final outcomes with hash-only feedback. Provider API acceptance is not delivered.

Signup validation can fail open. The send path must not bypass a known local restriction or cooldown during an outage.
On decision timeout, 429, or 503, preserve local suppression data and queue the message. Do not treat an error as allow.
This service is not a consent system, mail relay, SMTP verifier, or purchased bounce database.

## Pre-send API

Both endpoints require Authorization: Bearer <nobounce API key>:

- POST https://nobounce.dev/v1/send-decision
- POST https://nobounce.dev/v1/send-decision/batch

Single body:

```json
{"email_hash":"<64 hex>","sending_domain":"numerologo.ia.br","purpose":"transactional"}
```

Use email_hashes for a batch of 1-100 hashes. Results preserve input order.
Purpose must be transactional or marketing. Use the actual sender domain; subdomains match only their exact name.
Local unsubscribe blocks marketing. Complaint and provider suppression block both purposes.
An explicit transactional purpose does not bypass your own consent or provider restrictions.

Response:

```json
{"email_hash":"<64 hex>","decision":"hold","reason":"mailbox_full","retry_at":1234567890}
```

Decision is allow, hold, or block. retry_at is a Unix-second timestamp for a hold, otherwise null.
A cold address can return allow/history_unknown. That means the history is unknown, not that the mailbox is valid.
Known history without an active restriction returns allow/no_active_restriction; it is not mailbox or consent proof either.
A configured import feed older than 24 hours holds as feedback_stale; an incomplete feed holds as feedback_incomplete.
Known blocks take precedence. Refresh the feed; never delete local restrictions merely to remove a hold.

Send Idempotency-Key on retries of the same endpoint and exact body.
A different body returns 409 idempotency_key_reused. Decision retries refresh current restrictions without adding decision units.
They can therefore change from allow to block after feedback. They are not immutable cached permission to send.
First evaluations consume a separate monthly allowance of ten times the plan's included checks, including quota overrides.
They do not consume /v1/check quota. GET /v1/me reports send_decisions separately.
Limits are 1,000 new decision units and 600 transport requests per account per minute.
A repeated key does not add decision units, but its HTTP request still counts toward abuse control.
Decision counts are evaluations, not actual avoided sends. The server receives no sender acknowledgment of an avoided send.

## Feedback API

- POST https://nobounce.dev/v1/feedback: one event.
- POST https://nobounce.dev/v1/feedback/bulk: {"events":[...]} with 1-100 events and at most 64 KiB.
- POST https://nobounce.dev/v1/feedback/coverage: operator import freshness, not a delivery event.

Feedback event vocabulary: bounced, delivered, opened, hard_bounce, soft_bounce, temporary_failure, unknown_failure, provider_rejected, provider_suppressed, complaint, unsubscribe, restriction_cleared.

Legacy bounced, delivered, and opened retain their response shape: id, recorded, event, domain.
A legacy bounced report is an unknown temporary failure, not a permanent hard suppression.
Old account-local legacy bounces hold for 15 minutes only. They never become irreversible mailbox truth.

Typed events require email_hash, event, reason, occurred_at, and event_id.
The timestamp is Unix seconds. event_id must be the SHA-256 of a stable source event identity, never the raw ID.
Use source=customer for your verified reports; the operator importer uses source=cloudflare.
Source is a caller assertion. The API does not authenticate Cloudflare or another provider through that field.

| Event | Allowed reasons | Effect |
|---|---|---|
| hard_bounce | mailbox_not_found, recipient_domain_not_found | Block; requires final:true and confirmed recipient evidence |
| soft_bounce | mailbox_full, temporary, throttled | Temporary hold |
| temporary_failure | temporary, throttled, policy | Temporary hold; policy/throttle also holds the actual sender domain |
| unknown_failure | unknown | Temporary hold, never permanent mailbox classification |
| provider_rejected | provider_suppression, policy, unknown | Temporary observation; active suppression entries are imported separately |
| provider_suppressed | provider_suppression, policy, complaint | Block until the provider expiration, or until explicit verified recovery |
| complaint | complaint | Persistent block for both purposes |
| unsubscribe | unsubscribe | Persistent marketing block |
| restriction_cleared | confirmed_recovery | Clear only the specified target after independent verification |

Default holds: mailbox_full 24 hours; temporary/policy one hour; throttled/unknown 15 minutes.
Temporary expires_at cannot exceed seven days after occurred_at. A later weaker failure does not shorten an active hold.
Provider suppression follows its actual expires_at or null for no expiration.
Complaint, unsubscribe, and typed hard failure do not expire automatically.
Delivered and opened do not remove restrictions. A newer explicit clear is required for persistent restrictions.
An older report cannot reverse a newer state. At tied timestamps, an active restriction wins over a clear.

Default scope is account: every sender in this nobounce account. Confirmed permanent recipient failures require account scope.
Use scope=sending_domain for exact provider suppressions or sender-specific policy restrictions.
The optional domain is a validated recipient domain, never an address or free text.
Unexpected personal fields, raw provider IDs, subjects, errors, API keys, and arbitrary properties are rejected.

```json
{
  "email_hash":"<64 hex>",
  "event":"hard_bounce",
  "reason":"mailbox_not_found",
  "event_id":"<SHA-256 of stable provider identity>",
  "occurred_at":1234567890,
  "final":true,
  "sending_domain":"numerologo.ia.br",
  "scope":"account",
  "source":"customer"
}
```

The bulk operation is all-or-nothing. One invalid event or conflicting ID rolls back the entire batch.
Identical event IDs deduplicate across single and bulk endpoints. A changed event with the same ID returns 409.
Bulk replies give count, inserted, and duplicates. Feedback consumes no check quota.
Unique feedback records use a separate monthly allowance of 20 times included checks, including overrides.
Duplicates consume no new monthly feedback unit. GET /v1/me reports feedback usage separately.
The limit is 5,000 feedback entries and 600 transport requests per account per minute.
Reuse Idempotency-Key for exact request retries. Use stable event IDs to tolerate overlapping imports.
Only the declared provider snapshot observation time can change on a duplicate suppression snapshot.

## curl and MCP examples

```bash
curl -sS https://nobounce.dev/v1/send-decision \
  -H "Authorization: Bearer $NOBOUNCE_KEY" \
  -H 'Content-Type: application/json' \
  -H 'User-Agent: OpenAI File Downloader, XaiImageApiFetch/1.0' \
  -H "Idempotency-Key: $SEND_INTENT_ID" \
  -d '{"email_hash":"<64 hex>","sending_domain":"numerologo.ia.br","purpose":"transactional"}'
```

MCP tools/call arguments for the same decision:

```json
{"name":"send_decision","arguments":{"api_key":"<private key>","email_hash":"<64 hex>","sending_domain":"medium.ia.br","purpose":"transactional","idempotency_key":"<stable send intent UUID>"}}
```

For signup, check_email remains the DNS/typo tool. Show any suggestion to the customer and obtain confirmation first.
For verified outcome feedback, report_feedback accepts the exact event body below plus api_key and optional idempotency_key.
Never put an API key in a URL, log, or public analytics payload.

## Node example

This runs in your application's server. The names for local sender functions are examples, not a nobounce SDK.

```js
import { createHash, randomUUID } from 'node:crypto';
const hash = text => createHash('sha256').update(text).digest('hex');
const addressHash = address => hash(address.trim().toLowerCase());
async function nobounce(path, body, retryKey) {
  const response = await fetch('https://nobounce.dev' + path, {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.NOBOUNCE_KEY,
      'Content-Type': 'application/json',
      'User-Agent': 'OpenAI File Downloader, XaiImageApiFetch/1.0',
      'Idempotency-Key': retryKey,
    },
    body: JSON.stringify(body), signal: AbortSignal.timeout(5000),
  });
  if (!response.ok) throw new Error('Queue the message and inspect the safe fix field.');
  return response.json();
}

// At signup, a validator outage can fail open. Confirm any suggestion.
const verdict = await nobounce('/v1/check', { email: typedAddress }, stableSignupCheckId)
  .catch(() => ({ verdict: 'unknown', suggestion: null }));
if (verdict.verdict === 'undeliverable' && !verdict.suggestion) {
  throw new Error('Ask for another address or offer a non-email route.');
}
const confirmedAddress = verdict.suggestion
  ? await askCustomerToConfirm(typedAddress, verdict.suggestion) : typedAddress;
// Persist a random send-intent UUID once in your own queue; reuse it on retries.
const stableSendIntentId = randomUUID();
await enforceLocalConsentSuppressionsAndCooldowns(confirmedAddress);
const decision = await nobounce('/v1/send-decision', {
  email_hash: addressHash(confirmedAddress),
  sending_domain: 'numerologo.ia.br', purpose: 'transactional',
}, stableSendIntentId);
if (decision.decision !== 'allow') {
  await queueOrBlockLocally(decision);
} else {
  await sendFromYourApplication(confirmedAddress);
}

// In a verified final-outcome handler, not after API acceptance:
await nobounce('/v1/feedback', {
  email_hash: addressHash(confirmedAddress), event: 'delivered', reason: 'delivered',
  occurred_at: verifiedOutcomeUnixSeconds, event_id: hash(stableProviderEventId),
  final: true, source: 'customer', sending_domain: 'numerologo.ia.br',
}, stableFeedbackRetryId);
```

Verify the provider webhook in the customer app before reporting. Keep raw addresses and provider payloads there, not in nobounce.
MCP tools send_decision, send_decision_batch, report_feedback, and report_feedback_bulk use the same contract.
Pass api_key separately and idempotency_key for retries. Other required fields match the HTTP bodies.

## Owner adoption brief

For numerologo.ia.br: keep its subscription/verification and unsubscribe logic first; send the confirmed recipient hash with sending_domain=numerologo.ia.br.
For medium.ia.br: keep its own policies first; send with sending_domain=medium.ia.br. Exclude known test recipients from operator imports.
If the actual From domain is a subdomain, use that exact domain and review the operator mapping accordingly.
Use marketing for promotional mail and transactional only for explicitly permitted service messages.
Do not classify intermediate retry failures as permanent recipient failures.
The Cloudflare importer reads final isLastEvent=1, isNDR=0 rows and active scoped suppressions.
It needs reviewed owner mappings and relevant owned recipient hashes for account-wide suppressions. It defaults to dry-run.
No live import apply is part of this release. Sender adoption and observed outcomes must precede any reduced-bounce claim.
The history starts cold where feedback is absent. No SMTP probing or mailbox-existence guarantee is added.
