Skip to content

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.

Terminal window
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.

POST /emailshield HTTP/1.1
Host: hooks.yourdomain.com
Content-Type: application/json
User-Agent: EmailShield-Webhook/1
X-EmailShield-Event: job.completed
X-EmailShield-Job: 3f1c2a4e-…
X-EmailShield-Timestamp: 1791633663
X-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
eventjob.completed, or job.failed when the job failed or stopped early
job_id
statuscompleted or failed
total_emails, processed_emails
verdictsCounts by verdict, as on GET /v1/jobs/{job_id}
created_at, completed_atISO 8601, UTC
results_urlPath of the results endpoint. Prefix it with https://app.emailshield.co/api
errorOnly on job.failed

The message tells you the job is done; fetch the rows from results_url with your API key.

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:

  1. Take the raw request body exactly as received — don’t parse and re-serialise it.
  2. Build the string <X-EmailShield-Timestamp>.<raw body>.
  3. Compute HMAC-SHA256 of it with your secret, as lowercase hex.
  4. Compare it with the part after v1= in X-EmailShield-Signature, using a constant-time comparison.
  5. 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));
}
  • 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_id to 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.

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.