@edge-markets/connect-node 1.7.0 → 1.9.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
@@ -10,7 +10,17 @@ Server SDK for EDGE Connect token exchange and API calls.
10
10
  - 🛡️ **Typed errors** - Specific error classes for each scenario
11
11
  - 📝 **TypeScript first** - Complete type definitions
12
12
  - 🪝 **Webhook signature verification** - Constant-time HMAC-SHA256 with replay protection
13
+ - ✅ **Webhook parsing + validation** - Verify raw bodies and fail closed on malformed event payloads
13
14
  - 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
15
+ - 📥 **Webhook inbox orchestration** - Durable accept/process/replay control flow with partner-owned storage
16
+ - 🔐 **Token vault** - Versioned AES-256-GCM token encryption with key rotation
17
+ - 🔄 **User sessions** - Token-store callbacks with refresh, encryption, and single-flight refresh protection
18
+ - ⚙️ **Env config helper** - Build SDK config from standard EDGE env vars
19
+
20
+ These helpers are framework-neutral: the SDK owns repeatable Connect mechanics
21
+ such as token refresh, webhook validation, replay control flow, and transfer
22
+ intent checks, while partner applications keep ownership of persistence, wallet
23
+ mutation, admin authorization, and business rules.
14
24
 
15
25
  ## Installation
16
26
 
@@ -25,14 +35,11 @@ yarn add @edge-markets/connect-node
25
35
  ## Quick Start
26
36
 
27
37
  ```typescript
28
- import { EdgeConnectServer } from '@edge-markets/connect-node'
38
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
29
39
 
30
- // Create instance once (reuse for all requests)
31
- const edge = new EdgeConnectServer({
32
- clientId: process.env.EDGE_CLIENT_ID!,
33
- clientSecret: process.env.EDGE_CLIENT_SECRET!,
34
- environment: 'staging',
35
- })
40
+ // Create instance once (reuse for all requests). Reads EDGE_CLIENT_ID,
41
+ // EDGE_CLIENT_SECRET, EDGE_ENVIRONMENT, optional mTLS and partner sync env vars.
42
+ const { server: edge } = createEdgeConnectServerFromEnv()
36
43
 
37
44
  // Exchange code from EdgeLink for tokens
38
45
  const tokens = await edge.exchangeCode(code, codeVerifier)
@@ -51,6 +58,48 @@ Never expose your client secret to browsers or client-side code.
51
58
 
52
59
  ## Configuration
53
60
 
61
+ ### From environment variables
62
+
63
+ Most partner backends should use `createEdgeConnectServerFromEnv()` so mTLS
64
+ PEM handling, partner sync credentials, URL overrides, and timeout parsing are
65
+ consistent across integrations.
66
+
67
+ ```typescript
68
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
69
+
70
+ const { server: edge, capabilities, warnings } = createEdgeConnectServerFromEnv({
71
+ requireMtls: process.env.NODE_ENV === 'production',
72
+ })
73
+
74
+ warnings.forEach((warning) => logger.warn(warning))
75
+
76
+ if (!capabilities.webhookSyncConfigured) {
77
+ logger.warn('EDGE webhook sync is not configured; set EDGE_PARTNER_CLIENT_ID and EDGE_PARTNER_CLIENT_SECRET')
78
+ }
79
+ ```
80
+
81
+ Supported environment variables:
82
+
83
+ | Variable | Description |
84
+ |----------|-------------|
85
+ | `EDGE_CLIENT_ID` | User-facing OAuth client ID |
86
+ | `EDGE_CLIENT_SECRET` | User-facing OAuth client secret |
87
+ | `EDGE_ENVIRONMENT` | `production`, `staging`, `sandbox`, or `development` |
88
+ | `EDGE_API_BASE_URL` | Optional Connect API override |
89
+ | `EDGE_OAUTH_BASE_URL` | Optional OAuth/token URL override |
90
+ | `EDGE_PARTNER_API_BASE_URL` | Optional partner dashboard API override |
91
+ | `EDGE_PARTNER_CLIENT_ID` | Partner machine-to-machine client ID for webhook sync |
92
+ | `EDGE_PARTNER_CLIENT_SECRET` | Partner machine-to-machine client secret for webhook sync |
93
+ | `EDGE_MTLS_CERT` / `EDGE_MTLS_CERT_PATH` | Client certificate PEM or file path |
94
+ | `EDGE_MTLS_KEY` / `EDGE_MTLS_KEY_PATH` | Client private key PEM or file path |
95
+ | `EDGE_MTLS_CA` / `EDGE_MTLS_CA_PATH` | Optional extra server trust root PEM or file path |
96
+ | `EDGE_TIMEOUT_MS` | Optional request timeout in milliseconds |
97
+
98
+ Use inline PEMs with escaped newlines (`\n`) or file paths. If both are set,
99
+ the inline value wins and the helper returns a warning.
100
+
101
+ ### Manual configuration
102
+
54
103
  ```typescript
