Webhooks
Pass a webhook_url when you submit a bulk job and EmailShield will POST to it once, when the job finishes. You don’t have to poll.
curl -X POST https://app.emailshield.co/api/v1/verify/bulk \ -H "X-API-Key: $EMAILSHIELD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"emails": ["jane.doe@example.com"], "webhook_url": "https://hooks.yourdomain.com/emailshield"}'Webhooks are set per job, on POST /v1/verify/bulk.
What we send
Section titled “What we send”POST /emailshield HTTP/1.1Host: hooks.yourdomain.comContent-Type: application/jsonUser-Agent: EmailShield-Webhook/1X-EmailShield-Event: job.completedX-EmailShield-Job: 3f1c2a4e-…X-EmailShield-Timestamp: 1791633663X-EmailShield-Signature: v1=5d0c…e9
{"event":"job.completed","job_id":"3f1c2a4e-…","status":"completed","total_emails":2,"processed_emails":2,"verdicts":{"valid":1,"invalid":0,"catch_all":1,"risky":0,"role_based":0,"disposable":0,"unknown":0},"created_at":"2026-10-09T12:01:00Z","completed_at":"2026-10-09T12:03:41Z","results_url":"/v1/jobs/3f1c2a4e-…/results"}| Field | |
|---|---|
event | job.completed, or job.failed when the job failed or stopped early |
job_id | |
status | completed or failed |
total_emails, processed_emails | |
verdicts | Counts by verdict, as on GET /v1/jobs/{job_id} |
created_at, completed_at | ISO 8601, UTC |
results_url | Path of the results endpoint. Prefix it with https://app.emailshield.co/api |
error | Only on job.failed |
The message tells you the job is done; fetch the rows from results_url with your API key.
Verify the signature
Section titled “Verify the signature”Every webhook is signed with your account’s signing secret (starts with whsec_). Find it, copy it or rotate it in the app under Profile → API → Webhooks. One secret covers every job on your account. Rotating takes effect immediately, so update your receiver at the same time.
To check a webhook:
- Take the raw request body exactly as received — don’t parse and re-serialise it.
- Build the string
<X-EmailShield-Timestamp>.<raw body>. - Compute HMAC-SHA256 of it with your secret, as lowercase hex.
- Compare it with the part after
v1=inX-EmailShield-Signature, using a constant-time comparison. - Reject the message if the timestamp is more than about 300 seconds from now.
import hmac, hashlib, time
def verify_emailshield(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool: if abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(f"v1={expected}", signature)import crypto from 'node:crypto';
export function verifyEmailShield(secret, rawBody, timestamp, signature) { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); return expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}Delivery and retries
Section titled “Delivery and retries”- Reply with any 2xx within 10 seconds to acknowledge it. Do the slow work afterwards.
- On a timeout or a non-2xx answer we retry: 5 attempts in total, waiting 2 s, 10 s, 60 s and 300 s between them — about six minutes. Then we stop.
- Redirects are not followed.
- Each job’s webhook is delivered at most once successfully. Use
job_idto ignore duplicates anyway.
If you might miss a webhook (deploys, outages), keep a fallback: poll GET /v1/jobs/{job_id} for any job you haven’t heard about.
URL rules
Section titled “URL rules”The webhook_url must:
- use
https://; - resolve to a public address (no localhost, private networks or similar) — checked when you submit and again before every delivery;
- use port 443 or a port of 1024 or above, with no username or password in the URL;
- be at most 2,048 characters.
A URL that breaks these rules is refused when you submit the job, with 422 and the reason. Nothing is charged.