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.
| Event | When |
|---|---|
email.queued | Accepted and waiting on the send worker. |
email.sending | Handed to SES. |
email.sent | SES accepted the message. |
email.delivered | Recipient MTA accepted it (SES configuration set + SNS). |
email.bounced | Hard or soft bounce from SES. |
email.complained | Recipient marked the message as spam. |
email.failed | Send failed before or at SES. |
email.opened | Open tracking pixel (when enabled). |
email.clicked | Tracked link click (when enabled). |
webhook.test | Synthetic 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.
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.
Headers
Content-Type: application/jsonUser-Agent: PineMail-Webhooks/1.0X-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.
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.