# Add SafeBind to a buyer stack that already consumes TrustedForm or Jornaya LeadiD

**Who this is for.** You buy leads, your intake code already handles TrustedForm
certificate URLs or Jornaya LeadiD tokens, and you want SafeBind's Proof of Consent
running beside (or instead of) them. This document is written to be handed to an AI coding
assistant together with your existing integration code: it maps each thing that code
already does to the SafeBind call that does the equivalent job, so the port is a
translation, not a redesign.

**What this document is not.** It is a map, not the reference. Exact request and response
schemas live in the OpenAPI spec — `https://api.safebind.ai/openapi.json`, rendered at
`https://safebind.ai/docs`. When this page and the spec disagree, the spec wins.
TrustedForm, Jornaya, and LeadiD are ActiveProspect products; they are named here only to
map from integrations you already have.

---

## Instructions for the coding assistant

You are likely reading this alongside an existing lead-intake codebase. Do these in order:

1. **Find where the code extracts the incumbent certifier's reference** from the lead post:
   - TrustedForm: a URL field like `xxTrustedFormCertUrl` / `xx_trusted_form_cert_url`.
   - Jornaya LeadiD: a bare token field, typically `leadid_token` or `universal_leadid`.
   Add parallel handling for the SafeBind fields — **both** arrive on the post:
   `safebind_record_url` (`https://api.safebind.ai/c/<token>.<secret>`) for URL-shaped pipelines, and
   `safebind_record_id` (`<token>.<secret>`, the reference alone) for ID-keyed pipelines like a LeadiD-style one.
   **Everything is one call** — `POST https://api.safebind.ai/c/{token}` with the URL's whole last segment,
   `<token>.<secret>`, in the path; the JSON body says what you want (see "The call"). Keep the part
   after the dot: it is the lead secret that opens the record at once. Without it the record's contents
   stay locked until about an hour after the session.
2. **Add the API key** as configuration (e.g. `SAFEBIND_API_KEY`). Send it however the
   existing code already sends credentials — see "API keys" below: if the intake is built for
   TrustedForm, its HTTP Basic auth works unchanged with the SafeBind key as the password.
   Smoke-test it with `GET /v1/whoami`, which returns the account and capabilities the key
   resolves to; the buyer calls below need the `verify` capability.
3. **Tell the account holder to register their business names** before going live — see
   "Is this consent for YOU?" below. This is a console setting, not an API call, and without
   it every lead comes back `review`.
4. **Port the pre-bid check, the purchase and the decision read** — all the same call with
   different bodies (see "The call"), following the mapping tables.
5. Keep the existing certifier code paths untouched unless asked — publishers will send
   one, the other, or several, and the systems are independent.

Base URL for everything: `https://api.safebind.ai`. All bodies are JSON.

---

## API keys — how SafeBind's differ from what your code sends today

| | TrustedForm (ActiveProspect) | Jornaya LeadiD | SafeBind |
|---|---|---|---|
| How the credential is sent | HTTP Basic auth: username `API`, your account API key as the password | An account code and key pair (`lac` / `lak`) in each request, beside the token | **Your choice — all three work:** `Authorization: Bearer sk_live_…`; HTTP Basic auth with any username and the key as the password (`-u API:sk_live_…`, exactly as a TrustedForm integration sends it); or an `x-api-key: sk_live_…` header. The examples below use Bearer. |
| How many keys | One key per account, shared by every ActiveProspect product you subscribe to | One pair per account | As many as you like, each **named** — one per system (intake, CRM sync, reporting) |
| What a key can do | Everything the account can do | Everything the account can do | Only the capabilities chosen when it was created: `buy` for the buyer calls in this document. A key without `verify` is refused (`403`). |
| Rotation | Reset in ActiveProspect ID account settings; the new key then has to be propagated by logging in to each product | — | Create a new key, switch the one system to it, revoke the old one in the console (API keys). Other keys are untouched. A revoked key can keep working for up to about 10 minutes while caches expire, so switch before you revoke. |
| Key types | One | One | Your **API key** `sk_live_…` is the one for everything in this document. The other kind, a **site key** `ck_live_…`, lives in a seller's page snippet: the buyer call refuses it (`403`), and on a record URL it unlocks nothing beyond the anonymous view. Never ship an API key to a browser. |

Keys belong to one SafeBind account. If you both buy and sell leads, keep a key per account.

---

## The shape of the flow

Your existing code most likely does two things: a check before you bid, and a paid action
once you win. SafeBind has the same two moments, plus a third that is free — and **all three
are the same call**, `POST /c/{token}`, with a different body (TrustedForm's v4 API is also one certificate URL with the operation
in the body, so each of your calls maps across):

