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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,38 @@ pipelines.batch([

Each item is enveloped (auto-filled `messageId`, `timestamp`, `context.library`) before sending.

## Webhooks

### verifyRequestSignature(options)

Verify that an incoming [reporting webhook](https://docs.customer.io/api/webhooks/) request genuinely came from Customer.io. Customer.io signs each request with an HMAC-SHA256 over `v0:<timestamp>:<body>` and sends the hex-encoded result in the `X-CIO-Signature` header, along with the `X-CIO-Timestamp` header. This helper recomputes the signature from your signing secret and compares it in constant time.

```javascript
const { verifyRequestSignature } = require("customerio-node");

// e.g. inside an Express handler using a raw-body parser
const valid = verifyRequestSignature({
signingSecret: process.env.CIO_WEBHOOK_SECRET,
timestamp: req.headers["x-cio-timestamp"],
signature: req.headers["x-cio-signature"],
body: req.rawBody, // the raw, unparsed request body
});

if (!valid) {
res.status(400).send("invalid signature");
return;
}
```

#### Options

- **signingSecret**: Your webhook signing secret, from the webhook's settings page in Customer.io. Required; throws `MissingParamError` if empty.
- **timestamp**: The `X-CIO-Timestamp` header value.
- **signature**: The `X-CIO-Signature` header value.
- **body**: The raw request body as a `string` or `Buffer`. Use the exact bytes received — do not re-serialize with `JSON.stringify`, as formatting differences will change the hash.

Returns `true` when the signature is valid and `false` otherwise (including for a missing or malformed signature).

## Further examples

We've included functional examples in the [examples/ directory](https://github.com/customerio/customerio-node/tree/main/examples) of the repo to further assist in demonstrating how to use this library to integrate with Customer.io
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './lib/api';
export * from './lib/pipelines';
export * from './lib/regions';
export * from './lib/types';
export * from './lib/webhooks';
export { CustomerIORequestError, MissingParamError } from './lib/utils';
export type { ResponseLike } from './lib/utils';
export type { RequestDefaults, RetryOptions, PushRequestData, MetricRequestData } from './lib/request';
Expand Down
90 changes: 90 additions & 0 deletions lib/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { MissingParamError } from './utils';

/** Version prefix Customer.io includes when signing webhook requests. Always `v0`. */
const SIGNATURE_VERSION = 'v0';

/** Arguments for {@link verifyRequestSignature}. */
export interface VerifyRequestSignatureOptions {
/**
* Your webhook signing secret. Found on the webhook's settings page in
* Customer.io (e.g. the Email Activity / Reporting Webhook integration).
*/
signingSecret: string;
/** Value of the `X-CIO-Timestamp` request header. */
timestamp: string | number;
/**
* The raw, unparsed request body exactly as received. Do not re-serialize
* with `JSON.stringify` — subtle formatting differences will break the hash.
* Pass the original string or `Buffer`.
*/
body: string | Buffer;
/** Value of the `X-CIO-Signature` request header (hex-encoded HMAC-SHA256). */
signature: string;
}

/**
* Verifies that a webhook request genuinely originated from Customer.io.
*
* Customer.io signs each webhook by computing an HMAC-SHA256 of the string
* `v0:<timestamp>:<body>` (keyed with your webhook signing secret) and sends the
* hex-encoded result in the `X-CIO-Signature` header, alongside the
* `X-CIO-Timestamp` header. This recomputes that signature and compares it to
* the header value in constant time.
*
* @returns `true` if the signature is valid, `false` otherwise. A malformed or
* missing `signature` returns `false` rather than throwing.
* @throws {MissingParamError} if `signingSecret` is empty.
*
* @see https://docs.customer.io/api/webhooks/
*
* @example
* ```ts
* import { verifyRequestSignature } from 'customerio-node';
*
* const valid = verifyRequestSignature({
* signingSecret: process.env.CIO_WEBHOOK_SECRET,
* timestamp: req.headers['x-cio-timestamp'],
* signature: req.headers['x-cio-signature'],
* body: req.rawBody, // the raw request body, not the parsed object
* });
*
* if (!valid) {
* res.status(400).send('invalid signature');
* return;
* }
* ```
*/
export function verifyRequestSignature({
signingSecret,
timestamp,
body,
signature,
}: VerifyRequestSignatureOptions): boolean {
if (!signingSecret) {
throw new MissingParamError('signingSecret');
}

// Header values may be absent or, in Node, arrive as `string[]` when a header
// is repeated. Anything that isn't a non-empty string can't be a valid hex
// signature, so reject it before hashing.
if (typeof signature !== 'string' || signature.length === 0) {
return false;
}

const hmac = createHmac('sha256', signingSecret);
hmac.update(`${SIGNATURE_VERSION}:${timestamp}:`);
hmac.update(body);
const expected = hmac.digest('hex');

const expectedBuffer = Buffer.from(expected, 'utf8');
const providedBuffer = Buffer.from(signature, 'utf8');

// `timingSafeEqual` throws on length mismatch; the length of a hex digest is
// not secret, so compare it directly first.
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}

return timingSafeEqual(expectedBuffer, providedBuffer);
}
86 changes: 86 additions & 0 deletions test/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import test from 'ava';
import { createHmac } from 'crypto';
import { verifyRequestSignature } from '../lib/webhooks';
import { MissingParamError } from '../lib/utils';

const SIGNING_SECRET = 'signing-secret';
const TIMESTAMP = '1614265384';
const BODY = JSON.stringify({ event_id: 'abc', object_type: 'email', metric: 'delivered' });

const sign = (secret: string, timestamp: string | number, body: string | Buffer) =>
createHmac('sha256', secret).update(`v0:${timestamp}:`).update(body).digest('hex');

test('returns true for a valid signature', (t) => {
const signature = sign(SIGNING_SECRET, TIMESTAMP, BODY);

t.true(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature }));
});

