Webhooks
Webhooks POST email events to your server the moment they happen, so you can, for example, flag a bounced address in your CRM or record delivered invoices.
Setup
Write an endpoint
Prepare a public https:// URL on your server that accepts POST and returns 2xx (local-network and localhost addresses are refused for security, and redirects aren't followed). Queue the work and answer 200 right away; requests time out after 15 seconds.
Add it in the panel
Verify the signature
Anyone can call your URL. Verification guarantees the request really came from SingleMail and wasn't altered.
Payload
webhook-id: msg_8c0b6f3e-…
webhook-timestamp: 1791651120
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"type": "email.bounced",
"created_at": "2026-10-10T14:12:00.000Z",
"data": {
"email_id": "abe55c23-533d-41ad-8971-2dad84ad985b",
"from": "Optik Dünyası <fatura@firma.com>",
"to": ["olmayan.adres@gmail.com"],
"subject": "e-Faturanız hazır",
"tags": [{ "name": "type", "value": "invoice" }],
"bounce": { "type": "Permanent", "subType": "General" }
}
}Verification
The signature is an HMAC-SHA256 of webhook-id.webhook-timestamp.gövde computed with the secret (whsec_ prefix removed, base64-decoded), following Standard Webhooks. Use the raw body; re-serialising parsed JSON breaks the signature.
import express from "express";
import { SingleMail } from "@singlemail/node";
const sm = new SingleMail();
app.post("/hooks/singlemail", express.text({ type: "*/*" }), (req, res) => {
let event;
try {
event = sm.webhooks.verify({
payload: req.body,
headers: {
id: req.header("webhook-id"),
timestamp: req.header("webhook-timestamp"),
signature: req.header("webhook-signature"),
},
secret: process.env.SINGLEMAIL_WEBHOOK_SECRET,
});
} catch {
return res.sendStatus(400);
}
if (event.type === "email.bounced") markInvalid(event.data.to[0]);
res.sendStatus(200);
});$secret = base64_decode(substr(getenv("SINGLEMAIL_WEBHOOK_SECRET"), 6));
$payload = file_get_contents("php://input");
$signed = $_SERVER["HTTP_WEBHOOK_ID"] . "." . $_SERVER["HTTP_WEBHOOK_TIMESTAMP"] . "." . $payload;
$expected = "v1," . base64_encode(hash_hmac("sha256", $signed, $secret, true));
if (!hash_equals($expected, $_SERVER["HTTP_WEBHOOK_SIGNATURE"])) { http_response_code(400); exit; }
$event = json_decode($payload, true);Event types
| Event | When |
|---|---|
email.sent | The relay accepted the email. |
email.delivered | The recipient's server received it. |
email.delivery_delayed | Delivery was temporarily deferred. |
email.bounced | Hard bounce; the address was suppressed. |
email.complained | Marked as spam; the address was suppressed. |
email.opened | The email was opened. |
email.clicked | A link was clicked; the payload includes it. |
email.failed | Couldn't be sent; the payload includes the reason. |
email.scheduled | The email was scheduled for later. |
email.canceled | A scheduled email was canceled. |
Retries
Requests that don't return 2xx or time out are retried 8 times with growing gaps (5 s, 10 s, 20 s … ~10 min). The same event may arrive more than once, so de-duplicate on webhook-id.
