---
title: Verifying a webhook
description: How parse checks that a webhook came from your vendor, per vendor, where the secret goes, what a failed check throws, and what happens when you pass no secret.
sidebar:
  label: Verification
related:
  - /docs/webhooks
  - /docs/webhooks/events
  - /docs/capabilities
---

An endpoint that accepts webhooks is a public URL. Without a check, anyone who finds it can post events to it. `parse` checks a request when you pass `secret`, in one of two ways depending on the vendor.

| Vendor | `events.verify` | What `secret` is | What is checked |
| --- | --- | --- | --- |
| Instantly | `secret` | A value you choose | The request carries that value in its URL or a header |
| HeyReach | `secret` | A value you choose | The request carries that value in its URL or a header |
| Smartlead | `signature` | The webhook's `whsec_` signing secret from Smartlead | A Standard Webhooks signature over the body, and a timestamp no more than five minutes off |

`events.verify` is the adapter's [capability](/docs/capabilities) that says which one applies:

```ts title="capabilities.ts"
import { createOutbound } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";
import { instantly } from "outbound-sdk/instantly";
import { smartlead } from "outbound-sdk/smartlead";

// Constructing an adapter makes no request.
const adapters = {
  heyreach: createOutbound({ adapter: heyreach() }).capabilities,
  instantly: createOutbound({ adapter: instantly() }).capabilities,
  smartlead: createOutbound({ adapter: smartlead() }).capabilities,
};

for (const [provider, capabilities] of Object.entries(adapters)) {
  console.log(provider.padEnd(10), capabilities["events.verify"]);
}
```

```text title="Output"
heyreach   secret
instantly  secret
smartlead  signature
```

> **No secret, no check**
>
> When the `secret` option is `undefined`, `parse` checks nothing and parses
> whatever it is given. An environment variable that is not set is `undefined`.
> Read the secret once at start-up and refuse to run without it, as the handler
> on the [overview](/docs/webhooks#a-route-handler) does.

Verification is tested on requests the test suite builds itself. No check has been run on a live delivery from a vendor. See [what is verified](/docs/status).

## A shared secret: Instantly and HeyReach

Neither vendor signs its payloads. You choose a long random value, put it where the vendor will send it back with every delivery, and pass the same value as `secret`. `parse` looks for it in:

- any segment of the URL path, for example `https://your.app/hooks/<secret>`
- the query parameters `secret`, `token` or `key`
- the headers `x-webhook-secret` or `x-outbound-secret`
- an `Authorization: Bearer <secret>` header

```ts title="shared-secret.ts"
import { OutboundError } from "outbound-sdk";
import { parse, verify } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

const SECRET = "example-shared-secret";
const body = payload("instantly", "webhook.reply_received.json");
const options = { provider: "instantly", secret: SECRET };

/** How many events `parse` returns for a request. */
const count = async (request: Request, secret?: string): Promise<number> => {
  const events = await parse(request, { provider: "instantly", secret });

  return events.length;
};

// Every place the secret is looked for.
const carrying = {
  "a path segment": post(body, `https://your.app/hooks/${SECRET}`),
  "?secret=": post(body, `https://your.app/hooks/outbound?secret=${SECRET}`),
  "?token=": post(body, `https://your.app/hooks/outbound?token=${SECRET}`),
  "?key=": post(body, `https://your.app/hooks/outbound?key=${SECRET}`),
  "x-webhook-secret": post(body, undefined, { "x-webhook-secret": SECRET }),
  "x-outbound-secret": post(body, undefined, { "x-outbound-secret": SECRET }),
  "Authorization: Bearer": post(body, undefined, {
    authorization: `Bearer ${SECRET}`,
  }),
};

for (const [where, request] of Object.entries(carrying)) {
  // oxlint-disable-next-line no-await-in-loop -- one request at a time keeps the output in order.
  console.log(where.padEnd(22), await count(request, SECRET), "event");
}

// `verify` answers true or false and leaves the request readable.
const good = post(body, `https://your.app/hooks/${SECRET}`);

console.log("\nverify, secret present:", await verify(good, options));

console.log("then parse the same request:", await count(good, SECRET));

console.log("verify, secret missing:", await verify(post(body), options));

// `parse` throws when the check fails.
for (const request of [
  post(body),
  post(body, "https://your.app/hooks/wrong-secret"),
  post(body, undefined, { "x-webhook-secret": `${SECRET}x` }),
]) {
  try {
    // oxlint-disable-next-line no-await-in-loop -- one request at a time keeps the output in order.
    await parse(request, options);
  } catch (error) {
    if (OutboundError.is(error)) {
      console.log(error.code, "-", error.message, `(${error.provider})`);
    }
  }
}

// No `secret` option: nothing is checked, and anyone's request is parsed.
console.log("\nno secret option:", await count(post(body)), "event");
```

```text title="Output"
a path segment         1 event
?secret=               1 event
?token=                1 event
?key=                  1 event
x-webhook-secret       1 event
x-outbound-secret      1 event
Authorization: Bearer  1 event

verify, secret present: true
then parse the same request: 1
verify, secret missing: false
Unauthorized - The webhook does not carry the shared secret (instantly)
Unauthorized - The webhook does not carry the shared secret (instantly)
Unauthorized - The webhook does not carry the shared secret (instantly)

