@verifyapi/sdk 0.1.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/src/client.ts ADDED
@@ -0,0 +1,478 @@
1
+ /**
2
+ * @module @firsthandapi/sdk/client
3
+ * @description FirstHandAPI TypeScript SDK client.
4
+ * Typed client wrapping all v1 REST endpoints with auto-retry,
5
+ * idempotency key generation, and proper error handling.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { FirstHandClient } from '@firsthandapi/sdk';
10
+ *
11
+ * const client = new FirstHandClient({ apiKey: 'fh_live_...' });
12
+ * const job = await client.createJob({
13
+ * job_type: 'product_photo',
14
+ * title: 'Product photo for listing #1234',
15
+ * instructions: 'Take a clean photo of the product on white background',
16
+ * });
17
+ * ```
18
+ *
19
+ * @see api-contract.md — Full API specification
20
+ */
21
+
22
+ import type { Static } from '@sinclair/typebox';
23
+ import type {
24
+ CreateJobRequest,
25
+ JobResponse,
26
+ JobListParams,
27
+ CreateApiKeyRequest,
28
+ CreateApiKeyResponse,
29
+ ApiKeyListItem,
30
+ RotateApiKeyResponse,
31
+ CreateWebhookEndpointRequest,
32
+ WebhookEndpointResponse,
33
+ UpdateWebhookEndpointRequest,
34
+ RotateWebhookSecretResponse,
35
+ WebhookEventResponse,
36
+ CreditBalanceResponse,
37
+ CreditTransaction,
38
+ PurchaseCreditsRequest,
39
+ PurchaseCreditsResponse,
40
+ } from '@verifyapi/api-contracts';
41
+ import { FirstHandApiError, FirstHandConnectionError, type FirstHandErrorDetail } from './errors.js';
42
+
43
+ // ─── Types ──────────────────────────────────────────────────────────────
44
+
45
+ export interface FirstHandClientOptions {
46
+ /** API key (fh_live_* or fh_test_*) */
47
+ apiKey: string;
48
+ /** Base URL (default: https://api.firsthandapi.com) */
49
+ baseUrl?: string;
50
+ /** Request timeout in milliseconds (default: 30000) */
51
+ timeoutMs?: number;
52
+ /** Maximum retry attempts for retryable errors (default: 3) */
53
+ maxRetries?: number;
54
+ /** Custom fetch implementation (default: global fetch) */
55
+ fetch?: typeof globalThis.fetch;
56
+ }
57
+
58
+ export interface ListResponse<T> {
59
+ object: 'list';
60
+ data: T[];
61
+ has_more: boolean;
62
+ next_cursor: string | null;
63
+ }
64
+
65
+ export interface ListOptions {
66
+ [key: string]: unknown;
67
+ cursor?: string;
68
+ limit?: number;
69
+ }
70
+
71
+ export interface GetJobOptions {
72
+ /** Long-poll: wait up to N seconds for job to complete (max 60) */
73
+ waitSeconds?: number;
74
+ }
75
+
76
+ // ─── Client ─────────────────────────────────────────────────────────────
77
+
78
+ const DEFAULT_BASE_URL = 'https://api.firsthandapi.com';
79
+ const DEFAULT_TIMEOUT_MS = 30_000;
80
+ const DEFAULT_MAX_RETRIES = 3;
81
+ const RETRY_BASE_DELAY_MS = 1_000;
82
+
83
+ export class FirstHandClient {
84
+ private readonly apiKey: string;
85
+ private readonly baseUrl: string;
86
+ private readonly timeoutMs: number;
87
+ private readonly maxRetries: number;
88
+ private readonly fetchFn: typeof globalThis.fetch;
89
+
90
+ constructor(options: FirstHandClientOptions) {
91
+ if (!options.apiKey) {
92
+ throw new Error('apiKey is required');
93
+ }
94
+ this.apiKey = options.apiKey;
95
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
96
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
97
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
98
+ this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
99
+ }
100
+
101
+ // ── Jobs ───────────────────────────────────────────────────────────
102
+
103
+ /** Create a new job for content collection */
104
+ async createJob(
105
+ body: Static<typeof CreateJobRequest>,
106
+ idempotencyKey?: string,
107
+ ): Promise<Static<typeof JobResponse>> {
108
+ const key = idempotencyKey ?? generateIdempotencyKey();
109
+ return this.request('POST', '/v1/jobs', {
110
+ body,
111
+ headers: { 'Idempotency-Key': key },
112
+ expectedStatus: 201,
113
+ });
114
+ }
115
+
116
+ /** Get a job by ID. Supports long-polling via waitSeconds option. */
117
+ async getJob(
118
+ jobId: string,
119
+ options?: GetJobOptions,
120
+ ): Promise<Static<typeof JobResponse>> {
121
+ const headers: Record<string, string> = {};
122
+ if (options?.waitSeconds) {
123
+ headers['Prefer'] = `wait=${Math.min(options.waitSeconds, 60)}`;
124
+ }
125
+ return this.request('GET', `/v1/jobs/${jobId}`, { headers });
126
+ }
127
+
128
+ /** List jobs with optional filters */
129
+ async listJobs(
130
+ params?: Static<typeof JobListParams>,
131
+ ): Promise<ListResponse<Static<typeof JobResponse>>> {
132
+ return this.request('GET', '/v1/jobs', { query: params });
133
+ }
134
+
135
+ /** Get audit trail for a job */
136
+ async getJobAudit(
137
+ jobId: string,
138
+ ): Promise<Record<string, unknown>> {
139
+ return this.request('GET', `/v1/jobs/${jobId}/audit`);
140
+ }
141
+
142
+ /** Cancel a pending job */
143
+ async cancelJob(
144
+ jobId: string,
145
+ ): Promise<Static<typeof JobResponse>> {
146
+ return this.request('POST', `/v1/jobs/${jobId}/cancel`, {
147
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
148
+ });
149
+ }
150
+
151
+ /** Get files associated with a job (approved submissions with download URLs) */
152
+ async getJobFiles(
153
+ jobId: string,
154
+ ): Promise<ListResponse<unknown>> {
155
+ return this.request('GET', `/v1/jobs/${jobId}/files`);
156
+ }
157
+
158
+ /** Get submissions for a job */
159
+ async getJobSubmissions(
160
+ jobId: string,
161
+ ): Promise<ListResponse<unknown>> {
162
+ return this.request('GET', `/v1/jobs/${jobId}/submissions`);
163
+ }
164
+
165
+ /** Rate a completed job (1-5 stars with optional feedback) */
166
+ async rateJob(
167
+ jobId: string,
168
+ body: { rating: number; feedback?: string },
169
+ ): Promise<Static<typeof JobResponse>> {
170
+ return this.request('POST', `/v1/jobs/${jobId}/rate`, { body });
171
+ }
172
+
173
+ /**
174
+ * Create a job and wait for it to complete.
175
+ * Polls using long-poll until the job reaches a terminal state or timeout.
176
+ *
177
+ * @param body - Job creation request
178
+ * @param maxWaitSeconds - Maximum total wait time (default: 120)
179
+ * @param pollIntervalSeconds - Long-poll interval per request (default: 30)
180
+ */
181
+ async createJobAndWait(
182
+ body: Static<typeof CreateJobRequest>,
183
+ maxWaitSeconds = 120,
184
+ pollIntervalSeconds = 30,
185
+ ): Promise<Static<typeof JobResponse>> {
186
+ const job = await this.createJob(body);
187
+
188
+ const terminalStatuses = new Set(['resolved', 'completed', 'timed_out', 'failed', 'cancelled']);
189
+ if (terminalStatuses.has(job.status)) {
190
+ return job;
191
+ }
192
+
193
+ const deadline = Date.now() + maxWaitSeconds * 1000;
194
+
195
+ while (Date.now() < deadline) {
196
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
197
+ const waitSeconds = Math.min(pollIntervalSeconds, remaining, 60);
198
+
199
+ if (waitSeconds <= 0) break;
200
+
201
+ const updated = await this.getJob(job.id, { waitSeconds });
202
+ if (terminalStatuses.has(updated.status)) {
203
+ return updated;
204
+ }
205
+ }
206
+
207
+ // Return latest state even if not terminal
208
+ return this.getJob(job.id);
209
+ }
210
+
211
+ // ── API Keys ────────────────────────────────────────────────────────
212
+
213
+ /** Create a new API key */
214
+ async createApiKey(
215
+ body: Static<typeof CreateApiKeyRequest>,
216
+ ): Promise<Static<typeof CreateApiKeyResponse>> {
217
+ return this.request('POST', '/v1/api_keys', {
218
+ body,
219
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
220
+ expectedStatus: 201,
221
+ });
222
+ }
223
+
224
+ /** List API keys (secrets masked) */
225
+ async listApiKeys(
226
+ options?: ListOptions,
227
+ ): Promise<ListResponse<Static<typeof ApiKeyListItem>>> {
228
+ return this.request('GET', '/v1/api_keys', { query: options });
229
+ }
230
+
231
+ /** Rotate an API key (24h overlap window) */
232
+ async rotateApiKey(
233
+ keyId: string,
234
+ ): Promise<Static<typeof RotateApiKeyResponse>> {
235
+ return this.request('POST', `/v1/api_keys/${keyId}/rotate`, {
236
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
237
+ });
238
+ }
239
+
240
+ /** Revoke an API key immediately */
241
+ async revokeApiKey(keyId: string): Promise<void> {
242
+ await this.request('POST', `/v1/api_keys/${keyId}/revoke`, {
243
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
244
+ expectedStatus: 204,
245
+ parseBody: false,
246
+ });
247
+ }
248
+
249
+ // ── Webhooks ────────────────────────────────────────────────────────
250
+
251
+ /** Create a webhook endpoint */
252
+ async createWebhookEndpoint(
253
+ body: Static<typeof CreateWebhookEndpointRequest>,
254
+ ): Promise<Static<typeof WebhookEndpointResponse>> {
255
+ return this.request('POST', '/v1/webhook_endpoints', {
256
+ body,
257
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
258
+ expectedStatus: 201,
259
+ });
260
+ }
261
+
262
+ /** List webhook endpoints */
263
+ async listWebhookEndpoints(
264
+ options?: ListOptions,
265
+ ): Promise<ListResponse<Static<typeof WebhookEndpointResponse>>> {
266
+ return this.request('GET', '/v1/webhook_endpoints', { query: options });
267
+ }
268
+
269
+ /** Get a webhook endpoint by ID */
270
+ async getWebhookEndpoint(
271
+ endpointId: string,
272
+ ): Promise<Static<typeof WebhookEndpointResponse>> {
273
+ return this.request('GET', `/v1/webhook_endpoints/${endpointId}`);
274
+ }
275
+
276
+ /** Update a webhook endpoint */
277
+ async updateWebhookEndpoint(
278
+ endpointId: string,
279
+ body: Static<typeof UpdateWebhookEndpointRequest>,
280
+ ): Promise<Static<typeof WebhookEndpointResponse>> {
281
+ return this.request('PATCH', `/v1/webhook_endpoints/${endpointId}`, { body });
282
+ }
283
+
284
+ /** Delete a webhook endpoint */
285
+ async deleteWebhookEndpoint(endpointId: string): Promise<void> {
286
+ await this.request('DELETE', `/v1/webhook_endpoints/${endpointId}`, {
287
+ expectedStatus: 204,
288
+ parseBody: false,
289
+ });
290
+ }
291
+
292
+ /** Rotate webhook signing secret */
293
+ async rotateWebhookSecret(
294
+ endpointId: string,
295
+ ): Promise<Static<typeof RotateWebhookSecretResponse>> {
296
+ return this.request('POST', `/v1/webhook_endpoints/${endpointId}/rotate_secret`, {
297
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
298
+ });
299
+ }
300
+
301
+ // ── Webhook Events ─────────────────────────────────────────────────
302
+
303
+ /** List webhook events */
304
+ async listWebhookEvents(
305
+ options?: ListOptions & { status?: string; event_type?: string },
306
+ ): Promise<ListResponse<Static<typeof WebhookEventResponse>>> {
307
+ return this.request('GET', '/v1/webhook_events', { query: options });
308
+ }
309
+
310
+ /** Replay a webhook event */
311
+ async replayWebhookEvent(
312
+ eventId: string,
313
+ ): Promise<Static<typeof WebhookEventResponse>> {
314
+ return this.request('POST', `/v1/webhook_events/${eventId}/replay`, {
315
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
316
+ expectedStatus: 202,
317
+ });
318
+ }
319
+
320
+ // ── Billing ────────────────────────────────────────────────────────
321
+
322
+ /** Get current credit balance */
323
+ async getCreditBalance(): Promise<Static<typeof CreditBalanceResponse>> {
324
+ return this.request('GET', '/v1/billing/credits');
325
+ }
326
+
327
+ /** List credit transactions */
328
+ async listTransactions(
329
+ options?: ListOptions & { type?: string },
330
+ ): Promise<ListResponse<Static<typeof CreditTransaction>>> {
331
+ return this.request('GET', '/v1/billing/transactions', { query: options });
332
+ }
333
+
334
+ /** Initiate credit purchase via Stripe Checkout */
335
+ async purchaseCredits(
336
+ body: Static<typeof PurchaseCreditsRequest>,
337
+ ): Promise<Static<typeof PurchaseCreditsResponse>> {
338
+ return this.request('POST', '/v1/billing/credits/purchase', {
339
+ body,
340
+ headers: { 'Idempotency-Key': generateIdempotencyKey() },
341
+ });
342
+ }
343
+
344
+ // ── HTTP Layer ─────────────────────────────────────────────────────
345
+
346
+ private async request<T = unknown>(
347
+ method: string,
348
+ path: string,
349
+ options: {
350
+ body?: unknown;
351
+ query?: Record<string, unknown>;
352
+ headers?: Record<string, string>;
353
+ expectedStatus?: number;
354
+ parseBody?: boolean;
355
+ } = {},
356
+ ): Promise<T> {
357
+ const { body, query, headers: extraHeaders, expectedStatus, parseBody = true } = options;
358
+
359
+ let url = `${this.baseUrl}${path}`;
360
+ if (query) {
361
+ const params = new URLSearchParams();
362
+ for (const [key, value] of Object.entries(query)) {
363
+ if (value !== undefined && value !== null) {
364
+ params.set(key, String(value));
365
+ }
366
+ }
367
+ const qs = params.toString();
368
+ if (qs) url += `?${qs}`;
369
+ }
370
+
371
+ const headers: Record<string, string> = {
372
+ Authorization: `Bearer ${this.apiKey}`,
373
+ Accept: 'application/json',
374
+ ...extraHeaders,
375
+ };
376
+ if (body) {
377
+ headers['Content-Type'] = 'application/json';
378
+ }
379
+
380
+ let lastError: Error | undefined;
381
+
382
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
383
+ if (attempt > 0) {
384
+ // Exponential backoff with jitter
385
+ const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
386
+ const jitter = delay * 0.1 * Math.random();
387
+ await sleep(delay + jitter);
388
+ }
389
+
390
+ try {
391
+ const controller = new AbortController();
392
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
393
+
394
+ let response: Response;
395
+ try {
396
+ response = await this.fetchFn(url, {
397
+ method,
398
+ headers,
399
+ body: body ? JSON.stringify(body) : undefined,
400
+ signal: controller.signal,
401
+ });
402
+ } finally {
403
+ clearTimeout(timer);
404
+ }
405
+
406
+ // Success range
407
+ if (response.ok) {
408
+ if (!parseBody || response.status === 204) {
409
+ return undefined as T;
410
+ }
411
+ return (await response.json()) as T;
412
+ }
413
+
414
+ // Check expected status
415
+ if (expectedStatus && response.status === expectedStatus) {
416
+ if (!parseBody) return undefined as T;
417
+ return (await response.json()) as T;
418
+ }
419
+
420
+ // Parse error body
421
+ let errorBody: { error?: { type: string; message: string; request_id: string; details?: unknown[] } };
422
+ try {
423
+ errorBody = (await response.json()) as typeof errorBody;
424
+ } catch {
425
+ errorBody = {
426
+ error: {
427
+ type: 'unknown',
428
+ message: `HTTP ${response.status}`,
429
+ request_id: 'unknown',
430
+ },
431
+ };
432
+ }
433
+
434
+ const apiError = new FirstHandApiError(response.status, {
435
+ type: errorBody.error?.type ?? 'unknown',
436
+ message: errorBody.error?.message ?? `HTTP ${response.status}`,
437
+ request_id: errorBody.error?.request_id ?? 'unknown',
438
+ details: errorBody.error?.details as FirstHandErrorDetail[] | undefined,
439
+ });
440
+
441
+ // Only retry on retryable errors
442
+ if (!apiError.retryable || attempt >= this.maxRetries) {
443
+ throw apiError;
444
+ }
445
+
446
+ lastError = apiError;
447
+ } catch (err) {
448
+ if (err instanceof FirstHandApiError) throw err;
449
+
450
+ const connError = new FirstHandConnectionError(
451
+ `Connection failed: ${(err as Error).message}`,
452
+ err as Error,
453
+ );
454
+
455
+ if (attempt >= this.maxRetries) throw connError;
456
+ lastError = connError;
457
+ }
458
+ }
459
+
460
+ throw lastError ?? new FirstHandConnectionError('Request failed after retries');
461
+ }
462
+ }
463
+
464
+ // ─── Helpers ─────────────────────────────────────────────────────────────
465
+
466
+ function sleep(ms: number): Promise<void> {
467
+ return new Promise((resolve) => setTimeout(resolve, ms));
468
+ }
469
+
470
+ /** Generates a random idempotency key (UUID v4 format) */
471
+ export function generateIdempotencyKey(): string {
472
+ const bytes = new Uint8Array(16);
473
+ crypto.getRandomValues(bytes);
474
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4
475
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 1
476
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
477
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
478
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @module @firsthandapi/sdk/errors
3
+ * @description SDK error types for FirstHandAPI API responses.
4
+ */
5
+
6
+ export interface FirstHandErrorDetail {
7
+ field?: string;
8
+ message: string;
9
+ code?: string;
10
+ }
11
+
12
+ export interface FirstHandErrorBody {
13
+ type: string;
14
+ message: string;
15
+ request_id: string;
16
+ details?: FirstHandErrorDetail[];
17
+ }
18
+
19
+ /**
20
+ * Error thrown when FirstHandAPI returns a non-2xx response.
21
+ * Contains the parsed error envelope from the response body.
22
+ */
23
+ export class FirstHandApiError extends Error {
24
+ readonly status: number;
25
+ readonly type: string;
26
+ readonly requestId: string;
27
+ readonly details?: FirstHandErrorDetail[];
28
+
29
+ constructor(status: number, body: FirstHandErrorBody) {
30
+ super(body.message);
31
+ this.name = 'FirstHandApiError';
32
+ this.status = status;
33
+ this.type = body.type;
34
+ this.requestId = body.request_id;
35
+ this.details = body.details;
36
+ }
37
+
38
+ /** True if the error is retryable (429, 500, 502, 503, 504) */
39
+ get retryable(): boolean {
40
+ return this.status === 429 || this.status >= 500;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Error thrown when a network failure or timeout occurs.
46
+ */
47
+ export class FirstHandConnectionError extends Error {
48
+ readonly cause?: Error;
49
+
50
+ constructor(message: string, cause?: Error) {
51
+ super(message);
52
+ this.name = 'FirstHandConnectionError';
53
+ this.cause = cause;
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @module @firsthandapi/sdk
3
+ * @description TypeScript SDK for the FirstHandAPI REST API.
4
+ * Typed client wrapping all v1 endpoints with auto-retry,
5
+ * idempotency key generation, and webhook signature verification.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { FirstHandClient } from '@firsthandapi/sdk';
10
+ *
11
+ * const client = new FirstHandClient({ apiKey: 'fh_live_...' });
12
+ * const job = await client.createJob({
13
+ * job_type: 'product_photo',
14
+ * title: 'Product photo for listing #1234',
15
+ * instructions: 'Take a clean photo of the product on white background',
16
+ * });
17
+ * ```
18
+ */
19
+
20
+ export { FirstHandClient, generateIdempotencyKey } from './client.js';
21
+ export type { FirstHandClientOptions, ListResponse, ListOptions, GetJobOptions } from './client.js';
22
+ export { FirstHandApiError, FirstHandConnectionError } from './errors.js';
23
+ export type { FirstHandErrorBody, FirstHandErrorDetail } from './errors.js';
24
+ export { verifyWebhookSignature } from './webhooks.js';
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @module @firsthandapi/sdk/webhooks
3
+ * @description Webhook signature verification for FirstHandAPI webhook events.
4
+ * Clients use this to verify that webhook payloads were sent by FirstHandAPI.
5
+ *
6
+ * @see webhook-contract.md — Webhook signing specification
7
+ */
8
+
9
+ import { createHmac, timingSafeEqual } from 'node:crypto';
10
+
11
+ /**
12
+ * Verifies a webhook signature from FirstHandAPI.
13
+ *
14
+ * @param payload - Raw request body (string or Buffer)
15
+ * @param signature - Value of the `X-FirstHandAPI-Signature` header
16
+ * @param secret - Webhook signing secret from endpoint creation
17
+ * @param toleranceSeconds - Maximum age of the event in seconds (default: 300 = 5 minutes)
18
+ * @returns true if the signature is valid
19
+ * @throws Error if signature is invalid, expired, or malformed
20
+ */
21
+ export function verifyWebhookSignature(
22
+ payload: string | Buffer,
23
+ signature: string,
24
+ secret: string,
25
+ toleranceSeconds = 300,
26
+ ): boolean {
27
+ // Parse signature: "t=<timestamp>,v1=<hash>"
28
+ const parts = signature.split(',');
29
+ const timestampPart = parts.find((p) => p.startsWith('t='));
30
+ const hashPart = parts.find((p) => p.startsWith('v1='));
31
+
32
+ if (!timestampPart || !hashPart) {
33
+ throw new Error('Invalid webhook signature format. Expected "t=<timestamp>,v1=<hash>"');
34
+ }
35
+
36
+ const timestamp = parseInt(timestampPart.slice(2), 10);
37
+ const expectedHash = hashPart.slice(3);
38
+
39
+ if (isNaN(timestamp)) {
40
+ throw new Error('Invalid timestamp in webhook signature');
41
+ }
42
+
43
+ // Check tolerance
44
+ const now = Math.floor(Date.now() / 1000);
45
+ if (Math.abs(now - timestamp) > toleranceSeconds) {
46
+ throw new Error(
47
+ `Webhook timestamp too old (${Math.abs(now - timestamp)}s, tolerance: ${toleranceSeconds}s)`,
48
+ );
49
+ }
50
+
51
+ // Compute expected signature
52
+ const body = typeof payload === 'string' ? payload : payload.toString('utf8');
53
+ const signedPayload = `${timestamp}.${body}`;
54
+ const computedHash = createHmac('sha256', secret).update(signedPayload).digest('hex');
55
+
56
+ // Timing-safe comparison
57
+ const expected = Buffer.from(expectedHash, 'hex');
58
+ const computed = Buffer.from(computedHash, 'hex');
59
+
60
+ if (expected.length !== computed.length) {
61
+ throw new Error('Webhook signature verification failed');
62
+ }
63
+
64
+ if (!timingSafeEqual(expected, computed)) {
65
+ throw new Error('Webhook signature verification failed');
66
+ }
67
+
68
+ return true;
69
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
10
+ }