curl -X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"rows","data":"email,name\na@x.com,Ann\na@x.com,Ann B","options":{"keyColumns":[0],"hasHeader":true}}'
→ 200 {"result":"email,name\na@x.com,Ann","kept":1,"removed":1}Machine-readable copies of this page: /api.md (the whole reference as Markdown) and /openapi.json (OpenAPI 3.1).
Endpoint
POST https://dedupe.dev/api/dedupe. JSON in, JSON out, one endpoint, no versioning in the path. There is nothing else to call: no job queue, no polling, no webhooks. The response is the answer.
It is CORS-open, so browser code can call it directly. These four headers are on every response, success or error:
| Header | Value |
|---|---|
Content-Type | application/json |
Access-Control-Allow-Origin | * |
Access-Control-Allow-Methods | POST, OPTIONS |
Access-Control-Allow-Headers | Content-Type |
OPTIONS answers preflight with 204 and no body. GET, PUT, PATCH and DELETE answer 405. Retry-After is the only other header the endpoint ever sets, and only on a burst-limit 429. There are no X-RateLimit-* headers: if you need your remaining allowance, read limit and used off the 429 body or check your dashboard.
Requests are processed in memory and the result is returned. Nothing you send is written to storage. Keyed calls record usage metadata only: mode, input row count, rows removed, billable units, status and duration. Never the data.
Authentication
A key is optional. Without one you get the keyless evaluation tier; with one you get your plan's limits. Two header forms are accepted, and they are equivalent:
Authorization: Bearer dd_live_...
x-api-key: dd_live_...
Authorization is read first and must be Bearer <token>. If that header is absent or is not a bearer token, x-api-key is used instead. If neither is present the request is served as keyless.
Creating and revoking keys
Create keys at /app/keys. A token is dd_live_ followed by 64 hex characters (32 random bytes). The full token is shown once, at creation. Only its SHA-256 hash and a 12-character display prefix are stored, so a lost key cannot be recovered, only replaced.
Revoking is immediate and permanent: a revoked key stops matching on the next request. Free plans may hold 1 active key, Starter 3, Pro unlimited.
An unknown, revoked, or malformed key returns 401 with {"error":"invalid api key"}. So does a key that cannot be checked at all: verification fails closed rather than letting an unverified request through.
The token is parsed out of the header and never logged or persisted. Treat it as a secret: it is a bearer credential, and anyone holding it spends your monthly units.
Request body
The body is a single JSON object with three fields:
{
"mode": "lines" | "rows" | "json",
"data": <string for lines and rows, array for json>,
"options": { }
}
| Field | Type | Required | Notes |
|---|---|---|---|
mode | string | yes | One of lines, rows, json. Anything else is a 400. |
data | string or array | yes | Type depends on the mode, see below. |
options | object | no | Defaults to {}. Unrecognised keys are ignored. |
There is no Content-Type requirement: the body is read as text and parsed as JSON regardless of what you declare. Sending application/json is still the right thing to do.
lines mode
data is one string of text. It is split on \r\n, \n or \r, and duplicate lines are dropped. The first occurrence is kept and the surviving order is the original order.
| Option | Type | Default | Effect |
|---|---|---|---|
trim | boolean | false | Trim leading and trailing whitespace off each line before comparing. The trimmed line is what comes back. |
ignoreCase | boolean | false | Compare case-insensitively. The output keeps the first spelling seen, it is not lower-cased. |
dropEmpty | boolean | false | Discard empty lines entirely. Applied after trim, so whitespace-only lines go too when both are set. |
curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Content-Type: application/json" \
-d '{"mode":"lines","data":"a\nb\na","options":{"trim":true,"ignoreCase":true}}'
→ 200 {"result":["a","b"],"kept":2,"removed":1}
rows mode
data is a CSV string, not an array. It is parsed with a comma delimiter, double-quote quoting ("" is a literal quote inside a quoted field), and \r\n, \n or \r row breaks. A leading UTF-8 BOM, which Excel writes when you save as CSV UTF-8, is stripped. The delimiter is not configurable over the API: tab-separated and semicolon-separated files are not parsed as such, so convert them to commas first or use the in-browser CSV tool, which does detect them.
| Option | Type | Default | Effect |
|---|---|---|---|
keyColumns | int[] | [] | Zero-based column indexes that define a duplicate. Empty means the whole row. A missing cell counts as an empty string. |
hasHeader | boolean | true | Treat the first row as a header: never deduplicated, always returned first, and not billed as a row. |
caseSensitive | boolean | true | false compares keys case-insensitively. Output rows are returned unchanged either way. Ignored when fuzzy is on, which normalizes case itself. |
fuzzy | boolean | false | Fuzzy matching: keys are lowercased, punctuation-stripped and word-sorted, then compared by edit distance, so Smith, Jane matches jane smith. Requires an API key (any tier) and at most 50,000 data rows per request. |
threshold | number | 0.9 | Similarity two keys must reach to count as duplicates, from 0.5 to 1. 0.85 to 0.92 works for names; 1 means exact-after-normalization. |
Fuzzy matching is the one comparison that cannot run in the browser tools: naively it is pairwise, and it used to freeze the tab on real-world lists. Server-side it uses candidate blocking (a match must share a word or a character n-gram with an already-kept key, which anything above a realistic threshold does), so a request can carry up to 50,000 data rows, the same ceiling as the paid per-request unit cap, and still answer in seconds. At thresholds below about 0.8 on very short values, blocking can miss rare pairs; the 0.9 default and the 0.85 to 0.92 band recommended for names are unaffected. The first occurrence is always kept, nothing is merged, and billing is the same as any other request.
curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"rows","data":"name\n\"Smith, Jane\"\njane smith\nJane Smyth\nBob Ray","options":{"keyColumns":[0],"fuzzy":true,"threshold":0.85}}'
→ 200 {"result":"name\n\"Smith, Jane\"\nBob Ray","kept":2,"removed":2}
The result is re-serialised CSV: fields containing a comma, a double quote, or a line break are quoted and inner quotes are doubled; rows are joined with \n. Cell values are otherwise untouched, so a file that round-trips through the API comes back byte-comparable minus its duplicates and its BOM.
curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Content-Type: application/json" \
-d '{"mode":"rows","data":"email,name\na@x.com,Ann\na@x.com,Ann B","options":{"keyColumns":[0],"hasHeader":true}}'
→ 200 {"result":"email,name\na@x.com,Ann","kept":1,"removed":1}
json mode
data is an array of anything: objects, strings, numbers, nested structures. Items are compared by their JSON serialisation, or by one value inside them if you pass key.
| Option | Type | Default | Effect |
|---|---|---|---|
key | string | "" | Dot path to the value that identifies an item, for example user.id or profile.email. Empty compares the whole item. |
A path that does not resolve yields undefined, and every item with an unresolvable path collapses to the same key, so only the first of them survives. That is usually a sign the path is wrong. Without key, comparison is on JSON.stringify of the item, which means key order matters: {"a":1,"b":2} and {"b":2,"a":1} are two different items.
curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Content-Type: application/json" \
-d '{"mode":"json","data":[{"user":{"id":1}},{"user":{"id":1}},{"user":{"id":2}}],"options":{"key":"user.id"}}'
→ 200 {"result":[{"user":{"id":1}},{"user":{"id":2}}],"kept":2,"removed":1}
Responses
A successful call returns 200 and the same three fields in every mode. Only the type of result changes:
| Mode | result | kept | removed |
|---|---|---|---|
lines | string[], one per surviving line | surviving lines | duplicate lines dropped |
rows | string, CSV including the header when hasHeader is true | surviving data rows, header excluded | duplicate rows dropped |
json | array of the surviving items | surviving items | duplicate items dropped |
kept + removed equals the input count in lines and json mode. In rows mode it equals the input row count excluding the header. dropEmpty in lines mode is the one exception: dropped empty lines are counted in neither, because they were discarded rather than deduplicated.
Errors
Every error is JSON with an error string, and carries the same CORS headers as a success. The strings below are the literal ones the handler emits.
| Status | error | Cause |
|---|---|---|
| 400 | invalid JSON body | The request body is not parseable JSON. |
| 400 | mode must be one of: lines, rows, json | mode is missing, or is not one of the three modes. |
| 400 | lines mode expects data: string | mode is lines but data is not a string. |
| 400 | rows mode expects data: CSV string | mode is rows but data is not a string. |
| 400 | json mode expects data: array | mode is json but data is not an array. |
| 400 | fuzzy matching is available in rows mode only | options.fuzzy is set on a lines or json request. Fuzzy matching compares whole normalized records, which only means something for tabular data, so it is a rows-mode option. |
| 400 | threshold must be a number between 0.5 and 1 | options.threshold is present but is not a number between 0.5 and 1. |
| 403 | fuzzy matching requires an API key — create a free key at https://dedupe.dev/app/keys | A keyless request asked for fuzzy matching. Fuzzy is the expensive pairwise path and needs an accountable caller: any key works, including the free tier's. |
| 401 | invalid api key | The presented key is unknown, revoked, malformed, or could not be verified. Verification fails closed, so an unverifiable key is treated as an invalid one. |
| 405 | POST a JSON body — see https://dedupe.dev/api | The method is not POST or OPTIONS. GET, PUT, PATCH and DELETE all answer with this. |
| 413 | payload too large (256 KB plan limit) | A keyless request is over the 256 KB keyless body cap. |
| 413 | payload too large (512 KB Free plan limit) | A keyed request declares a Content-Length over the plan's body cap. Refused before the body is read, so it never buffers. The same cap is re-checked after reading, and that second check reports payload too large (512 KB plan limit) without the plan name. |
| 413 | fuzzy matching is capped at 50,000 rows per request (got 50,500); split the file or use exact matching | A fuzzy request carries more than 50,000 data rows. That is the same ceiling as the paid plans' per-request unit cap, so in practice the plan cap is what you hit first. The number in the message is your row count. |
| 413 | request too large: 812 units, over the 200-unit Free per-request limit | The request parsed fine but costs more units than the plan allows in one call. A distinct message from the size cap: this one is about row count, not bytes. |
| 429 | monthly unit limit reached | This request would take the account past its monthly unit allowance. No Retry-After: resets is the ISO timestamp the allowance refills at. |
| 429 | rate limit reached: 60 requests per hour on Free | The key exceeded its burst rate limit. Sent with a Retry-After header in seconds. |
| 429 | keyless daily unit limit reached — create a free API key at https://dedupe.dev/app/keys | A keyless caller spent its 500-unit daily budget for that IP. No Retry-After. |
Checks run in this order for a keyed request, and the first one to fail is what you get back: key verification, declared body size, JSON parse and mode validation (including the fuzzy row cap), per-request unit cap, monthly unit quota, burst rate limit. So a request that is both unauthenticated and oversized returns 401, not 413.
400, invalid body
Seven distinct messages, in the order the parser reaches them. The first is a parse failure, the rest are shape failures found after the body parsed.
{
"error": "invalid JSON body"
}
{
"error": "mode must be one of: lines, rows, json"
}
{
"error": "lines mode expects data: string"
}
{
"error": "rows mode expects data: CSV string"
}
{
"error": "json mode expects data: array"
}
{
"error": "fuzzy matching is available in rows mode only"
}
{
"error": "threshold must be a number between 0.5 and 1"
}
401, bad key
{
"error": "invalid api key"
}
403, fuzzy without a key
{
"error": "fuzzy matching requires an API key — create a free key at https://dedupe.dev/app/keys"
}
405, wrong method
Returned with the CORS headers, so a misrouted browser call still sees the message.
{
"error": "POST a JSON body — see https://dedupe.dev/api"
}
413, too large
Three different limits. The first two messages are about bytes, the third about the fuzzy row cap, the fourth about units. A 100 KB body of very short rows can pass the size cap and still fail the unit cap.
{
"error": "payload too large (256 KB plan limit)"
}
{
"error": "payload too large (512 KB Free plan limit)"
}
{
"error": "fuzzy matching is capped at 50,000 rows per request (got 50,500); split the file or use exact matching"
}
{
"error": "request too large: 812 units, over the 200-unit Free per-request limit",
"units": 812,
"limit": 200
}
429, out of allowance
Three cases, and they are not interchangeable. The monthly one will not clear until resets. The burst one clears in retryAfter seconds and is the only response that carries a Retry-After header. The keyless one clears the next UTC day, or immediately if you create a key.
{
"error": "monthly unit limit reached",
"limit": 1000,
"used": 987,
"resets": "2026-08-01T00:00:00.000Z"
}
{
"error": "rate limit reached: 60 requests per hour on Free",
"limit": 60,
"windowSeconds": 3600,
"retryAfter": 42
}
{
"error": "keyless daily unit limit reached — create a free API key at https://dedupe.dev/app/keys",
"limit": 500,
"units": 12
}
How billing works
One unit is one row of up to 1 KB. Every request costs:
units = max(rows, ceil(bytes / 1024))
rows is what you sent us: lines in lines mode, data rows excluding the header in rows mode, array items in json mode. bytes is the size of the whole request body. Not what comes back: you are billed for the input you asked us to read, so removing more duplicates never costs more. A request that reaches the handler always costs at least one unit.
Worked examples:
- A 50,000-row CSV averaging 80 bytes a row = 50,000 units. Rows win: 4 MB of body is only about 3.9k units, so the row count is the bigger of the two.
- The same 50,000 rows split across 50 calls of 1,000 rows each = 50,000 units. Batching changes nothing, which is the point. The old per-call meter could be gamed by packing everything into one request.
- One 5 MB row of dense JSON = about 4.9k units. Bytes win. Bandwidth is our biggest cost per request, so a fat row is priced like the many small rows it costs us as much as.
- A 12-line email list in a 400-byte body = 12 units. Neither term is large, so the row count decides again.
Units reset on the 1st of each month, UTC, and the allowance is for the whole account rather than per key. Three keys on Starter share the same 100,000. Free stops at 1,000 units with a 429; Starter and Pro currently do the same at their caps, and metered overage pricing is on the way so paid plans can run past them instead of stopping.
The in-browser tools are free and completely unmetered. They run entirely in your browser and never touch this endpoint, so none of this applies to them. Only the API is billed. If a browser tab can do the job, use the tools and pay nothing.
A request we refuse is never billed. 401, 413 and 429 responses record a status row for your dashboard and consume no units.
| Plan | Units/month | Max units/request | Max request | Keys |
|---|---|---|---|---|
| Keyless | 500 per IP, per day | 500 | 256 KB | none |
| Free key (free) | 1,000 | 200 | 512 KB | 1 |
| Starter key ($19/mo) | 100,000 | 50,000 | 5 MB | 3 |
| Pro key ($49/mo) | 300,000 | 50,000 | 5 MB | unlimited |
The per-request byte cap is enforced twice: once against the declared Content-Length, before the body is buffered, and once against the real length after reading. No plan can be raised above 5 MB without changing hosting: the platform answers a larger body itself, before our handler runs.
Rate limits
Separate from the monthly meter. The monthly quota is what you bought; the burst limiter just stops one runaway script from monopolising the function.
| Plan | Requests per window | Window | Retry-After |
|---|---|---|---|
| Keyless | not rate limited | daily 500-unit budget per IP | not sent |
| Free | 60 | 1 hour | sent |
| Starter | 60 | 60 seconds | sent |
| Pro | 300 | 60 seconds | sent |
It is a fixed window, counted per key. Exceeding it returns 429 with Retry-After set to the whole number of seconds until the current window ends, never less than 1. The same number is in the body as retryAfter.
It is enforced best effort, in the memory of whichever serverless instance answered, so it is not shared between instances and resets on a cold start. In practice you may occasionally get slightly more than the table says. Do not design around that: the monthly unit quota is the real meter and it is counted in the database.
What a client should do
- Respect
Retry-Afterbefore anything else. Sleep at least that long. It is exact, not advisory. - Add exponential backoff on top, so a fleet of workers that all hit the wall together do not all wake up together. Sleep
Retry-Afterplus a growing jitter. - Treat a
429withoutRetry-Afteras terminal. That is the monthly quota or the keyless daily budget, and retrying will not clear it. Readresets, and alert rather than loop. - Cap your attempts. Five is plenty. A job that has retried five times has a different problem.
- Batch rather than parallelise. Units are charged the same either way, so one 50,000-row call costs exactly what fifty 1,000-row calls cost and uses one request instead of fifty.
Recipes
Clean a CRM export before importing it
Sales exports arrive with the same contact three times. Dedupe on the email column and import the result. keyColumns is zero-based, so change the index if email is not the first column.
jq -Rs '{mode:"rows", data:., options:{keyColumns:[0], hasHeader:true, caseSensitive:false}}' \
< contacts.csv \
| curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
| jq -r '.result' > contacts-clean.csv
Dedupe an email list
One address per line. trim strips stray whitespace, dropEmpty removes blank lines, and ignoreCase compares case-insensitively while returning the first spelling it saw.
jq -Rs '{mode:"lines", data:., options:{trim:true, ignoreCase:true, dropEmpty:true}}' \
< emails.txt \
| curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
| jq -r '.result[]' > emails-clean.txt
Use it in a shell pipeline with jq
json mode takes an array straight out of another API and dedupes it on a dot path, so nothing has to touch disk.
curl -s https://api.example.com/v1/contacts \
| jq '{mode:"json", data:., options:{key:"profile.email"}}' \
| curl -s -X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
| jq '{removed, kept}'
Run it from cron
A nightly job that fails loudly. Note the exit status check: a silent cron job that swallows a 429 will publish yesterday's list forever.
#!/usr/bin/env bash
# /etc/cron.daily/dedupe-leads
set -euo pipefail
: "${DEDUPE_KEY:?DEDUPE_KEY is not set}"
code=$(jq -Rs '{mode:"lines", data:., options:{trim:true, dropEmpty:true}}' < /srv/leads.txt \
| curl -s -o /tmp/dedupe.json -w '%{http_code}' \
-X POST https://dedupe.dev/api/dedupe \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
--data-binary @-)
if [ "$code" != "200" ]; then
echo "dedupe.dev returned $code: $(cat /tmp/dedupe.json)" >&2
exit 1
fi
jq -r '.result[]' /tmp/dedupe.json > /srv/leads-clean.txt
The validation endpoint
POST https://dedupe.dev/api/validate checks email addresses before you import or send. Hygiene-grade, and we say exactly what that means: syntax, MX-record presence, disposable-domain detection, and role-account flagging (info@, support@). It is NOT SMTP-grade verification: no mailbox pinging and no catch-all detection, so an address can pass every check and still bounce. Dedicated verifiers charge around $7 to $10 per 1,000 emails for the full job; this does the cheap, honest subset at API prices.
An API key is required (any tier; there is no keyless mode, because each unique domain costs a DNS lookup). Billing is 2 units per address against the same monthly allowance as /api/dedupe, and the per-request unit cap applies to the billed units, so one request may carry at most maxUnitsPerRequest / 2 addresses (100 on Free, 25,000 on paid plans). Refused requests are never billed.
curl -s -X POST https://dedupe.dev/api/validate \
-H "Authorization: Bearer $DEDUPE_KEY" \
-H "Content-Type: application/json" \
-d '{"emails":["ann@example.com","info@example.com","burner@mailinator.com","not-an-email"]}'
→ 200 {"results":[
{"email":"ann@example.com","valid":true,"reason":"ok","mx":true,"disposable":false,"role":false},
{"email":"info@example.com","valid":true,"reason":"ok","mx":true,"disposable":false,"role":true},
{"email":"burner@mailinator.com","valid":false,"reason":"disposable","mx":true,"disposable":true,"role":false},
{"email":"not-an-email","valid":false,"reason":"syntax","mx":null,"disposable":false,"role":false}
],"summary":{"total":4,"valid":2,"invalid":2,"disposable":1,"role":1}}
Per address: valid (syntax ok, not disposable, and MX did not come back definitely absent), reason (ok, syntax, disposable, or no_mx), mx (true, false, or null when the lookup itself failed, which never fails an address), disposable, and role. Role accounts stay valid; whether to mail info@ is your call, so it is a flag, not a verdict. Addresses are processed in memory and never stored, the same policy as every other endpoint.
Retrying safely
The same backoff in three languages. Each one distinguishes the burst 429, which is worth retrying, from the quota 429, which is not.
async function dedupe(body, attempt = 0) {
const res = await fetch("https://dedupe.dev/api/dedupe", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DEDUPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status !== 429 || attempt >= 5) return res;
// Retry-After is only sent for the burst limiter. The monthly-quota 429
// carries "resets" in the body instead, and retrying will not help.
const after = Number(res.headers.get("retry-after"));
if (!after) throw new Error((await res.json()).error);
await new Promise((r) => setTimeout(r, after * 1000 + 2 ** attempt * 250));
return dedupe(body, attempt + 1);
}What this API is not
Scoping, honestly, so you do not integrate it and then find out. This endpoint removes exact duplicates from text lines, CSV rows, and JSON arrays. That is the whole product.
- Fuzzy matching is bounded, not unlimited.
rowsmode takesfuzzy: truewith a similarity threshold, capped at 50,000 data rows per request (the paid per-request unit cap), and it needs an API key (the free one counts). It is record dedupe with normalization, blocking and edit distance, not probabilistic entity resolution: no training, no scoring model, no cross-request memory. - It does not deduplicate photos, files, or music. There is no image hashing here. For duplicate photos see the photo guides, which point at tools that read your device.
- It does not connect to anything. No CRM, no Google Sheets, no Salesforce, no OAuth, no webhooks. You send it data, it sends data back. Anything that fetches from your systems is code you write.
- It does not store your data, which also means it cannot remember it. There is no persistent index of what you have already sent, so it cannot tell you that a row you are uploading today duplicates one from last month. Deduplication is within a single request.
- It does not merge records. Duplicates are dropped, not combined. The first occurrence survives untouched and the rest are discarded, so if the second copy had the phone number the first was missing, that phone number is gone.
- It is not an async job API. One request, one response, bounded by your plan's size and unit caps. There is nothing to poll.
And the case for not using it at all: if your data already lives in SQL or a dataframe, you probably do not need this. SELECT DISTINCT, a GROUP BY, a window function over ROW_NUMBER(), or df.drop_duplicates() will be faster, free, and one less dependency. We wrote the SQL guide and the pandas guide partly so you can check that first. This API earns its place when the data is in flight rather than at rest: a webhook payload, a file upload, a shell pipeline, a serverless function with no database to lean on.
And if you just have a file on your desk, do not write code at all. The browser tools are free, unmetered, and never upload anything.
FAQ
Frequently asked questions
Do I need an API key?
Not to try it. The endpoint answers unauthenticated requests on a small per-IP daily budget (500 units). Create a free key at /app/keys for 1,000 units a month, and upgrade if you need more: Starter is 100,000 units a month at $19, Pro 300,000 at $49.
What is a unit?
One row of up to 1 KB. A request costs max(rows, bytes ÷ 1024) units, so a 50,000-row CSV costs 50,000 units whether you send it in one call or fifty, and a single very large row is billed by its size. Only the API is metered. The in-browser tools are free and unlimited.
What formats does it accept?
Three modes: lines (a string of text lines), rows (a CSV string with optional key columns), and json (an array, optionally deduped by key path).
Does the API do fuzzy matching?
Yes, in rows mode: set options.fuzzy to true and names like "Smith, Jane" and "jane smith" match after normalization for case, punctuation and word order, with a similarity threshold you control (default 0.9). It requires an API key (any tier, including free) and handles up to 50,000 data rows per request, the same ceiling as the paid per-request unit cap. The browser tools are exact-only.
Can the API validate email addresses?
Yes: POST {"emails": [...]} to /api/validate for hygiene-grade checks (syntax, MX presence, disposable domains, role accounts) at 2 units per address against the same monthly allowance. It is not SMTP-grade verification and the docs say so plainly: no mailbox pinging, no catch-all detection. An API key is required; any tier works.
Is my data stored?
No. The function processes the request in memory and returns the result; nothing is written to storage or logs beyond standard error metadata. Keyed calls record usage counts, mode, status and timing, never request bodies.
What happens when I hit a limit?
Over your plan's per-request size or unit cap you get a 413. Past your monthly units you get a 429 carrying limit, used and resets. Too many requests too fast gets a 429 carrying a Retry-After header, which is the only case worth retrying.
Last updated