API Documentation
The Postlark API is a small set of HTTPS endpoints that return structured email-verification verdicts. Authentication is by API key. Responses are JSON.
Base URL
All API requests use the production base URL:
https://app.postlark.io
Staging is available at https://staging.app.postlark.io
for testing during integration. Both speak the same API; only the data and rate limits differ.
Authentication
Every request must include your API key in the Authorization
header using the Bearer
scheme:
Authorization: Bearer evk_live_…
Issue keys from the Keys page in your dashboard. Keys are shown in plaintext exactly once at creation — store them in a secrets manager. We hash keys at rest with Argon2id; if you lose a key, issue a new one and revoke the old.
Requests with a missing, malformed, revoked, or unknown key receive HTTP 401 with code unauthenticated.
POST /v1/verify
Submit a single email address; receive a structured verdict.
Request
POST /v1/verify HTTP/1.1
Host: app.postlark.io
Authorization: Bearer evk_live_…
Content-Type: application/json
{"email": "user@example.com"}
Request body
| Field | Type | Description |
|---|---|---|
email |
string (required) | The email address to verify. UTF-8, max 254 characters. |
Response (200 OK)
{
"request_id": "GKzAVmPVTIs0oLcAABFR",
"email": "user@example.com",
"verdict": "deliverable",
"confidence": "medium",
"verified_at": "2026-05-05T18:57:10.390732Z",
"cached": false,
"signals": {
"syntax_valid": true,
"has_mx": true,
"disposable": false,
"role_account": false,
"free_provider": false,
"typo_suggestion": null,
"smtp_reachable": null,
"catch_all": null
}
}
Top-level fields
| Field | Type | Description |
|---|---|---|
request_id |
string | Unique id for the request. Include when contacting support. |
email |
string | The address that was verified. Lowercased / trimmed exactly as evaluated. |
verdict |
string |
One of deliverable, undeliverable, risky, unknown. See "Verdict semantics" below.
|
confidence |
string |
One of high, medium, low. Reflects how strongly the signals support the verdict; for example, an undeliverable verdict driven by a missing MX record is high, while a deliverable verdict without an SMTP probe is medium.
|
verified_at |
string (ISO 8601) |
UTC timestamp of when the verdict was first produced. For cached: true
responses this is the original verification time, not the time of the cache hit.
|
cached |
boolean |
true
when the verdict came from the result cache (default 7-day TTL); false
for a freshly computed verdict.
|
signals |
object | Per-signal breakdown — see next table. |
Signals
| Field | Type | Description |
|---|---|---|
syntax_valid |
boolean | Whether the address parses per RFC 5322 plus our heuristics. |
has_mx |
boolean | Whether the domain has resolvable MX records. |
disposable |
boolean | Whether the domain matches a community-curated disposable list (Mailinator, 10MinuteMail, etc.). |
role_account |
boolean | Whether the local part is a typical role account (info@, support@, sales@, …). |
free_provider |
boolean | Whether the domain is a major free email provider (gmail.com, yahoo.com, outlook.com, …). |
typo_suggestion |
string | null |
A suggested correction (e.g., gmial.com
→ gmail.com) when our typo detector matches; otherwise null.
|
smtp_reachable |
boolean | null |
Whether the recipient's MX accepted a RCPT TO probe.
null when SMTP probing is disabled or skipped.
SMTP probing is currently disabled on all v1 plans (deferred to v2).
|
catch_all |
boolean | null |
Whether the domain accepts mail to any local part. null
when SMTP probing is disabled or the verdict is otherwise undetermined.
|
Verdict semantics
-
deliverable— The address looks legitimate: valid syntax, an MX record, and not flagged by any negative signal. -
undeliverable— At least one strong negative signal: invalid syntax, no MX, or a high-confidence typo of a known good domain. -
risky— The address is technically deliverable but carries warning signals (disposable domain, role account). You may want to challenge or rate-limit before relying on it. -
unknown— We couldn't form a confident verdict (transient DNS issues, etc.). Retrying after a short delay is reasonable.
GET /v1/account
Returns the authenticated account's plan, current-period usage, and quota.
GET /v1/account HTTP/1.1
Host: app.postlark.io
Authorization: Bearer evk_live_…
Response (200 OK)
{
"request_id": "GKzAWrKrcTI0oLcAABJB",
"plan": "scale",
"plan_quota": 500000,
"status": "active",
"current_period_start": "2026-05-01T00:00:00Z",
"current_period_end": "2026-06-01T00:00:00Z",
"usage": {
"verifications_count": 12345,
"overage_count": 0
}
}
Fields
| Field | Type | Description |
|---|---|---|
plan |
string |
One of free, starter, growth, scale, payg.
|
plan_quota |
integer |
Verifications included this billing period. 0
for PAYG (everything is metered).
|
status |
string | null |
Subscription status: active, past_due, canceled, etc.
null
on the free tier.
|
current_period_start |
string (ISO 8601) | null |
Start of the current Stripe billing period. null on the free tier.
|
current_period_end |
string (ISO 8601) | null | End of the current Stripe billing period. null on the free tier. |
usage.verifications_count |
integer | Total verifications in the current period. |
usage.overage_count |
integer |
Verifications above plan_quota in the current period (paid plans only).
|
GET /v1/health
Liveness check. No authentication required.
GET /v1/health HTTP/1.1
Host: app.postlark.io
Returns 200 with {"status":"ok","request_id":"…"}
when the service is up.
Errors
Errors return a non-2xx status and a JSON body of consistent shape:
{
"error": {
"code": "unauthenticated",
"message": "Invalid API key"
},
"request_id": "GKxx5TjekFhpggYAAAUi"
}
Some errors include a details
object inside error
for additional context (e.g., the field that failed validation). Clients should not assume
details
is present.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request |
Malformed JSON, missing required field, or invalid value (including syntactically invalid email). |
| 401 | unauthenticated |
API key missing, malformed, revoked, or unknown. |
| 403 | forbidden |
Authenticated but not authorized for the requested resource. |
| 404 | not_found |
Path doesn't exist. |
| 429 | rate_limit_exceeded |
Too many requests in a short window. Retry-After header included. |
| 429 | quota_exceeded |
Monthly quota exceeded (free tier). |
| 500 | internal_error |
Unexpected server error. Include the request_id when reporting. |
Rate limits & quotas
Each plan has a monthly verification quota that resets at the start of your Stripe billing period. The free tier additionally enforces a per-minute rate limit (10 requests/min) to protect SMTP-probe IP reputation; paid plans have no per-minute cap.
| Plan | Monthly quota | Per-minute cap | Overage |
|---|---|---|---|
| Free | 100 | 10 | 429 quota_exceeded |
| Starter | 10,000 | uncapped | billed at plan rate |
| Growth | 100,000 | uncapped | billed at plan rate |
| Scale | 500,000 | uncapped | billed at plan rate |
| PAYG | n/a | uncapped | every call billed at plan rate |
Request IDs
Every response includes a unique request_id
in the JSON body. Include it whenever you contact
support
— it lets us trace the exact call through our logs.