feat: add webhooks (#334)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@usesend/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.lint.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@usesend/lib",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.2",
|
||||
"@usesend/eslint-config": "workspace:*",
|
||||
"@usesend/typescript-config": "workspace:*",
|
||||
"eslint": "^8.57.1",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export const DELIVERY_DELAY_ERRORS = {
|
||||
InternalFailure: "An internal useSend issue caused the message to be delayed.",
|
||||
General: "A generic failure occurred during the SMTP conversation.",
|
||||
MailboxFull:
|
||||
"The recipient's mailbox is full and is unable to receive additional messages.",
|
||||
SpamDetected:
|
||||
"The recipient's mail server has detected a large amount of unsolicited email from your account.",
|
||||
RecipientServerError:
|
||||
"A temporary issue with the recipient's email server is preventing the delivery of the message.",
|
||||
IPFailure:
|
||||
"The IP address that's sending the message is being blocked or throttled by the recipient's email provider.",
|
||||
TransientCommunicationFailure:
|
||||
"There was a temporary communication failure during the SMTP conversation with the recipient's email provider.",
|
||||
BYOIPHostNameLookupUnavailable:
|
||||
"useSend was unable to look up the DNS hostname for your IP addresses. This type of delay only occurs when you use Bring Your Own IP.",
|
||||
Undetermined:
|
||||
"useSend wasn't able to determine the reason for the delivery delay.",
|
||||
SendingDeferral:
|
||||
"useSend has deemed it appropriate to internally defer the message.",
|
||||
};
|
||||
|
||||
export const BOUNCE_ERROR_MESSAGES = {
|
||||
Undetermined: "useSend was unable to determine a specific bounce reason.",
|
||||
Permanent: {
|
||||
General:
|
||||
"useSend received a general hard bounce. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
NoEmail:
|
||||
"useSend received a permanent hard bounce because the target email address does not exist. If you receive this type of bounce, you should remove the recipient's email address from your mailing list.",
|
||||
Suppressed:
|
||||
"useSend has suppressed sending to this address because it has a recent history of bouncing as an invalid address. To override the global suppression list, see Using the useSend account-level suppression list.",
|
||||
OnAccountSuppressionList:
|
||||
"useSend has suppressed sending to this address because it is on the account-level suppression list. This does not count toward your bounce rate metric.",
|
||||
},
|
||||
Transient: {
|
||||
General:
|
||||
"useSend received a general bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MailboxFull:
|
||||
"useSend received a mailbox full bounce. You may be able to successfully send to this recipient in the future.",
|
||||
MessageTooLarge:
|
||||
"useSend received a message too large bounce. You may be able to successfully send to this recipient if you reduce the size of the message.",
|
||||
ContentRejected:
|
||||
"useSend received a content rejected bounce. You may be able to successfully send to this recipient if you change the content of the message.",
|
||||
AttachmentRejected:
|
||||
"useSend received an attachment rejected bounce. You may be able to successfully send to this recipient if you remove or change the attachment.",
|
||||
},
|
||||
};
|
||||
|
||||
export const COMPLAINT_ERROR_MESSAGES = {
|
||||
abuse: "Indicates unsolicited email or some other kind of email abuse.",
|
||||
"auth-failure": "Email authentication failure report.",
|
||||
fraud: "Indicates some kind of fraud or phishing activity.",
|
||||
"not-spam":
|
||||
"Indicates that the entity providing the report does not consider the message to be spam. This may be used to correct a message that was incorrectly tagged or categorized as spam.",
|
||||
other:
|
||||
"Indicates any other feedback that does not fit into other registered types.",
|
||||
virus: "Reports that a virus is found in the originating message.",
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export function invariant(
|
||||
condition: unknown,
|
||||
message = "Invariant failed"
|
||||
): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUnreachable(value: never): never {
|
||||
throw new Error(`Reached unreachable code with value: ${String(value)}`);
|
||||
}
|
||||
|
||||
export const isDefined = <T>(
|
||||
value: T | null | undefined
|
||||
): value is T => value !== null && value !== undefined;
|
||||
|
||||
export {
|
||||
BOUNCE_ERROR_MESSAGES,
|
||||
COMPLAINT_ERROR_MESSAGES,
|
||||
DELIVERY_DELAY_ERRORS,
|
||||
} from "./constants/ses-errors";
|
||||
@@ -0,0 +1,214 @@
|
||||
export const ContactEvents = [
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"contact.deleted",
|
||||
] as const;
|
||||
|
||||
export type ContactWebhookEventType = (typeof ContactEvents)[number];
|
||||
|
||||
export const DomainEvents = [
|
||||
"domain.created",
|
||||
"domain.verified",
|
||||
"domain.updated",
|
||||
"domain.deleted",
|
||||
] as const;
|
||||
|
||||
export type DomainWebhookEventType = (typeof DomainEvents)[number];
|
||||
|
||||
export const EmailEvents = [
|
||||
"email.queued",
|
||||
"email.sent",
|
||||
"email.delivery_delayed",
|
||||
"email.delivered",
|
||||
"email.bounced",
|
||||
"email.rejected",
|
||||
"email.rendering_failure",
|
||||
"email.complained",
|
||||
"email.failed",
|
||||
"email.cancelled",
|
||||
"email.suppressed",
|
||||
"email.opened",
|
||||
"email.clicked",
|
||||
] as const;
|
||||
|
||||
export type EmailWebhookEventType = (typeof EmailEvents)[number];
|
||||
|
||||
export const WebhookTestEvents = ["webhook.test"] as const;
|
||||
|
||||
export type WebhookTestEventType = (typeof WebhookTestEvents)[number];
|
||||
|
||||
export const WebhookEvents = [
|
||||
...ContactEvents,
|
||||
...DomainEvents,
|
||||
...EmailEvents,
|
||||
...WebhookTestEvents,
|
||||
] as const;
|
||||
|
||||
export type WebhookEventType = (typeof WebhookEvents)[number];
|
||||
|
||||
export type EmailStatus =
|
||||
| "QUEUED"
|
||||
| "SENT"
|
||||
| "DELIVERY_DELAYED"
|
||||
| "DELIVERED"
|
||||
| "BOUNCED"
|
||||
| "REJECTED"
|
||||
| "RENDERING_FAILURE"
|
||||
| "COMPLAINED"
|
||||
| "FAILED"
|
||||
| "CANCELLED"
|
||||
| "SUPPRESSED"
|
||||
| "OPENED"
|
||||
| "CLICKED"
|
||||
| "SCHEDULED";
|
||||
|
||||
export type EmailBasePayload = {
|
||||
id: string;
|
||||
status: EmailStatus;
|
||||
from: string;
|
||||
to: Array<string>;
|
||||
occurredAt: string;
|
||||
campaignId?: string | null;
|
||||
contactId?: string | null;
|
||||
domainId?: number | null;
|
||||
subject?: string;
|
||||
templateId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ContactPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
contactBookId: string;
|
||||
subscribed: boolean;
|
||||
properties: Record<string, unknown>;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DomainPayload = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
region: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
clickTracking: boolean;
|
||||
openTracking: boolean;
|
||||
subdomain?: string | null;
|
||||
sesTenantId?: string | null;
|
||||
dkimStatus?: string | null;
|
||||
spfDetails?: string | null;
|
||||
dmarcAdded?: boolean | null;
|
||||
};
|
||||
|
||||
export type EmailBouncedPayload = EmailBasePayload & {
|
||||
bounce: {
|
||||
type: "Transient" | "Permanent" | "Undetermined";
|
||||
subType:
|
||||
| "General"
|
||||
| "NoEmail"
|
||||
| "Suppressed"
|
||||
| "OnAccountSuppressionList"
|
||||
| "MailboxFull"
|
||||
| "MessageTooLarge"
|
||||
| "ContentRejected"
|
||||
| "AttachmentRejected";
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmailFailedPayload = EmailBasePayload & {
|
||||
failed: {
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmailSuppressedPayload = EmailBasePayload & {
|
||||
suppression: {
|
||||
type: "Bounce" | "Complaint" | "Manual";
|
||||
reason: string;
|
||||
source?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmailOpenedPayload = EmailBasePayload & {
|
||||
open: {
|
||||
timestamp: string;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
platform?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type EmailClickedPayload = EmailBasePayload & {
|
||||
click: {
|
||||
timestamp: string;
|
||||
url: string;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
platform?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebhookTestPayload = {
|
||||
test: boolean;
|
||||
webhookId: string;
|
||||
sentAt: string;
|
||||
};
|
||||
|
||||
export type EmailEventPayloadMap = {
|
||||
"email.queued": EmailBasePayload;
|
||||
"email.sent": EmailBasePayload;
|
||||
"email.delivery_delayed": EmailBasePayload;
|
||||
"email.delivered": EmailBasePayload;
|
||||
"email.bounced": EmailBouncedPayload;
|
||||
"email.rejected": EmailBasePayload;
|
||||
"email.rendering_failure": EmailBasePayload;
|
||||
"email.complained": EmailBasePayload;
|
||||
"email.failed": EmailFailedPayload;
|
||||
"email.cancelled": EmailBasePayload;
|
||||
"email.suppressed": EmailSuppressedPayload;
|
||||
"email.opened": EmailOpenedPayload;
|
||||
"email.clicked": EmailClickedPayload;
|
||||
};
|
||||
|
||||
export type DomainEventPayloadMap = {
|
||||
"domain.created": DomainPayload;
|
||||
"domain.verified": DomainPayload;
|
||||
"domain.updated": DomainPayload;
|
||||
"domain.deleted": DomainPayload;
|
||||
};
|
||||
|
||||
export type ContactEventPayloadMap = {
|
||||
"contact.created": ContactPayload;
|
||||
"contact.updated": ContactPayload;
|
||||
"contact.deleted": ContactPayload;
|
||||
};
|
||||
|
||||
export type WebhookTestEventPayloadMap = {
|
||||
"webhook.test": WebhookTestPayload;
|
||||
};
|
||||
|
||||
export type WebhookEventPayloadMap = EmailEventPayloadMap &
|
||||
DomainEventPayloadMap &
|
||||
ContactEventPayloadMap &
|
||||
WebhookTestEventPayloadMap;
|
||||
|
||||
export type WebhookPayloadData<TType extends WebhookEventType> =
|
||||
WebhookEventPayloadMap[TType];
|
||||
|
||||
export type WebhookEvent<TType extends WebhookEventType> = {
|
||||
id: string;
|
||||
type: TType;
|
||||
createdAt: string;
|
||||
data: WebhookPayloadData<TType>;
|
||||
};
|
||||
|
||||
export type WebhookEventData = {
|
||||
[T in WebhookEventType]: WebhookEvent<T>;
|
||||
}[WebhookEventType];
|
||||
|
||||
export const WEBHOOK_EVENT_VERSION = "2026-01-18";
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@usesend/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@usesend/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "turbo", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -29,7 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -85,7 +85,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -99,18 +99,18 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
{children}
|
||||
<span className="ml-auto flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
@@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -149,7 +149,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -145,21 +145,24 @@
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
/* Optional, if you also want font styles */
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbars but preserve scroll behavior */
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* .app,
|
||||
|
||||
Reference in New Issue
Block a user