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
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,50 @@ const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)

## Verifying webhooks

textbee signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. Pass the raw request body, not a re-serialized object, whenever your framework gives you access to it.
textbee signs each webhook delivery with your signing secret and sends a timestamped signature in the `X-Textbee-Signature` header:

```
X-Textbee-Signature: t=1762300000,v1=5f2b...
```

`v1` is the hex HMAC-SHA256 of `<t>.<raw body>`. `verifyWebhookRequest` reads the signature header, checks `v1` against the raw body in constant time, and rejects the request when `t` is more than 300 seconds from now. The signed timestamp makes a captured delivery useless after that window.

Verify the raw body exactly as received. A parsed and re-serialized body can differ by one byte and fail.

```js
import { verifyWebhookSignature } from '@textbee/sdk'
import express from 'express'
import { verifyWebhookRequest } from '@textbee/sdk'

const app = express()

app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => {
const valid = await verifyWebhookSignature({
payload: req.body.toString('utf8'),
signature: req.get('x-signature'),
const valid = await verifyWebhookRequest({
payload: req.body, // a Buffer, from express.raw
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
headers: req.headers,
signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET,
})

if (!valid) return res.sendStatus(401)

const event = JSON.parse(req.body.toString('utf8'))
// Retries carry the same idempotencyKey. Skip keys you have already handled.
res.sendStatus(200)
})
```

`payload` takes a string or bytes (`Buffer`, `Uint8Array`). `headers` takes Node's `req.headers`, a plain object, or a Fetch `Headers` object, and header names match in any case.

Options:

- `toleranceSeconds`: the largest allowed distance between `t` and now, in either direction. Defaults to `300`. Keep your server clock in sync (NTP).
- `allowLegacy`: an explicit opt-in for old receivers. Off by default. When it is `true` and the signature header is absent, the request is checked against `X-Signature` instead, which has no replay protection.

To check the signature header value yourself, call `verifyTimestampedWebhookSignature({ payload, signatureHeader, signingSecret })`. It takes the same `toleranceSeconds` option.

Retries and duplicates: a failed delivery is retried with a fresh `t`, so each attempt passes the freshness check. Every payload carries an `idempotencyKey`. Store the keys you have processed and skip repeats.

