PineMail

Webhooks

PineMail POSTs a signed JSON event to your HTTPS URL when a transactional email changes state. Configure endpoints in the dashboard or through the REST API. Production URLs must be HTTPS.

Events

Subscribe to one or more of these keys. Inbox compose and inbound mail do not emit these events today — they apply to messages sent through POST /v1/emails.

EventWhen
email.queuedAccepted and waiting on the send worker.
email.sendingHanded to SES.
email.sentSES accepted the message.
email.deliveredRecipient MTA accepted it (SES configuration set + SNS).
email.bouncedHard or soft bounce from SES.
email.complainedRecipient marked the message as spam.
email.failedSend failed before or at SES.
email.openedOpen tracking pixel (when enabled).
email.clickedTracked link click (when enabled).
webhook.testSynthetic ping from POST /v1/webhooks/{id}/test.

Payload

Every delivery is a JSON object with id, type, created_at, and data. Respond with any 2xx within 10 seconds. Bodies larger than 4 KB are stored truncated on the delivery log.

json
{ "id": "evt_01J...", "type": "email.sent", "created_at": "2026-09-20T16:02:11.000Z", "data": { "email_id": "em_01J...", "status": "sent", "from": "[email protected]", "to": ["[email protected]"], "subject": "Welcome", "provider_message_id": "010001...", "tags": ["onboarding"], "event_payload": null } }

Signature

Each request includes X-Mail-Signature: t=<unix>,v1=<hex>.v1 is HMAC-SHA256 of "<unix>.<raw_body>" using the webhook secret (whsec_...), shown once at create time. Reject timestamps older than 5 minutes.

javascript
import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyPineMailSignature(rawBody, secret, header, toleranceSec = 300) { const parts = Object.fromEntries( header.split(",").map((p) => { const [k, v] = p.split("="); return [k.trim(), (v ?? "").trim()]; }), ); const t = Number(parts.t); if (!Number.isFinite(t)) return false; if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); const provided = Buffer.from(parts.v1, "hex"); return provided.length === expected.length && timingSafeEqual(provided, expected); }

Headers

Content-Type: application/json
User-Agent: PineMail-Webhooks/1.0
X-Mail-Signature: t=…,v1=…

Retries

Non-2xx and network errors retry up to 12 attempts with exponential backoff starting at 30 seconds (about 17 hours). A successful delivery is never sent twice. Disabled endpoints stop retrying and mark the row failed.

Create and test

Creating a webhook returns the signing secret once. POST /v1/webhooks/{id}/test sends a webhook.test event immediately and records the HTTP status. Requires the webhooks:write scope.

bash
curl -X POST $API/v1/webhooks \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/pinemail", "events": ["email.sent", "email.delivered", "email.bounced", "email.failed"] }' curl -X POST $API/v1/webhooks/$ID/test \ -H "Authorization: Bearer pm_live_..."'

Manage

GET /v1/webhooks lists endpoints. GET /v1/webhooks/{id} includes the last 20 deliveries. PATCH accepts url, events, and enabled. DELETE removes the endpoint. Rotate the secret from the dashboard if it leaks — the API does not return it again.