Inspect API
One call to confirm a record is genuine — and, in the same request, return its metadata, the consent language the applicant saw, and a privacy-preserving match against the buyer’s own PII.
Store API
One call to take possession of a record — confirming it and writing a permanent, timestamped entry showing that your account, and no one else’s, stored it.
Integrate API
One call to subscribe your CRM or lead system to Attestio events, so the proof behind every lead you store arrives where you work — automatically, and without personal data.
Form Field Annotation Guide
Add one HTML attribute to each field that holds contact details, and one to anything that should never be seen. Tagged fields are mapped exactly, so advertisers can confirm each lead.
One API key works for Inspect, Store and Integrate. Every field documented here is backed by data Attestio captures and stores today. Personal data you send is only ever matched as a hash, and the form’s values are never returned; the visitor’s IP address is returned in plain text.
1Overview & design principle
The Inspect API answers one question a lead buyer asks at the moment of purchase or onboarding: “Is this record real, and what do you know about it?”
Its guiding principle is one call, everything you need. A buyer never has to chain requests — they name the record and optionally pass hashed PII to be matched, all in a single POST. The response always carries everything: the record (its ID, issue time, expiration, and when you stored it), the account and form that produced the record, a true/false per PII field, and the session — the page, where the visitor was, their network and device, the consent and how it was shown, how they interacted and how the form was filled — so the buyer can decide from one round trip.
Record (ID, issued, expiration, stored) + account + form + PII match + session (timing, page, geo, network, device, consent, interaction, behavior) — always returned together. No sections to choose, no second request, no polling.
2Base URL & versioning
All requests use HTTPS and are versioned in the path.
The API is served on its own api. subdomain, separate from the app and the tracker.
3Authentication
Every request authenticates with an API key, sent as a bearer token. A key is created once, shown once, and pasted into the buyer’s CRM or integration tool. It never expires and can be revoked at any time — revocation takes effect on the next request. Attestio stores only a hash of the key, and records who created it and when it was last used.
Create your key in the dashboard under Settings → API keys; the same key works for Inspect, Store and Integrate. A key acts as the account that created it, and can inspect any record by its record ID (the ID is what proves you were given the record). An unknown record ID returns 404.
Authorization: Bearer atk_live_7f3aQm2Xk9…
A session token from a signed-in dashboard user is also accepted in the same header. A session token can inspect the records that account can open in the dashboard; for integrations, always use an API key.
Testing without writing code
Two ways to make a first call before wiring up an integration:
- Attestio Testing Tool. In the dashboard, open Account settings → API Functions, click Open Attestio Testing Tool and choose the Advertiser Testing Tools tab. Pick Inspect, enter a record ID and, optionally, an email, phone or full name to match (each value is hashed in your browser and sent as
email_hash,phone_hashorname_hash), then press Send request. Paste your API key to call the API with it, or leave the key blank to call as your signed-in account. The status, timing and JSON response appear alongside, and the cURL tab gives the equivalent command to copy for a developer. - Session token. While signed in to the dashboard, the value of the
attestio_sessioncookie is a valid bearer token for the same endpoint. Useful for a quick check from a terminal or API client without minting a key; it expires with the session, so integrations should always use an API key.
The API key, JSON field names and 22-character record IDs are case-sensitive (base64 — A and a differ). PII values are not case-sensitive — emails, names, addresses and postal codes are normalised before hashing, and phone numbers are compared digit-only with the US country code ignored. A hex hash may be any case; a base64 hash is case-sensitive.
A missing or invalid token returns 401. An unknown record returns 404; with a session token, so does a record that account cannot open in the dashboard — identical on purpose, so a token cannot be used to probe which records exist.
4The Inspect endpoint
Request body
| Field | Type | Description |
|---|---|---|
| record_id | string | Required The record to inspect: its 22-character ID, e.g. A7guruOFQQKvUK-vHAGx4w. |
| email_hash phone_hash name_hash address_hash postal_code_hash date_of_birth_hash | string | Optional Values to match against the captured submission, one field per value you want checked. Every value is a hash — SHA-256 of the normalised value, no salt — so no plaintext ever crosses the wire (see §5, Hashed PII). Send it as hex (64 characters, any letter case) or base64 (43 or 44 characters, standard or URL-safe, with or without padding); the length tells us which. A plain value is rejected with 400. Masked and unmasked fields are inspected the same way.SSN, card number and income are not inspectable: ssn_hash, card_hash and income_hash return 400. |
Response fields
Always returned complete, in this order. Every timestamp is a *_timestamp field in UTC, ISO 8601 with milliseconds (e.g. 2026-10-20T22:05:34.456Z).
| Field | Type | Description |
|---|---|---|
| record.id | string | The record’s 22-character ID. An unknown or invalid ID returns an error instead, with no record. |
| record.issued_timestamp | string | When the record was issued. |
| record.expiration_timestamp | string | Until when the record is kept for you. While your account stores it (Store API): when your store ends. Otherwise: issued_timestamp + 7 days. It never reflects other accounts’ stores. |
| record.stored_timestamp | string | Present only while your account stores the record: when that store began. Absent otherwise — including after your store has ended. |
| account.id account.name | string | null | The account that generated the record, and its name (the company name, else the account owner’s name, else the domain of the owner’s email). Never a full email address. |
| form.id form.name | string | null | The form that generated the record, and its name (Forms and Domains). |
| pii_match.* | boolean | One per field you sent: true when it matches what was captured, false when it does not — or when that field was not captured on this record (it cannot be proven). The stored value is never returned. Absent when you sent no PII. |
| session.begin_timestamp session.end_timestamp | string | null | When the visitor’s session began and ended (ISO 8601, milliseconds). |
| session.duration | number | null | Session length in seconds, e.g. 202.333. |
| session.age | number | null | Seconds since the session ended, at the moment of your call — how fresh the lead is. Grows with every call. |
| session.page.url | string | null | The page the form was submitted on — origin and path only. The query string is left out because funnels often carry personal data in it. |
| session.page.referrer | string | null | The page the visitor came from, origin and path only; null when they came directly. |
| session.page.frame | string | Where the form ran on the page: "top-level", "iframe-same-origin" or "iframe-cross-origin" (a form embedded from another website). |
| session.geo.city session.geo.region session.geo.country | string | null | Where the visitor was, from their IP address, e.g. "Sturgis", "MI", "US"; null when it could not be placed. |
| session.network.ip | string | null | The visitor’s IP address as captured, in plain text (not hashed), e.g. "203.0.113.24" or an IPv6 address. |
| session.network.ip_network session.network.vpn_detected | string | null boolean | null | What kind of network the IP belongs to — "unrecognized" (an ordinary connection), "vpn", "tor" or "hosting" (a datacenter) — and whether a VPN or proxy was detected. null when not looked up. |
| session.device.type | string | null | "mobile", "tablet" or "desktop". |
| session.device.browser session.device.os | string | null | The visitor’s browser and operating system, e.g. "Facebook 568" (an in-app browser) and "iOS 26.6". |
| session.device.user_agent | string | null | The visitor’s browser user agent, as sent when the form was submitted. |
| session.device.viewport_width session.device.viewport_height | number | null | The size of the visitor’s browser window in pixels, e.g. 390 × 844; each null when not captured. |
| session.device.headless_browser | boolean | null | true when the browser showed signs of being automated (headless). |
| session.consent.text | string | null | The consent language captured on the page, or null when none was captured. |
| session.consent.detected | boolean | true when a consent disclosure was found on the page the visitor saw. |
| session.consent.font_size_px session.consent.contrast_ratio | number | null | How the disclosure rendered: its font size in pixels, and its text-to-background contrast (WCAG ratio, 1–21). null until measured. |
| session.consent.placement | string | null | Where the disclosure sat when the form was shown, as the record’s Placement card says: "in_viewport", "partly_in_viewport" or "out_of_viewport"; null until measured. |
| session.consent.above_submit | boolean | null | true when the disclosure sat above the submit button. |
| session.interaction | object | Raw counts of what the visitor did: events_recorded (events in the session recording), mouse_events, mouse_clicks, keypresses and cursor_distance_px. Each null when not captured. |
| session.behavior.verdict | string | Our reading of how the form was filled — the record’s form-behavior tag: "normal", "prefilled" (a field arrived filled), "autofilled" (the browser filled most of the form) or "possible_bot" (a headless browser in under 10 seconds). |
| session.behavior.input_method | object | How the form was filled, in percent: prefilled, manual, autofill, paste. |
| session.behavior.typing_speed_wpm | number | null | Typing speed in words per minute; null on embedded forms. |
| session.behavior.fields_completed session.behavior.fields_total session.behavior.backtracking | number number boolean | null | How many fields were filled out of how many, and whether the visitor went back to an earlier field. |
Errors
Every error is one error object with a message saying what went wrong; the HTTP status tells the kind of failure. No record is returned with an error. A request fails for one reason, so one error is returned; if a case ever needs several, they come as an errors array of the same objects.
{
"error": {
"message": "Invalid Attestio record ID."
}
}| Status | When |
|---|---|
| 400 | The record_id is not a valid record ID: Invalid Attestio record ID. |
| 400 | Malformed request — the message says what to fix: a missing record_id; a plain value instead of a hash; a hash of the wrong length; an unsupported field such as ssn_hash; the same field sent twice. |
| 400 | The body is not valid JSON. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key is limited to the Store API (an older single-purpose key). |
| 404 | No record with that ID — the same message as a malformed ID (Invalid Attestio record ID.), so IDs cannot be probed. |
Every ID is unpadded base64url: record IDs are 22 characters (128 bits); account and form IDs are shorter.
5Hashed PII
To check whether an email, a name, or any other field matches what the record captured, you send a hash of the value — never the value itself. For security, raw PII must never cross the wire. The hash is a plain SHA-256 of the normalised value (no salt) — the same hash Attestio’s tracker computes for a masked field at capture — and the server compares it with what it captured. It follows two steps: hash the value on your own machine, then call the API with that hash.
The raw value stays on your machine — hash it in your browser (the Attestio Testing Tool), your CRM, or a terminal. Because it is the same hash the tracker made when the publisher masked the field, even a value Attestio never stored in the clear can be inspected. Every *_hash value is checked this way — a raw value is rejected with 400.
Step 1 — Hash your email
Run this in any terminal. It prints the hash of the email; the email itself is never sent anywhere. Replace the email with the one you want to check.
printf %s "you@example.com" | tr -d ' ' | tr '[:upper:]' '[:lower:]' | shasum -a 256 | awk '{print $1}'Prefer base64? The same hash, written shorter (44 characters) — either form is accepted:
printf %s "you@example.com" | tr -d ' ' | tr '[:upper:]' '[:lower:]' | openssl dgst -sha256 -binary | base64Send the SHA-256 hash in whichever form your tool gives you — all of these are accepted and give the same result: hex lowercase (64 characters), hex UPPERCASE (64), base64 with padding (44, ends in =), base64 without padding (43) or base64 URL-safe (43, uses - and _). You never say which one you used — the length tells us. Examples of each are under Accepted hash formats below.
Want to match the name too? Hash it the same way (run Step 1 with the name) and add it to the request, separated by a comma, like this: "email_hash": "<hash>", "name_hash": "<hash>".
Step 2 — Call the Inspect API
Paste the hash from Step 1 into email_hash, set your record ID, and send the request. Your API key is used only as the bearer token that authenticates the call — it is not part of the hash, so any key on the account gives the same result. Non-developers can simply paste a key into the Authorization header.
curl -X POST https://api.attestio.com/v1/inspect \ -H "Authorization: Bearer atk_live_7f3aQm2Xk9…" \ -H "Content-Type: application/json" \ -d '{ "record_id": "A7guruOFQQKvUK-vHAGx4w", "email_hash": "PASTE_YOUR_HASH" }'
Step 3 — Read the response
The call always returns the complete response (field by field in §4), with true or false per PII field you sent. The stored value is never returned. For example:
{
"record": {
"id": "A7guruOFQQKvUK-vHAGx4w",
"issued_timestamp": "2026-10-20T22:05:35.210Z",
"expiration_timestamp": "2026-10-27T22:05:35.210Z"
},
"account": { "id": "obLD1OX2obLD1OX2", "name": "Affiliate A" },
"form": { "id": "nXj8yUJWTzoqLcir", "name": "Long Form" },
"pii_match": { "email": true },
"session": {
"begin_timestamp": "2026-10-20T22:02:12.123Z",
"end_timestamp": "2026-10-20T22:05:34.456Z",
"duration": 202.333,
"age": 65,
"page": {
"url": "https://quotes.example.com/long-form/",
"referrer": "https://www.google.com/",
"frame": "top-level"
},
"geo": {
"city": "Atlanta",
"region": "GA",
"country": "US"
},
"network": {
"ip": "203.0.113.24",
"ip_network": "unrecognized",
"vpn_detected": false
},
"device": {
"type": "desktop",
"browser": "Chrome 155",
"os": "macOS",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
"viewport_width": 1512,
"viewport_height": 827,
"headless_browser": false
},
"consent": {
"text": "By clicking Submit, I agree to be contacted…",
"detected": true,
"font_size_px": 12,
"contrast_ratio": 7.21,
"placement": "in_viewport",
"above_submit": true
},
"interaction": {
"events_recorded": 412,
"mouse_events": 14,
"mouse_clicks": 10,
"keypresses": 22,
"cursor_distance_px": 7517
},
"behavior": {
"verdict": "normal",
"input_method": { "prefilled": 0, "manual": 60, "autofill": 40, "paste": 0 },
"typing_speed_wpm": 38,
"fields_completed": 6,
"fields_total": 6,
"backtracking": false
}
}
}Here your account does not store the record, so expiration_timestamp is 7 days after it was issued and there is no stored_timestamp. While your account stores it, expiration_timestamp is when your store ends instead, and stored_timestamp says when that store began:
"record": { "id": "A7guruOFQQKvUK-vHAGx4w", "issued_timestamp": "2026-10-20T22:05:35.210Z", "expiration_timestamp": "2031-10-21T22:10:00.000Z", "stored_timestamp": "2026-10-20T22:10:00.000Z" }
The recipe
Every hash is computed the same way. The Attestio Testing Tool and any client must match it exactly:
hash = SHA256( normalize(field, value) ) # send as hex (64 chars, any case) or base64 (43/44 chars), no saltHow to use it. For each field you want matched:
- 1. Normalise the value with the field’s rule from the table below.
- 2. SHA-256 the normalised value and write the digest as hex (64 characters, any letter case) or base64 (43 or 44 characters, standard or URL-safe) — whichever your tools produce. Do not add a salt or your API key — the key authenticates in the
Authorizationheader only. (Attestio stores masked fields as this same hash, protected server-side with a secret pepper, which is why the hash you send must be the plain one.) - 3. Send it as
"<field>_hash": "<hash>"— e.g."email_hash"— one entry per field.
Accepted hash formats
The same hash can be written several ways; all of these are accepted and give the same result. This is SHA-256("jane@example.com") — send whichever your tools produce:
| Format | Length | Example |
|---|---|---|
| Hex, lowercase | 64 | 8c87b489ce35cf2e2f39f80e282cb2e804932a56a213983eeeb428407d43b52d |
| Hex, UPPERCASE | 64 | 8C87B489CE35CF2E2F39F80E282CB2E804932A56A213983EEEB428407D43B52D |
| Base64, with padding | 44 | jIe0ic41zy4vOfgOKCyy6ASTKlaiE5g+7rQoQH1DtS0= |
| Base64, without padding | 43 | jIe0ic41zy4vOfgOKCyy6ASTKlaiE5g+7rQoQH1DtS0 |
| Base64, URL-safe | 43 | jIe0ic41zy4vOfgOKCyy6ASTKlaiE5g-7rQoQH1DtS0 |
We tell them apart by length (64 = hex, 43/44 = base64), so you never say which one you used. Anything else — including the plain value — is rejected with 400.
Normalisation per field
Apply the field’s rule to the value before hashing, so trivially-different spellings still match. Both the caller and the server use these exact rules.
| Field | Normalise the value to… |
|---|---|
| trim, then lowercase. "Jane@Example.com" → "jane@example.com" | |
| phone | digits only; drop a leading 1 country code if 11 digits. "+1 (555) 123-4567" → "5551234567" |
| name | lowercase; letters and spaces only (punctuation → space); collapse spaces. "Jane M. Doe" → "jane m doe" |
| address | lowercase; alphanumerics and spaces only; expand street/avenue/road/drive/boulevard to st/ave/rd/dr/blvd; drop unit designators (apt, unit, suite…) and their number; collapse spaces. "123 Main Street, Apt 4" → "123 main st" |
| postal_code | alphanumerics only, uppercase, first 5. "33101-2210" → "33101" |
| date_of_birth | digits only, in the captured order. "1990-02-01" → "19900201"; masked by default — still inspectable via the stored hash |
Date of birth is masked at capture by default, and a publisher can mask any other category per form. Attestio never stores a masked field’s raw value — only the hash of this same recipe — so masked and unmasked fields are inspected identically: true or false. A field the record never captured returns false. SSN, card number and income can be masked but are not inspectable. The Attestio Testing Tool runs the whole recipe for you: type the raw value and it normalises and hashes in your browser, sending only the hash.
1Overview & the store model
A record reaches you one of two ways: a publisher issues it to you, or someone shares it with you. Before you act on it, you inspect it — confirm it is genuine, that the consent was shown, and that its details match your own data on the lead. Once you are satisfied it is real, you store it.
Storing is the deliberate act of taking possession. Its guiding principle is inspect to trust, store to keep. A single POST confirms the record and writes an entry showing that your account stored this record, at this moment, kept for as long as you choose — or your account’s default period (5 years unless your account changed it). That entry is the record’s chain of custody.
Inspect answers “is this record real?” and reads its data (inspecting another account’s record also lists it in your account until the record is 90 days old). Store says “I am keeping this one” — it notes who stored it and returns the record with its stored time, without re-sending the data you already read on inspect. Inspect as many times as you like; store when you have decided to use the lead.
2Base URL & versioning
All requests use HTTPS and are versioned in the path. Breaking changes ship under a new major version; additive fields do not.
The Store API shares the Inspect API’s host and key — it is served on the same api. subdomain, separate from the app and the tracker.
3Authentication
Every request authenticates with an API key, sent as a bearer token — the same key you use for Inspect. A key is created once, shown once, and pasted into the buyer’s CRM or integration tool. It never expires and can be revoked at any time — revocation takes effect on the next request. Attestio stores only a hash of the key, and records who created it and when it was last used.
Create your key in the dashboard under Settings → API keys. A key acts as the account that created it: every store is logged against that account, so always use a key from your own account.
Any account holding a record’s ID may store it — the ID is what proves you were given the record. An unknown ID returns 404, so IDs cannot be probed. Storing is non-exclusive — any number of accounts may store the same record, and every store is logged separately.
Authorization: Bearer atk_live_7f3aQm2Xk9…
A signed session token (the JWT Attestio issues at login) is also accepted in the same header, so a record can be stored from an authenticated session without creating a key.
A missing or invalid token returns 401. A key limited to the Inspect API (an older single-purpose key) returns 403. An unknown record returns 404, so tokens cannot be used to probe for which records exist. A malformed body returns 400. Every error is one error object with a message (see §4).
4The Store endpoint
Storing takes the record to store and, optionally, how long to keep it: expiration_in_days or expiration_timestamp — never both. Send neither and your account’s default store period applies (5 years for a new account; change it in Settings → API keys). Any new value replaces the old one, longer or shorter. There are no sections to select — reading the record’s data (metadata, consent, the captured fields, PII matching) is the Inspect API’s job, and you do that before you store. Store does one thing: note that you have taken possession.
Request body
| Field | Type | Description |
|---|---|---|
| record_id | string | Required The record to store: its 22-character ID, e.g. fyqJkSSJRNub0RxVxe8Wdw. |
| expiration_in_days | integer | Optional How many days from now your store keeps the record: a whole number from 0 to 3653 (10 years). 0 ends your store. |
| expiration_timestamp | string | Optional The date until which your store keeps the record — a date only, no time — at most 10 years from now. US order (month, day, year): 01-01-2028 or 01/01/2028; year first 2028-01-01 also works. The record is kept to the end of that day in UTC, which the response shows as 2028-01-01T23:59:59.999Z. A date in the past ends your store (it is saved as now). |
The lead’s data — metadata, consent language, and the captured fields — is returned by the Inspect API, which you call first. Store intentionally does not re-send it; its response is a confirmation and a receipt, not a second copy of the data.
Call it with cURL
Set your record ID and send the request. Your API key is the bearer token that authenticates the call. Non-developers can simply paste a key into the Authorization header.
curl -X POST https://api.attestio.com/v1/store \ -H "Authorization: Bearer atk_live_7f3aQm2Xk9…" \ -H "Content-Type: application/json" \ -d '{ "record_id": "fyqJkSSJRNub0RxVxe8Wdw", "expiration_in_days": 365 }'
Or until a date — or send neither to use your account’s default period:
{
"record_id": "fyqJkSSJRNub0RxVxe8Wdw",
"expiration_timestamp": "10-10-2030"
}| Request body | What happens |
|---|---|
{ "record_id": "…" } | Kept for your account’s default period. If your store is already active, nothing changes. |
"expiration_in_days": 0 | Ends your store now — the signal that you no longer need the record kept. |
"expiration_timestamp": "10-10-2030" (or "2030-10-10") | Kept until the end of October 10, 2030 (UTC): 2030-10-10T23:59:59.999Z. US order is month first, so 03-04-2030 is March 4. |
a time, e.g. "2030-10-10T00:00:00.000Z" | Rejected with 400: send a date only. |
a past date, e.g. "01-01-2020" | Ends your store now (saved as now, not the past date). |
| both fields | Rejected with 400: send one, not both. |
What it returns
The call returns the record object — the same one the Inspect API returns — now with stored_timestamp. Its presence is your confirmation that the store went through. It does not re-send the record’s data; that is read on inspect. Every timestamp is a *_timestamp field in UTC, ISO 8601 with milliseconds.
{
"record": {
"id": "fyqJkSSJRNub0RxVxe8Wdw",
"issued_timestamp": "2026-08-26T20:43:25.123Z",
"expiration_timestamp": "2031-10-07T14:12:09.481Z",
"stored_timestamp": "2026-10-06T14:12:09.481Z"
}
}When the call ends your store ("expiration_in_days": 0 or a past date in expiration_timestamp), the record shows the store that just ended: expiration_timestamp is the moment it ended. Inspect then treats the record as not stored. If you had no store to end, the record comes back without stored_timestamp.
{
"record": {
"id": "fyqJkSSJRNub0RxVxe8Wdw",
"issued_timestamp": "2026-08-26T20:43:25.123Z",
"expiration_timestamp": "2026-11-02T09:30:00.000Z",
"stored_timestamp": "2026-10-06T14:12:09.481Z"
}
}Errors
Every error is one error object with a message saying what went wrong; the HTTP status tells the kind of failure. No record is returned with an error. A request fails for one reason, so one error is returned; if a case ever needs several, they come as an errors array of the same objects.
{
"error": {
"message": "Invalid Attestio record ID."
}
}| Status | When |
|---|---|
| 400 | The record_id is not a valid record ID: Invalid Attestio record ID. |
| 400 | Malformed request — the message says what to fix: a missing record_id, or both expiration_timestamp and expiration_in_days sent (Send expiration_timestamp or expiration_in_days, not both.). |
| 400 | expiration_in_days is not a whole number from 0 to 3653, or expiration_timestamp is not a date (10-26-2031, 10/26/2031 or 2031-10-26), includes a time, is not a real date (e.g. 13-01-2031), or is more than 10 years ahead. |
| 400 | The body is not valid JSON. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key is limited to the Inspect API (an older single-purpose key). |
| 404 | No record with that ID — the same message as a malformed ID (Invalid Attestio record ID.), so IDs cannot be probed. |
| 500 | The store could not be written. Nothing was stored; it is safe to retry. |
5The store receipt & chain of custody
Every successful store writes one row to the record’s chain of custody: which account stored which record, and when. That row is the point of the endpoint — it is what the account, and if it ever comes to it a court, reads as “who took possession of this evidence.” The record in the response reflects that row: stored_timestamp is when it was written and expiration_timestamp is until when it keeps the record for you.
| Field | Type | Description |
|---|---|---|
| record.id | string | The record’s 22-character ID. |
| record.issued_timestamp | string | When the record was issued. |
| record.expiration_timestamp | string | Until when the record and its recording are kept for you: the expiry you sent, else stored_timestamp + your account’s default period. About now when the call ended your store. |
| record.stored_timestamp | string | When the store this call left in place began (UTC, ISO 8601 with milliseconds). Absent only when you asked to end a store you did not have. |
Storing is idempotent: if your account has already stored this record, a repeat call returns the same stored_timestamp rather than writing a second row. Sending a new expiration_in_days or expiration_timestamp replaces your expiry — longer or shorter. Once your store has ended, storing again starts a new store: stored_timestamp is that new time. Every store and every end is logged, so the full history stays in the chain of custody.
A store is kept for your account’s default period — 5 years (1827 days) for a new account, changeable in Settings → API keys from 1 day to 10 years — or for the expiry you send, up to 10 years. A record you only inspected (not stored) stays in your account’s list until it is 90 days old, then drops out.
The response confirms your own store. It never discloses the identities of any other account that may have stored the same record. A non-exclusive store is logged for every account that makes one; no account is shown another’s.
Keep the record_id (and the stored_timestamp) alongside the matching submission in your own database, so each lead in your CRM is permanently linked to the record that proves it.
One API key works for Inspect, Store and Integrate. Like every Attestio API, deliveries never contain a lead’s personal data: they carry the record ID and its proof, which your system links to the lead it already holds.
1Overview & the event model
Inspect and Store are calls your system makes. Integrate works the other way round: you tell Attestio once where to send events, and Attestio pushes each one to your system as it happens. No polling, no batch exports.
Its guiding principle is the proof travels with the lead. When a record is stored, your CRM receives the record ID, the store receipt and the consent evidence, ready to attach to the lead it already has.
Payloads never include names, emails, phone numbers or addresses. Your CRM already holds those from the form; the record_id is what links the two.
2Base URL & authentication
All requests use HTTPS and are versioned in the path, on the same host as Inspect and Store.
Every request authenticates with an API key from Settings → API keys, sent as a bearer token. A missing or invalid key returns 401.
Authorization: Bearer atk_live_7f3aQm2Xk9…
3Create an integration
Request body
| Field | Type | Description |
|---|---|---|
| event | string | Required The event to receive: record.stored, record.inspected or record.created. |
| target_url | string | Required The HTTPS address in your system that receives deliveries. |
| include | string[] | Optional Sections to add to each delivery: store, consent, form. record_id is always sent. |
| description | string | Optional A label for your team, such as the system it feeds. |
{
"integration_id": "Hc2Lp4QwZ8mT1xVb6RkN3g",
"event": "record.stored",
"target_url": "https://crm.example.com/hooks/attestio",
"status": "active",
"signing_secret": "whsec_…"
}The signing_secret is shown once, when the integration is created. Keep it with your receiver: it is how you confirm a delivery really came from Attestio (see §5).
4Events & delivery payload
| Event | Sent when |
|---|---|
| record.created | A form on one of your domains issues a new record. |
| record.inspected | Your account, or an account a record is shared with, inspects it. |
| record.stored | Your account stores a record — through the API or the dashboard. |
Each delivery is a POST to your target_url, with a JSON body like this:
{
"event": "record.stored",
"delivery_id": "Qm9Xa2Kp1Lw4Tz8RbN0cVg",
"record_id": "fyqJkSSJRNub0RxVxe8Wdw",
"store": { "store_id": "7PF4ibByTPi-SLJHhPRHaA", "stored_timestamp": "2026-10-13T14:12:09.481Z" },
"consent": { "text": "By clicking Submit, I agree to be contacted…" },
"form": { "id": "nXj8yUJWTzoqLcir", "name": "Default form" }
}5Checking delivery signatures
Every delivery carries an X-Attestio-Signature header: an HMAC-SHA256 of the raw request body, keyed with your signing_secret, plus a timestamp. Compute the same HMAC on your side and accept the delivery only if they match and the timestamp is recent.
X-Attestio-Signature: t=1760364729,v1=5b2e9c…
- Respond quickly. Return any
2xxwithin 10 seconds; do slow work afterwards. - Retries. If your system doesn’t answer with
2xx, Attestio retries with growing gaps for up to 24 hours. - Idempotent. A retry repeats the same
delivery_id. Store it and ignore duplicates.
6Testing an integration
Sends one sample delivery for the integration’s event to its target_url, marked "test": true, and returns the response your system gave. In the dashboard, the Integrate card’s Send test call does the same.
A test delivery uses a sample record ID and never counts toward billing or your reports.
Add data-attestio-field="email" (or phone, first-name, …) to every field that holds contact details, and data-attestio-mask to anything that must never be seen. That is the whole job. Untagged fields keep working exactly as before.
1Why tag your fields
Tagging tells Attestio exactly which field is which, so advertisers can confirm each lead through the Inspect API. The two attributes do two different jobs:
| Attribute | Job | What happens to the value |
|---|---|---|
| data-attestio-field="…" | Mapping. Names the field (this input is the email). | Captured as typed, under its standard name. |
| data-attestio-mask | Masking. Hides the field. | Hidden in the visitor’s browser, kept out of the session replay and the form data. Only a one-way fingerprint (SHA-256) is kept. |
Use both on one field to name it and hide it. Inspect can still confirm a hidden field by comparing fingerprints.
The field is still captured and still shows in the session replay. Attestio guesses what it is from its name, id, type and autocomplete: names like email, phone, full_name, first_name, last_name, address, city, state, zip and dob are recognised, and so are type="email", type="tel" and standard autocomplete values. A field with a name like field_7 is captured but not mapped, so tag it to tell Attestio what it is.
Never captured. Passwords, file uploads and buttons are left out of the form data, and so are hidden technical fields such as CSRF tokens and TrustedForm or Jornaya certificate IDs. Empty fields are skipped.
2How the tracker works
What the Attestio snippet does with your form, step by step.
1. Every field is captured, tagged or not. Each field is saved under its own name (or id), which is what the certificate shows. Passwords, file uploads, buttons, hidden technical fields and empty fields are left out.
2. It reads the tags live from the page, in this order.
data-attestio-field="email"(orphone,first-name, …) tells Attestio what the field is.data-attestio-maskhides the field. It is kept out of the session replay and the stored form data.- The older
attestio-*classes still work as a fallback, also on a form builder’s wrapper around a single field. data-attestio-unmaskkeeps a field visible and beats every masking rule.
3. No tag: Attestio guesses. It reads the field’s name, id, type and autocomplete. Names like email, phone, first_name, address and state are recognised. A name like field_7 stays unmapped but is still captured.
4. Some fields are hidden automatically. Date of birth, SSN and card fields are hidden by their name or autocomplete, even without a tag, and so is any type turned on under Sensitive fields in the dashboard.
5. A hidden field keeps a fingerprint when its type is known. The type comes from the tag, from Sensitive fields, or from the guess in step 3. The fingerprint (SHA-256) is made in the visitor’s browser before the value is dropped, so the value itself never leaves the page. A hidden field whose type is unknown is hidden with no fingerprint.
6. The tags are sent with the submit. Only the tag names travel, never values. Attestio uses them as the field’s official meaning, so Inspect knows which field is the email, phone or name, whatever the field is called.
7. The record ID is added to the form. A hidden attestio_record_id field carries it to your server, and window.Attestio.getRecordID() returns it. A multi-step form keeps one record ID, and the tags, fingerprints and form data of every step are combined into that one record.
Inspect compares the advertiser’s fingerprint of an email, phone or name with what Attestio captured: first the fingerprint of a hidden field, then the field mapped by its tag or guess, then any field whose name looks right. A match is reported as true; Attestio never returns the value itself.
3Tag the contact fields
Add data-attestio-field to the field element itself — the input, select or textarea. Values are lowercase. Your existing classes and attributes stay as they are.
| Value | Use it on | Same as autocomplete |
|---|---|---|
| name | A single full-name field | name |
| first-name | First name | given-name |
| last-name | Last name | family-name |
| Email address | ||
| phone | Phone; add it to every part of a split phone field | tel |
| address-line1 | Street address | address-line1 |
| address-line2 | Apartment, suite or unit | address-line2 |
| city | City | address-level2 |
| state | State or region | address-level1 |
| postal-code | ZIP or postal code | postal-code |
| consent | The consent checkbox | — |
| tcpa | The element that holds your TCPA disclaimer text, so the record keeps the exact wording | — |
<form action="/submit" method="post"> <input type="text" name="fname" data-attestio-field="first-name"> <input type="text" name="lname" data-attestio-field="last-name"> <input type="email" name="em" data-attestio-field="email"> <input type="tel" name="ph" data-attestio-field="phone"> <input type="text" name="street" data-attestio-field="address-line1"> <input type="text" name="apt" data-attestio-field="address-line2"> <input type="text" name="city" data-attestio-field="city"> <select name="st" data-attestio-field="state">…</select> <input type="text" name="zip" data-attestio-field="postal-code"> <button type="submit">Get my quote</button> </form>
4Hide sensitive fields
Add data-attestio-mask to any field that should never be seen: a Social Security number, card or bank details, date of birth, income. The value is hidden in the visitor’s browser and never reaches the session replay or the form data.
<!-- Hidden. Shows as •••• in the replay and is left out of the form data --> <input type="text" name="ssn_last4" data-attestio-mask> <input type="text" name="acct_no" data-attestio-mask> <input type="date" name="birthday" data-attestio-mask>
Name it and hide it. Put both attributes on one field to hide a contact detail and still let advertisers confirm it. Attestio keeps only a fingerprint, under that field’s name.
<!-- Hidden; its fingerprint is kept under "email" for Inspect --> <input type="email" name="em" data-attestio-field="email" data-attestio-mask>
data-attestio-mask alone hides the value, and Attestio then has to guess what the field was from its name, id, type or autocomplete (for example name="email" or type="tel"). When it can, it keeps a fingerprint under that type and Inspect can still match it; a name is matched only when the full name, or both first and last name, are hidden. When the field’s name says nothing, such as field_7, no fingerprint is kept and Inspect can only match it if its type is set under Sensitive fields (below). For an email, phone or name you want hidden, always add both attributes.
Matching a masked SSN, date of birth, card or income. Set the field’s type under Forms and Domains → Sensitive fields in the dashboard. Attestio then fingerprints it under that type (ssn, dob, card, income) for the Inspect API. SSN, date of birth and card are switched on by default, so those are fingerprinted without any change; you only need this for income, or if one of the defaults was switched off.
Masked automatically. These fields are hidden even without the attribute:
autocomplete="bday"and anyautocomplete="cc-…"(card) field- fields whose
nameoridcontainsssn,social,dob,birth,cardnumberorcreditcard
Opting out. If one of those is not sensitive on your form, add data-attestio-unmask to it. It beats every other masking rule.
<!-- "social_handle" matches the built-in rule but is not sensitive here --> <input type="text" name="social_handle" data-attestio-unmask>
5Special cases
Phone split into three boxes. Put the same tag on every box. Attestio joins them, in page order, into one phone number.
<input type="tel" name="phone_area" maxlength="3" data-attestio-field="phone"> <input type="tel" name="phone_prefix" maxlength="3" data-attestio-field="phone"> <input type="tel" name="phone_line" maxlength="4" data-attestio-field="phone">
First and last name. Tag each with its own value. When they are masked, the two are joined into one name fingerprint, so an advertiser matches the full name.
<input name="fname" data-attestio-field="first-name" data-attestio-mask> <input name="lname" data-attestio-field="last-name" data-attestio-mask>
Consent checkbox and TCPA text. Tag the checkbox the person ticks, and the element that holds the disclaimer, so the record keeps the exact wording they saw.
<label> <input type="checkbox" name="agree" data-attestio-field="consent"> I agree to be contacted. </label> <p data-attestio-field="tcpa"> By clicking "Get my quote", I consent to receive calls and texts from … </p>
Multi-step forms. Tag the fields on every step. Attestio combines the tags from all steps into one record, so the final record knows every field.
6Your record ID
Every visit Attestio records gets a record ID, a 22-character code such as kq2b7w3m9paXc4TzR1vY0Q. Keep it with the lead in your own system and pass it on to your buyers: it is the ID they use with the Inspect and Store APIs.
In a hidden field, automatically. Attestio adds a hidden field named attestio_record_id to your form. When the form is submitted, the record ID reaches your server or CRM with the rest of the lead. You don’t add this field yourself.
<!-- Added by Attestio; you don't write this --> <input type="hidden" name="attestio_record_id" value="kq2b7w3m9paXc4TzR1vY0Q">
In JavaScript, for more flexibility. If your form is sent by your own code, or you want the record ID somewhere else, read it with window.Attestio.getRecordID().
form.addEventListener('submit', async (e) => { e.preventDefault(); const lead = Object.fromEntries(new FormData(form)); lead.attestio_record_id = window.Attestio?.getRecordID(); await fetch('/api/leads', { method: 'POST', body: JSON.stringify(lead) }); });
window.Attestio exists once the Attestio snippet has loaded, so call getRecordID() when the form is submitted, not as the page opens. On a multi-step form, the same record ID covers every step. The hidden field is added to every form on the page: if your server rejects fields it does not expect, allow attestio_record_id; if your code picks which fields to send, add it as in the example above.
7Rules
| Rule | What it means |
|---|---|
| One value per field | data-attestio-field holds a single value. Give each field its own; if two separate fields carry the same value, the one submitted last wins. The only exception is a split phone, where every box shares phone. |
| What names a field | data-attestio-field first, then an attestio-* class (on the field, then on a single-field wrapper), then a guess from the field’s name, id, type or autocomplete. |
| What hides a field | data-attestio-unmask beats everything. Then data-attestio-mask or the attestio-mask class, then your dashboard settings and the automatic rules. |
| Masking only adds | A tag can hide more than your dashboard settings, never less. |
| Unknown values | A value not in the table, such as data-attestio-field="emial", is ignored: the field is captured as if untagged. The Field reader in onboarding flags it. |
| Names, never values | Attestio reads which tag sits on which field. The tags themselves carry no personal data. |