> ## Documentation Index
> Fetch the complete documentation index at: https://docs.drin.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Subscribe an endpoint to delivery, bounce, complaint, open, click, and inbound_received events. Every delivery is signed with a Drin-Signature header (t=<seconds>,v1=<hmac>); verify it in one call with drin.webhooks.verify().

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.

## 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](/receiving).

<CardGroup cols={3}>
  <Card title="delivery" icon="bolt">
    The receiving mail server accepted the message.
  </Card>

  <Card title="bounce" icon="shield">
    A hard or soft bounce. The address is added to your suppression list.
  </Card>

  <Card title="complaint" icon="shield">
    The recipient marked it as spam. The address is suppressed.
  </Card>

  <Card title="open">Recipient opened the message (tracking pixel).</Card>
  <Card title="click">Recipient clicked a tracked link.</Card>

  <Card title="inbound_received" icon="inbox">
    A new email arrived at one of your inboxes.
  </Card>
</CardGroup>

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

## 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`**

<CodeGroup>
  ```bash cURL theme={null}
  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"]
    }'
  ```

  ```typescript Node.js theme={null}
  import { DrinClient } from "@drin00/sdk";

  const drin = new DrinClient({ apiKey: process.env.DRIN_API_KEY });

  const hook = await drin.webhooks.create({
    url: "https://yourapp.com/webhooks/drin",
    eventTypes: ["delivery", "bounce", "complaint", "inbound_received"],
  });

  // signingSecret is returned ONCE — store it now, you can't read it again.
  console.log(hook.signingSecret); // "whsec_..."
  ```
</CodeGroup>

```json 201 Created theme={null}
{
  "id": "whk_5d2a...",
  "url": "https://yourapp.com/webhooks/drin",
  "enabled": true,
  "eventTypes": ["delivery", "bounce", "complaint", "inbound_received"],
  "signingSecret": "whsec_3f9c0a..."
}
```

<Warning>
  **The signing secret is shown once.** Drin 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.
</Warning>

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)`.

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

```json Webhook body theme={null}
{
  "id": "evt_91bc...",
  "type": "delivery",
  "createdAt": "2026-06-02T12:00:00.000Z",
  "data": {
    "messageId": "msg_out_02",
    "senderId": "snd_1b9d...",
    "email": "carol@example.com"
  }
}
```

<Note>
  **Respond fast, process async.** Return `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`.
</Note>

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

```http Request headers theme={null}
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.

```typescript Express + drin.webhooks.verify() theme={null}
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);
    }
  },
);
```

<Warning>
  **Verify the raw body.** Sign 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`.
</Warning>

`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:

```typescript Manual verification theme={null}
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),
  );
}
```

## End to end

<Steps>
  <Step title="Create the webhook">
    `POST /v1/webhooks` with your URL and event types. Save the `signingSecret` from the response.
  </Step>

  <Step title="Capture the raw body">
    Configure your framework to hand you the unparsed request body (e.g. `express.raw({ type: "application/json" })`).
  </Step>

  <Step title="Verify, then act">
    Call `drin.webhooks.verify(rawBody, header, secret)`. On success, branch on `payload.type`; on failure, return `400`.
  </Step>

  <Step title="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](/receiving#simulate).
  </Step>
</Steps>

<Card title="Receive & reply" icon="inbox" href="/receiving">
  The `inbound_received` webhook is the front door to the agent inbox loop — receive, read the thread, and reply in one call.
</Card>