| moment | your existing integration | SafeBind body |
|---|---|---|
| **Pre-bid, free** | ping / lead-match against the record | `{}` or `{"phone": …, "email": …}` → `status` (+ `match`) |
| **On win, the one paid call** | retain the TrustedForm certificate / query the audit | `{"phone": …, "buy": true}` → `purchase` + `report` |
| **After acquisition, free forever** | (rule results are a separate paid product) | any body → `report`: your ruleset's buy / reject / review, unlimited |

The single charge in the SafeBind flow is the purchase. Everything before it and everything
after it is free, including `GET /v1/reporting?role=buyer` for bulk export.

---

## Concept map — from a TrustedForm-shaped integration

| in your TrustedForm code | SafeBind equivalent | what changes |
|---|---|---|
| Cert URL field on the lead post (`xxTrustedFormCertUrl`) | `safebind_record_url` → `https://api.safebind.ai/c/<token>.<secret>` | Same pattern: a URL that rides with the lead. The SafeBind URL is also a page a human — or a court — can open. |
| Retain (TrustedForm's paid claim on the certificate) | `"buy": true` on the call | One charge per record, idempotent on retry (`purchase.firstSave: false`, no second charge). Requires proof of possession: send the consumer `phone` or `email` you were sold, or the call is refused (`403 possession_required`) with no charge. |
| Lead matching (`match_lead`-style fingerprint checks) | Free pre-bid: send `phone` / `email` → `match`. After purchase: `report.contact_match` (adds `name_match` / `name_assessment`); the IP comparison is `report.ip_match`. | The pre-bid match returns booleans only — `false` means the record is for a **different consumer** (strong do-not-buy), `null` means not comparable. Nothing about the consumer is ever returned. Email may be sent as `SHA1(lower(trim(email)))` if you'd rather not transmit the address. |
| Verify add-on (TrustedForm's paid rule results on the certificate) | `report` — included, free, unlimited **after the purchase** | `report.buyer_decision.outcome` (`buy` / `reject` / `review`) with machine-readable `reasons[]`, evaluated against **your** account's ruleset. Before you buy, `report` is `null`. |
| Insights / page scans | The report's `consent`, `device`, `navigation`, `identity_churn`, `answer_provenance`, `network` and `consent_freshness` blocks, plus `fraud_band` / `fraud_reasons` | The consent facts are not just "the text was on the page": they record that the consumer **acted** — the affirmation, the control, the second it happened, and the disclosure it happened under. |
| Retention settings on retained TrustedForm certs | Purchase = **5 years from the purchase**, fixed | Evidence nobody keeps is destroyed **one year from capture** (`status.retention_until`). Not configurable — every record's evidence runs on the same schedule, not a setting someone chose. |
| The window your code assumes (~90 days) | `status.savable_until` / `status.days_left_to_save` | 90 days from capture, **fixed once the record is signed** — 7 days when no consent submission was detected — so read the field rather than assuming 90. After it closes (and within the one-year horizon) retrieval is a paid service, not self-serve. |

## Concept map — from a Jornaya LeadiD-shaped integration

| in your LeadiD code | SafeBind equivalent | what changes |
|---|---|---|
| The bare token on the lead post (`leadid_token` / `universal_leadid`) | `safebind_record_id` (`<token>.<secret>`) — an ID-shaped value; `safebind_record_url` arrives beside it | Your token-keyed storage and joins port directly: store the whole value, secret included. The URL form adds something the bare token never had: an address a human or a court can open. |
| Account-code + key credentials on each query (`lac` / `lak`) | Your API key, sent as a header (Bearer, Basic or `x-api-key` — your choice) | No per-request credential pair in the body — see **API keys** above. |
| The intelligence/audit query on the token | The one call: free `status` pre-bid, `"buy": true` (the one charge), then `report` | The witness facts come back in `report` — free and unlimited once you have bought, rather than metered per query. |

If your intake code parses an intelligence response, this is where each parser points now —
all fields below are inside `report` unless marked otherwise:

| response field your code parses | SafeBind field | notes |
|---|---|---|
| `authentic` | `authenticity.status` + `authenticity.publisher_domain_verified` | Richer than a boolean: `verified` / `authentic_unverified_domain` (provisional — treat as review) / `pending_authentication` / `failed`. Domain verification is frozen at capture, never backdated. A fabricated token already fails free before you bid (`404`). |
| `lead_age.seconds` / `bracket` | `acquisition.lead_age_at_save_seconds`; pre-bid, compute age from `status.captured_at` | Exact seconds from consent capture to your purchase. No bracket codes — do your own banding. |
| `duration_seconds` | No stable response field — test it in your **ruleset** via the `time_on_form_ms` fact | The form-duration measurement exists and your buy/reject rules can gate on it; it just isn't exposed as a report property today, so don't build a parser for one. |
| `provider.lpc` / `provider.domain` | `record.publisher_domain` (+ `authenticity.publisher_domain_verified`); the seller's own lead id is `record.external_id` | The capture domain, with **DNS-verified ownership** rather than a self-reported provider code. |
| `consent_details.disclosure_status` | `consent.scope`, judged by `consent.scope_check` | Not a match code: `scope` decomposes the disclosure into a locked attribute catalog (covers_sms, covers_autodialer, single_named_seller, …), each `yes` / `no` / `unclear` **with the substantiating sentence**; `scope_check` applies YOUR required attributes to it, and its lists distinguish `missing_required` from `unclear_required` from `present_rejected`. `unclear` is not `no` — don't collapse them. |
| `consent_details.consent_action` / `input_type` | `consent.consent_type` + `consent.prechecked`, and `consent.consent_control_final_checked` | The enum carries the legal distinction directly: `opt_in_checkbox`, `clickwrap`, `prechecked_checkbox` (their "passive"), `opted_out` (an affirmative refusal), `none`. `consent_control_final_checked` is the box's state at submit and is tri-state: only `false` means it was left unchecked; `null` means no box, or not observable. |
| `consent_details.prominence_score`, `contrast_rule_value`, `visibility_rule_value` | `disclosure_conspicuous` + `disclosure_conspicuous_determined`, `disclosure_contrast_ratio`, `disclosure_font_px`, `conspicuousness_reasons` | Measurements, not tier codes: the worst **proven** WCAG contrast pair, the smallest proven font size, and named cloaking findings (`display_none`, `opacity_0`, `offscreen`, …). Gate on the *pair* — `disclosure_conspicuous: true` with `determined: false` means unmeasured, not proven conspicuous. |
| `tcpa_guardian.capture_status` / `is_stored` / `playback_url` | The record **is** the recording: watch the masked session projection in the SafeBind console; `durability.durable` in the report | Session capture isn't a separate add-on tier that may or may not have run. The projection is PII-free and available **before** you buy; `durability.durable` confirms an immutable second-domain archive of the signed evidence. |
| `data_match.phone_match` / `email_match` / `integrity_code` | Free pre-bid: `match` booleans. After purchase: `contact_match` (`phone_match`, `email_match`, `name_match`, `name_assessment`) | Tri-state, and the distinction is load-bearing: `false` = **contradicted** (different consumer), `null` = not comparable. Nothing about the consumer is echoed back. |
| Fraud / risk scores | `fraud_band` (`low` / `medium` / `high`) **with** `fraud_reasons` | The seller's capture-time risk read, advisory. Always read the band together with its reasons — the reason codes are what makes the band checkable. |
| `velocity.*` (device / ip / consumer / lead counters) and `network.total_hops` / `total_entities` / `lead_dupe_check` | **No equivalent — deliberately.** Nearest in-session facts: the `device` and `identity_churn` blocks, plus ruleset facts such as `fill_velocity_keys_per_sec` your rules can test | Those counters come from observing the whole network's pings; SafeBind's authenticity is per-session and cryptographic instead. If cross-network velocity drives your rules today, keep that feed — SafeBind replaces the consent evidence, not the network graph. |

One consolidation note: since January 2026 TrustedForm and Jornaya LeadiD are issued by the
same company — see `https://safebind.ai/jornaya-alternative`. It changes nothing in the
mappings above; SafeBind runs beside either or both.

**SafeBind facts with no counterpart in your existing code — worth wiring in:**

- A fabricated or mistyped token returns `404` — a free way to catch a made-up
  proof-of-consent reference **before** it costs you a bid.
- `status.is_test` (and `report.record.is_test`): `true` marks integration-test evidence
  that must never be filed as proof. Reject it in production intake — you can do so before you
  bid.
- The price before you pay: `status` carries `save_price_micros`, `save_pricing`, `claimed`, and
  `shared_record_program` (a seller-sponsored record costs you nothing, though you
  still prove possession). Buying an `is_test` record is free too, so a test run costs
  nothing.
- `reference` on the buy: your own CRM/lead id (≤200 chars), stored once, returned as
  `report.acquisition.reference` and filterable with `GET /v1/reporting?role=buyer&reference=` —
  a join key between your records and ours.
- `purchase.funding` and `purchase.chargedMicros`: `shared` means the seller sponsored the
  acquisition, and `chargedMicros` is what actually left your balance. Treat these as the
  authoritative price answer.
- The record URL itself, opened in a browser or fetched with no key, shows anyone — a
  consumer, a court — four non-sensitive facts. No account needed.

---

## Is this consent for YOU? — register your business names

A consent that names someone else is not consent to be contacted by you. SafeBind checks
that the consent names your business — in the disclosure text, or on the partner list the
disclosure links to — and **this check is part of every decision by default**:

| `consent.buyer_named` | meaning | effect on `buyer_decision` (default setting) |
|---|---|---|
| `named` | the disclosure names you | none |
| `partner_list` | you are on the partner list it links to | none — unless you set "Named behind a link" to Don't accept, then `reject` (reason `buyer_named_link_only`) when the disclosure itself doesn't name you |
| `not_named` | **established**: the disclosure was read, doesn't name you, and the partner side is settled | `reject`, reason `buyer_named` |
| `unknown` | not established — the partner check is still running, you have no names registered, or the partner list wasn't captured (records signed before partner lists were) or couldn't be settled (not fully read, or a model match that couldn't be verified) | `review`, reason `buyer_named_unconfirmed` — a running check settles on a later read; missing names settle once you register them (or send `buyerName`); an uncaptured or unsettleable list stays `unknown` |
| `not_applicable` | not checked: you created this record, or you bought it without proving possession | none |

**Your names come from the console,** at `https://app.safebind.ai` → Lead screening → "Your
business names". Register every spelling you trade under — legal name, DBA, brand. This is
not settable through the API. You can also send `buyerName` on a call to add a name for that
read, but the decision recorded **at purchase** uses only the registered names.
With no names at all, every lead is `unknown` → `review`. The account holder can switch the
check off ("You're not named at all: Accept") in the same place.

Only `not_named` is an established miss. Do not assemble "not authorized" yourself from the
supporting fields — read `buyer_named`. Those fields remain as the evidence behind it:

- `consent.authorized_via` tells you WHICH route found your name: `named` (the disclosure
  text) or `partner_list` (the list it links). A match, not a grant — see below.
- `consent.buyer_authorization_status` says whether the question was put at all (`checked`,
  or why not).
- `consent.partner_check` is the signed partner-list check; `consent.partner_check_preview`
  is the early read before signing (below).

### `authorized_via` is a match, not a grant — read the sentence

Neither `named` nor `partner_list` means the consumer agreed that *you* may contact them. It
means your business name appears as a word in the text that was matched, and those are
different claims.

The disclosure route hands you the evidence to tell them apart. `consent.named_grant`
carries the `party_name` that matched and the `quote` that granted contact rights. Read the
quote before you act on the decision: *"I agree that Zenith may contact me about Auto
insurance"* names Auto and grants to Zenith. A matcher sees the word; only the sentence says
who the consumer agreed to hear from. If it does not run to you, treat the authorization as
unproven whatever `authorized_via` says.

On the partner-list route, `consent.partner_check.matched_name` is the entry that matched and
`match_tier` is how. Neither tier is a literal string comparison. `exact` is a *normalized*
equality: both names are lower-cased, `&` is spelled out, punctuation is dropped, and corporate
suffixes (`LLC`, `Inc`, `Corp`, `Ltd`, `Co`, `LP`, and the rest of that family) are removed
before comparing — so `Foo, LLC` and `Foo Inc.` are one string, and a listing for a different
legal entity sharing your trading name reports `exact`. `llm` is a judgment about a near-miss
that survived that normalization. A list of similarly-named companies is exactly where a wrong
name gets accepted, so surface `matched_name` and the tier to whoever reviews the lead rather
than collapsing both into "authorized".

### Before signing: `partner_check_preview`

The partner list is signed with the record, and buyers routinely read before that.
`consent.partner_check_preview` is the early read: it fetches the linked page directly and
matches your registered names against it.

- **A positive preview already counts.** Before signing, `authorized_via: partner_list` (and
  `buyer_named: partner_list`) comes from it, citing the exact page version it read
  (`matched_url`, `matched_body_sha256`). The signed check replaces it once the record
  is signed; the record's own signed copy is the evidence.
- **A negative is not a finding.** `authorized: false` with a non-null `withheld`
  (`render_dependent`, `no_authorizing_occurrence`, `unavailable`) means this early read
  cannot say — `buyer_authorization_status` is then `unavailable` and `buyer_named` is
  `unknown`. Re-read once the record is fully signed.
- `pages[].established` tells you how each page's identity was settled — `fetched` (we
  hashed those bytes) or `revalidated` (the origin confirmed them unchanged since we did).

## The call

Everything is one request to the record URL that came with the lead:

```bash
curl -s -X POST https://api.safebind.ai/c/$TOKEN \
  -H "Authorization: Bearer $SAFEBIND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ … }'
```

`$TOKEN` is the `safebind_record_id` value (or the last path segment of `safebind_record_url`). The
body decides what you get back; the response always has the same four parts:
`{ "status", "match", "purchase", "report" }`.

### Before you bid — free

```bash
  -d '{}'                                          # the status alone
  -d '{"phone": "+1 310 555 0147"}'                # the status and the contact match
```

Gate the bid on `status.evidence_available` (can the recording, disclosure and screening facts
be read right now?). There is deliberately no signing flag to wait for: signing — what makes
the record tamper-evident — usually finishes after the bid, and every decision is made as
soon as the evidence is readable. Also read `savable_until` / `days_left_to_save`,
`retention_until`, `is_test`, `claimed` and the price fields (`save_price_micros`,
`save_pricing`, `shared_record_program`) here.

With a `phone` and/or `email`, `match` carries `phone_match` / `email_match`: `true` agrees,
`false` means the record is for a **different consumer** (strong do-not-buy), `null` means
not comparable. Nothing about the consumer is returned. Email may be sent as
`SHA1(lower(trim(email)))`. The contact travels in the body, never the URL.

### On win — the one paid call

```bash
  -d '{"phone": "+1 310 555 0147", "buy": true, "reference": "crm-lead-84213"}'
```

`buy: true` proves possession with the `phone` or `email` you were sold (required — without
one you get `403 possession_required`, never a charge), buys the record, keeps it for five
years, and records your Lead screening decision as of that moment (using your registered
business names). `reference` is your own id for the acquisition. The response's `purchase`
has `saved`, `recordId`, `retentionUntil`, `funding`, `chargedMicros` and `firstSave`; the
`report` normally comes back in the same response. Retries are safe: a repeat returns
`firstSave: false`, charges nothing, and never overwrites the recorded `reference`.

A refused purchase keeps its own status (see the error table) and charges nothing. A
**successful** purchase is always `200` — if the report could not be produced on that call,
`report` is `null` and `report_unavailable` says why (`status`, `retryable`, `retry_after_ms`).
You still own the record; call again without `buy` to fetch the report.

### Any time after — free and unlimited

```bash
  -d '{"buyerName": "Acme Insurance Services", "leadName": "Jane Q Consumer",
       "phone": "+1 310 555 0147", "claimedRegion": "CA", "publisherIp": "203.0.113.7"}'
```

Once you own it, every call returns the `report`, re-scored against your current Lead screening
rules (so it can differ from the decision recorded at purchase). Each optional input unlocks its
comparison: `contact_match`, `ip_match`, geo distance, and whether `buyerName` appears in the
disclosure / partner list. The full input list — including `rulesetKey` (evaluate a named ruleset
instead of your default), `claimedCountry`, `claimedLatitude` / `claimedLongitude`,
`publisherAsn` and `publisherLatitude` / `publisherLongitude` — is in the spec. The
`email_quality` and name-quality screening facts are **always scored on what the consumer
actually typed on the form**, never on the `email` / `leadName` you send, which feed only the
match checks — a manufactured capture can't be laundered by attaching a clean address.

`report.buyer_decision.outcome` is `buy`, `reject`, or `review`, with `reasons[]` carrying a
machine `code` and human `message` per failed check — including the `buyer_named` family
above — plus the sections the decision was derived from (`consent`, `authenticity`,
`contact_match`, `ip_match`, `device`, `navigation`, `consent_freshness` among them),
`fraud_band` with `fraud_reasons`, and `record.is_test`.

## What the responses look like

Trimmed to the fields an intake usually reads; values are from a **test** record on a test
account, which is not billed. The full schemas are in the spec.

Before you bid, `{"phone": "+1 310 555 0147"}`:

```json
{
  "status": {
    "exists": true,
    "evidence_available": true,
    "captured_at": "2026-09-21T13:35:40.36258+00:00",
    "savable_until": "2026-12-20T13:35:40.36258+00:00",
    "days_left_to_save": 88,
    "retention_until": "2027-09-21T13:35:40.36258+00:00",
    "is_test": true,
    "claimed": false,
    "save_price_micros": 0,
    "save_pricing": null,
    "shared_record_program": false
  },
  "match": { "phone_match": true },
  "purchase": null,
  "report": null
}
```

A fabricated or mistyped token: `404` with `{"exists": false}`.

On win, `{"phone": "+1 310 555 0147", "buy": true}`:

```json
{
  "status": { "exists": true, "evidence_available": true, "claimed": false, "…": "…" },
  "match": null,
  "purchase": {
    "saved": true,
    "recordId": "4d951836-5a5f-437b-ad47-8ec2b4bc2b02",
    "retentionUntil": "2031-09-23T18:50:07.120251+00:00",
    "firstSave": true,
    "funding": "buyer",
    "chargedMicros": 0
  },
  "report": {
    "buyer_decision": { "outcome": "buy", "ruleset_id": "buyer_default_v2", "reasons": [] },
    "record": {
      "id": "4d951836-5a5f-437b-ad47-8ec2b4bc2b02",
      "publisher_domain": "uninsuredquote.com",
      "external_id": null,
      "is_test": true
    },
    "authenticity": { "status": "verified", "publisher_domain_verified": true, "provisional": false },
    "consent": {
      "consent_type": "opt_in_checkbox",
      "prechecked": false,
      "consent_control_final_checked": true,
      "consent_given_at": "2026-09-21T13:35:57.695Z",
      "buyer_named": "partner_list",
      "authorized_via": "partner_list",
      "buyer_authorization_status": "checked",
      "partner_check": { "authorized": true, "matched_name": "SafeBind AI", "match_tier": "exact", "coverage_complete": true },
      "disclosure_conspicuous": true,
      "disclosure_conspicuous_determined": true
    },
    "fraud_band": "low",
    "fraud_reasons": [],
    "contact_match": { "phone_match": true, "email_match": null, "name_match": null },
    "consent_freshness": { "flags": [], "recommendation": "proceed" },
    "acquisition": { "saved_at": "2026-09-23T18:50:07.120251+00:00", "buyer_external_id": null, "lead_age_at_save_seconds": 191666 },
    "durability": { "durable": true }
  }
}
```

A later call returns the same `report` (re-scored) with `purchase` =
`{ "saved": true, "recordId": "…", "saved_at": "…" }`. A reject carries its reasons, for example
`{"outcome": "reject", "reasons": [{"code": "buyer_named", "message": "The consent doesn't name you, not in the disclosure and not on its partner list."}]}`.

After a successful buy whose report was not ready yet:

```json
{ "status": { "…": "…" }, "match": null, "purchase": { "saved": true, "…": "…" }, "report": null,
  "report_unavailable": { "status": 202, "retryable": true, "retry_after_ms": 500, "readiness": "…" } }
```

A refused buy keeps the refusal's own status and body, for example:

```json
{ "code": "possession_required", "reason": "contradicted", "attemptsRemaining": 2, "shared_record_program": false, "error": "…" }
{ "code": "possession_throttled", "retryAfterSeconds": 600, "shared_record_program": false, "error": "…" }
```

(`403` and `429`; the numbers are illustrative — read them from the response.)

## Reference implementation (TypeScript)

A complete, dependency-free client for the call, with the retry rules built in: it retries only
`202`, `429` and `503`, never sooner than `retry_after_ms` / `retryAfterSeconds` / `Retry-After`
asks, and hands a longer wait (a possession throttle can be minutes) back to you as
`retryAfterMs` so you can defer the lead. It never retries a `403`, treats a contact check it
could not complete as a "no", and never mistakes "bought, report not ready" for "not bought".
Node 18+ (global `fetch`). Port it to your language as-is; the logic is the contract.

```ts
// SafeBind buyer integration — one call, POST /c/{token}. Node 18+ (global fetch), no dependencies.
const BASE = "https://api.safebind.ai";
/** The longest this client waits in-line before a retry. A longer advertised delay is returned to
 *  the caller as `retryAfterMs` so it can defer the lead instead of retrying early. */
const MAX_INLINE_WAIT_MS = 30_000;

interface Contact {
  phone?: string;
  email?: string;
}

/** Everything the call accepts; all optional. */
interface CallBody extends Contact {
  buy?: boolean;
  reference?: string;
  leadName?: string;
  buyerName?: string;
}

interface Answer {
  httpStatus: number;
  status: Record<string, unknown> | null;
  match: Record<string, unknown> | null;
  purchase: Record<string, unknown> | null;
  report: Record<string, unknown> | null;
  reportUnavailable: Record<string, unknown> | null;
  /** The refusal body when httpStatus is not 200. */
  error: Record<string, unknown> | null;
  /** Set when the API asked us to wait longer than MAX_INLINE_WAIT_MS. */
  retryAfterMs?: number;
}

type Outcome = "buy" | "reject" | "review";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const recordOrNull = (value: unknown): Record<string, unknown> | null => (isRecord(value) ? value : null);

const isOutcome = (value: unknown): value is Outcome =>
  value === "buy" || value === "reject" || value === "review";

/** How long the API asked us to wait, in ms — body hints first, then the Retry-After header. */
function advertisedDelayMs(body: Record<string, unknown>, headers: Headers, attempt: number): number {
  if (typeof body.retry_after_ms === "number") return body.retry_after_ms;
  if (typeof body.retryAfterSeconds === "number") return body.retryAfterSeconds * 1000;
  const headerSeconds = Number(headers.get("retry-after"));
  return Number.isFinite(headerSeconds) && headerSeconds > 0 ? headerSeconds * 1000 : 1000 * attempt;
}

/** THE call. Retries ONLY what the API marks retryable (202 finalizing, 429, 503), never sooner than
 *  it asks. Every other status is returned as-is — a 403 is never retried. */
export async function safebind(apiKey: string, token: string, body: CallBody = {}): Promise<Answer> {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(`${BASE}/c/${encodeURIComponent(token)}`, {
      method: "POST",
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify(body)
    });
    const parsed: unknown = await response.json().catch(() => ({}));
    const json = isRecord(parsed) ? parsed : {};
    const retryable = response.status === 202 || response.status === 429 || response.status === 503;
    const delayMs = retryable ? advertisedDelayMs(json, response.headers, attempt) : 0;
    if (retryable && attempt < 5 && delayMs <= MAX_INLINE_WAIT_MS) {
      await sleep(delayMs);
      continue;
    }
    const ok = response.status === 200;
    return {
      httpStatus: response.status,
      status: ok ? recordOrNull(json.status) : null,
      match: ok ? recordOrNull(json.match) : null,
      purchase: ok ? recordOrNull(json.purchase) : null,
      report: ok ? recordOrNull(json.report) : null,
      reportUnavailable: ok ? recordOrNull(json.report_unavailable) : null,
      error: ok ? null : json,
      ...(retryable && { retryAfterMs: delayMs })
    };
  }
}

/** Before you bid — free. Anything we could not check is a "no". */
export async function preBid(apiKey: string, token: string, contact: Contact) {
  const answer = await safebind(apiKey, token, contact);
  if (answer.httpStatus === 404) return { bid: false, reason: "no such record (fabricated or mistyped)" };
  if (answer.status === null) return { bid: false, reason: `check failed (${answer.httpStatus})` };
  if (answer.status.is_test === true) return { bid: false, reason: "test record — never file as proof" };
  if (answer.status.evidence_available !== true) return { bid: false, reason: "evidence not readable yet" };
  if (contact.phone !== undefined || contact.email !== undefined) {
    // Before purchase the comparison is `match`; on a record you already own it is the
    // report's `contact_match`. A comparison we could not complete is NOT a pass.
    const compared = answer.match ?? recordOrNull(answer.report?.contact_match);
    if (compared === null || compared.match_note !== undefined) {
      return { bid: false, reason: "contact check unavailable" };
    }
    // false = the record belongs to a DIFFERENT consumer. null = not comparable.
    if (compared.phone_match === false || compared.email_match === false) {
      return { bid: false, reason: "record is for a different consumer" };
    }
  }
  return {
    bid: true,
    priceMicros: answer.status.save_price_micros,
    savableUntil: answer.status.savable_until
  };
}

/** Read the decision out of a report, or null if there is none yet. */
function decisionOf(report: Record<string, unknown> | null) {
  const decision = report?.buyer_decision;
  if (!isRecord(decision) || !isOutcome(decision.outcome) || !Array.isArray(decision.reasons)) return null;
  const consent = report?.consent;
  return {
    outcome: decision.outcome,
    reasons: decision.reasons.filter(isRecord).map((reason) => ({
      code: String(reason.code),
      message: String(reason.message)
    })),
    buyerNamed: isRecord(consent) && typeof consent.buyer_named === "string" ? consent.buyer_named : null
  };
}

/** On win — the one paid call. Needs the phone or email you were sold (possession). */
export async function buy(apiKey: string, token: string, contact: Contact, reference?: string) {
  const answer = await safebind(apiKey, token, { ...contact, buy: true, reference });
  if (answer.httpStatus !== 200 || answer.purchase === null) {
    // 403 possession_required (reason: not_supplied | contradicted | nothing_to_compare): do NOT retry
    // the same value. 429 with retryAfterMs: retry the lead later. 402 top up; 409 window closed;
    // 422 never verifiable. None of these charged.
    throw new Error(
      `buy refused (${answer.httpStatus}${answer.retryAfterMs === undefined ? "" : `, retry in ${answer.retryAfterMs} ms`}): ${JSON.stringify(answer.error)}`
    );
  }
  // You own it now. The report usually comes back in the same response; if it could not be
  // produced yet, `reportUnavailable` says why and `decision` is null — call `report()` later.
  return {
    recordId: String(answer.purchase.recordId),
    chargedMicros: answer.purchase.chargedMicros,
    decision: decisionOf(answer.report),
    reportUnavailable: answer.reportUnavailable
  };
}

/** After you bought it — free and unlimited: your current decision. */
export async function report(apiKey: string, token: string, lead: Contact & { leadName?: string } = {}) {
  const answer = await safebind(apiKey, token, lead);
  if (answer.httpStatus !== 200) throw new Error(`call failed (${answer.httpStatus})`);
  // You own it, but the report could not be produced on this call: `retryable` says whether to
  // try again later.
  if (answer.reportUnavailable !== null) {
    throw new Error(`report not available yet: ${JSON.stringify(answer.reportUnavailable)}`);
  }
  const decision = decisionOf(answer.report);
  if (decision === null) throw new Error("not bought yet — buy it first");
  return decision;
}
```

Wiring it into an intake handler:

```ts
const key = process.env.SAFEBIND_API_KEY ?? "";
const token = lead.safebind_record_id; // or the last path segment of lead.safebind_record_url
const contact = { phone: lead.phone, email: lead.email };

const check = await preBid(key, token, contact);
if (!check.bid) return reject(check.reason);
// … win the lead …
const bought = await buy(key, token, contact, lead.crmId);
const decision = bought.decision ?? (await report(key, token, { ...contact, leadName: lead.fullName }));
```

## Testing before you go live

- **Your key:** `GET /v1/whoami` must return your account and `"capabilities": ["verify"]`
  (and `"scheme": "sk_live"`).
- **The refusals are free to test:** a made-up token must come back `404`, and
  `{"buy": true}` with no `phone`/`email` must come back `403 possession_required`. Neither
  charges.
- **A full run needs a real record and its contact.** SafeBind has no public sandbox
  record for buyers today. Ask a publisher you buy from to put a page in test mode
  (`&test=1` on their snippet) and submit a test lead with a phone or email you control; you
  then hold everything a real run needs. Those records carry `is_test: true`, so your
  production intake must reject them — which is itself worth testing. Buying a test
  record is free (`chargedMicros: 0`), so the whole run, purchase included, costs nothing.

## Done when

- [ ] `SAFEBIND_API_KEY` is configured, and `GET /v1/whoami` returns `verify`.
- [ ] Your business names are registered in the console (Lead screening → "Your business
      names"), or your code sends `buyerName` on every call.
- [ ] The intake reads `safebind_record_id` (or `safebind_record_url`) from the lead post, beside
      the existing certifier fields, which still work.
- [ ] Before the bid: a fabricated token is rejected (`404`), an `is_test` record is
      rejected, and a contradicted contact (`match.phone_match: false`) is rejected — before
      any money moves.
- [ ] The buy sends the phone or email you were sold and your own `reference`; the purchase's
      `recordId` is stored against the lead.
- [ ] `report.buyer_decision.outcome` drives buy / reject / review, and `reasons[]` are logged
      or shown to whoever reviews the lead; `report_unavailable` after a buy is retried later,
      not treated as a failed purchase.
- [ ] Retries: only `202`, `429`, `503`, with the advertised delay; `403` is never retried with
      the same value.

## Retry and error semantics

| code | meaning | do |
|---|---|---|
| `202` | On a buy: record still finalizing (`retry_after_ms` + `Retry-After` header) | Retry after the delay. Never charged. |
| `401` | Missing or invalid API key | Fix the key. A presented-but-invalid key is always `401`, never a silent downgrade. |
| `402` | On a buy: balance cannot cover the purchase | Top up. Nothing charged. |
| `403` | `possession_required` — possession was not **established**. `reason` says why: `not_supplied` (no `phone`/`email`), `contradicted` (the value names a different consumer), or `nothing_to_compare` (the record holds no contact of that kind); `attemptsRemaining` is your budget left on this record. Also returned when the key lacks the `buy` capability. | Send the contact you actually hold. Do not blindly retry the same value. Nothing charged. |
| `404` | No such record — including fabricated tokens | Treat as a failed pre-bid check. |
| `409` | `save_window_closed` on a buy | Self-serve buying closed; retrieval is a paid service until the one-year horizon. |
| `413` | `request_too_large` — the body is over the size limit | Send a smaller body. Nothing charged. |
| `422` | Terminal — the record will never be verifiable | Stop polling. Nothing charged. |
| `429` | Rate limited (per account, `Retry-After`) — or, on a buy, `possession_throttled`: too many possession attempts against **this record** (`retryAfterSeconds`) | Back off for the stated time. Nothing charged. |
| `503` | Transient fault | Retry with backoff. Nothing charged. |

---

*"TrustedForm", "Jornaya" and "LeadiD" are trademarks of ActiveProspect, Inc. SafeBind is
not affiliated with, endorsed by, or sponsored by that company; names are used for
identification only, to map from integrations a buyer already runs. Statements about those
products describe the typical shape of existing integration code, not their current
behavior or pricing.*
