@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 +323 -71
- package/dist/index.d.mts +539 -66
- package/dist/index.d.ts +539 -66
- package/dist/index.js +1881 -85
- package/dist/index.mjs +1826 -57
- package/package.json +3 -2
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 {
|
|
38
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
29
39
|
|
|
30
|
-
// Create instance once (reuse for all requests)
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
228
|
+
### Encrypting stored tokens
|
|
138
229
|
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
###
|
|
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 `
|
|
245
|
-
|
|
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 {
|
|
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
|
|
265
|
-
req.
|
|
266
|
-
req.
|
|
267
|
-
process.env.EDGE_WEBHOOK_SECRET!,
|
|
268
|
-
)
|
|
269
|
-
|
|
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 {
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 `
|
|
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 {
|
|
498
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
358
499
|
|
|
359
|
-
const edge =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|