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.
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).
/v1/webhookscurl 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"]
}'{
"id": "whk_5d2a...",
"url": "https://yourapp.com/webhooks/drin",
"enabled": true,
"eventTypes": ["delivery", "bounce", "complaint", "inbound_received"],
"signingSecret": "whsec_3f9c0a..."
}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.
{
"id": "evt_91bc...",
"type": "delivery",
"createdAt": "2026-06-02T12:00:00.000Z",
"data": {
"messageId": "msg_out_02",
"senderId": "snd_1b9d...",
"email": "carol@example.com"
}
}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.
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 tocreatedAt).v1— lowercase hexHMAC-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.
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);
}
},
);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:
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
Create the webhook
POST /v1/webhookswith your URL and event types. Save thesigningSecretfrom the response. - 2
Capture the raw body
Configure your framework to hand you the unparsed request body (e.g.express.raw({ type: "application/json" })). - 3
Verify, then act
Calldrin.webhooks.verify(rawBody, header, secret). On success, branch onpayload.type; on failure, return400. - 4
Test it
Fire a synthetic inbound event withPOST /v1/inbound/simulate— it delivers a real, signedinbound_receivedwebhook flaggedtest_mode. See Inbound & threads.
inbound_received webhook is the front door to the agent inbox loop — receive, read the thread, and reply in one call.