Every email you send generates events — delivered, opened, clicked, bounced, marked as spam. These events tell you what happened after the email left your server. The problem is that email providers make it painful to work with them.
PushMail sits between your email provider and your application. It receives raw provider events, normalizes them into a consistent schema, stores them, and forwards them to your endpoints with HMAC signatures. You write one webhook handler, and it works regardless of what's happening at the provider level.
The raw webhook problem
SendGrid posts events as JSON arrays — multiple events batched into a single request. Each event has a different shape depending on its type. A bounce has reason and type fields. A click has a url. An open has neither. The timestamp is a Unix epoch integer, not ISO. The message ID includes a filter suffix you need to strip. There's no signature verification by default.
Every provider does this differently. Postmark sends individual events with its own field names. SES wraps events in SNS notifications. Mailgun uses form-encoded POST bodies. If you want consistent handling, you're building a normalization layer yourself.
How PushMail normalizes events
PushMail receives raw events from your email provider and normalizes them into a consistent schema with eight event types:
delivered— the recipient's mail server accepted the emailopen— the recipient opened the email (pixel tracking)click— the recipient clicked a link in the emailbounce— the email was rejected (hard or soft bounce)spam_report— the recipient reported the email as spamunsubscribe— the recipient clicked the unsubscribe linkdropped— the provider dropped the message before sending (suppressed address, invalid recipient)deferred— the provider attempted delivery but the receiving server temporarily refused it
Every event is stored in PushMail's events table, linked to the original send record. Provider-specific fields like bounce reasons and click URLs are preserved in a structured data object, but the top-level schema is always the same.
Incoming webhooks: provider to PushMail
PushMail exposes a webhook endpoint for each supported provider. For SendGrid, it's:
POST https://pushmail.dev/api/v1/webhooks/sendgridYou configure this URL in your SendGrid account's Event Webhook settings. SendGrid posts events to PushMail as they happen. PushMail parses the batch array, matches each event to the original send record by message ID, normalizes the event type and fields, and stores it.
PushMail also takes action based on event type. Bounces update the contact to bounced and add the address to a KV suppression list. Spam reports mark the contact as complained. A fast-path circuit breaker pauses sending if your bounce rate exceeds 8% or complaint rate exceeds 0.3% over 7 days, catching problems within seconds.
Querying events via API
All stored events are queryable through the API:
curl "https://pushmail.dev/api/v1/events?site_id=1&type=bounce" \
-H "Authorization: Bearer pm_live_your_api_key"You can filter by event type, site, and time range. This is useful for building dashboards, debugging delivery issues, or auditing what happened to a specific send. Every event includes the sendId, contactId, email, timestamp, and event-specific data.
Outgoing webhooks: PushMail to your app
Storing events is useful. Forwarding them to your application in real-time is where the real value is. PushMail's outgoing webhooks let you register endpoints that receive normalized events as they happen.
Create a webhook endpoint with the API:
curl -X POST https://pushmail.dev/api/v1/webhooks \
-H "Authorization: Bearer pm_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/email",
"events": ["delivered", "bounce", "spam_report", "unsubscribe"],
"description": "Production bounce handler"
}'The response includes a secret — a 32-byte hex string used for HMAC signature verification. This secret is only returned once, at creation time. Store it securely.
You can optionally scope a webhook endpoint to a specific site by including siteId in the creation request. If omitted, the endpoint receives events for all sites in your organization.
HMAC-SHA256 signature verification
Every outgoing webhook request includes two headers for verification:
X-PushMail-Signature— HMAC-SHA256 hex digestX-PushMail-Timestamp— Unix timestamp of when the payload was signed
The signature is computed over {timestamp}.{body} using your endpoint's secret as the HMAC key. Here's how to verify it in Node.js:
import crypto from "crypto";
function verifyWebhook(req, secret) {
const signature = req.headers["x-pushmail-signature"];
const timestamp = req.headers["x-pushmail-timestamp"];
const body = JSON.stringify(req.body);
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
// Timing-safe comparison to prevent timing attacks
if (signature.length !== expected.length) return false;
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Always verify signatures. Without verification, anyone who discovers your webhook URL can send fake events — triggering false unsubscribes, inflating bounce counts, or corrupting your analytics.
Payload format
Each outgoing webhook request sends a single event as a JSON body:
{
"event": "bounce",
"sendId": 48291,
"contactId": 1523,
"email": "jane@example.com",
"campaignId": 12,
"sequenceId": null,
"timestamp": "2025-12-12T14:32:00Z",
"data": {
"reason": "550 5.1.1 The email account does not exist",
"type": "hard"
}
}The event field is always one of the eight normalized types. campaignId and sequenceId are included when the send originated from a campaign or sequence, null otherwise. The data object contains event-specific details — reason for bounces, url for clicks, type for bounce classification.
Common patterns
Auto-suppress on hard bounce. When you receive a bounce event with data.type: "hard", mark the contact as undeliverable in your database. PushMail suppresses them internally, but syncing your records prevents re-enrollment attempts.
Slack alert on spam report. Forward spam_report events to a Slack channel. A single report isn't cause for alarm, but a spike means something is wrong — bad list hygiene, misleading subject lines, or mismatched expectations.
Analytics pipeline. Forward delivered, open, and click events to your data warehouse. This gives you email engagement data alongside product analytics, letting you correlate campaigns with user behavior.
Sequence conversion tracking. Listen for click events where sequenceId is set. When a contact clicks a link in a sequence email, trigger downstream actions — activate a trial, update a CRM, or notify a sales rep.
Webhooks bridge your email infrastructure and your application logic. PushMail normalizes the messy provider layer so you can focus on what to do with the events, not how to parse them.