feat: add webhooks (#334)

This commit is contained in:
KM Koushik
2026-01-18 20:50:54 +11:00
committed by GitHub
parent f40a311cc9
commit 8676965019
58 changed files with 5334 additions and 245 deletions
+83
View File
@@ -115,3 +115,86 @@ await usesend.campaigns.pause(campaign.data.id);
// Resume a campaign
await usesend.campaigns.resume(campaign.data.id);
```
## Webhooks
Verify webhook signatures and get typed events:
```ts
import { UseSend } from "usesend";
const usesend = new UseSend("us_12345");
const webhooks = usesend.webhooks(process.env.USESEND_WEBHOOK_SECRET!);
// In a Next.js App Route
export async function POST(request: Request) {
try {
const rawBody = await request.text(); // important: raw body, not parsed JSON
const event = webhooks.constructEvent(rawBody, {
headers: request.headers,
});
if (event.type === "email.delivered") {
// event.data is strongly typed here
}
return new Response("ok");
} catch (error) {
return new Response((error as Error).message, { status: 400 });
}
}
```
You can also use the `Webhooks` class directly:
```ts
import { Webhooks } from "usesend";
const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!);
const event = webhooks.constructEvent(rawBody, { headers: request.headers });
```
Need only signature verification? Use the `verify` method:
```ts
const isValid = webhooks.verify(rawBody, { headers: request.headers });
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
```
Express example (ensure raw body is preserved):
```ts
import express from "express";
import { Webhooks } from "usesend";
const webhooks = new Webhooks(process.env.USESEND_WEBHOOK_SECRET!);
const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = webhooks.constructEvent(req.body, {
headers: req.headers,
});
if (event.type === "email.bounced") {
// handle bounce
}
res.status(200).send("ok");
} catch (error) {
res.status(400).send((error as Error).message);
}
});
```
Headers sent by UseSend:
- `X-UseSend-Signature`: `v1=` + HMAC-SHA256 of `${timestamp}.${rawBody}`
- `X-UseSend-Timestamp`: Unix epoch in milliseconds
- `X-UseSend-Event`: webhook event type
- `X-UseSend-Call`: unique webhook attempt id
By default, signatures are only accepted within 5 minutes of the timestamp. Override with `toleranceMs` if needed.
+14
View File
@@ -1,3 +1,17 @@
export { UseSend } from "./src/usesend";
export { UseSend as Unsend } from "./src/usesend"; // deprecated alias
export { Campaigns } from "./src/campaign";
export {
Webhooks,
WebhookVerificationError,
WEBHOOK_EVENT_HEADER,
WEBHOOK_CALL_HEADER,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
} from "./src/webhooks";
export type {
WebhookEvent,
WebhookEventData,
WebhookEventPayloadMap,
WebhookEventType,
} from "./src/webhooks";
+2 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
"build": "rm -rf dist && tsup index.ts --format esm,cjs --dts --noExternal @usesend/lib",
"publish-sdk": "pnpm run build && pnpm publish --no-git-checks",
"openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts"
},
@@ -19,6 +19,7 @@
"@types/node": "^22.15.2",
"@types/react": "^19.1.2",
"@usesend/eslint-config": "workspace:*",
"@usesend/lib": "workspace:*",
"@usesend/typescript-config": "workspace:*",
"openapi-typescript": "^7.6.1",
"tsup": "^8.4.0",
+23 -1
View File
@@ -3,6 +3,7 @@ import { Contacts } from "./contact";
import { Emails } from "./email";
import { Domains } from "./domain";
import { Campaigns } from "./campaign";
import { Webhooks } from "./webhooks";
const defaultBaseUrl = "https://app.usesend.com";
// eslint-disable-next-line turbo/no-undeclared-env-vars
@@ -19,7 +20,6 @@ type RequestOptions = {
export class UseSend {
private readonly baseHeaders: Headers;
// readonly domains = new Domains(this);
readonly emails = new Emails(this);
readonly domains = new Domains(this);
readonly contacts = new Contacts(this);
@@ -171,4 +171,26 @@ export class UseSend {
return this.fetchRequest<T>(path, requestOptions);
}
/**
* Creates a webhook handler with the given secret.
* Follows the Stripe pattern: `usesend.webhooks(secret).constructEvent(...)`
*
* @param secret - Webhook signing secret from your UseSend dashboard
* @returns Webhooks instance for verifying webhook events
*
* @example
* ```ts
* const usesend = new UseSend('us_xxx');
* const webhooks = usesend.webhooks('whsec_xxx');
*
* // In your webhook route
* const event = webhooks.constructEvent(req.body, {
* headers: req.headers
* });
* ```
*/
webhooks(secret: string): Webhooks {
return new Webhooks(secret);
}
}
+279
View File
@@ -0,0 +1,279 @@
import { createHmac, timingSafeEqual } from "crypto";
import type {
WebhookEvent,
WebhookEventData,
WebhookEventPayloadMap,
WebhookEventType,
} from "@usesend/lib/src/webhook/webhook-events";
type RawBody = string | Buffer | ArrayBuffer | ArrayBufferView | Uint8Array;
type HeaderLike =
| Headers
| Record<string, string | string[] | undefined>
| undefined
| null;
export type WebhookVerificationErrorCode =
| "MISSING_SIGNATURE"
| "MISSING_TIMESTAMP"
| "INVALID_SIGNATURE_FORMAT"
| "INVALID_TIMESTAMP"
| "TIMESTAMP_OUT_OF_RANGE"
| "SIGNATURE_MISMATCH"
| "INVALID_BODY"
| "INVALID_JSON";
export class WebhookVerificationError extends Error {
constructor(
public readonly code: WebhookVerificationErrorCode,
message: string,
) {
super(message);
this.name = "WebhookVerificationError";
}
}
export const WEBHOOK_SIGNATURE_HEADER = "X-UseSend-Signature";
export const WEBHOOK_TIMESTAMP_HEADER = "X-UseSend-Timestamp";
export const WEBHOOK_EVENT_HEADER = "X-UseSend-Event";
export const WEBHOOK_CALL_HEADER = "X-UseSend-Call";
const SIGNATURE_PREFIX = "v1=";
const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
export class Webhooks {
constructor(private secret: string) {}
/**
* Verify webhook signature without parsing the event.
*
* @param body - Raw webhook body (string or Buffer)
* @param options - Headers and optional configuration
* @returns true if signature is valid, false otherwise
*
* @example
* ```ts
* const usesend = new UseSend(apiKey);
* const webhooks = usesend.webhooks('whsec_xxx');
*
* const isValid = webhooks.verify(body, {
* headers: request.headers
* });
*
* if (!isValid) {
* return new Response('Invalid signature', { status: 401 });
* }
* ```
*/
verify(
body: RawBody,
options: {
headers: HeaderLike;
secret?: string;
tolerance?: number;
},
): boolean {
try {
this.verifyInternal(body, options);
return true;
} catch {
return false;
}
}
/**
* Verify and parse a webhook event.
*
* @param body - Raw webhook body (string or Buffer)
* @param options - Headers and optional configuration
* @returns Verified and typed webhook event
*
* @example
* ```ts
* const usesend = new UseSend(apiKey);
* const webhooks = usesend.webhooks('whsec_xxx');
*
* // Next.js App Router
* const event = webhooks.constructEvent(await request.text(), {
* headers: request.headers
* });
*
* // Next.js Pages Router
* const event = webhooks.constructEvent(req.body, {
* headers: req.headers
* });
*
* // Express
* const event = webhooks.constructEvent(req.body, {
* headers: req.headers
* });
*
* // Type-safe event handling
* if (event.type === 'email.delivered') {
* console.log(event.data.to);
* }
* ```
*/
constructEvent(
body: RawBody,
options: {
headers: HeaderLike;
secret?: string;
tolerance?: number;
},
): WebhookEventData {
this.verifyInternal(body, options);
const bodyString = toUtf8String(body);
try {
return JSON.parse(bodyString) as WebhookEventData;
} catch {
throw new WebhookVerificationError(
"INVALID_JSON",
"Webhook payload is not valid JSON",
);
}
}
private verifyInternal(
body: RawBody,
options: {
headers: HeaderLike;
secret?: string;
tolerance?: number;
},
): void {
const webhookSecret = options.secret ?? this.secret;
const signature = getHeader(options.headers, WEBHOOK_SIGNATURE_HEADER);
const timestamp = getHeader(options.headers, WEBHOOK_TIMESTAMP_HEADER);
if (!signature) {
throw new WebhookVerificationError(
"MISSING_SIGNATURE",
`Missing ${WEBHOOK_SIGNATURE_HEADER} header`,
);
}
if (!timestamp) {
throw new WebhookVerificationError(
"MISSING_TIMESTAMP",
`Missing ${WEBHOOK_TIMESTAMP_HEADER} header`,
);
}
if (!signature.startsWith(SIGNATURE_PREFIX)) {
throw new WebhookVerificationError(
"INVALID_SIGNATURE_FORMAT",
"Signature header must start with v1=",
);
}
const timestampNum = Number(timestamp);
if (!Number.isFinite(timestampNum)) {
throw new WebhookVerificationError(
"INVALID_TIMESTAMP",
"Timestamp header must be a number (milliseconds since epoch)",
);
}
const toleranceMs = options.tolerance ?? DEFAULT_TOLERANCE_MS;
const now = Date.now();
if (toleranceMs >= 0 && Math.abs(now - timestampNum) > toleranceMs) {
throw new WebhookVerificationError(
"TIMESTAMP_OUT_OF_RANGE",
"Webhook timestamp is outside the allowed tolerance",
);
}
const bodyString = toUtf8String(body);
const expected = computeSignature(webhookSecret, timestamp, bodyString);
if (!safeEqual(expected, signature)) {
throw new WebhookVerificationError(
"SIGNATURE_MISMATCH",
"Webhook signature does not match",
);
}
}
}
function computeSignature(secret: string, timestamp: string, body: string) {
const hmac = createHmac("sha256", secret);
hmac.update(`${timestamp}.${body}`);
return `${SIGNATURE_PREFIX}${hmac.digest("hex")}`;
}
function toUtf8String(body: RawBody): string {
if (typeof body === "string") {
return body;
}
if (Buffer.isBuffer(body)) {
return body.toString("utf8");
}
if (body instanceof ArrayBuffer) {
return Buffer.from(body).toString("utf8");
}
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString(
"utf8",
);
}
throw new WebhookVerificationError(
"INVALID_BODY",
"Unsupported raw body type",
);
}
function getHeader(headers: HeaderLike, name: string): string | null {
if (!headers) {
return null;
}
if (typeof (headers as Headers).get === "function") {
const headerValue = (headers as Headers).get(name);
if (headerValue !== null) {
return headerValue;
}
}
const lowerName = name.toLowerCase();
const record = headers as Record<string, string | string[] | undefined>;
const matchingKey = Object.keys(record).find(
(key) => key.toLowerCase() === lowerName,
);
if (!matchingKey) {
return null;
}
const value = record[matchingKey];
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}
function safeEqual(a: string, b: string) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) {
return false;
}
return timingSafeEqual(aBuf, bBuf);
}
export type {
WebhookEvent,
WebhookEventData,
WebhookEventPayloadMap,
WebhookEventType,
};