@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 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 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.
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 { verifyWebhookSignature } from '@textbee/sdk'
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 verifyWebhookSignature({
126
- payload: req.body.toString('utf8'),
127
- signature: req.get('x-signature'),
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.4.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, encoder.encode(message));
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.4.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, encoder.encode(message));
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textbee/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@9.14.2",