**Deprecated:** `verifyWebhookSignature` checks only `X-Signature`. It still works because textbee still sends that header, but it has no replay protection. See [textbee.dev/docs/webhooks](https://textbee.dev/docs/webhooks) for the upgrade steps.

## SMS utilities

Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee. Import only what you need and the rest is tree-shaken away.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@textbee/sdk",
"version": "0.4.0",
"version": "0.5.0",
"description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
"license": "MIT",
"packageManager": "pnpm@9.14.2",
Expand Down
13 changes: 11 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,24 @@ export {
normalizePhoneNumber,
sanitizeForGsm7,
} from './sms-utils'
export { verifyWebhookSignature } from './webhooks'
export {
verifyTimestampedWebhookSignature,
verifyWebhookRequest,
verifyWebhookSignature,
} from './webhooks'

export type {
NormalizePhoneNumberOptions,
SanitizeForGsm7Options,
SmsEncoding,
SmsSegmentInfo,
} from './sms-utils'
export type { VerifyWebhookSignatureOptions } from './webhooks'
export type {
VerifyTimestampedWebhookSignatureOptions,
VerifyWebhookRequestOptions,
VerifyWebhookSignatureOptions,
WebhookRequestHeaders,
} from './webhooks'
export type {
Device,
IterateMessagesOptions,
Expand Down
210 changes: 205 additions & 5 deletions src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/** Default maximum distance, in seconds, between a signed timestamp and now. */
const DEFAULT_TOLERANCE_SECONDS = 300

const encoder = new TextEncoder()

export interface VerifyWebhookSignatureOptions {
/**
* The webhook request body. Prefer the raw body string.
Expand All @@ -15,11 +20,61 @@ export interface VerifyWebhookSignatureOptions {
signingSecret: string
}

export interface VerifyTimestampedWebhookSignatureOptions {
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
payload: string | Uint8Array

/** Value of the `X-Textbee-Signature` request header: `t=<unix seconds>,v1=<hex>`. */
signatureHeader: string | null | undefined

/** Signing secret shown with the webhook subscription in the dashboard. */
signingSecret: string

/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
toleranceSeconds?: number

/** Current time in unix seconds. Defaults to the system clock. */
now?: number
}

/** Request headers as a Fetch `Headers` object or a plain object such as Node's `req.headers`. */
export type WebhookRequestHeaders =
| { get(name: string): string | null }
| Record<string, string | string[] | undefined>

export interface VerifyWebhookRequestOptions {
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
payload: string | Uint8Array

/** The request headers. Names are matched case-insensitively. */
headers: WebhookRequestHeaders

/** Signing secret shown with the webhook subscription in the dashboard. */
signingSecret: string

/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
toleranceSeconds?: number

/** Current time in unix seconds. Defaults to the system clock. */
now?: number

/**
* Accept the `X-Signature` header when `X-Textbee-Signature` is absent.
* Off by default, because `X-Signature` has no replay protection. Turn it on
* only for old receivers that cannot read the signature header yet.
*/
allowLegacy?: boolean
}

/**
* Check that a webhook really came from textbee.
* Check that a webhook really came from textbee, using the `X-Signature` header.
*
* textbee signs deliveries with HMAC-SHA256 over the request body and sends the
* hex digest in the `X-Signature` header. Comparison is constant time.
*
* @deprecated This header has no timestamp, so a captured delivery can be
* replayed. textbee still sends it, so this keeps working. Use
* {@link verifyWebhookRequest} or {@link verifyTimestampedWebhookSignature}.
*/
export async function verifyWebhookSignature(
options: VerifyWebhookSignatureOptions,
Expand All @@ -31,21 +86,166 @@ export async function verifyWebhookSignature(
}

const body = typeof payload === 'string' ? payload : JSON.stringify(payload)
const expected = await hmacSha256Hex(signingSecret, body)
const expected = await hmacSha256Hex(signingSecret, encoder.encode(body))

return constantTimeEqual(expected, signature.toLowerCase())
}

async function hmacSha256Hex(secret: string, message: string): Promise<string> {
const encoder = new TextEncoder()
/**
* Check the timestamped signature in the `X-Textbee-Signature` header.
*
* The header is `t=<unix seconds>,v1=<hex>`, where the hex value is
* HMAC-SHA256 over `<t>.<raw body>`. Any matching `v1` entry passes. Returns
* false when the header is malformed, no `v1` entry matches, or `t` is more
* than `toleranceSeconds` away from now in either direction.
*/
export async function verifyTimestampedWebhookSignature(
options: VerifyTimestampedWebhookSignatureOptions,
): Promise<boolean> {
const {
payload,
signatureHeader,
signingSecret,
toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
now = Math.floor(Date.now() / 1000),
} = options

const body = toBytes(payload)
if (!signatureHeader || !signingSecret || !body) {
return false
}

const parsed = parseSignatureHeader(signatureHeader)
if (!parsed) {
return false
}

// Written as a negated <= so a NaN tolerance or clock rejects instead of passing.
if (!(Math.abs(now - Number(parsed.timestamp)) <= toleranceSeconds)) {
return false
}

const prefix = encoder.encode(`${parsed.timestamp}.`)
const signed = new Uint8Array(prefix.length + body.length)
signed.set(prefix)
signed.set(body, prefix.length)

const expected = await hmacSha256Hex(signingSecret, signed)

let matched = false
for (const candidate of parsed.signatures) {
if (constantTimeEqual(expected, candidate.toLowerCase())) {
matched = true
}
}

return matched
}

/**
* Check that a webhook request came from textbee, from its headers.
*
* Verifies the timestamped signature in `X-Textbee-Signature`. When that
* header is absent and `allowLegacy` is true, falls back to `X-Signature`,
* which has no replay protection.
*/
export async function verifyWebhookRequest(
options: VerifyWebhookRequestOptions,
): Promise<boolean> {
const { payload, headers, signingSecret, toleranceSeconds, now, allowLegacy = false } = options

const signatureHeader = getHeader(headers, 'x-textbee-signature')
if (signatureHeader !== undefined) {
return verifyTimestampedWebhookSignature({
payload,
signatureHeader,
signingSecret,
toleranceSeconds,
now,
})
}

if (!allowLegacy) {
return false
}

const legacySignature = getHeader(headers, 'x-signature')
const body = toBytes(payload)
if (!legacySignature || !signingSecret || !body) {
return false
}

const expected = await hmacSha256Hex(signingSecret, body)

return constantTimeEqual(expected, legacySignature.toLowerCase())
}

function parseSignatureHeader(
header: string,
): { timestamp: string; signatures: string[] } | null {
let timestamp: string | undefined
const signatures: string[] = []

for (const part of header.split(',')) {
const separator = part.indexOf('=')
if (separator === -1) {
continue
}

const key = part.slice(0, separator).trim()
const value = part.slice(separator + 1).trim()

if (key === 't') {
if (timestamp !== undefined || !/^\d+$/.test(value)) {
return null
}
timestamp = value
} else if (key === 'v1' && value) {
signatures.push(value)
}
}

if (timestamp === undefined || signatures.length === 0) {
return null
}

return { timestamp, signatures }
}

function getHeader(headers: WebhookRequestHeaders, name: string): string | undefined {
if (typeof headers.get === 'function') {
return (headers as { get(name: string): string | null }).get(name) ?? undefined
}

// Every case variant counts, so a second copy of a header cannot hide behind the first.
const values: string[] = []
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === name && value !== undefined) {
values.push(...(Array.isArray(value) ? value : [value]))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

return values.length > 0 ? values.join(',') : undefined
}

function toBytes(payload: unknown): Uint8Array | undefined {
if (typeof payload === 'string') {
return encoder.encode(payload)
}
// A body parser that skipped the request can leave undefined or {} here.
return payload instanceof Uint8Array ? payload : undefined
}

async function hmacSha256Hex(secret: string, message: Uint8Array): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const digest = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
// Copied so a view over a SharedArrayBuffer or a larger Buffer pool is safe to sign.
const digest = await crypto.subtle.sign('HMAC', key, new Uint8Array(message))

return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
Expand Down
Loading
Loading