Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,64 @@ const auth = client.authenticateChannel(
);
```

## Webhooks

Each webhook endpoint you create in the console has its own signing secret
(`whsec_...`). Verify deliveries with that secret, not the app's API secret, and
pass the raw request body exactly as received.

```ts
import express from "express";
import { Apinator } from "@apinator/server";

const client = new Apinator({ /* appId, key, secret, cluster */ });
const app = express();
const processed = new Set<string>(); // use a persistent store in production

app.post("/webhooks/apinator", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");

if (!client.verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, req.headers, body, 300)) {
return res.status(401).send("Invalid signature");
}

const event = JSON.parse(body);
// {"id":"<event uuid>","event":"member_added","app_id":"...","channel":"presence-room",
// "user_id":"42","time_ms":1760000000000}
// user_id is only present on member_added / member_removed.

// Deliveries are at-least-once: dedupe on event.id.
if (processed.has(event.id)) return res.sendStatus(200);
processed.add(event.id);

switch (event.event) {
case "channel_occupied":
case "channel_vacated":
case "member_added":
case "member_removed":
// handle event
break;
}
res.sendStatus(200);
});
```

Headers sent with each delivery: `X-Realtime-Signature` (`sha256=<hex>`),
`X-Realtime-Timestamp` (unix seconds), `X-Realtime-Event` and
`X-Realtime-Delivery-ID`. The console's "send test" button sends event
`webhook.test`. With `maxAge` set, timestamps older than `maxAge` seconds or more
than 300 seconds in the future are rejected.

The standalone `verifyWebhook(webhookSecret, headers, body, maxAge?)` export does
the same without a client.

## API

- `trigger({ name, channel?, channels?, data, socketId? }): Promise<void>`
- `authenticateChannel(socketId, channelName, channelData?): { auth, channel_data? }`
- `getChannels(prefix?): Promise<ChannelInfo[]>`
- `getChannel(channelName): Promise<ChannelInfo>`
- `verifyWebhook(headers, body, maxAge?): boolean`
- `verifyWebhook(webhookSecret, headers, body, maxAge?): boolean`

See:
- [API Reference](docs/api-reference.md)
Expand Down
38 changes: 33 additions & 5 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,44 @@ List channels, optionally filtered by prefix.

Get channel details.

#### `verifyWebhook(headers, body, maxAge?): boolean`
#### `verifyWebhook(webhookSecret, headers, body, maxAge?): boolean`

Verify webhook signature.
Verify a webhook delivery's signature.

Accepted header shape:
- `webhookSecret: string` — the webhook endpoint's signing secret (`whsec_...`,
shown in the console per endpoint). Not the app's API secret. Throws
`ValidationError` if empty.
- `headers` — request headers (case-insensitive; array values use the first entry):

```ts
Record<string, string | string[] | undefined>
```ts
Record<string, string | string[] | undefined>
```

- `body: string` — raw request body, exactly as received.
- `maxAge?: number` — recommended `300`. When set, timestamps older than `maxAge`
seconds or more than 300 seconds in the future are rejected.

The signature is `X-Realtime-Signature: sha256=hex(hmac_sha256(webhookSecret, "{timestamp}.{raw_body}"))`
with the timestamp from `X-Realtime-Timestamp`. Deliveries also carry
`X-Realtime-Event` and `X-Realtime-Delivery-ID`.

Payload:

```json
{"id":"<event uuid>","event":"member_added","app_id":"...","channel":"presence-room","user_id":"42","time_ms":1760000000000}
```

`event` is one of `channel_occupied`, `channel_vacated`, `member_added`,
`member_removed` (`user_id` only on the member events); the console's test sends
`webhook.test`. Deliveries are at-least-once, so dedupe on `id`.

## Standalone functions

#### `verifyWebhook(secret, headers, body, maxAge?): boolean`

Same check as the client method, without a client. `secret` is the webhook
signing secret.

## Errors

