@instantdb/webhooks 0.0.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/index.ts ADDED
@@ -0,0 +1,1047 @@
1
+ import {
2
+ InstantAPIError,
3
+ InstantError,
4
+ InstantSchemaDef,
5
+ ResolveAttrs,
6
+ version as coreVersion,
7
+ } from '@instantdb/core';
8
+
9
+ type Config<Schema extends InstantSchemaDef<any, any, any>> = {
10
+ appId?: string | null | undefined;
11
+ adminToken?: string | null | undefined;
12
+ token?: string | null | undefined;
13
+ apiURI?: string | null | undefined;
14
+ schema?: Schema | null | undefined;
15
+ /**
16
+ * Optional hook used by {@link WebhooksManager} to obtain the bearer token
17
+ * for each management request. Lets callers (e.g. the platform SDK) wrap
18
+ * the operation in token-refresh / retry logic.
19
+ *
20
+ * If omitted, the manager uses the static `adminToken`/`token` from this
21
+ * config.
22
+ */
23
+ withAuth?: WithAuth;
24
+ };
25
+
26
+ type JsonFetch = (
27
+ input: RequestInfo,
28
+ init?: RequestInit | undefined,
29
+ ) => Promise<any>;
30
+
31
+ /**
32
+ * Runs a webhook management operation that needs a bearer token. The runner
33
+ * is responsible for supplying the token, and may retry the operation with a
34
+ * fresh token if the first attempt fails with an auth error.
35
+ */
36
+ export type WithAuth = <T>(
37
+ operation: (token: string) => Promise<T>,
38
+ ) => Promise<T>;
39
+
40
+ export type WebhookBody = {
41
+ payloadUrl: string;
42
+ token: string;
43
+ };
44
+
45
+ export type WebhookEntity<
46
+ Schema extends InstantSchemaDef<any, any, any>,
47
+ EtypeName extends keyof Schema['entities'],
48
+ > = { id: string } & ResolveAttrs<Schema['entities'], EtypeName, false>;
49
+
50
+ export type WebhookPayloadRecord<
51
+ Schema extends InstantSchemaDef<any, any, any>,
52
+ > = {
53
+ [EtypeName in keyof Schema['entities']]:
54
+ | {
55
+ etype: EtypeName;
56
+ id: string;
57
+ action: 'create';
58
+ before: null;
59
+ after: WebhookEntity<Schema, EtypeName>;
60
+ idempotencyKey: string;
61
+ }
62
+ | {
63
+ etype: EtypeName;
64
+ id: string;
65
+ action: 'update';
66
+ before: WebhookEntity<Schema, EtypeName>;
67
+ after: WebhookEntity<Schema, EtypeName>;
68
+ idempotencyKey: string;
69
+ }
70
+ | {
71
+ etype: EtypeName;
72
+ id: string;
73
+ action: 'delete';
74
+ before: WebhookEntity<Schema, EtypeName>;
75
+ after: null;
76
+ idempotencyKey: string;
77
+ };
78
+ }[keyof Schema['entities']];
79
+
80
+ export type WebhookPayload<Schema extends InstantSchemaDef<any, any, any>> = {
81
+ data: WebhookPayloadRecord<Schema>[];
82
+ idempotencyKey: string;
83
+ };
84
+
85
+ export type WebhookAction = 'create' | 'update' | 'delete';
86
+
87
+ /**
88
+ * Whether Instant will currently deliver events for a webhook.
89
+ * `disabled` webhooks remain configured but no new events are queued.
90
+ */
91
+ export type WebhookStatus = 'active' | 'disabled';
92
+
93
+ /**
94
+ * Stage in the delivery lifecycle of a single webhook event.
95
+ *
96
+ * - `pending`: queued, not yet picked up for delivery
97
+ * - `processing`: a sender is actively attempting delivery
98
+ * - `success`: the receiver acknowledged with a 2xx response
99
+ * - `error`: an attempt failed; another retry is scheduled
100
+ * - `failed`: all retries exhausted; will not be retried automatically
101
+ * (use {@link WebhooksManager.resendEvent} to retry manually)
102
+ */
103
+ export type WebhookEventStatus =
104
+ | 'pending'
105
+ | 'processing'
106
+ | 'success'
107
+ | 'error'
108
+ | 'failed';
109
+
110
+ export type WebhookInfo = {
111
+ /** Unique identifier for the webhook. */
112
+ id: string;
113
+ /** Where Instant POSTs event payloads to. */
114
+ sink: {
115
+ /** HTTPS endpoint that Instant POSTs to. */
116
+ url: string;
117
+ };
118
+ /**
119
+ * The entity types (namespaces) this webhook listens to. `null` if every
120
+ * etype the webhook referenced has since been removed from the schema.
121
+ */
122
+ etypes: string[] | null;
123
+ /** Which write actions trigger delivery. */
124
+ actions: WebhookAction[];
125
+ /** Whether the webhook is currently delivering events. */
126
+ status: WebhookStatus;
127
+ /**
128
+ * Human-readable reason the webhook is disabled. Set automatically when
129
+ * Instant disables the webhook (e.g. after repeated delivery failures) or
130
+ * supplied by the caller via {@link WebhooksManager.disable}. `null` when
131
+ * `status` is `'active'`.
132
+ */
133
+ disabledReason: string | null;
134
+ /** When the webhook was created. */
135
+ createdAt: Date;
136
+ /** When the webhook's config was last changed. */
137
+ updatedAt: Date;
138
+ };
139
+
140
+ /**
141
+ * Record of a single HTTP delivery attempt for a webhook event.
142
+ * Stored in attempt order (oldest first) on the event's `attempts` array.
143
+ */
144
+ export type WebhookAttempt = {
145
+ /** When the attempt started. */
146
+ attemptAt: Date | null;
147
+ /** Time from request start to response received (or error), in milliseconds. */
148
+ durationMs: number | null;
149
+ /** `true` if the receiver returned a 2xx response. */
150
+ success: boolean | null;
151
+ /** HTTP status code returned by the receiver, if a response was received. */
152
+ statusCode: number | null;
153
+ /**
154
+ * First 256 bytes of the response body, for debugging. `null` if no
155
+ * response was received (e.g. on a network error).
156
+ */
157
+ responseText: string | null;
158
+ /**
159
+ * Short tag classifying a delivery failure. One of `timeout`, `dns`,
160
+ * `connect`, `tls`, `protocol`, `network`, or `unknown`. `null` on success.
161
+ */
162
+ errorType: string | null;
163
+ /** Free-form description of the failure. `null` on success. */
164
+ errorMessage: string | null;
165
+ };
166
+
167
+ export type WebhookEventInfo = {
168
+ /**
169
+ * Instant Sequence Number — a stable, totally ordered identifier for the
170
+ * event. Doubles as the pagination cursor and is used to address the event
171
+ * in {@link WebhooksManager.getEvent} and {@link WebhooksManager.resendEvent}.
172
+ */
173
+ isn: string;
174
+ /** Current stage in the delivery lifecycle. */
175
+ status: WebhookEventStatus;
176
+ /**
177
+ * Per-attempt records, in attempt order (oldest first). `null` if the
178
+ * event has not been attempted yet.
179
+ */
180
+ attempts: WebhookAttempt[] | null;
181
+ /**
182
+ * The next retry will not happen before this time. `null` once the event
183
+ * reaches a terminal status (`success` or `failed`).
184
+ */
185
+ nextAttemptAfter: Date | null;
186
+ /** When the event was queued. */
187
+ createdAt: Date;
188
+ /** When the event last transitioned status. */
189
+ updatedAt: Date;
190
+ };
191
+
192
+ export type WebhookEventsPage = {
193
+ /** The events on this page, newest first. */
194
+ events: WebhookEventInfo[];
195
+ pageInfo: {
196
+ /** Cursor pointing to the first event on this page. */
197
+ startCursor: string | null;
198
+ /**
199
+ * Cursor pointing to the last event on this page. Pass as
200
+ * {@link WebhooksManager.listEvents}'s `after` option to fetch the next page.
201
+ */
202
+ endCursor: string | null;
203
+ /** Whether more events are available after `endCursor`. */
204
+ hasNextPage: boolean;
205
+ };
206
+ };
207
+
208
+ export type CreateWebhookParams<
209
+ Schema extends InstantSchemaDef<any, any, any>,
210
+ > = {
211
+ /**
212
+ * HTTPS endpoint Instant will POST events to. Must use the `https` scheme
213
+ * and resolve to a public host.
214
+ */
215
+ url: string;
216
+ /**
217
+ * Entity types (namespaces) the webhook will listen to. Must reference at
218
+ * least one entity in the app's schema.
219
+ */
220
+ etypes: (keyof Schema['entities'] & string)[];
221
+ /** Write actions that should trigger delivery. Must contain at least one. */
222
+ actions: WebhookAction[];
223
+ };
224
+
225
+ export type UpdateWebhookParams<
226
+ Schema extends InstantSchemaDef<any, any, any>,
227
+ > = {
228
+ /** New delivery URL. Omit to leave unchanged. */
229
+ url?: string;
230
+ /** New set of entity types. Omit to leave unchanged. */
231
+ etypes?: (keyof Schema['entities'] & string)[];
232
+ /** New set of actions. Omit to leave unchanged. */
233
+ actions?: WebhookAction[];
234
+ };
235
+
236
+ export type WebhookPayloadRecordFor<
237
+ Schema extends InstantSchemaDef<any, any, any>,
238
+ EtypeName extends keyof Schema['entities'],
239
+ Action extends WebhookAction,
240
+ > = Extract<WebhookPayloadRecord<Schema>, { etype: EtypeName; action: Action }>;
241
+
242
+ export type WebhookHandlerFn<
243
+ Schema extends InstantSchemaDef<any, any, any>,
244
+ EtypeName extends keyof Schema['entities'],
245
+ Action extends WebhookAction,
246
+ Result = any,
247
+ > = (
248
+ record: WebhookPayloadRecordFor<Schema, EtypeName, Action>,
249
+ ) => Result | Promise<Result>;
250
+
251
+ export type DefaultKey = '$default';
252
+
253
+ export type ResolveHandlerAction<Action> = Action extends DefaultKey
254
+ ? WebhookAction
255
+ : Action extends WebhookAction
256
+ ? Action
257
+ : never;
258
+
259
+ export type WebhookHandlers<Schema extends InstantSchemaDef<any, any, any>> = {
260
+ [EtypeName in keyof Schema['entities']]?: {
261
+ [Action in WebhookAction | DefaultKey]?: WebhookHandlerFn<
262
+ Schema,
263
+ EtypeName,
264
+ ResolveHandlerAction<Action>,
265
+ any
266
+ >;
267
+ };
268
+ } & {
269
+ $default?: WebhookHandlerFn<
270
+ Schema,
271
+ keyof Schema['entities'],
272
+ WebhookAction,
273
+ any
274
+ >;
275
+ };
276
+
277
+ export type TypedHandlerEntry<
278
+ Schema extends InstantSchemaDef<any, any, any>,
279
+ EtypeName extends keyof Schema['entities'],
280
+ Action extends WebhookAction | DefaultKey,
281
+ > = {
282
+ [E in EtypeName]: {
283
+ [A in Action]: WebhookHandlerFn<
284
+ Schema,
285
+ EtypeName,
286
+ ResolveHandlerAction<Action>,
287
+ any
288
+ >;
289
+ };
290
+ };
291
+
292
+ export type TypedDefaultEntry<Schema extends InstantSchemaDef<any, any, any>> =
293
+ {
294
+ $default: WebhookHandlerFn<
295
+ Schema,
296
+ keyof Schema['entities'],
297
+ WebhookAction,
298
+ any
299
+ >;
300
+ };
301
+
302
+ export type WebhookHelpers<Schema extends InstantSchemaDef<any, any, any>> = {
303
+ typedHandlers: {
304
+ (
305
+ etype: DefaultKey,
306
+ handler: WebhookHandlerFn<
307
+ Schema,
308
+ keyof Schema['entities'],
309
+ WebhookAction,
310
+ any
311
+ >,
312
+ ): TypedDefaultEntry<Schema>;
313
+ <
314
+ EtypeName extends keyof Schema['entities'],
315
+ Action extends WebhookAction | DefaultKey,
316
+ >(
317
+ etype: EtypeName,
318
+ action: Action,
319
+ handler: WebhookHandlerFn<
320
+ Schema,
321
+ EtypeName,
322
+ ResolveHandlerAction<Action>,
323
+ any
324
+ >,
325
+ ): TypedHandlerEntry<Schema, EtypeName, Action>;
326
+ };
327
+ combineHandlers: (
328
+ ...entries: Array<WebhookHandlers<Schema>>
329
+ ) => WebhookHandlers<Schema>;
330
+ };
331
+
332
+ const knownKeys = {
333
+ 'https://api.instantdb.com': {
334
+ keys: [
335
+ {
336
+ kty: 'OKP',
337
+ crv: 'Ed25519',
338
+ alg: 'EdDSA',
339
+ use: 'sig',
340
+ kid: '1034696293',
341
+ x: 'N-C41432STKAKkXAWmeIOXMnZcGRR1b9u1L3bTVqI_o',
342
+ },
343
+ ],
344
+ },
345
+ 'http://localhost:8888': {
346
+ keys: [
347
+ {
348
+ kty: 'OKP',
349
+ crv: 'Ed25519',
350
+ alg: 'EdDSA',
351
+ use: 'sig',
352
+ kid: '503090235',
353
+ x: 'qrSkwDaMITRMF9nOgpueqxgaAiuFmJperYE3mkyl8Ow',
354
+ },
355
+ ],
356
+ },
357
+ };
358
+
359
+ type ImportAlgorithm =
360
+ | AlgorithmIdentifier
361
+ | RsaHashedImportParams
362
+ | EcKeyImportParams;
363
+
364
+ function inferWebCryptoAlg(jwk: any): ImportAlgorithm {
365
+ if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') {
366
+ return { name: 'Ed25519' };
367
+ }
368
+
369
+ if (jwk.kty === 'EC') {
370
+ return { name: 'ECDSA', namedCurve: jwk.crv }; // e.g., P-256, P-384
371
+ }
372
+
373
+ if (jwk.kty === 'RSA') {
374
+ // RSA keys often specify the exact hash in the 'alg' field (e.g., RS256)
375
+ const hashMap: Record<string, string> = {
376
+ RS256: 'SHA-256',
377
+ RS384: 'SHA-384',
378
+ RS512: 'SHA-512',
379
+ };
380
+ return {
381
+ name: 'RSASSA-PKCS1-v1_5',
382
+ hash: hashMap[jwk.alg] || 'SHA-256',
383
+ };
384
+ }
385
+
386
+ throw new Error(
387
+ `Unsupported JWK configuration: kty=${jwk.kty}, crv=${jwk.crv}`,
388
+ );
389
+ }
390
+
391
+ async function importKey(
392
+ jwk: any,
393
+ ): Promise<{ alg: ImportAlgorithm; key: CryptoKey }> {
394
+ const alg = inferWebCryptoAlg(jwk);
395
+ const key = await crypto.subtle.importKey('jwk', jwk, alg, false, ['verify']);
396
+ return { alg, key };
397
+ }
398
+
399
+ function verify(
400
+ alg: ImportAlgorithm,
401
+ key: CryptoKey,
402
+ signature: BufferSource,
403
+ message: BufferSource,
404
+ ): Promise<boolean> {
405
+ return crypto.subtle.verify(
406
+ alg as AlgorithmIdentifier,
407
+ key,
408
+ signature,
409
+ message,
410
+ );
411
+ }
412
+
413
+ function hexToUint8Array(hexString: string): Uint8Array<ArrayBuffer> {
414
+ const bytes = new Uint8Array(Math.ceil(hexString.length / 2));
415
+ for (let i = 0; i < bytes.length; i++) {
416
+ bytes[i] = parseInt(hexString.substring(i * 2, i * 2 + 2), 16);
417
+ }
418
+ return bytes;
419
+ }
420
+
421
+ // XXX: Test in nextjs app router
422
+ // XXX: Test in nextjs pages router
423
+ // XXX: Test with express, deno, koa, nestjs
424
+ // XXX: Test with cloudflare
425
+
426
+ function parseSignatureHeader(h: string): {
427
+ t: string;
428
+ kid: string;
429
+ v1: string;
430
+ } {
431
+ let t: string | undefined, kid: string | undefined, v1: string | undefined;
432
+ for (const part of h.split(',')) {
433
+ const [k, v] = part.split('=');
434
+ switch (k) {
435
+ case 't': {
436
+ t = v;
437
+ break;
438
+ }
439
+ case 'kid': {
440
+ kid = v;
441
+ break;
442
+ }
443
+ case 'v1': {
444
+ v1 = v;
445
+ break;
446
+ }
447
+ }
448
+ }
449
+
450
+ const missingKeys: string[] = [];
451
+ if (!t) {
452
+ missingKeys.push('t');
453
+ }
454
+ if (!kid) {
455
+ missingKeys.push('kid');
456
+ }
457
+ if (!v1) {
458
+ missingKeys.push('v1');
459
+ }
460
+
461
+ if (missingKeys.length || !t || !kid || !v1) {
462
+ throw new InstantError('Invalid Instant-Signature header.', {
463
+ header: h,
464
+ missingKeys,
465
+ });
466
+ }
467
+
468
+ return { t, kid, v1 };
469
+ }
470
+
471
+ function validateT(receivedAt: Date, t: string, tolerance: number): void {
472
+ const age = Math.floor(receivedAt.getTime() / 1000) - parseInt(t, 10);
473
+ if (age > tolerance) {
474
+ throw new InstantError('Webhook signature is too old', {
475
+ tolerance,
476
+ receivedAt,
477
+ t,
478
+ });
479
+ }
480
+ }
481
+
482
+ // We make this a global cache so that it will survive across
483
+ // restarts. It will only store valid signing keys from instant, and we don't
484
+ // create many of them, so the memory usage will be only 1 or 2 keys.
485
+ const keyCache: Record<string, { alg: ImportAlgorithm; key: CryptoKey }> = {};
486
+ const defaultTolerance = 300; // 5 minutes
487
+
488
+ async function jsonReject(
489
+ rejectFn: (err: InstantAPIError) => any,
490
+ res: Response,
491
+ ) {
492
+ const body = await res.text();
493
+ try {
494
+ const json = JSON.parse(body);
495
+ return rejectFn(new InstantAPIError({ status: res.status, body: json }));
496
+ } catch (_e) {
497
+ return rejectFn(
498
+ new InstantAPIError({
499
+ status: res.status,
500
+ body: { type: undefined, message: body },
501
+ }),
502
+ );
503
+ }
504
+ }
505
+
506
+ const defaultJsonFetch: JsonFetch = async (input, init) => {
507
+ const headers = {
508
+ ...(init?.headers || {}),
509
+ 'Instant-Core-Version': coreVersion,
510
+ };
511
+ const res = await fetch(input, { ...init, headers });
512
+ if (res.status === 200) {
513
+ return res.json();
514
+ }
515
+ return jsonReject((x) => Promise.reject(x), res);
516
+ };
517
+
518
+ function parseDate(s: string | null | undefined): Date | null {
519
+ return s ? new Date(s) : null;
520
+ }
521
+
522
+ function toWebhookInfo(raw: any): WebhookInfo {
523
+ return {
524
+ id: raw.id,
525
+ sink: raw.sink,
526
+ etypes: raw.etypes ?? null,
527
+ actions: raw.actions,
528
+ status: raw.status,
529
+ disabledReason: raw.disabled_reason ?? null,
530
+ createdAt: new Date(raw.created_at),
531
+ updatedAt: new Date(raw.updated_at),
532
+ };
533
+ }
534
+
535
+ function toWebhookAttempt(raw: any): WebhookAttempt {
536
+ return {
537
+ attemptAt: parseDate(raw['attempt-at']),
538
+ durationMs: raw['duration-ms'] ?? null,
539
+ success: raw['success?'] ?? null,
540
+ statusCode: raw['status-code'] ?? null,
541
+ responseText: raw['response-text'] ?? null,
542
+ errorType: raw['error-type'] ?? null,
543
+ errorMessage: raw['error-message'] ?? null,
544
+ };
545
+ }
546
+
547
+ function toWebhookEventInfo(raw: any): WebhookEventInfo {
548
+ return {
549
+ isn: raw.isn,
550
+ status: raw.status,
551
+ attempts: raw.attempts ? raw.attempts.map(toWebhookAttempt) : null,
552
+ nextAttemptAfter: parseDate(raw.next_attempt_after),
553
+ createdAt: new Date(raw.created_at),
554
+ updatedAt: new Date(raw.updated_at),
555
+ };
556
+ }
557
+
558
+ export class WebhooksManager<Schema extends InstantSchemaDef<any, any, any>> {
559
+ #appId: string | null | undefined;
560
+ #apiURI: string;
561
+ #token: string | null | undefined;
562
+ #withAuth: WithAuth | undefined;
563
+ #jsonFetch: JsonFetch;
564
+
565
+ constructor(opts: {
566
+ appId: string | null | undefined;
567
+ apiURI: string;
568
+ token: string | null | undefined;
569
+ withAuth?: WithAuth;
570
+ jsonFetch: JsonFetch;
571
+ }) {
572
+ this.#appId = opts.appId;
573
+ this.#apiURI = opts.apiURI;
574
+ this.#token = opts.token;
575
+ this.#withAuth = opts.withAuth;
576
+ this.#jsonFetch = opts.jsonFetch;
577
+ }
578
+
579
+ #authedFetch(
580
+ path: string,
581
+ opts?: { method?: string; body?: unknown },
582
+ ): Promise<any> {
583
+ if (!this.#appId) {
584
+ throw new InstantError(
585
+ 'appId is required to manage webhooks. Pass it to the Webhooks constructor.',
586
+ );
587
+ }
588
+ const run = (token: string) => {
589
+ const init: RequestInit = {
590
+ method: opts?.method,
591
+ headers: {
592
+ authorization: `Bearer ${token}`,
593
+ 'content-type': 'application/json',
594
+ },
595
+ };
596
+ if (opts?.body !== undefined) {
597
+ init.body = JSON.stringify(opts.body);
598
+ }
599
+ return this.#jsonFetch(`${this.#apiURI}${path}`, init);
600
+ };
601
+ if (this.#withAuth) {
602
+ return this.#withAuth(run);
603
+ }
604
+ if (!this.#token) {
605
+ throw new InstantError(
606
+ 'A token is required to manage webhooks. Pass `adminToken` or `token` to the Webhooks constructor.',
607
+ );
608
+ }
609
+ return run(this.#token);
610
+ }
611
+
612
+ /**
613
+ * Returns every webhook configured on the app, newest first. Includes both
614
+ * active and disabled webhooks.
615
+ */
616
+ async list(): Promise<WebhookInfo[]> {
617
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks`);
618
+ return (res.webhooks || []).map(toWebhookInfo);
619
+ }
620
+
621
+ /**
622
+ * Creates a new webhook. The webhook is created in the `active` state and
623
+ * starts receiving matching events immediately.
624
+ *
625
+ * The server rejects the request if `url` is not an HTTPS URL pointing at a
626
+ * public host, if `etypes` doesn't reference any entity in the app's
627
+ * schema, if `actions` is empty, or if the app has hit its webhook limit.
628
+ *
629
+ * @example
630
+ * const webhook = await db.webhooks.manager.create({
631
+ * url: 'https://example.com/instant',
632
+ * etypes: ['posts', 'comments'],
633
+ * actions: ['create', 'update'],
634
+ * });
635
+ */
636
+ async create(params: CreateWebhookParams<Schema>): Promise<WebhookInfo> {
637
+ const res = await this.#authedFetch(`/dash/apps/${this.#appId}/webhooks`, {
638
+ method: 'POST',
639
+ body: params,
640
+ });
641
+ return toWebhookInfo(res.webhook);
642
+ }
643
+
644
+ /**
645
+ * Updates a webhook's `url`, `etypes`, and/or `actions`. Pass only the
646
+ * fields you want to change; omitted fields keep their current value.
647
+ *
648
+ * Does not affect the webhook's status — use {@link enable} or
649
+ * {@link disable} for that.
650
+ */
651
+ async update(
652
+ webhookId: string,
653
+ params: UpdateWebhookParams<Schema>,
654
+ ): Promise<WebhookInfo> {
655
+ const res = await this.#authedFetch(
656
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}`,
657
+ { method: 'POST', body: params },
658
+ );
659
+ return toWebhookInfo(res.webhook);
660
+ }
661
+
662
+ /**
663
+ * Deletes a webhook. No further events will be queued for it. Returns the
664
+ * webhook as it looked just before deletion.
665
+ */
666
+ async delete(webhookId: string): Promise<WebhookInfo> {
667
+ const res = await this.#authedFetch(
668
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}`,
669
+ { method: 'DELETE' },
670
+ );
671
+ return toWebhookInfo(res.webhook);
672
+ }
673
+
674
+ /**
675
+ * Re-enables a disabled webhook. Clears `disabledReason` and resumes
676
+ * delivery for new events. Has no effect if the webhook is already active.
677
+ *
678
+ * Events that occurred while the webhook was disabled are not retroactively
679
+ * delivered.
680
+ */
681
+ async enable(webhookId: string): Promise<WebhookInfo> {
682
+ const res = await this.#authedFetch(
683
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}/enable`,
684
+ { method: 'POST', body: {} },
685
+ );
686
+ return toWebhookInfo(res.webhook);
687
+ }
688
+
689
+ /**
690
+ * Disables a webhook. No new events will be queued until it is re-enabled
691
+ * via {@link enable}. In-flight events already being processed will still
692
+ * complete.
693
+ *
694
+ * @param opts.reason Optional human-readable note stored on the webhook
695
+ * and surfaced in the dashboard.
696
+ */
697
+ async disable(
698
+ webhookId: string,
699
+ opts?: { reason?: string | null | undefined } | null | undefined,
700
+ ): Promise<WebhookInfo> {
701
+ const body = opts?.reason ? { reason: opts.reason } : {};
702
+ const res = await this.#authedFetch(
703
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}/disable`,
704
+ { method: 'POST', body },
705
+ );
706
+ return toWebhookInfo(res.webhook);
707
+ }
708
+
709
+ /**
710
+ * Returns a page of events for a webhook, newest first.
711
+ *
712
+ * Events are retained for ~60 days. To paginate, pass the previous page's
713
+ * `pageInfo.endCursor` as `opts.after`; stop when `pageInfo.hasNextPage`
714
+ * is `false`.
715
+ */
716
+ async listEvents(
717
+ webhookId: string,
718
+ opts?: { after?: string | null | undefined } | null | undefined,
719
+ ): Promise<WebhookEventsPage> {
720
+ const qs = opts?.after ? `?after=${encodeURIComponent(opts.after)}` : '';
721
+ const res = await this.#authedFetch(
722
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}/events${qs}`,
723
+ );
724
+ return {
725
+ events: (res.events || []).map(toWebhookEventInfo),
726
+ pageInfo: {
727
+ startCursor: res.pageInfo?.startCursor ?? null,
728
+ endCursor: res.pageInfo?.endCursor ?? null,
729
+ hasNextPage: !!res.pageInfo?.hasNextPage,
730
+ },
731
+ };
732
+ }
733
+
734
+ /**
735
+ * Fetches a single webhook event by its `isn`.
736
+ */
737
+ async getEvent(webhookId: string, isn: string): Promise<WebhookEventInfo> {
738
+ const res = await this.#authedFetch(
739
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}/events/${isn}`,
740
+ );
741
+ return toWebhookEventInfo(res.event);
742
+ }
743
+
744
+ /** Returns the full payload for an event. */
745
+ async getPayload(
746
+ webhookId: string,
747
+ isn: string,
748
+ ): Promise<WebhookPayload<Schema>> {
749
+ return this.#authedFetch(
750
+ `/webhooks/payload/${this.#appId}/${webhookId}/${isn}`,
751
+ );
752
+ }
753
+
754
+ /**
755
+ * Re-queues an event for delivery, regardless of its current status. Use
756
+ * this to retry a `failed` event or force a redelivery of a `success` one.
757
+ *
758
+ * The server rate-limits resends; if the event was queued or resent very
759
+ * recently the call will fail with a validation error asking you to try
760
+ * again in about a minute.
761
+ */
762
+ async resendEvent(webhookId: string, isn: string): Promise<WebhookEventInfo> {
763
+ const res = await this.#authedFetch(
764
+ `/dash/apps/${this.#appId}/webhooks/${webhookId}/events/${isn}`,
765
+ { method: 'POST', body: {} },
766
+ );
767
+ return toWebhookEventInfo(res.event);
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Verify incoming webhook requests from Instant, dispatch their records to
773
+ * typed handlers, and manage webhook subscriptions (via {@link manager}).
774
+ *
775
+ * Usually accessed as `db.webhooks` on the admin or platform SDK rather than
776
+ * constructed directly.
777
+ */
778
+ export class Webhooks<Schema extends InstantSchemaDef<any, any, any>> {
779
+ /** App this instance is bound to. */
780
+ appId: string | null | undefined;
781
+ /** Schema used to type webhook payloads and handler records. */
782
+ schema: Schema | null | undefined;
783
+ #token: string | null | undefined;
784
+ /** Base URL for the Instant API. */
785
+ apiURI: string;
786
+ #jsonFetch: JsonFetch;
787
+ /** Manage webhook subscriptions and inspect delivery events. */
788
+ manager: WebhooksManager<Schema>;
789
+
790
+ /**
791
+ * Schema-bound helpers for building typed handler maps.
792
+ *
793
+ * - `typedHandlers(etype, action, handler)` builds a single typed entry.
794
+ * Pass `'$default'` for `etype` to register a catch-all handler.
795
+ * - `combineHandlers(...entries)` merges entries into a
796
+ * {@link WebhookHandlers} object suitable for {@link processPayload} and
797
+ * {@link processRequest}.
798
+ *
799
+ * @example
800
+ * const { typedHandlers, combineHandlers } = Webhooks.helpers<typeof schema>();
801
+ * const handlers = combineHandlers(
802
+ * typedHandlers('posts', 'create', (record) => { ... }),
803
+ * typedHandlers('comments', '$default', (record) => { ... }),
804
+ * typedHandlers('$default', (record) => { ... }),
805
+ * );
806
+ */
807
+ static helpers<
808
+ Schema extends InstantSchemaDef<any, any, any>,
809
+ >(): WebhookHelpers<Schema> {
810
+ function typedHandlers(...args: any[]): any {
811
+ if (args.length === 2) {
812
+ return { $default: args[1] };
813
+ }
814
+ const [etype, action, handler] = args;
815
+ return { [etype]: { [action]: handler } };
816
+ }
817
+ function combineHandlers(...entries: any[]): any {
818
+ const result: any = {};
819
+ for (const entry of entries) {
820
+ for (const key of Object.keys(entry)) {
821
+ if (key === '$default') {
822
+ result.$default = entry.$default;
823
+ } else {
824
+ result[key] = { ...result[key], ...entry[key] };
825
+ }
826
+ }
827
+ }
828
+ return result;
829
+ }
830
+ return {
831
+ typedHandlers: typedHandlers as any,
832
+ combineHandlers: combineHandlers as any,
833
+ };
834
+ }
835
+
836
+ constructor(config: Config<Schema>, jsonFetch?: JsonFetch) {
837
+ this.appId = config.appId;
838
+ this.schema = config.schema;
839
+
840
+ this.#token = config.adminToken || config.token;
841
+ this.apiURI = config.apiURI || 'https://api.instantdb.com';
842
+ this.#jsonFetch = jsonFetch || defaultJsonFetch;
843
+ this.manager = new WebhooksManager<Schema>({
844
+ appId: this.appId,
845
+ apiURI: this.apiURI,
846
+ token: this.#token,
847
+ withAuth: config.withAuth,
848
+ jsonFetch: this.#jsonFetch,
849
+ });
850
+ }
851
+
852
+ /** Fetches Instant's JWK set for verifying webhook signatures. */
853
+ async fetchJwks() {
854
+ const resp = await this.#jsonFetch(
855
+ `${this.apiURI}/.well-known/webhooks/jwks.json`,
856
+ );
857
+ return resp;
858
+ }
859
+
860
+ /**
861
+ * Resolves a `kid` to an imported {@link CryptoKey}, hitting a
862
+ * process-wide cache on repeat calls. Falls back to {@link fetchJwks} if
863
+ * the key isn't already known.
864
+ */
865
+ async keyOfKid(
866
+ kid: string,
867
+ ): Promise<{ alg: ImportAlgorithm; key: CryptoKey }> {
868
+ const cached = keyCache[kid];
869
+ if (cached) {
870
+ return cached;
871
+ }
872
+
873
+ const jwk =
874
+ knownKeys[this.apiURI]?.keys.find((k: any) => k.kid === kid) ||
875
+ (await this.fetchJwks())?.keys?.find((k: any) => k.kid === kid);
876
+
877
+ if (!jwk) {
878
+ throw new InstantError('Could not find matching signing key', { kid });
879
+ }
880
+
881
+ const res = await importKey(jwk);
882
+ keyCache[kid] = res;
883
+ return res;
884
+ }
885
+
886
+ /**
887
+ * Verifies an `Instant-Signature` header against a body and returns the
888
+ * parsed {@link WebhookBody} (containing the `payloadUrl` and a JWT
889
+ * `token` for fetching the records).
890
+ *
891
+ * Throws if the signature doesn't validate, the signature is older than
892
+ * `opts.tolerance` (default 300 seconds), or the body doesn't decode to
893
+ * the expected shape.
894
+ *
895
+ * @param body Either the raw body string, or a function returning it.
896
+ * Use a function to defer reading the body until after the
897
+ * header has been parsed.
898
+ */
899
+ async validate(
900
+ signatureHeader: string,
901
+ body: string | (() => Promise<string>),
902
+ opts?:
903
+ | {
904
+ receivedAt?: Date | null | undefined;
905
+ tolerance?: number | null | undefined;
906
+ }
907
+ | null
908
+ | undefined,
909
+ ): Promise<WebhookBody> {
910
+ const receivedAt = opts?.receivedAt || new Date();
911
+ const { t, kid, v1 } = parseSignatureHeader(signatureHeader);
912
+ const tolerance = opts?.tolerance || defaultTolerance;
913
+ validateT(receivedAt, t, tolerance);
914
+
915
+ const { alg, key } = await this.keyOfKid(kid);
916
+ const bodyText = typeof body === 'function' ? await body() : body;
917
+
918
+ const message = new TextEncoder().encode(`${t}.${bodyText}`);
919
+
920
+ const verified = await verify(alg, key, hexToUint8Array(v1), message);
921
+
922
+ if (!verified) {
923
+ throw new InstantError('Instant Signature did not validate', {
924
+ header: signatureHeader,
925
+ });
926
+ }
927
+
928
+ const res = JSON.parse(bodyText);
929
+ if (
930
+ typeof res !== 'object' ||
931
+ typeof res.payloadUrl !== 'string' ||
932
+ typeof res.token !== 'string'
933
+ ) {
934
+ throw new InstantError(
935
+ 'Invalid webhook body, expected an object with payloadUrl and token fields',
936
+ { body: res },
937
+ );
938
+ }
939
+ return res;
940
+ }
941
+
942
+ /**
943
+ * Pulls the `Instant-Signature` header and body from a `Request` and
944
+ * delegates to {@link validate}. Throws if the header is missing.
945
+ */
946
+ async validateRequest(
947
+ req: Request,
948
+ opts?:
949
+ | {
950
+ tolerance?: number | null | undefined;
951
+ receivedAt?: Date | null | undefined;
952
+ }
953
+ | null
954
+ | undefined,
955
+ ): Promise<WebhookBody> {
956
+ const signatureHeader = req.headers.get('instant-signature');
957
+ if (!signatureHeader) {
958
+ throw new InstantError('Request is missing Instant-Signature header');
959
+ }
960
+ return this.validate(signatureHeader, () => req.text(), opts);
961
+ }
962
+
963
+ /**
964
+ * Fetches the records and `idempotencyKey` for a validated
965
+ * {@link WebhookBody}, authenticating with the JWT `token` it carries.
966
+ */
967
+ fetchPayloads({
968
+ payloadUrl,
969
+ token,
970
+ }: WebhookBody): Promise<WebhookPayload<Schema>> {
971
+ return this.#jsonFetch(payloadUrl, {
972
+ headers: { Authorization: `Bearer ${token}`, accept: 'application/json' },
973
+ });
974
+ }
975
+
976
+ /**
977
+ * Dispatches each record in `payload` to its matching handler in
978
+ * `handlers`. Resolution order per record: exact `etype` + `action` →
979
+ * `etype`'s `$default` → top-level `$default`. Records with no matching
980
+ * handler are skipped.
981
+ *
982
+ * Handlers run concurrently. The returned promise resolves once every
983
+ * handler has settled (success or failure); rejections in individual
984
+ * handlers do not bubble up.
985
+ */
986
+ async processPayload(
987
+ handlers: WebhookHandlers<Schema>,
988
+ payload: WebhookPayload<Schema>,
989
+ ): Promise<void> {
990
+ const results: any[] = [];
991
+ for (const record of payload.data) {
992
+ const { etype, action } = record;
993
+ const handler =
994
+ handlers?.[etype]?.[action] ||
995
+ handlers?.[etype]?.$default ||
996
+ handlers?.$default;
997
+ if (handler) {
998
+ // We need the as any here because typescript
999
+ // has trouble correlating the handler to the
1000
+ // record etype and action
1001
+ results.push(handler(record as any));
1002
+ }
1003
+ }
1004
+ await Promise.allSettled(results);
1005
+ }
1006
+
1007
+ /**
1008
+ * The one-liner for handling webhooks. Hand it your handlers and the
1009
+ * incoming `Request` — it verifies the signature, fetches the records, and
1010
+ * dispatches each one to your code.
1011
+ *
1012
+ * Async handlers are executed in parallel, the return promise will resolve once
1013
+ * all handlers complete and will reject if any of the handlers fails.
1014
+ *
1015
+ * @example
1016
+ * const { typedHandlers, combineHandlers } = Webhooks.helpers<typeof schema>();
1017
+ *
1018
+ * const handlers = combineHandlers(
1019
+ * typedHandlers('posts', 'create', async (record) => {
1020
+ * await sendNewPostEmail(record.after);
1021
+ * }),
1022
+ * typedHandlers('$default', (record) => {
1023
+ * console.log('webhook event', record);
1024
+ * }),
1025
+ * );
1026
+ *
1027
+ * export async function POST(req: Request) {
1028
+ * await db.webhooks.processRequest(handlers, req);
1029
+ * return new Response('ok');
1030
+ * }
1031
+ */
1032
+ async processRequest(
1033
+ handlers: WebhookHandlers<Schema>,
1034
+ req: Request,
1035
+ opts?:
1036
+ | {
1037
+ tolerance?: number | null | undefined;
1038
+ receivedAt?: Date | null | undefined;
1039
+ }
1040
+ | null
1041
+ | undefined,
1042
+ ): Promise<void> {
1043
+ const body = await this.validateRequest(req, opts);
1044
+ const payload = await this.fetchPayloads(body);
1045
+ await this.processPayload(handlers, payload);
1046
+ }
1047
+ }