test('matches the documented v0:timestamp:body HMAC-SHA256 hex scheme', (t) => {
// Golden value computed independently; locks the signing scheme against regressions.
const signature = '6f55cf23f9b73a2ff07733fe04421f64ce9ec5e409696edda6b2a80311944230';

t.true(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature }));
});

test('accepts a numeric timestamp equivalently to its string form', (t) => {
const signature = sign(SIGNING_SECRET, TIMESTAMP, BODY);

t.true(
verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: Number(TIMESTAMP), body: BODY, signature }),
);
});

test('accepts a raw Buffer body', (t) => {
const body = Buffer.from(BODY, 'utf8');
const signature = sign(SIGNING_SECRET, TIMESTAMP, body);

t.true(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body, signature }));
});

test('returns false when the body has been tampered with', (t) => {
const signature = sign(SIGNING_SECRET, TIMESTAMP, BODY);
const tamperedBody = BODY.replace('delivered', 'bounced');

t.false(
verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: tamperedBody, signature }),
);
});

test('returns false when the timestamp does not match the signed one', (t) => {
const signature = sign(SIGNING_SECRET, TIMESTAMP, BODY);

t.false(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: '1614265385', body: BODY, signature }));
});

test('returns false when signed with a different secret', (t) => {
const signature = sign('other-secret', TIMESTAMP, BODY);

t.false(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature }));
});

test('returns false for a signature of the wrong length', (t) => {
t.false(
verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature: 'abc' }),
);
});

test('returns false for an empty signature', (t) => {
t.false(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature: '' }));
});

test('returns false for a non-string signature (e.g. a repeated header)', (t) => {
const signature = ['sig-a', 'sig-b'] as unknown as string;

t.false(verifyRequestSignature({ signingSecret: SIGNING_SECRET, timestamp: TIMESTAMP, body: BODY, signature }));
});

test('throws MissingParamError when the signing secret is empty', (t) => {
const signature = sign(SIGNING_SECRET, TIMESTAMP, BODY);

const error = t.throws(
() => verifyRequestSignature({ signingSecret: '', timestamp: TIMESTAMP, body: BODY, signature }),
{ instanceOf: MissingParamError },
);
t.is(error.message, 'signingSecret is required');
});