SingleMail
TRLog in

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

  1. 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.

  2. Add it in the panel

    On the Webhooks page, enter the URL and choose events. The signing secret (whsec_…) is shown right away and stays visible on the webhook's page.

    Webhook detail
  3. Verify the signature

    Anyone can call your URL. Verification guarantees the request really came from SingleMail and wasn't altered.

Payload

POST /hooks/singlemail
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.

Express (Node.js)
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);
});
PHP
$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

EventWhen
email.sentThe relay accepted the email.
email.deliveredThe recipient's server received it.
email.delivery_delayedDelivery was temporarily deferred.
email.bouncedHard bounce; the address was suppressed.
email.complainedMarked as spam; the address was suppressed.
email.openedThe email was opened.
email.clickedA link was clicked; the payload includes it.
email.failedCouldn't be sent; the payload includes the reason.
email.scheduledThe email was scheduled for later.
email.canceledA 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.

If an endpoint keeps failing, disable the webhook from its page; disabled webhooks receive no events.