- `RealtimeError`
Expand Down
11 changes: 11 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,21 @@ const auth = client.authenticateChannel(

## 5. Verify webhooks

Use the webhook endpoint's signing secret (`whsec_...`, from the console), not the
app's API secret, and the raw request body:

```ts
const isValid = client.verifyWebhook(
process.env.APINATOR_WEBHOOK_SECRET!,
req.headers as Record<string, string | string[] | undefined>,
rawBody,
300
);
if (!isValid) {
return res.status(401).send("Invalid signature");
}

const event = JSON.parse(rawBody);
// {"id":"...","event":"member_added","app_id":"...","channel":"presence-room","user_id":"42","time_ms":1760000000000}
// Deliveries are at-least-once: ignore an event.id you have already processed.
```
23 changes: 17 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,34 +237,45 @@ export class Apinator {
/**
* Verify the authenticity of a webhook request.
*
* This function verifies that the webhook was sent by the Realtime service
* by validating the HMAC signature in the request headers.
* Webhooks are signed with the webhook endpoint's own signing secret
* (`whsec_...`, shown in the console for each webhook endpoint), not with the
* app's API secret, so it must be passed explicitly.
*
* @param webhookSecret - The webhook endpoint's signing secret (`whsec_...`)
* @param headers - HTTP headers from the webhook request
* @param body - Raw request body as string
* @param body - Raw request body as string, exactly as received
* @param maxAge - Optional maximum age in seconds for the webhook timestamp
* (300 recommended). When set, timestamps older than `maxAge` or more than
* 300 seconds in the future are rejected.
* @returns true if the webhook is valid, false otherwise
* @throws {ValidationError} If `webhookSecret` is not a non-empty string
*
* @example Express.js webhook handler
* ```ts
* app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
* const body = req.body.toString('utf8');
*
* if (!client.verifyWebhook(req.headers, body, 300)) {
* if (!client.verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, req.headers, body, 300)) {
* return res.status(401).send('Invalid signature');
* }
*
* const event = JSON.parse(body);
* // Process webhook...
* // Deliveries are at-least-once: skip event.id if already processed.
* });
* ```
*/
verifyWebhook(
webhookSecret: string,
headers: Record<string, string | string[] | undefined>,
body: string,
maxAge?: number
): boolean {
return verifyWebhookSignature(this.secret, headers, body, maxAge);
if (typeof webhookSecret !== "string" || webhookSecret === "") {
throw new ValidationError(
"verifyWebhook requires the webhook signing secret (whsec_...) as its first argument"
);
}
return verifyWebhookSignature(webhookSecret, headers, body, maxAge);
}

/**
Expand Down
29 changes: 21 additions & 8 deletions src/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { timingSafeEqual } from "crypto";
import { signWebhookPayload } from "./crypto";

/**
* How far in the future (in seconds) a webhook timestamp may be before it is
* rejected, when a `maxAge` is given. Allows for clock skew between the
* Apinator servers and the receiver.
*/
export const WEBHOOK_MAX_FUTURE_SKEW_SECONDS = 300;

/**
* Verify the authenticity of a webhook request.
*
* This function verifies that the webhook was sent by the Realtime service
* by validating the HMAC signature in the request headers.
* This function verifies that the webhook was sent by Apinator by validating
* the `X-Realtime-Signature` header:
* `sha256=hex(hmac_sha256(secret, "{timestamp}.{raw_body}"))`.
*
* @param secret - Webhook secret
* @param headers - HTTP headers from the webhook request (case-insensitive keys)
* @param body - Raw request body as string
* @param secret - The webhook endpoint's signing secret (`whsec_...`, shown in
* the console for each webhook endpoint). This is NOT the app's API secret.
* @param headers - HTTP headers from the webhook request (case-insensitive keys;
* array values use the first entry)
* @param body - Raw request body as string, exactly as received
* @param maxAge - Optional maximum age in seconds for the webhook timestamp
* (300 recommended). When set, timestamps older than `maxAge` or more than
* 300 seconds in the future are rejected.
* @returns true if the webhook is valid, false otherwise
*
* @example
Expand All @@ -20,7 +32,7 @@ import { signWebhookPayload } from "./crypto";
* const headers = req.headers;
* const body = req.body.toString('utf8');
*
* if (!verifyWebhook('my-webhook-secret', headers, body, 300)) {
* if (!verifyWebhook(process.env.APINATOR_WEBHOOK_SECRET!, headers, body, 300)) {
* return res.status(401).send('Invalid signature');
* }
*
Expand Down Expand Up @@ -56,7 +68,8 @@ export function verifyWebhook(
return false;
}

// If maxAge provided, check timestamp is within maxAge of current time
// If maxAge provided, check timestamp is within maxAge in the past and
// within the allowed clock skew in the future
if (maxAge !== undefined) {
const webhookTime = parseInt(timestamp, 10);
if (isNaN(webhookTime)) {
Expand All @@ -66,7 +79,7 @@ export function verifyWebhook(
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - webhookTime;

if (age > maxAge || age < 0) {
if (age > maxAge || -age > WEBHOOK_MAX_FUTURE_SKEW_SECONDS) {
return false;
}
}
Expand Down
86 changes: 55 additions & 31 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,67 +351,91 @@ describe("Apinator client", () => {
});

describe("verifyWebhook", () => {
it("should verify valid webhook signature", () => {
const client = new Apinator(options);

// Create a valid signature
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = '{"event":"channel_occupied","channel":"test"}';
const webhookSecret =
"whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const body =
'{"id":"cbb368a1-bc93-42bc-b939-51c7352cbd03","event":"member_added","app_id":"test-app-id","channel":"presence-room","user_id":"42","time_ms":1760000000000}';

// We need to compute the signature using the same secret
function signedHeaders(secret: string, timestamp: string) {
const crypto = require("crypto");
const input = `${timestamp}.${body}`;
const signature = crypto
.createHmac("sha256", options.secret)
.update(input, "utf8")
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`, "utf8")
.digest("hex");

const headers = {
return {
"x-realtime-signature": `sha256=${signature}`,
"x-realtime-timestamp": timestamp,
};
}

it("should verify a webhook signed with the webhook secret", () => {
const client = new Apinator(options);
const timestamp = Math.floor(Date.now() / 1000).toString();

const result = client.verifyWebhook(headers, body);
const result = client.verifyWebhook(
webhookSecret,
signedHeaders(webhookSecret, timestamp),
body,
300
);
expect(result).toBe(true);
});

it("should not use the client's API secret", () => {
const client = new Apinator(options);
const timestamp = Math.floor(Date.now() / 1000).toString();

// Signed with the API secret: must not verify against the webhook secret.
const result = client.verifyWebhook(
webhookSecret,
signedHeaders(options.secret, timestamp),
body
);
expect(result).toBe(false);
});

it("should reject invalid webhook signature", () => {
const client = new Apinator(options);

const timestamp = Math.floor(Date.now() / 1000).toString();
const body = '{"event":"channel_occupied","channel":"test"}';

const headers = {
"x-realtime-signature": "sha256=invalid",
"x-realtime-timestamp": timestamp,
};

const result = client.verifyWebhook(headers, body);
const result = client.verifyWebhook(webhookSecret, headers, body);
expect(result).toBe(false);
});

it("should respect maxAge parameter", () => {
const client = new Apinator(options);

// Create timestamp 400 seconds ago
const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString();
const body = '{"event":"channel_occupied","channel":"test"}';
const headers = signedHeaders(webhookSecret, oldTimestamp);

const crypto = require("crypto");
const input = `${oldTimestamp}.${body}`;
const signature = crypto
.createHmac("sha256", options.secret)
.update(input, "utf8")
.digest("hex");
expect(client.verifyWebhook(webhookSecret, headers, body)).toBe(true);
expect(client.verifyWebhook(webhookSecret, headers, body, 300)).toBe(false);
});

const headers = {
"x-realtime-signature": `sha256=${signature}`,
"x-realtime-timestamp": oldTimestamp,
};
it("should reject a timestamp more than 300s in the future", () => {
const client = new Apinator(options);
const futureTimestamp = (Math.floor(Date.now() / 1000) + 600).toString();
const headers = signedHeaders(webhookSecret, futureTimestamp);

// Should fail with maxAge of 300 seconds
const result = client.verifyWebhook(headers, body, 300);
expect(result).toBe(false);
expect(client.verifyWebhook(webhookSecret, headers, body, 300)).toBe(false);
});

it("should throw when the webhook secret is missing", () => {
const client = new Apinator(options);
const timestamp = Math.floor(Date.now() / 1000).toString();
const headers = signedHeaders(webhookSecret, timestamp);

expect(() => client.verifyWebhook("", headers, body)).toThrow(ValidationError);
// Old call shape (headers first) from untyped JavaScript.
const legacyCall = client.verifyWebhook as unknown as (
...args: unknown[]
) => boolean;
expect(() => legacyCall.call(client, headers, body, 300)).toThrow(ValidationError);
});
});
});
Loading
Loading