no secret option: 1 event
```

A shared secret proves the sender knows the secret. It does not prove the body was not changed on the way, and a captured request can be sent again. Use HTTPS, and prefer a header to the URL where the vendor lets you set one, because URLs are commonly written to access logs.

## A signature: Smartlead

Smartlead can sign a webhook following the Standard Webhooks specification. Signing is off until you turn it on for the webhook in Smartlead. Pass the webhook's `whsec_` secret as `secret`. `parse` then requires:

- the headers `webhook-id`, `webhook-timestamp` and `webhook-signature`
- a timestamp within five minutes of now, in either direction
- a `v1` signature equal to the HMAC-SHA256 of `<id>.<timestamp>.<body>` under the secret. Several signatures may be sent, separated by spaces; one match is enough.

The sample signs Smartlead's documented payload itself, with a made-up secret, the way the specification describes.

```ts title="signature.ts"
import { createHmac } from "node:crypto";

import { OutboundError } from "outbound-sdk";
import { parse, verify } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

// A made-up signing secret in Smartlead's format: `whsec_` and base64 bytes.
const KEY = Buffer.from("example-signing-key-0001");
const SECRET = `whsec_${KEY.toString("base64")}`;
const body = payload("smartlead", "webhook.EMAIL_REPLY.json");
const options = { provider: "smartlead", secret: SECRET };

/** Signs a body the way the Standard Webhooks specification describes. */
const signed = (
  text: string,
  sentAt = Math.floor(Date.now() / 1000),
  id = "msg_example_0001"
): Request => {
  const signature = createHmac("sha256", KEY)
    .update(`${id}.${sentAt}.${text}`)
    .digest("base64");

  return post(text, undefined, {
    "webhook-id": id,
    "webhook-signature": `v1,${signature}`,
    "webhook-timestamp": String(sentAt),
  });
};

const [event] = await parse(signed(body), options);

console.log("signed:", event?.type, event?.id);

// The id comes from the `webhook-id` header, so a retry has the same one.
const [retry] = await parse(
  signed(body, Math.floor(Date.now() / 1000) + 30),
  options
);

console.log("same id on a retry:", event?.id === retry?.id);

const attempts = {
  "a body changed after signing": post(
    body.replace("EMAIL_REPLY", "EMAIL_SENT"),
    undefined,
    Object.fromEntries(signed(body).headers)
  ),
  "a timestamp six minutes old": signed(
    body,
    Math.floor(Date.now() / 1000) - 360
  ),
  "no signature headers": post(body),
  "the secret in the URL instead": post(
    body,
    `https://your.app/hooks/${SECRET}`
  ),
};

for (const [what, request] of Object.entries(attempts)) {
  // oxlint-disable-next-line no-await-in-loop -- one request at a time keeps the output in order.
  console.log(`${what}:`, await verify(request, options));
}

try {
  await parse(post(body), options);
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message, `(${error.provider})`);
  }
}
```

```text title="Output"
signed: reply.received f1ca8981474cedb588e3e190582dd2dd
same id on a retry: true
a body changed after signing: false
a timestamp six minutes old: false
no signature headers: false
the secret in the URL instead: false
Unauthorized - The webhook signature does not match (smartlead)
```

With `provider: "smartlead"` and a `secret`, an unsigned Smartlead webhook always fails, and so does a shared secret in the URL: only the signature is accepted. If you cannot turn signing on, there is no check for Smartlead through `parse` with the provider named.

The signature covers the exact bytes of the body, which `parse` reads from the `Request` itself. Pass it the original request, not one rebuilt from parsed JSON.

## When the check fails

`parse` throws an `OutboundError` with the code `Unauthorized` and the provider:

| Vendor check  | Message                                        |
| ------------- | ---------------------------------------------- |
| Shared secret | `The webhook does not carry the shared secret` |
| Signature     | `The webhook signature does not match`         |

Answer with a 401 and do not process the body. `Unauthorized` is the same code a rejected API key uses; see [errors](/docs/api/errors).

## verify

`verify(request, { provider, secret })` runs the same check and resolves to `true` or `false`. It does not throw for a failed check, and it reads a copy of the request, so you can still pass the request to `parse` afterwards. It does throw `Invalid` for a provider that has no parser. `secret` is required.

## With provider auto

With `provider: "auto"`, the kind of check follows the vendor detected from the body: a body that looks like Smartlead's is checked by signature, and any other body, including one no parser recognises, is checked for the shared secret. Naming the provider removes that dependence on the body's shape.

## carriesSecret and timingSafeEqual

The two building blocks are exported for use outside `parse`, for example on an endpoint that receives something the SDK does not parse.

```ts title="helpers.ts"
import { carriesSecret, timingSafeEqual } from "outbound-sdk/webhooks";

const SECRET = "example-shared-secret";

// The check `parse` runs for a vendor that does not sign, on its own.
console.log(
  carriesSecret(`https://your.app/hooks/${SECRET}`, new Headers(), SECRET),
  carriesSecret(
    "https://your.app/hooks/outbound",
    new Headers({ "x-webhook-secret": SECRET }),
    SECRET
  ),
  carriesSecret("https://your.app/hooks/outbound", new Headers(), SECRET)
);

// A comparison that takes the same time whether or not the strings match.
console.log(
  timingSafeEqual("abc", "abc"),
  timingSafeEqual("abc", "abd"),
  timingSafeEqual("abc", "abcd")
);
```

```text title="Output"
true true false
true false false
```

- `carriesSecret(url, headers, secret)` is the shared-secret check: `true` when any of the places listed above holds the secret.
- `timingSafeEqual(left, right)` compares two strings without stopping at the first difference, so the time it takes does not reveal how much of a guess was right. Use it for any secret comparison of your own.