55
104
  interface EdgeConnectServerConfig {
56
105
  clientId: string // Your user-facing OAuth client ID
@@ -59,6 +108,7 @@ interface EdgeConnectServerConfig {
59
108
 
60
109
  // Optional
61
110
  apiBaseUrl?: string // Custom API URL (dev only)
111
+ partnerApiBaseUrl?: string // Custom partner dashboard API URL (dev only)
62
112
  oauthBaseUrl?: string // Custom OAuth URL (dev only)
63
113
  timeout?: number // Request timeout (default: 30000ms)
64
114
  retry?: RetryConfig // Retry configuration
@@ -72,6 +122,11 @@ interface EdgeConnectServerConfig {
72
122
  partnerClientId?: string
73
123
  partnerClientSecret?: string
74
124
 
125
+ // By default the SDK derives partnerApiBaseUrl from apiBaseUrl by replacing
126
+ // /connect/v1 with /v1. For example:
127
+ // https://connect-staging.edgeboost.io/connect/v1
128
+ // -> https://connect-staging.edgeboost.io/v1
129
+
75
130
  // Optional: Message Level Encryption (Connect endpoints only)
76
131
  mle?: {
77
132
  enabled: boolean
@@ -81,6 +136,14 @@ interface EdgeConnectServerConfig {
81
136
  partnerKeyId: string // Your key ID (kid) expected in response headers
82
137
  strictResponseEncryption?: boolean // default true
83
138
  }
139
+
140
+ // Optional: Mutual TLS client authentication
141
+ mtls?: {
142
+ enabled: boolean
143
+ cert: string // Your client certificate PEM
144
+ key: string // Your client private key PEM
145
+ ca?: string | string[] // Additional server trust roots, if EDGE provides them
146
+ }
84
147
  }
85
148
  ```
86
149
 
@@ -103,6 +166,34 @@ const edge = new EdgeConnectServer({
103
166
 
104
167
  When enabled, the SDK sends `X-Edge-MLE: v1`, encrypts request bodies, and decrypts encrypted Connect responses.
105
168
 
169
+ ### Mutual TLS (mTLS)
170
+
171
+ Use `mtls` when EDGE has issued your partner backend a client certificate and key:
172
+
173
+ ```typescript
174
+ const edge = new EdgeConnectServer({
175
+ clientId: process.env.EDGE_CLIENT_ID!,
176
+ clientSecret: process.env.EDGE_CLIENT_SECRET!,
177
+ environment: 'staging',
178
+ mtls: {
179
+ enabled: true,
180
+ cert: process.env.EDGE_MTLS_CERT!,
181
+ key: process.env.EDGE_MTLS_KEY!,
182
+ },
183
+ })
184
+ ```
185
+
186
+ `cert` and `key` authenticate your backend to EDGE Connect. They are client
187
+ authentication material and must stay on your server.
188
+
189
+ `ca` is optional server trust material. Public EDGE Connect gateways such as
190
+ `https://connect.edgeboost.io` and `https://connect-staging.edgeboost.io` use
191
+ public certificates, so most partners should omit `ca`. If EDGE provides a
192
+ private server CA for a non-public endpoint, pass it as `ca`; the SDK appends it
193
+ to Node's default public trust roots instead of replacing them.
194
+
195
+ Do not disable TLS verification and do not set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
196
+
106
197
  ## Token Exchange
107
198
 
108
199
  After EdgeLink completes, exchange the code for tokens:
@@ -134,35 +225,76 @@ export async function POST(req: Request) {
134
225
  }
135
226
  ```
136
227
 
137
- ## Token Refresh
228
+ ### Encrypting stored tokens
138
229
 
139
- Refresh tokens before they expire:
230
+ Store access and refresh tokens encrypted at rest. `EdgeTokenVault` gives
231
+ partners a stable AES-256-GCM envelope with key IDs and key rotation support;
232
+ you still choose the database table and user mapping.
140
233
 
141
234
  ```typescript
142
- async function getValidAccessToken(userId: string): Promise<string> {
143
- const connection = await db.edgeConnections.get(userId)
144
-
145
- // Refresh 5 minutes before expiry
146
- const BUFFER = 5 * 60 * 1000
147
-
148
- if (Date.now() > connection.expiresAt.getTime() - BUFFER) {
149
- const newTokens = await edge.refreshTokens(
150
- decrypt(connection.refreshToken)
151
- )
152
-
153
- await db.edgeConnections.update(userId, {
154
- accessToken: encrypt(newTokens.accessToken),
155
- refreshToken: encrypt(newTokens.refreshToken),
156
- expiresAt: new Date(newTokens.expiresAt),
157
- })
158
-
159
- return newTokens.accessToken
160
- }
161
-
162
- return decrypt(connection.accessToken)
163
- }
235
+ import { createEdgeTokenVault } from '@edge-markets/connect-node'
236
+
237
+ const tokenVault = createEdgeTokenVault({
238
+ currentKey: {
239
+ id: process.env.EDGE_TOKEN_ENCRYPTION_KEY_ID ?? '2026-06',
240
+ key: process.env.EDGE_TOKEN_ENCRYPTION_KEY!, // 32 bytes as base64, base64url, or hex
241
+ },
242
+ previousKeys: [
243
+ // { id: '2026-05', key: process.env.EDGE_TOKEN_ENCRYPTION_KEY_2026_05! },
244
+ ],
245
+ })
246
+
247
+ const tokens = await edge.exchangeCode(code, codeVerifier)
248
+ await db.edgeConnections.upsert({
249
+ userId,
250
+ encryptedTokens: tokenVault.encryptTokens(tokens),
251
+ })
252
+
253
+ const connection = await db.edgeConnections.get(userId)
254
+ const decrypted = tokenVault.decryptTokens(connection.encryptedTokens)
255
+ const client = edge.forUser(decrypted.accessToken)
164
256
  ```
165
257
 
258
+ ## Token Refresh And User Sessions
259
+
260
+ Use `EdgeUserSession` when you store encrypted tokens and want the SDK to
261
+ handle decrypt-refresh-save-client lifecycle. You still own the database table
262
+ and user mapping.
263
+
264
+ ```typescript
265
+ const session = edge.createUserSession({
266
+ subjectId: userId,
267
+ tokenVault,
268
+ tokenStore: {
269
+ load: async (subjectId) => {
270
+ const row = await db.edgeConnections.get(subjectId)
271
+ return row?.encryptedTokens
272
+ },
273
+ save: async (subjectId, encryptedTokens, { tokens }) => {
274
+ await db.edgeConnections.update(subjectId, {
275
+ encryptedTokens,
276
+ expiresAt: new Date(tokens.expiresAt),
277
+ })
278
+ },
279
+ },
280
+ refreshSkewMs: 60_000,
281
+ })
282
+
283
+ const balance = await session.withClient((client) => client.getBalance())
284
+ ```
285
+
286
+ `EdgeUserSession` refreshes near-expiry tokens, preserves refresh tokens when
287
+ the token endpoint does not rotate them, saves the refreshed token envelope
288
+ before returning a client, and single-flights concurrent refreshes on the same
289
+ process. In horizontally scaled services, keep your storage callbacks
290
+ idempotent or add DB-level optimistic locking.
291
+
292
+ For migrations from split token columns, use `createSessionTokenRecord`,
293
+ `parseSessionTokenRecord`, `normalizeSessionExpiresAt`, and
294
+ `encryptLegacySessionTokens`. Plaintext migration requires an explicit
295
+ `allowPlaintext: true` option so production request paths do not silently accept
296
+ raw tokens.
297
+
166
298
  ## API Methods
167
299
 
168
300
  All user-scoped API methods live on `EdgeUserClient`, created via `edge.forUser(accessToken)`:
@@ -237,13 +369,13 @@ fail, expire, or when a user revokes consent. The SDK provides two helpers:
237
369
  **signature verification** for the live HTTP delivery channel, and a
238
370
  **reconciliation sync method** for the cron-driven safety net.
239
371
 
240
- ### Verify webhook signatures
372
+ ### Parse and verify webhook deliveries
241
373
 
242
374
  Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
243
375
  format — HMAC-SHA256 over `${timestamp}.${rawBody}` keyed by your webhook
244
- secret. Use `verifyWebhookSignature` to check it. The function does
245
- constant-time comparison and rejects events older than 5 minutes by default
246
- to prevent replay attacks.
376
+ secret. Use `parseAndVerifyWebhook` to verify the signature, parse JSON, and
377
+ runtime-validate the typed event envelope in one step. The helper rejects
378
+ events older than 5 minutes by default to prevent replay attacks.
247
379
 
248
380
  > ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
249
381
  > object that you re-stringified. `JSON.stringify(req.body)` reorders keys
@@ -251,7 +383,7 @@ to prevent replay attacks.
251
383
  > raw body via your framework's raw-body middleware.
252
384
 
253
385
  ```typescript
254
- import { verifyWebhookSignature, type EdgeWebhookEvent } from '@edge-markets/connect-node'
386
+ import { parseAndVerifyWebhook } from '@edge-markets/connect-node'
255
387
  import express from 'express'
256
388
 
257
389
  const app = express()
@@ -261,14 +393,13 @@ app.post(
261
393
  // 👇 Critical: this gives you req.body as a Buffer, not a parsed object
262
394
  express.raw({ type: 'application/json' }),
263
395
  (req, res) => {
264
- const ok = verifyWebhookSignature(
265
- req.headers['x-edge-signature'] as string,
266
- req.body.toString('utf8'),
267
- process.env.EDGE_WEBHOOK_SECRET!,
268
- )
269
- if (!ok) return res.status(401).send('invalid signature')
270
-
271
- const event: EdgeWebhookEvent = JSON.parse(req.body.toString('utf8'))
396
+ const { event, eventId, signatureTimestamp } = parseAndVerifyWebhook({
397
+ rawBody: req.body,
398
+ signatureHeader: req.headers['x-edge-signature'],
399
+ secret: process.env.EDGE_WEBHOOK_SECRET!,
400
+ })
401
+
402
+ // Store eventId + signatureTimestamp in your durable inbox if you use one.
272
403
  // Process asynchronously and return 200 immediately — see "Best Practices"
273
404
  void processEdgeEvent(event)
274
405
  res.status(200).send('ok')
@@ -283,26 +414,36 @@ app.post(
283
414
  const app = await NestFactory.create(AppModule, { rawBody: true })
284
415
 
285
416
  // edge-webhook.controller.ts
286
- import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
417
+ import { Controller, Post, Req, HttpCode, UnauthorizedException, type RawBodyRequest } from '@nestjs/common'
287
418
  import type { Request } from 'express'
288
- import { verifyWebhookSignature } from '@edge-markets/connect-node'
419
+ import { EdgeAuthenticationError, parseAndVerifyWebhook } from '@edge-markets/connect-node'
289
420
 
290
421
  @Controller('webhooks')
291
422
  export class EdgeWebhookController {
292
423
  @Post('edge')
293
424
  @HttpCode(200)
294
425
  handle(@Req() req: RawBodyRequest<Request>) {
295
- const ok = verifyWebhookSignature(
296
- req.headers['x-edge-signature'] as string,
297
- req.rawBody?.toString('utf8') ?? '',
298
- process.env.EDGE_WEBHOOK_SECRET!,
299
- )
300
- if (!ok) throw new UnauthorizedException('invalid signature')
301
- // ...
426
+ try {
427
+ const { event } = parseAndVerifyWebhook({
428
+ rawBody: req.rawBody ?? Buffer.from(''),
429
+ signatureHeader: req.headers['x-edge-signature'],
430
+ secret: process.env.EDGE_WEBHOOK_SECRET!,
431
+ })
432
+ void processEdgeEvent(event)
433
+ } catch (error) {
434
+ if (error instanceof EdgeAuthenticationError) {
435
+ throw new UnauthorizedException('invalid signature')
436
+ }
437
+ throw error
438
+ }
302
439
  }
303
440
  }
304
441
  ```
305
442
 
443
+ `verifyWebhookSignature` remains exported for advanced integrations, but
444
+ `parseAndVerifyWebhook` is the recommended default because it fails closed on
445
+ unknown event types, malformed payloads, and parsed-body mistakes.
446
+
306
447
  ### Type-safe event handling with `EdgeWebhookEvent`
307
448
 
308
449
  `EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
@@ -342,7 +483,7 @@ function processEdgeEvent(event: EdgeWebhookEvent) {
342
483
  number, to preserve decimal precision — parse it with a decimal-aware
343
484
  library on your side.
344
485
 
345
- ### Reconcile missed events with `syncWebhookEvents`
486
+ ### Reconcile missed events with `createWebhookReconciler`
346
487
 
347
488
  Even with retries and a dead-letter queue, network or partner outages can
348
489
  drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
@@ -354,32 +495,34 @@ This method requires the partner-level credentials (`partnerClientId` /
354
495
  handles a 401 by refreshing the token once and retrying.
355
496
 
356
497
  ```typescript
357
- import { EdgeConnectServer } from '@edge-markets/connect-node'
498
+ import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
358
499
 
359
- const edge = new EdgeConnectServer({
360
- clientId: process.env.EDGE_CLIENT_ID!,
361
- clientSecret: process.env.EDGE_CLIENT_SECRET!,
362
- environment: 'staging',
363
- partnerClientId: process.env.EDGE_PARTNER_CLIENT_ID!,
364
- partnerClientSecret: process.env.EDGE_PARTNER_CLIENT_SECRET!,
500
+ const { server: edge } = createEdgeConnectServerFromEnv()
501
+
502
+ const reconciler = edge.createWebhookReconciler({
503
+ cursorStore: {
504
+ load: () => db.cursors.get('edge_webhook_sync'),
505
+ save: (cursor) => db.cursors.set('edge_webhook_sync', cursor),
506
+ },
507
+ processEvent: processEdgeEvent, // same idempotent handler as live webhooks
508
+ limit: 100,
509
+ maxPages: 10,
365
510
  })
366
511
 
367
512
  async function reconcile() {
368
- let cursor = await db.cursors.get('edge') // your stored watermark
369
- while (true) {
370
- const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
371
- for (const event of events) {
372
- await processEdgeEvent(event) // same idempotent handler as live webhooks
373
- cursor = event.id
374
- }
375
- if (events.length > 0) await db.cursors.set('edge', cursor!)
376
- if (!hasMore) break
377
- }
513
+ const result = await reconciler.run()
514
+ logger.info('EDGE webhook reconciliation complete', result)
378
515
  }
379
516
 
380
517
  setInterval(reconcile, 5 * 60 * 1000)
381
518
  ```
382
519
 
520
+ The reconciler saves the cursor only after `processEvent(event)` succeeds and
521
+ skips overlapping runs on the same instance. You provide cursor storage and
522
+ the idempotent event handler; the SDK owns pagination and cursor advancement.
523
+
524
+ If you need full control, `edge.syncWebhookEvents()` remains available.
525
+
383
526
  The server enforces a **30-day lookback**. If your cursor is older than 30
384
527
  days, you will receive events from 30 days ago — re-bootstrap from a known
385
528
  good state via `getTransfer` if you have been offline for longer.
@@ -396,6 +539,115 @@ good state via `getTransfer` if you have been offline for longer.
396
539
  4. **Run reconciliation as a backstop** — webhooks are the fast path,
397
540
  `syncWebhookEvents` is the safety net. They share the same handler.
398
541
 
542
+ `syncWebhookEvents` uses the same SDK mTLS transport as user-scoped API
543
+ calls, fetches and caches the partner `client_credentials` token internally,
544
+ retries transient sync failures with the SDK retry policy, and redacts token
545
+ material from observability hooks.
546
+
547
+ ### Durable webhook inbox with `createWebhookInbox`
548
+
549
+ For production integrations, accept a verified webhook into durable storage
550
+ before returning `200`, then process it asynchronously. The SDK can own the
551
+ state machine while your app owns the database callbacks and wallet mutation.
552
+
553
+ ```typescript
554
+ import {
555
+ createWebhookInbox,
556
+ parseWebhookHttpRequest,
557
+ } from '@edge-markets/connect-node'
558
+
559
+ const inbox = createWebhookInbox({
560
+ store: {
561
+ insert: db.webhookInbox.insertIdempotently,
562
+ find: db.webhookInbox.find,
563
+ markProcessing: db.webhookInbox.markProcessing,
564
+ markProcessed: db.webhookInbox.markProcessed,
565
+ markFailed: db.webhookInbox.markFailed,
566
+ markPending: db.webhookInbox.markPending,
567
+ list: db.webhookInbox.list,
568
+ },
569
+ processEvent: async (event) => {
570
+ // Partner-owned business logic. Keep wallet mutation idempotent.
571
+ await processEdgeEvent(event)
572
+ },
573
+ })
574
+
575
+ app.post('/webhooks/edge', express.raw({ type: 'application/json' }), async (req, res) => {
576
+ const parsed = parseWebhookHttpRequest({
577
+ request: { headers: req.headers, rawBody: req.body },
578
+ secret: process.env.EDGE_WEBHOOK_SECRET!,
579
+ })
580
+
581
+ const accepted = await inbox.acceptRaw(
582
+ parsed,
583
+ Buffer.from(req.body).toString('utf8'),
584
+ String(req.headers['x-edge-signature'] ?? ''),
585
+ )
586
+
587
+ res.status(200).json({
588
+ received: true,
589
+ duplicate: accepted.duplicate,
590
+ status: accepted.status,
591
+ })
592
+
593
+ void inbox.process(accepted.eventId).catch((error) => {
594
+ logger.error('EDGE webhook processing failed', { eventId: accepted.eventId, error })
595
+ })
596
+ })
597
+ ```
598
+
599
+ The inbox statuses are `pending`, `processing`, `processed`, and `failed`.
600
+ Duplicate `event.id` deliveries are idempotent if the payload fingerprint
601
+ matches. A duplicate event ID with different payload fails closed.
602
+
603
+ Sync reconciliation can use the same inbox path:
604
+
605
+ ```typescript
606
+ const reconciler = edge.createWebhookReconciler({
607
+ cursorStore,
608
+ inbox,
609
+ limit: 100,
610
+ })
611
+ ```
612
+
613
+ The reconciler accepts each synced event into the inbox, processes it, and saves
614
+ the cursor only after processing succeeds or the event is already processed.
615
+ Use `inbox.replay(eventId)` for explicit admin replays and
616
+ `inbox.recoverStaleProcessing()` to make crashed `processing` rows replayable.
617
+
618
+ ### Transfer safety helpers
619
+
620
+ Use transfer helpers to avoid decimal and direction mistakes:
621
+
622
+ ```typescript
623
+ import {
624
+ assertWebhookMatchesTransfer,
625
+ createTransferIdempotencyKey,
626
+ getConnectTransferDirection,
627
+ mapTransferStatusToPartnerState,
628
+ normalizeMoneyAmount,
629
+ validateTransferAmount,
630
+ } from '@edge-markets/connect-node'
631
+
632
+ const amount = validateTransferAmount(input.amount, { min: '1.00', max: '10000.00' }, { allowNumber: false })
633
+ const idempotencyKey = createTransferIdempotencyKey({
634
+ partnerUserId: user.id,
635
+ type: 'debit',
636
+ amount,
637
+ category: 'sportsbook',
638
+ externalId: bet.id,
639
+ })
640
+
641
+ // debit = EDGE user to partner; credit = partner to EDGE user
642
+ const direction = getConnectTransferDirection('debit')
643
+
644
+ assertWebhookMatchesTransfer(event, {
645
+ transferId: localTransfer.edgeTransferId,
646
+ type: 'debit',
647
+ amount: localTransfer.amount,
648
+ })
649
+ ```
650
+
399
651
  ## Error Handling
400
652
 
401
653
  ```typescript