Docs
guides · webhooks

Webhooks

Webhooks push email events to your server as they happen — deliveries, bounces, complaints, opens, clicks, and inbound messages. Every delivery is signed so you can trust it came from Drin and wasn't replayed. Verifying is one call with the SDK.

Instead of polling, register an HTTPS endpoint and Drin POSTs it a small JSON envelope whenever a subscribed event occurs. The two things you must get right: subscribe to the events you care about, and verify the signature on every delivery before acting on it.

01 Event types

Subscribe to any subset of these. The outbound lifecycle events fire as a message moves through the pipeline; the inbound event fires when mail arrives at one of your inboxes.

deliveryThe receiving mail server accepted the message.
bounceA hard or soft bounce. The address is added to your suppression list.
complaintThe recipient marked it as spam. The address is suppressed.
openRecipient opened the message (tracking pixel).
clickRecipient clicked a tracked link.
inbound_receivedA new email arrived at one of your inboxes.

The full set also includes the intermediate lifecycle states accepted, queued, sending, sent, delivery_delayed, rejected, rendering_failure, failed, and inbound_rejected. Subscribe only to what you act on — most integrations want delivery, bounce, complaint, and inbound_received.

02 Subscribe an endpoint

Create a webhook with your endpoint URL and the events it should receive. The response includes a signingSecret it is returned only once, so store it immediately (you'll use it to verify deliveries).

POST/v1/webhooks
curl https://api.drin.run/v1/webhooks \
  -H "Authorization: Bearer $DRIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/drin",
    "eventTypes": ["delivery", "bounce", "complaint", "inbound_received"]
  }'
201 Created
{
  "id": "whk_5d2a...",
  "url": "https://yourapp.com/webhooks/drin",
  "enabled": true,
  "eventTypes": ["delivery", "bounce", "complaint", "inbound_received"],
  "signingSecret": "whsec_3f9c0a..."
}
The signing secret is shown onceDrin never returns signingSecret again on GET or PATCH. If you lose it, you can't recover it — delete the webhook and create a new one. Store it as a server-side secret, never in client code.

Pause, repoint, or re-subscribe an endpoint without rotating its secret via PATCH /v1/webhooks/{id} (drin.webhooks.update(id, { enabled: false })). List and delete with drin.webhooks.list() and drin.webhooks.delete(id).

03 The delivery payload

Every event is delivered as the same envelope: a delivery id, the event type, an ISO-8601 createdAt, and a data object. For outbound events data.messageId ties it back to the message; for bounces and complaints data.email is the affected address; for inbound_received the messageId points at the arriving message.

Webhook body
{
  "id": "evt_91bc...",
  "type": "delivery",
  "createdAt": "2026-06-02T12:00:00.000Z",
  "data": {
    "messageId": "msg_out_02",
    "senderId": "snd_1b9d...",
    "email": "carol@example.com"
  }
}
Respond fast, process asyncReturn 2xx quickly. Do the real work after acknowledging — a slow or failing endpoint is retried with backoff, and persistent failures can disable the webhook. Treat deliveries as at-least-once and dedupe on the envelope id.

04 Verify the signature

Each request carries a Drin-Signature header. It is a Stripe-style scheme: a timestamp and an HMAC-SHA256 of the body, keyed by your endpoint's signing secret.

Request headers
POST /webhooks/drin HTTP/1.1
Host: yourapp.com
Content-Type: application/json
Drin-Signature: t=1780747200,v1=3a7b1f4e8c9d...

The header is two comma-separated fields:

  • t — the Unix-seconds timestamp the payload was signed at (equal to createdAt).
  • v1 — lowercase hex HMAC-SHA256(secret, "<t>.<rawBody>").

To verify: recompute the HMAC over <t>.<rawBody> with your signing secret and compare it to v1 in constant time. The SDK does exactly this — including the constant-time compare, the t-vs-createdAt binding, and an optional freshness window — in one call.

Express + drin.webhooks.verify()
import express from "express";
import { DrinClient } from "@drin00/sdk";

const drin = new DrinClient({ apiKey: process.env.DRIN_API_KEY });
const SECRET = process.env.DRIN_WEBHOOK_SECRET!; // the signingSecret from create

const app = express();

// IMPORTANT: verify the RAW body, not a re-serialized object.
app.post(
  "/webhooks/drin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("Drin-Signature") ?? "";
    try {
      const { payload } = drin.webhooks.verify(
        req.body.toString("utf8"), // raw bytes exactly as received
        signature,
        SECRET,
        { toleranceSeconds: 300 }, // reject deliveries older than 5 minutes
      );

      switch (payload.type) {
        case "delivery":
          markDelivered(payload.data.messageId);
          break;
        case "bounce":
        case "complaint":
          suppress(payload.data.email);
          break;
        case "inbound_received":
          handleInbound(payload.data.messageId);
          break;
      }

      res.sendStatus(200);
    } catch {
      // Tampered body, wrong secret, or a stale/replayed delivery.
      res.sendStatus(400);
    }
  },
);
Verify the raw bodySign and verify the exact bytes you received. If a framework parses JSON and you re-serialize the object, key order or whitespace can change and the signature will no longer match. Capture the raw request body (e.g. express.raw()) and pass that string to verify.

drin.webhooks.verify() returns { payload, timestamp } on success and throws DrinWebhookVerificationError on any failure — a tampered body, the wrong secret, a malformed header, a t that disagrees with createdAt, or a delivery outside the toleranceSeconds window. It's a pure local operation, no network call. For edge runtimes without node:crypto, inject a WebCrypto HMAC via the hmacSha256 option.

Verifying without the SDK

The scheme is simple enough to implement directly in any language — this is the whole of it:

Manual verification
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  // header = "t=<seconds>,v1=<hex>"
  const parts = Object.fromEntries(
    header.split(",").map((s) => s.split("=").map((x) => x.trim())),
  );
  const signed = `${parts.t}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signed, "utf8")
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1),
  );
}

05 End to end

  1. 1

    Create the webhook

    POST /v1/webhooks with your URL and event types. Save the signingSecret from the response.
  2. 2

    Capture the raw body

    Configure your framework to hand you the unparsed request body (e.g. express.raw({ type: "application/json" })).
  3. 3

    Verify, then act

    Call drin.webhooks.verify(rawBody, header, secret). On success, branch on payload.type; on failure, return 400.
  4. 4

    Test it

    Fire a synthetic inbound event with POST /v1/inbound/simulate — it delivers a real, signed inbound_received webhook flagged test_mode. See Inbound & threads.
Receive & replyThe inbound_received webhook is the front door to the agent inbox loop — receive, read the thread, and reply in one call.