Documentation
PricklyMails is an async email-verification API: submit a job, then poll for the result or subscribe to a webhook.
Introduction
PricklyMails checks whether an email address is real and deliverable: syntax, DNS and MX records, disposable and role-account detection, catch-all detection, and an optional SMTP probe.
Base URL: https://api.pricklymails.com
Verification is asynchronous: submit an email and receive a job uuid immediately, then poll that job until it reaches a terminal status.
curl -X POST https://api.pricklymails.com/api/verify \
-H "Authorization: Bearer pm_live_..." \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'HTTP/1.1 202 Accepted
Location: /api/verify/018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f
Retry-After: 4
{ "job_uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f", "status": "queued" }Authentication
Every request to the verification API carries a Bearer API key, created from the dashboard: Authorization: Bearer pm_live_...
Never expose a key in client-side code. Keys are meant for server-to-server calls only.
Create a key in the dashboardVerify an email
The core flow is two calls: submit an email, then poll the job until it reaches a terminal status.
Submit a verification
POST the email to /api/verify. An optional Idempotency-Key header makes retries safe.
curl -X POST https://api.pricklymails.com/api/verify \
-H "Authorization: Bearer pm_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <your-key>" \
-d '{"email": "user@gmial.com"}'HTTP/1.1 202 Accepted
Location: /api/verify/018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f
Retry-After: 4
{
"job_uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f",
"status": "queued",
"estimated_cost": 7,
"idempotency_key": "your-key",
"replayed": false,
"email": "user@gmial.com",
"suggestion": "user@gmail.com"
}The response carries a Location header pointing at the poll URL and a Retry-After header (in seconds) as the suggested poll interval.
Replaying the same key with the same Idempotency-Key returns 200 OK with an Idempotent-Replayed: true header instead of creating a new job.
When your token wallet has no balance left, the request returns 402 Payment Required instead of 403, so your integration can distinguish top-up needed from scope missing.
HTTP/1.1 402 Payment Required
{ "error": { "code": "submit_verification.insufficient_balance" } }Poll for the result
GET the job by uuid with the same Bearer auth. While the job is not terminal, the response carries a Retry-After: 4 header as the suggested poll interval in seconds. Statuses: queued, processing, completed, failed, canceled.
curl https://api.pricklymails.com/api/verify/018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f \
-H "Authorization: Bearer pm_live_..."HTTP/1.1 200 OK
{
"job_uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f",
"status": "completed",
"verifications": [
{
"email_hash": "5d41402abc4b2a76b9719d911017c592",
"domain_hash": "6ffca39b6bb9db3a7c9c1a1e7d0d8b1f",
"status": "invalid",
"reason": "no_mx_records",
"score": 15,
"duration_ms": 126,
"verified_at": "2026-07-02T10:04:12Z",
"checks": {
"syntax": true,
"mx": { "valid": false, "records": [] },
"disposable": false,
"role": false,
"free": false,
"typo": { "detected": true },
"smtp": { "status": "skipped" },
"catchall": false
}
}
]
}Raw email addresses are never stored: completed results expose HMAC hashes (email_hash, domain_hash) instead. The typo suggestion is only returned by the submit call above, not on the completed job.
Typo suggestions
suggestion is the standout field on the submit response: when a likely typo is detected, PricklyMails returns the corrected address, not just a flag, so you can fix and resend right away.
{
"job_uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f",
"status": "queued",
"email": "user@gmial.com",
"suggestion": "user@gmail.com"
}suggestion is transient like the echoed email field: it is never persisted, so GET /api/verify/<uuid> never carries it.
Preview
No signup required. POST to /api/preview and get the same check shape synchronously (SMTP is skipped). Rate-limited per IP, good for a quick test.
curl -X POST https://api.pricklymails.com/api/preview \
-H "Content-Type: application/json" \
-d '{"email": "user@gmial.com"}'Webhooks
Register an endpoint to receive a signed HTTP notification when a job reaches a terminal status, instead of polling for the result.
Manage webhooks in the dashboardRegister an endpoint
POST a target URL and the event types to subscribe to. The signing secret is returned once, in the create response only; store it immediately.
curl -X POST https://api.pricklymails.com/api/webhooks \
-H "Authorization: Bearer <dashboard-jwt-access-token>" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/pricklymails", "events": ["job.completed", "job.failed"]}'HTTP/1.1 201 Created
{
"uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e70",
"url": "https://example.com/webhooks/pricklymails",
"events": ["job.completed", "job.failed"],
"status": "active",
"total_deliveries": 0,
"successful_deliveries": 0,
"consecutive_failures": 0,
"created_at": "2026-07-02T10:00:00Z",
"secret": "6f2c1e4a9b7d3f0851c6a2e4d8b7f102c9a4e6f0b3d7185ac2e6f4a9b7d3f08"
}The secret field is shown only here. Subsequent reads of the endpoint (GET, PATCH, DELETE) never include it again.
Events
Two event types fire today, both tied to job completion:
job.completedfires when the job finishes with a verified result.job.failedfires when the job fails before producing a result.
Delivery payload
Every delivery is a signed POST with a zero-retention JSON body: no raw email address or other PII, only the job identity and outcome.
POST https://example.com/webhooks/pricklymails HTTP/1.1
Content-Type: application/json
X-PricklyMails-Event: job.completed
X-PricklyMails-Signature: t=1751500000,v1=5257a869e7bff7a97d0731ff6dd9c1c3f9a3d1e69f0b7f6a1d3d3f6a4c8f9b2e
{
"event": "job.completed",
"job_uuid": "018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f",
"status": "completed",
"timestamp": 1751500000
}Verify the signature
The X-PricklyMails-Signature header carries the format t=<unix-timestamp>,v1=<hex-hmac>. Compute HMAC-SHA256 over the string "<timestamp>.<raw body>" with your endpoint secret and compare the result to v1 with a constant-time comparison.
Reject the delivery if the timestamp is older than 288 seconds, to guard against replay of a captured request.
const crypto = require('crypto')
function verifyWebhookSignature(rawBody, signatureHeader, secret, toleranceSeconds = 288) {
const parts = Object.fromEntries(signatureHeader.split(',').map((part) => part.split('=')))
const timestamp = Number(parts.t)
const expected = parts.v1
if (!timestamp || !expected) {
throw new Error('malformed signature header')
}
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp)
if (age > toleranceSeconds) {
throw new Error('signature timestamp too old')
}
const signedPayload = `${timestamp}.${rawBody}`
const computed = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex')
const a = Buffer.from(computed, 'hex')
const b = Buffer.from(expected, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('signature mismatch')
}
}Retries and fallback
A failed delivery retries with backoff, up to a configurable limit (4 by default, 8 maximum). An endpoint auto-disables after 8 consecutive failures. GET /api/verify/<uuid> is always the fallback source of truth if a delivery is missed.
Errors
Every error response shares the same shape: a stable translation key, never a stack trace or an internal detail.
{ "error": { "code": "<key>" } }| Status | Meaning |
|---|---|
| 400 | Malformed request body or invalid parameters. |
| 401 | Missing or invalid API key. |
| 402 | Wallet balance is empty. Top up to continue. |
| 403 | The key does not have the required scope. |
| 404 | The resource does not exist or does not belong to you. |
| 409 | Idempotency key conflict: the same key was reused with a different request body. |
| 429 | Rate limit exceeded. Retry after the given delay. |
| 503 | The service is temporarily unavailable. Retry after the given delay. |
Rate limits
Each API key has a plan-driven requests-per-minute budget, enforced independently of your token wallet. The wallet bounds total monthly volume; the per-minute limit bounds burst rate.
The per-key limit applies to /api/verify and /api/verify/<uuid>, scoped to the key uuid, not shared with other keys on the same plan.
Unauthenticated routes like /api/preview are capped per IP instead. Every rate-limited response returns 429 with a Retry-After header in seconds.