Build on NVM
Seal your email’s links with a signing key, let NVM answer every click, and read who was a person from the API. Or send NVM the opens and clicks you track yourself and get a verdict for each.
Quickstart
- Make two keys in the admin. An API key,
nvm_live_…, reads verdicts and judges activity. A signing key,nvm_sk_…, seals your links. Keep both on your servers. - Seal every link of each email with the SDK, or implement the token format in your own language. Sealing happens on your servers, with no request to NVM per link.
- Send as usual. Turn off your email platform’s own click tracking, so a reader passes one redirect, not two.
- Read the verdicts by campaign, by click or by reader.
// The SDK, one file with no dependencies:
// https://nvm-6amcity.vercel.app/sdk/nvm.ts
import { Nvm } from './nvm';
const nvm = new Nvm({
// nvm_sk_<id>_<secret>, from the admin.
secret: process.env.NVM_SIGNING_SECRET,
// NVM's address, or your own link domain.
origin: 'https://nvm-6amcity.vercel.app',
});
const html = nvm.wrap(issueHtml, {
// Your id for the issue, up to 64 bytes.
campaign: 'greenville-2026-10-14',
// Your id for the reader, never the address.
recipient: subscriber.id,
// Only its domain is used: who hosts the mailbox.
email: subscriber.email,
// When you hand this email to your platform.
sentAt: new Date(),
});curl https://nvm-6amcity.vercel.app/v1/campaigns/greenville-2026-10-14 \ -H "Authorization: Bearer $NVM_API_KEY" # Every click NVM settled since your last sync, oldest first: curl "https://nvm-6amcity.vercel.app/v1/clicks?sort=updated&updated_since=2026-10-14T10:00:00Z&limit=1000" \ -H "Authorization: Bearer $NVM_API_KEY"
Keys
Send the API key on every request to https://nvm-6amcity.vercel.app/v1 as Authorization: Bearer nvm_live_… or as X-API-Key. Each key has a limit of requests a minute, set in the admin, and answers 429 with Retry-After beyond it. A revoked key stops working within two minutes.
A signing key seals links; NVM keeps no copy of its secret and shows it once. Revoke it in the admin and its links answer 410 Gone within a minute. To change keys without breaking links in readers’ inboxes, make a new key, seal new sends with it, and revoke the old one when its emails no longer matter.
Links
A sealed link is https://nvm-6amcity.vercel.app/c/<token> and the open pixel https://nvm-6amcity.vercel.app/o/<token>.gif. NVM answers each click in the US region nearest the reader, from the request alone:
| NVM decides | When | The reader sees |
|---|---|---|
| pass | A browser’s own headers and TLS handshake, on a home or mobile network, with no sign of a machine | The destination, at once, by a 302 redirect |
| prove | Unsure: an unfamiliar network, a click soon after the send, a company network | A page that checks the browser in a few hundred milliseconds and goes on by itself |
| challenge | Most likely a machine: a cloud or security vendor’s network, a forged browser name, every link at once | The same page, which goes on after one tap |
| stop | A machine that says so, or sends what no browser sends: a robot’s name, a script, a HEAD request, a prefetch, a cut-off browser name, a request for part of the page | A blank page |
No sum of soft signs ever stops a click: only what no person’s browser sends does. Three policy flags, sealed into each link, change how strict NVM is: strict checks every click in the browser, for links sold by the click; anyCountry drops the weight on readers abroad; and early drops the weight on clicks soon after the send, for transactional email.
Your own link domain. Point a subdomain such as links.yournewsletter.com at NVM with a CNAME to cname.vercel-dns.com, DNS only, never through a proxy: NVM reads the reader’s own address, network and TLS handshake, which a proxy in front would replace with its own. Ask NVM’s team to add the domain.
Hidden trap link. With trap: true, wrap() adds a link no person sees, which catches scanners that fetch every link before delivery. It is off by default: Gmail’s sender guidelines say hidden content can send mail to spam, and NVM’s browser check stops those scanners anyway.
Verdicts
Every click settles into one verdict within a minute, or eleven minutes when its reader got the browser check:
| verdict | label | Meaning |
|---|---|---|
| human | human_verified | A person, and more than the request shows it: the browser proved itself, or the click came from a mobile carrier or a webmail page |
| human | human_likely | A person by the request alone |
| unverified | unknown | The check page went out and no proof came back within ten minutes: most often a scanner that runs no scripts |
| machine | bot | Stopped, failed its check, or found afterwards to be a scanner by its email’s trap or by four links at once |
| pending | pending | The check page is out and its ten minutes are not over |
released says whether the reader went on to the destination; basis lists the signs the verdict rests on. A campaign’s unique_humans per link counts each reader a person clicked for once: the payable click.
Opens come in classes. apple-privacy: Apple Mail loads every image, read or not. gmail-proxy and yahoo-proxy: the mail service fetches for a reader who opened the email. Then outlook, scanner, person and unknown. A reader’s engagement counts human clicks and opens only; machine clicks and opens never make a reader active.
Judge any open or click
Send NVM the opens and clicks your own pixel, redirect or email platform saw, one or up to 1,000 per request, and NVM answers for each in order, in about a millisecond: human, true or false; a verdict of human, machine or uncertain, a confidence, and the signs. Give what you have. An IP address and a User-Agent are enough; request headers, your edge’s network and TLS readings, and the delivery time make the answer surer. A missing header weighs nothing either way.
// An SES open or click event, as SES publishes it, judged by NVM:
const verdicts = await fetch('https://nvm-6amcity.vercel.app/v1/classify', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.NVM_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ events: sesEvents.map(e => ({
id: e.mail.messageId,
type: e.eventType === 'Open' ? 'open' : 'click',
at: (e.open ?? e.click).timestamp,
ip: (e.open ?? e.click).ipAddress,
user_agent: (e.open ?? e.click).userAgent,
url: e.click?.link,
sent_at: e.mail.timestamp,
campaign: e.mail.tags?.campaign?.[0],
recipient: e.mail.tags?.recipient?.[0],
})) }),
}).then(r => r.json());Events of one email in one request are judged together: four links of one email opened from one network within three seconds are a scanner. NVM keeps each event for its own learning; the address is kept 30 days, then only its network block.
Send events in batches as they arrive, not one request each. In NVM’s tests a batch of 1,000 was answered in about 0.2 seconds, and an address that sent thousands of requests within seconds was turned away by Vercel’s protection before NVM saw them.
Conventions
- JSON in and out; times are ISO 8601 in UTC; every object names its type in
object. - Lists return
has_moreandnext_cursor; pass the cursor back for the next page. - Incremental sync:
sort=updatedwithupdated_sincereturns every row NVM wrote since, oldest first, at least 30 seconds behind, so no row is skipped. - Errors are
{ "error": { "type", "code", "message", "param" } }with 400, 401, 404, 409, 429, 500 or 503. - A 429 or a 503 carries
Retry-After: send the same request again after that many seconds. A 503 means NVM could not reach its data for a moment. - The OpenAPI document is at
/v1/openapi.json.
Reference
GET /v1/clicks
List clicks
Every click on your links with its verdict, newest first. A click’s verdict settles within a minute, or eleven minutes when its reader got the browser check; sort=updated with updated_since returns every click NVM wrote since your last sync.
| Parameter | Type | About |
|---|---|---|
campaign | string | Your campaign id: only its clicks. |
recipient | string | Your recipient id: only their clicks. |
slot | integer | Only clicks on the link in this place of the email. |
verdict | string | Comma-separated verdicts: human, machine, unverified, pending. |
released | true | false | true: only clicks whose reader went on to the destination. false: only the others. |
since | string | Clicks at or after this time: a date, YYYY-MM-DD, from midnight in New York, or an ISO date-time. Default: 30 days ago, unless campaign or recipient is given. |
until | string | Clicks before this time, which is not included. |
updated_since | string | ISO date-time. With sort=updated: every click NVM wrote after it, the basis of incremental sync. |
sort | recent | updated | recent, the default: newest first. updated: by the time NVM last wrote the click, oldest first, at least 30 seconds behind. |
limit | integer | Default 100. |
cursor | string | The next_cursor of the page before. |
Answers 200 A page of clicks. · 400 A parameter is invalid.
GET /v1/clicks/{id}
Retrieve a click and why NVM judged it so
| Parameter | Type | About |
|---|---|---|
idrequired | string |
Answers 200 The click, with every request it made. · 404 No such click.
GET /v1/opens
List opens
Every request for your emails’ pixels, newest first, each named by who fetched it.
| Parameter | Type | About |
|---|---|---|
campaign | string | Your campaign id: only its opens. |
recipient | string | Your recipient id: only their opens. |
class | string | Comma-separated classes: apple-privacy, gmail-proxy, yahoo-proxy, outlook, scanner, person, unknown. |
human | true | false | true: only opens by a person reading. false: only the others. |
since | string | A date or an ISO date-time. Default: 30 days ago, unless campaign or recipient is given. |
until | string | Opens before this time, which is not included. |
limit | integer | Default 100. |
cursor | string | The next_cursor of the page before. |
Answers 200 A page of opens. · 400 A parameter is invalid.
GET /v1/campaigns
List campaigns with clicks
Campaigns with a click since a time, the latest first, each with its counts.
| Parameter | Type | About |
|---|---|---|
since | string | A date or an ISO date-time. Default: 7 days ago. |
limit | integer | Default 50. |
cursor | string | The next_cursor of the page before. |
Answers 200 A page of campaigns. · 400 A parameter is invalid.
GET /v1/campaigns/{id}
A campaign’s clicks and opens
Counts for the whole campaign, for each link by its slot, and for each mailbox class, and its opens by class. unique_humans of a link is its payable clicks: one for each recipient a person clicked for.
| Parameter | Type | About |
|---|---|---|
idrequired | string | |
since | string | A date or an ISO date-time. Default: 60 days ago. |
until | string | Counts up to this time, which is not included. Default: now. |
Answers 200 The campaign. · 400 A parameter is invalid. · 404 No click or open of this campaign in the window.
GET /v1/recipients
Sync recipients’ engagement
Every recipient whose human or machine clicks and opens changed after updated_since, in the order of the change. A click counts here eleven minutes after it happens, when nothing can change its verdict; the list runs at least 30 seconds behind.
| Parameter | Type | About |
|---|---|---|
updated_since | string | ISO date-time. Default: every recipient. |
limit | integer | Default 1000. |
cursor | string | The next_cursor of the page before. |
Answers 200 A page of recipients. · 400 A parameter is invalid.
GET /v1/recipients/{id}
A recipient’s engagement
The recipient’s last human click and open, and counts of human and machine clicks and opens. Machine clicks and opens never make a recipient active.
| Parameter | Type | About |
|---|---|---|
idrequired | string |
Answers 200 The recipient. · 404 No click or open of this recipient yet.
POST /v1/classify
Is this open or click a person?
Send each open and click your own pixel, redirect or email platform saw, one or up to 1,000 at a time; NVM answers for each, in order, in about a millisecond. Give what you have: an IP address and a User-Agent are enough for most events, and headers, the network, the TLS fingerprint and the delivery time make the answer surer. Events of one email, with the same campaign and recipient, in one batch are judged together: four links opened in three seconds are a scanner.
| Body field | Type | About |
|---|---|---|
eventsrequired | object[] |
Answers 200 A verdict for each event, in the order sent. · 400 An event is invalid.
POST /v1/links
Sign links, pixels and traps
NVM signs with your newest live signing key. For whole sends, sign in your own code with the SDK, with no request per link; this endpoint is for tools that cannot hold a secret.
| Body field | Type | About |
|---|---|---|
linksrequired | object[] |
Answers 200 The links, in the order asked. · 400 A link is invalid. · 409 No live signing key.
POST /v1/wrap
Wrap every link of an email
Wraps every http and https link of the HTML in order from slot 1, leaving any link marked data-nvm="off" as it is, and adds the open pixel before </body>. With trap: true it also adds a hidden trap link; it is off by default because, by Gmail’s sender guidelines, hidden content can send mail to spam. The SDK’s wrap function does the same in your code.
| Body field | Type | About |
|---|---|---|
campaignrequired | string | Your id for the campaign or issue, at most 64 bytes. |
recipientrequired | string | Your id for the recipient, at most 64 bytes. Never the address: links travel with forwarded emails. |
email | string | The recipient’s address. NVM uses only its domain, to name who hosts the mailbox; it never enters the link. |
sent_at | string | When the email is handed to the email platform. Default: now. |
strict | boolean | Every click proves itself in the browser, even one the request alone shows to be a person. For links sold by the click. |
any_country | boolean | No weight on a reader outside the United States. |
early | boolean | No weight on a click soon after the send. For transactional email. |
htmlrequired | string | |
trap | boolean | |
pixel | boolean |
Answers 200 The wrapped HTML and the number of links wrapped. · 409 No live signing key.
GET /v1/signing-keys
Your signing keys
The keys your links are signed with, never their secrets. A link signed with a revoked key answers 410 Gone.
Answers 200 Every signing key of your account.
Token format
An NVM link is https://<link domain>/c/<token>, an open pixel is https://<link domain>/o/<token>.gif. The token carries everything NVM needs to answer the request: the destination, the email it came in, the link's place in the email, when the email was sent and how strict to be. NVM answers a click without a database lookup, so a send of any size cannot slow it down.
A client seals tokens on its own servers with a signing key from NVM's admin. It can use the SDK (one TypeScript file with no dependencies, at /sdk/nvm.ts on NVM's site) or implement this page in any language. The API also seals links (POST /v1/links, POST /v1/wrap) for tools that cannot hold a secret.
The token is sealed, not only signed. Security scanners take links apart and fetch any URL they find inside a parameter; a sealed token shows them nothing.
The signing secret
The admin shows a signing key once, as a string:
nvm_sk_<key id>_<secret>
<key id>: the key's number, 1 to 65535, in decimal.<secret>: 32 random bytes in base64url without padding (43 characters).
Two keys come from the secret:
encKey = HMAC-SHA256(secret, "nvm-link-enc-v1") macKey = HMAC-SHA256(secret, "nvm-link-mac-v1")
The plaintext
| Bytes | Field | Value |
|---|---|---|
| 0 | kind and policy | kind in the high 4 bits (1 click, 2 open, 3 trap); policy bits in the low 4 |
| 1 | mailbox class | in the high 4 bits; the low 4 bits are 0 |
| 2-5 | sent at | Unix seconds, unsigned 32-bit big-endian; 0 when not known |
| 6 | slot | the link's place in the email, 0-255 (0 for an open or a trap) |
| 7 | campaign length | 0-64, then the campaign id in UTF-8 |
| next | recipient length | 0-64, then the recipient id in UTF-8 |
| next | URL prefix code | then the rest of the URL in UTF-8 (nothing for an open or a trap) |
Policy bits. 1 strict: every click proves itself in the browser, even one the request alone shows to be a person (for links sold by the click). 2 any country: no weight on a reader outside the United States. 4 early: no weight on a click soon after the send (transactional email). 8 is reserved and must be 0.
Mailbox classes, from the recipient's address domain: 0 unknown, 1 gmail, 2 yahoo (Yahoo, AOL, AT&T, Verizon, Frontier mail), 3 microsoft (Outlook.com, Hotmail, Live, MSN), 4 apple (iCloud, me.com, mac.com), 5 isp (Comcast, Spectrum, Cox and the like), 6 business (any other domain), 7 education, 8 government, 9 relay (Hide My Email and other forwarding addresses), 10 other. The address itself never enters the token: links travel with forwarded emails.
Ids. The campaign and the recipient are the client's own ids, at most 64 bytes each in UTF-8. Never put an email address in either.
URL prefix codes. 0 none, 1 https://, 2 https://www., 3 http://, 4 http://www.. Use the longest prefix the URL starts with, matching case exactly, so the URL comes back exactly as given. A destination must be an absolute http or https URL of at most 2,000 characters, with no spaces or control characters.
Sealing
header = [1, keyId >> 8, keyId & 0xff] 3 bytes: version 1, the key id big-endian siv = HMAC-SHA256(macKey, header || plaintext)[0..16] 16 bytes ctr = siv with the top bit of byte 8 and of byte 12 cleared body = AES-256-CTR(encKey, initial counter block = ctr, plaintext) token = base64url(header || siv || body), without padding
This is RFC 5297's SIV construction with HMAC-SHA256 as the pseudo-random function: deterministic (the same claims give the same token), authenticated, and safe however many tokens one key seals. The counter-bit clearing is RFC 5297's, so any AES-CTR implementation, with a 32, 64 or 128-bit counter, produces the same bytes for any URL NVM accepts.
A token is at most 2,400 bytes before base64url. NVM answers a token it cannot open with 404 and a token of a revoked key with 410.
Test vector
signing secret nvm_sk_7_AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE (32 bytes of 0x01)
encKey 965896e1669a53c559e80794c844ab8134b07078c0a86a19be2566845a0b02b1
macKey a14475917caec3e2ef8fa09b6954578618a7277b938e04b032e5a08913de2abd
claims click, policy 0, mailbox gmail, sent at 1760000000, slot 3,
campaign "greenville-2026-10-14", recipient "sub_8d1f20",
url "https://example.com/story?a=1"
plaintext 101068e778000315677265656e76696c6c652d323032362d31302d31340a7375625f386431663230016578616d706c652e636f6d2f73746f72793f613d31
siv a57939f2a6e6ca55299ce8d1a8219290
token AQAHpXk58qbmylUpnOjRqCGSkBdI8J8P0OrdvA8Z5e87mO8DZyKQcN_QyzS6J03EzWvjuRwiPfek7Gs6eTL1hZmtv7ooMIo6r3TJX5-Pr-gNpackages/sdk/test/sdk.test.ts checks this vector, so the page and the code cannot drift apart.