@textbee/sdk 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -5
- package/dist/index.cjs +106 -4
- package/dist/index.d.cts +57 -2
- package/dist/index.d.ts +57 -2
- package/dist/index.js +104 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,25 +116,50 @@ const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId)
|
|
|
116
116
|
|
|
117
117
|
## Verifying webhooks
|
|
118
118
|
|
|
119
|
-
textbee signs each webhook delivery with
|
|
119
|
+
textbee signs each webhook delivery with your signing secret and sends a timestamped signature in the `X-Textbee-Signature` header:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
X-Textbee-Signature: t=1762300000,v1=5f2b...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`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.
|
|
126
|
+
|
|
127
|
+
Verify the raw body exactly as received. A parsed and re-serialized body can differ by one byte and fail.
|
|
120
128
|
|
|
121
129
|
```js
|
|
122
|
-
import
|
|
130
|
+
import express from 'express'
|
|
131
|
+
import { verifyWebhookRequest } from '@textbee/sdk'
|
|
132
|
+
|
|
133
|
+
const app = express()
|
|
123
134
|
|
|
124
135
|
app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
125
|
-
const valid = await
|
|
126
|
-
payload: req.body.
|
|
127
|
-
|
|
136
|
+
const valid = await verifyWebhookRequest({
|
|
137
|
+
payload: req.body, // a Buffer, from express.raw
|
|
138
|
+
headers: req.headers,
|
|
128
139
|
signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET,
|
|
129
140
|
})
|
|
130
141
|
|
|
131
142
|
if (!valid) return res.sendStatus(401)
|
|
132
143
|
|
|
133
144
|
const event = JSON.parse(req.body.toString('utf8'))
|
|
145
|
+
// Retries carry the same idempotencyKey. Skip keys you have already handled.
|
|
134
146
|
res.sendStatus(200)
|
|
135
147
|
})
|
|
136
148
|
```
|
|
137
149
|
|
|
150
|
+
`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.
|
|
151
|
+
|
|
152
|
+
Options:
|
|
153
|
+
|
|
154
|
+
- `toleranceSeconds`: the largest allowed distance between `t` and now, in either direction. Defaults to `300`. Keep your server clock in sync (NTP).
|
|
155
|
+
- `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.
|
|
156
|
+
|
|
157
|
+
To check the signature header value yourself, call `verifyTimestampedWebhookSignature({ payload, signatureHeader, signingSecret })`. It takes the same `toleranceSeconds` option.
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
**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.
|
|
162
|
+
|
|
138
163
|
## SMS utilities
|
|
139
164
|
|
|
140
165
|
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.
|
package/dist/index.cjs
CHANGED
|
@@ -28,6 +28,8 @@ __export(index_exports, {
|
|
|
28
28
|
isValidE164: () => isValidE164,
|
|
29
29
|
normalizePhoneNumber: () => normalizePhoneNumber,
|
|
30
30
|
sanitizeForGsm7: () => sanitizeForGsm7,
|
|
31
|
+
verifyTimestampedWebhookSignature: () => verifyTimestampedWebhookSignature,
|
|
32
|
+
verifyWebhookRequest: () => verifyWebhookRequest,
|
|
31
33
|
verifyWebhookSignature: () => verifyWebhookSignature
|
|
32
34
|
});
|
|
33
35
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -48,7 +50,7 @@ var TextbeeError = class extends Error {
|
|
|
48
50
|
|
|
49
51
|
// src/client.ts
|
|
50
52
|
var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
|
|
51
|
-
var CLIENT_ID = `textbee-js/${"0.
|
|
53
|
+
var CLIENT_ID = `textbee-js/${"0.5.0"}`;
|
|
52
54
|
var Textbee = class {
|
|
53
55
|
#apiKey;
|
|
54
56
|
#baseUrl;
|
|
@@ -483,17 +485,115 @@ function normalizeCountryCode(value) {
|
|
|
483
485
|
}
|
|
484
486
|
|
|
485
487
|
// src/webhooks.ts
|
|
488
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
489
|
+
var encoder = new TextEncoder();
|
|
486
490
|
async function verifyWebhookSignature(options) {
|
|
487
491
|
const { payload, signature, signingSecret } = options;
|
|
488
492
|
if (!signature || !signingSecret) {
|
|
489
493
|
return false;
|
|
490
494
|
}
|
|
491
495
|
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
492
|
-
const expected = await hmacSha256Hex(signingSecret, body);
|
|
496
|
+
const expected = await hmacSha256Hex(signingSecret, encoder.encode(body));
|
|
493
497
|
return constantTimeEqual(expected, signature.toLowerCase());
|
|
494
498
|
}
|
|
499
|
+
async function verifyTimestampedWebhookSignature(options) {
|
|
500
|
+
const {
|
|
501
|
+
payload,
|
|
502
|
+
signatureHeader,
|
|
503
|
+
signingSecret,
|
|
504
|
+
toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
|
|
505
|
+
now = Math.floor(Date.now() / 1e3)
|
|
506
|
+
} = options;
|
|
507
|
+
const body = toBytes(payload);
|
|
508
|
+
if (!signatureHeader || !signingSecret || !body) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
512
|
+
if (!parsed) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
if (!(Math.abs(now - Number(parsed.timestamp)) <= toleranceSeconds)) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
519
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
520
|
+
signed.set(prefix);
|
|
521
|
+
signed.set(body, prefix.length);
|
|
522
|
+
const expected = await hmacSha256Hex(signingSecret, signed);
|
|
523
|
+
let matched = false;
|
|
524
|
+
for (const candidate of parsed.signatures) {
|
|
525
|
+
if (constantTimeEqual(expected, candidate.toLowerCase())) {
|
|
526
|
+
matched = true;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return matched;
|
|
530
|
+
}
|
|
531
|
+
async function verifyWebhookRequest(options) {
|
|
532
|
+
const { payload, headers, signingSecret, toleranceSeconds, now, allowLegacy = false } = options;
|
|
533
|
+
const signatureHeader = getHeader(headers, "x-textbee-signature");
|
|
534
|
+
if (signatureHeader !== void 0) {
|
|
535
|
+
return verifyTimestampedWebhookSignature({
|
|
536
|
+
payload,
|
|
537
|
+
signatureHeader,
|
|
538
|
+
signingSecret,
|
|
539
|
+
toleranceSeconds,
|
|
540
|
+
now
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (!allowLegacy) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
const legacySignature = getHeader(headers, "x-signature");
|
|
547
|
+
const body = toBytes(payload);
|
|
548
|
+
if (!legacySignature || !signingSecret || !body) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
const expected = await hmacSha256Hex(signingSecret, body);
|
|
552
|
+
return constantTimeEqual(expected, legacySignature.toLowerCase());
|
|
553
|
+
}
|
|
554
|
+
function parseSignatureHeader(header) {
|
|
555
|
+
let timestamp;
|
|
556
|
+
const signatures = [];
|
|
557
|
+
for (const part of header.split(",")) {
|
|
558
|
+
const separator = part.indexOf("=");
|
|
559
|
+
if (separator === -1) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const key = part.slice(0, separator).trim();
|
|
563
|
+
const value = part.slice(separator + 1).trim();
|
|
564
|
+
if (key === "t") {
|
|
565
|
+
if (timestamp !== void 0 || !/^\d+$/.test(value)) {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
timestamp = value;
|
|
569
|
+
} else if (key === "v1" && value) {
|
|
570
|
+
signatures.push(value);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (timestamp === void 0 || signatures.length === 0) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return { timestamp, signatures };
|
|
577
|
+
}
|
|
578
|
+
function getHeader(headers, name) {
|
|
579
|
+
if (typeof headers.get === "function") {
|
|
580
|
+
return headers.get(name) ?? void 0;
|
|
581
|
+
}
|
|
582
|
+
const values = [];
|
|
583
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
584
|
+
if (key.toLowerCase() === name && value !== void 0) {
|
|
585
|
+
values.push(...Array.isArray(value) ? value : [value]);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return values.length > 0 ? values.join(",") : void 0;
|
|
589
|
+
}
|
|
590
|
+
function toBytes(payload) {
|
|
591
|
+
if (typeof payload === "string") {
|
|
592
|
+
return encoder.encode(payload);
|
|
593
|
+
}
|
|
594
|
+
return payload instanceof Uint8Array ? payload : void 0;
|
|
595
|
+
}
|
|
495
596
|
async function hmacSha256Hex(secret, message) {
|
|
496
|
-
const encoder = new TextEncoder();
|
|
497
597
|
const key = await crypto.subtle.importKey(
|
|
498
598
|
"raw",
|
|
499
599
|
encoder.encode(secret),
|
|
@@ -501,7 +601,7 @@ async function hmacSha256Hex(secret, message) {
|
|
|
501
601
|
false,
|
|
502
602
|
["sign"]
|
|
503
603
|
);
|
|
504
|
-
const digest = await crypto.subtle.sign("HMAC", key,
|
|
604
|
+
const digest = await crypto.subtle.sign("HMAC", key, new Uint8Array(message));
|
|
505
605
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
506
606
|
}
|
|
507
607
|
function constantTimeEqual(a, b) {
|
|
@@ -524,5 +624,7 @@ function constantTimeEqual(a, b) {
|
|
|
524
624
|
isValidE164,
|
|
525
625
|
normalizePhoneNumber,
|
|
526
626
|
sanitizeForGsm7,
|
|
627
|
+
verifyTimestampedWebhookSignature,
|
|
628
|
+
verifyWebhookRequest,
|
|
527
629
|
verifyWebhookSignature
|
|
528
630
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -411,12 +411,67 @@ interface VerifyWebhookSignatureOptions {
|
|
|
411
411
|
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
412
412
|
signingSecret: string;
|
|
413
413
|
}
|
|
414
|
+
interface VerifyTimestampedWebhookSignatureOptions {
|
|
415
|
+
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
|
|
416
|
+
payload: string | Uint8Array;
|
|
417
|
+
/** Value of the `X-Textbee-Signature` request header: `t=<unix seconds>,v1=<hex>`. */
|
|
418
|
+
signatureHeader: string | null | undefined;
|
|
419
|
+
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
420
|
+
signingSecret: string;
|
|
421
|
+
/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
|
|
422
|
+
toleranceSeconds?: number;
|
|
423
|
+
/** Current time in unix seconds. Defaults to the system clock. */
|
|
424
|
+
now?: number;
|
|
425
|
+
}
|
|
426
|
+
/** Request headers as a Fetch `Headers` object or a plain object such as Node's `req.headers`. */
|
|
427
|
+
type WebhookRequestHeaders = {
|
|
428
|
+
get(name: string): string | null;
|
|
429
|
+
} | Record<string, string | string[] | undefined>;
|
|
430
|
+
interface VerifyWebhookRequestOptions {
|
|
431
|
+
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
|
|
432
|
+
payload: string | Uint8Array;
|
|
433
|
+
/** The request headers. Names are matched case-insensitively. */
|
|
434
|
+
headers: WebhookRequestHeaders;
|
|
435
|
+
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
436
|
+
signingSecret: string;
|
|
437
|
+
/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
|
|
438
|
+
toleranceSeconds?: number;
|
|
439
|
+
/** Current time in unix seconds. Defaults to the system clock. */
|
|
440
|
+
now?: number;
|
|
441
|
+
/**
|
|
442
|
+
* Accept the `X-Signature` header when `X-Textbee-Signature` is absent.
|
|
443
|
+
* Off by default, because `X-Signature` has no replay protection. Turn it on
|
|
444
|
+
* only for old receivers that cannot read the signature header yet.
|
|
445
|
+
*/
|
|
446
|
+
allowLegacy?: boolean;
|
|
447
|
+
}
|
|
414
448
|
/**
|
|
415
|
-
* Check that a webhook really came from textbee.
|
|
449
|
+
* Check that a webhook really came from textbee, using the `X-Signature` header.
|
|
416
450
|
*
|
|
417
451
|
* textbee signs deliveries with HMAC-SHA256 over the request body and sends the
|
|
418
452
|
* hex digest in the `X-Signature` header. Comparison is constant time.
|
|
453
|
+
*
|
|
454
|
+
* @deprecated This header has no timestamp, so a captured delivery can be
|
|
455
|
+
* replayed. textbee still sends it, so this keeps working. Use
|
|
456
|
+
* {@link verifyWebhookRequest} or {@link verifyTimestampedWebhookSignature}.
|
|
419
457
|
*/
|
|
420
458
|
declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
|
|
459
|
+
/**
|
|
460
|
+
* Check the timestamped signature in the `X-Textbee-Signature` header.
|
|
461
|
+
*
|
|
462
|
+
* The header is `t=<unix seconds>,v1=<hex>`, where the hex value is
|
|
463
|
+
* HMAC-SHA256 over `<t>.<raw body>`. Any matching `v1` entry passes. Returns
|
|
464
|
+
* false when the header is malformed, no `v1` entry matches, or `t` is more
|
|
465
|
+
* than `toleranceSeconds` away from now in either direction.
|
|
466
|
+
*/
|
|
467
|
+
declare function verifyTimestampedWebhookSignature(options: VerifyTimestampedWebhookSignatureOptions): Promise<boolean>;
|
|
468
|
+
/**
|
|
469
|
+
* Check that a webhook request came from textbee, from its headers.
|
|
470
|
+
*
|
|
471
|
+
* Verifies the timestamped signature in `X-Textbee-Signature`. When that
|
|
472
|
+
* header is absent and `allowLegacy` is true, falls back to `X-Signature`,
|
|
473
|
+
* which has no replay protection.
|
|
474
|
+
*/
|
|
475
|
+
declare function verifyWebhookRequest(options: VerifyWebhookRequestOptions): Promise<boolean>;
|
|
421
476
|
|
|
422
|
-
export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageIssueEntry, type MessageIssueSource, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
|
|
477
|
+
export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageIssueEntry, type MessageIssueSource, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyTimestampedWebhookSignatureOptions, type VerifyWebhookRequestOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, type WebhookRequestHeaders, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyTimestampedWebhookSignature, verifyWebhookRequest, verifyWebhookSignature };
|
package/dist/index.d.ts
CHANGED
|
@@ -411,12 +411,67 @@ interface VerifyWebhookSignatureOptions {
|
|
|
411
411
|
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
412
412
|
signingSecret: string;
|
|
413
413
|
}
|
|
414
|
+
interface VerifyTimestampedWebhookSignatureOptions {
|
|
415
|
+
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
|
|
416
|
+
payload: string | Uint8Array;
|
|
417
|
+
/** Value of the `X-Textbee-Signature` request header: `t=<unix seconds>,v1=<hex>`. */
|
|
418
|
+
signatureHeader: string | null | undefined;
|
|
419
|
+
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
420
|
+
signingSecret: string;
|
|
421
|
+
/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
|
|
422
|
+
toleranceSeconds?: number;
|
|
423
|
+
/** Current time in unix seconds. Defaults to the system clock. */
|
|
424
|
+
now?: number;
|
|
425
|
+
}
|
|
426
|
+
/** Request headers as a Fetch `Headers` object or a plain object such as Node's `req.headers`. */
|
|
427
|
+
type WebhookRequestHeaders = {
|
|
428
|
+
get(name: string): string | null;
|
|
429
|
+
} | Record<string, string | string[] | undefined>;
|
|
430
|
+
interface VerifyWebhookRequestOptions {
|
|
431
|
+
/** The raw request body, exactly as received. Do not parse and re-serialize it. */
|
|
432
|
+
payload: string | Uint8Array;
|
|
433
|
+
/** The request headers. Names are matched case-insensitively. */
|
|
434
|
+
headers: WebhookRequestHeaders;
|
|
435
|
+
/** Signing secret shown with the webhook subscription in the dashboard. */
|
|
436
|
+
signingSecret: string;
|
|
437
|
+
/** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */
|
|
438
|
+
toleranceSeconds?: number;
|
|
439
|
+
/** Current time in unix seconds. Defaults to the system clock. */
|
|
440
|
+
now?: number;
|
|
441
|
+
/**
|
|
442
|
+
* Accept the `X-Signature` header when `X-Textbee-Signature` is absent.
|
|
443
|
+
* Off by default, because `X-Signature` has no replay protection. Turn it on
|
|
444
|
+
* only for old receivers that cannot read the signature header yet.
|
|
445
|
+
*/
|
|
446
|
+
allowLegacy?: boolean;
|
|
447
|
+
}
|
|
414
448
|
/**
|
|
415
|
-
* Check that a webhook really came from textbee.
|
|
449
|
+
* Check that a webhook really came from textbee, using the `X-Signature` header.
|
|
416
450
|
*
|
|
417
451
|
* textbee signs deliveries with HMAC-SHA256 over the request body and sends the
|
|
418
452
|
* hex digest in the `X-Signature` header. Comparison is constant time.
|
|
453
|
+
*
|
|
454
|
+
* @deprecated This header has no timestamp, so a captured delivery can be
|
|
455
|
+
* replayed. textbee still sends it, so this keeps working. Use
|
|
456
|
+
* {@link verifyWebhookRequest} or {@link verifyTimestampedWebhookSignature}.
|
|
419
457
|
*/
|
|
420
458
|
declare function verifyWebhookSignature(options: VerifyWebhookSignatureOptions): Promise<boolean>;
|
|
459
|
+
/**
|
|
460
|
+
* Check the timestamped signature in the `X-Textbee-Signature` header.
|
|
461
|
+
*
|
|
462
|
+
* The header is `t=<unix seconds>,v1=<hex>`, where the hex value is
|
|
463
|
+
* HMAC-SHA256 over `<t>.<raw body>`. Any matching `v1` entry passes. Returns
|
|
464
|
+
* false when the header is malformed, no `v1` entry matches, or `t` is more
|
|
465
|
+
* than `toleranceSeconds` away from now in either direction.
|
|
466
|
+
*/
|
|
467
|
+
declare function verifyTimestampedWebhookSignature(options: VerifyTimestampedWebhookSignatureOptions): Promise<boolean>;
|
|
468
|
+
/**
|
|
469
|
+
* Check that a webhook request came from textbee, from its headers.
|
|
470
|
+
*
|
|
471
|
+
* Verifies the timestamped signature in `X-Textbee-Signature`. When that
|
|
472
|
+
* header is absent and `allowLegacy` is true, falls back to `X-Signature`,
|
|
473
|
+
* which has no replay protection.
|
|
474
|
+
*/
|
|
475
|
+
declare function verifyWebhookRequest(options: VerifyWebhookRequestOptions): Promise<boolean>;
|
|
421
476
|
|
|
422
|
-
export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageIssueEntry, type MessageIssueSource, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyWebhookSignature };
|
|
477
|
+
export { type Device, type IterateMessagesOptions, type ListMessagesOptions, type Message, type MessageDevice, type MessageIssueEntry, type MessageIssueSource, type MessageList, type MessageListMeta, type NormalizePhoneNumberOptions, type SanitizeForGsm7Options, type SendSmsImmediateResponse, type SendSmsQueuedResponse, type SendSmsRequest, type SendSmsResponse, type SendSmsWarning, type Sms, type SmsBatch, type SmsBatchResult, type SmsEncoding, type SmsSegmentInfo, Textbee, TextbeeError, type TextbeeOptions, type VerifyTimestampedWebhookSignatureOptions, type VerifyWebhookRequestOptions, type VerifyWebhookSignatureOptions, type WebhookEvent, type WebhookRequestHeaders, countSmsSegments, findNonGsm7Characters, getSmsEncoding, isValidE164, normalizePhoneNumber, sanitizeForGsm7, verifyTimestampedWebhookSignature, verifyWebhookRequest, verifyWebhookSignature };
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var TextbeeError = class extends Error {
|
|
|
14
14
|
|
|
15
15
|
// src/client.ts
|
|
16
16
|
var DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1";
|
|
17
|
-
var CLIENT_ID = `textbee-js/${"0.
|
|
17
|
+
var CLIENT_ID = `textbee-js/${"0.5.0"}`;
|
|
18
18
|
var Textbee = class {
|
|
19
19
|
#apiKey;
|
|
20
20
|
#baseUrl;
|
|
@@ -449,17 +449,115 @@ function normalizeCountryCode(value) {
|
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
// src/webhooks.ts
|
|
452
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
453
|
+
var encoder = new TextEncoder();
|
|
452
454
|
async function verifyWebhookSignature(options) {
|
|
453
455
|
const { payload, signature, signingSecret } = options;
|
|
454
456
|
if (!signature || !signingSecret) {
|
|
455
457
|
return false;
|
|
456
458
|
}
|
|
457
459
|
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
458
|
-
const expected = await hmacSha256Hex(signingSecret, body);
|
|
460
|
+
const expected = await hmacSha256Hex(signingSecret, encoder.encode(body));
|
|
459
461
|
return constantTimeEqual(expected, signature.toLowerCase());
|
|
460
462
|
}
|
|
463
|
+
async function verifyTimestampedWebhookSignature(options) {
|
|
464
|
+
const {
|
|
465
|
+
payload,
|
|
466
|
+
signatureHeader,
|
|
467
|
+
signingSecret,
|
|
468
|
+
toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
|
|
469
|
+
now = Math.floor(Date.now() / 1e3)
|
|
470
|
+
} = options;
|
|
471
|
+
const body = toBytes(payload);
|
|
472
|
+
if (!signatureHeader || !signingSecret || !body) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
476
|
+
if (!parsed) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (!(Math.abs(now - Number(parsed.timestamp)) <= toleranceSeconds)) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
const prefix = encoder.encode(`${parsed.timestamp}.`);
|
|
483
|
+
const signed = new Uint8Array(prefix.length + body.length);
|
|
484
|
+
signed.set(prefix);
|
|
485
|
+
signed.set(body, prefix.length);
|
|
486
|
+
const expected = await hmacSha256Hex(signingSecret, signed);
|
|
487
|
+
let matched = false;
|
|
488
|
+
for (const candidate of parsed.signatures) {
|
|
489
|
+
if (constantTimeEqual(expected, candidate.toLowerCase())) {
|
|
490
|
+
matched = true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return matched;
|
|
494
|
+
}
|
|
495
|
+
async function verifyWebhookRequest(options) {
|
|
496
|
+
const { payload, headers, signingSecret, toleranceSeconds, now, allowLegacy = false } = options;
|
|
497
|
+
const signatureHeader = getHeader(headers, "x-textbee-signature");
|
|
498
|
+
if (signatureHeader !== void 0) {
|
|
499
|
+
return verifyTimestampedWebhookSignature({
|
|
500
|
+
payload,
|
|
501
|
+
signatureHeader,
|
|
502
|
+
signingSecret,
|
|
503
|
+
toleranceSeconds,
|
|
504
|
+
now
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (!allowLegacy) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
const legacySignature = getHeader(headers, "x-signature");
|
|
511
|
+
const body = toBytes(payload);
|
|
512
|
+
if (!legacySignature || !signingSecret || !body) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
const expected = await hmacSha256Hex(signingSecret, body);
|
|
516
|
+
return constantTimeEqual(expected, legacySignature.toLowerCase());
|
|
517
|
+
}
|
|
518
|
+
function parseSignatureHeader(header) {
|
|
519
|
+
let timestamp;
|
|
520
|
+
const signatures = [];
|
|
521
|
+
for (const part of header.split(",")) {
|
|
522
|
+
const separator = part.indexOf("=");
|
|
523
|
+
if (separator === -1) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const key = part.slice(0, separator).trim();
|
|
527
|
+
const value = part.slice(separator + 1).trim();
|
|
528
|
+
if (key === "t") {
|
|
529
|
+
if (timestamp !== void 0 || !/^\d+$/.test(value)) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
timestamp = value;
|
|
533
|
+
} else if (key === "v1" && value) {
|
|
534
|
+
signatures.push(value);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (timestamp === void 0 || signatures.length === 0) {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
return { timestamp, signatures };
|
|
541
|
+
}
|
|
542
|
+
function getHeader(headers, name) {
|
|
543
|
+
if (typeof headers.get === "function") {
|
|
544
|
+
return headers.get(name) ?? void 0;
|
|
545
|
+
}
|
|
546
|
+
const values = [];
|
|
547
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
548
|
+
if (key.toLowerCase() === name && value !== void 0) {
|
|
549
|
+
values.push(...Array.isArray(value) ? value : [value]);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return values.length > 0 ? values.join(",") : void 0;
|
|
553
|
+
}
|
|
554
|
+
function toBytes(payload) {
|
|
555
|
+
if (typeof payload === "string") {
|
|
556
|
+
return encoder.encode(payload);
|
|
557
|
+
}
|
|
558
|
+
return payload instanceof Uint8Array ? payload : void 0;
|
|
559
|
+
}
|
|
461
560
|
async function hmacSha256Hex(secret, message) {
|
|
462
|
-
const encoder = new TextEncoder();
|
|
463
561
|
const key = await crypto.subtle.importKey(
|
|
464
562
|
"raw",
|
|
465
563
|
encoder.encode(secret),
|
|
@@ -467,7 +565,7 @@ async function hmacSha256Hex(secret, message) {
|
|
|
467
565
|
false,
|
|
468
566
|
["sign"]
|
|
469
567
|
);
|
|
470
|
-
const digest = await crypto.subtle.sign("HMAC", key,
|
|
568
|
+
const digest = await crypto.subtle.sign("HMAC", key, new Uint8Array(message));
|
|
471
569
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
472
570
|
}
|
|
473
571
|
function constantTimeEqual(a, b) {
|
|
@@ -489,5 +587,7 @@ export {
|
|
|
489
587
|
isValidE164,
|
|
490
588
|
normalizePhoneNumber,
|
|
491
589
|
sanitizeForGsm7,
|
|
590
|
+
verifyTimestampedWebhookSignature,
|
|
591
|
+
verifyWebhookRequest,
|
|
492
592
|
verifyWebhookSignature
|
|
493
593
|
};
|