Skip to content

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.

Submit a job
curl -X POST https://api.pricklymails.com/api/verify \
  -H "Authorization: Bearer pm_live_..." \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'
202 Accepted
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 dashboard

Verify 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.

Submit a job
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"}'
202 Accepted
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.

402 Payment Required
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.

Poll the job
curl https://api.pricklymails.com/api/verify/018f2c3a-6b91-7c44-9c2e-1a2b3c4d5e6f \
  -H "Authorization: Bearer pm_live_..."
200 OK, completed
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.

Submit response with a suggestion
{
  "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.

Preview check
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 dashboard

Register 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.

Create a webhook endpoint
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"]}'
201 Created
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.completed fires when the job finishes with a verified result.
  • job.failed fires 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.

Delivery request
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.

Node.js signature verification
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 shape
{ "error": { "code": "<key>" } }
StatusMeaning
400Malformed request body or invalid parameters.
401Missing or invalid API key.
402Wallet balance is empty. Top up to continue.
403The key does not have the required scope.
404The resource does not exist or does not belong to you.
409Idempotency key conflict: the same key was reused with a different request body.
429Rate limit exceeded. Retry after the given delay.
503The 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.