@edge-markets/connect-node 1.6.1 → 1.7.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
@@ -9,6 +9,8 @@ Server SDK for EDGE Connect token exchange and API calls.
9
9
  - 📡 **Full API client** - User, balance, transfers via `forUser()` pattern
10
10
  - 🛡️ **Typed errors** - Specific error classes for each scenario
11
11
  - 📝 **TypeScript first** - Complete type definitions
12
+ - 🪝 **Webhook signature verification** - Constant-time HMAC-SHA256 with replay protection
13
+ - 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
12
14
 
13
15
  ## Installation
14
16
 
@@ -51,10 +53,10 @@ Never expose your client secret to browsers or client-side code.
51
53
 
52
54
  ```typescript
53
55
  interface EdgeConnectServerConfig {
54
- clientId: string // Your OAuth client ID
55
- clientSecret: string // Your OAuth client secret (keep secret!)
56
+ clientId: string // Your user-facing OAuth client ID
57
+ clientSecret: string // Your user-facing OAuth client secret (keep secret!)
56
58
  environment: EdgeEnvironment // 'production' | 'staging' | 'sandbox'
57
-
59
+
58
60
  // Optional
59
61
  apiBaseUrl?: string // Custom API URL (dev only)
60
62
  oauthBaseUrl?: string // Custom OAuth URL (dev only)
@@ -63,6 +65,13 @@ interface EdgeConnectServerConfig {
63
65
  onRequest?: (info) => void // Hook called before each request
64
66
  onResponse?: (info) => void // Hook called after each response
65
67
 
68
+ // Optional: Partner-level credentials for non-user-scoped endpoints.
69
+ // Required ONLY when calling syncWebhookEvents — the rest of the SDK
70
+ // works without them. These are a DIFFERENT OAuth client than clientId
71
+ // (machine-to-machine, not user-facing).
72
+ partnerClientId?: string
73
+ partnerClientSecret?: string
74
+
66
75
  // Optional: Message Level Encryption (Connect endpoints only)
67
76
  mle?: {
68
77
  enabled: boolean
@@ -195,7 +204,10 @@ const session = await client.createVerificationSession(transfer.transferId, {
195
204
  // 3. Listen for the postMessage event from the iframe (or poll
196
205
  // getVerificationSessionStatus) to learn when verification completes.
197
206
  // A `transfer.completed` webhook will be delivered to your configured
198
- // webhook URL once the transfer settles.
207
+ // webhook URL once the transfer settles. For browser-crash recovery,
208
+ // your backend should also reconcile against
209
+ // GET /v1/partner/webhooks/events/sync using a dashboard
210
+ // client_credentials token from POST /connect/oauth/token.
199
211
 
200
212
  // Get transfer status
201
213
  const status = await client.getTransfer(transfer.transferId)
@@ -218,6 +230,172 @@ await client.revokeConsent()
218
230
  await db.edgeConnections.delete(userId)
219
231
  ```
220
232
 
233
+ ## Webhooks
234
+
235
+ EDGE Connect delivers webhook events to your server when transfers complete,
236
+ fail, expire, or when a user revokes consent. The SDK provides two helpers:
237
+ **signature verification** for the live HTTP delivery channel, and a
238
+ **reconciliation sync method** for the cron-driven safety net.
239
+
240
+ ### Verify webhook signatures
241
+
242
+ Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
243
+ 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.
247
+
248
+ > ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
249
+ > object that you re-stringified. `JSON.stringify(req.body)` reorders keys
250
+ > and silently breaks verification with no diagnostic. Always capture the
251
+ > raw body via your framework's raw-body middleware.
252
+
253
+ ```typescript
254
+ import { verifyWebhookSignature, type EdgeWebhookEvent } from '@edge-markets/connect-node'
255
+ import express from 'express'
256
+
257
+ const app = express()
258
+
259
+ app.post(
260
+ '/webhooks/edge',
261
+ // 👇 Critical: this gives you req.body as a Buffer, not a parsed object
262
+ express.raw({ type: 'application/json' }),
263
+ (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'))
272
+ // Process asynchronously and return 200 immediately — see "Best Practices"
273
+ void processEdgeEvent(event)
274
+ res.status(200).send('ok')
275
+ },
276
+ )
277
+ ```
278
+
279
+ #### NestJS
280
+
281
+ ```typescript
282
+ // main.ts
283
+ const app = await NestFactory.create(AppModule, { rawBody: true })
284
+
285
+ // edge-webhook.controller.ts
286
+ import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
287
+ import type { Request } from 'express'
288
+ import { verifyWebhookSignature } from '@edge-markets/connect-node'
289
+
290
+ @Controller('webhooks')
291
+ export class EdgeWebhookController {
292
+ @Post('edge')
293
+ @HttpCode(200)
294
+ 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
+ // ...
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### Type-safe event handling with `EdgeWebhookEvent`
307
+
308
+ `EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
309
+ narrows `event.data` automatically — no casts required.
310
+
311
+ ```typescript
312
+ import type { EdgeWebhookEvent } from '@edge-markets/connect-node'
313
+
314
+ function processEdgeEvent(event: EdgeWebhookEvent) {
315
+ switch (event.type) {
316
+ case 'transfer.completed':
317
+ // event.data is { transferId, status: 'completed', type, amount }
318
+ creditWallet(event.data.transferId, event.data.amount)
319
+ break
320
+ case 'transfer.failed':
321
+ // event.data.reason is `string | undefined`
322
+ logFailure(event.data.transferId, event.data.reason ?? 'unknown')
323
+ break
324
+ case 'transfer.expired':
325
+ cancelPending(event.data.transferId)
326
+ break
327
+ case 'transfer.processing':
328
+ // @experimental — reserved for ledger dual-write hand-off
329
+ break
330
+ case 'consent.revoked':
331
+ deactivateLink(event.data.userId, event.data.clientId)
332
+ break
333
+ default: {
334
+ const _exhaustive: never = event
335
+ return _exhaustive
336
+ }
337
+ }
338
+ }
339
+ ```
340
+
341
+ `event.data.amount` is intentionally a `string` (e.g. `'100.00'`), not a
342
+ number, to preserve decimal precision — parse it with a decimal-aware
343
+ library on your side.
344
+
345
+ ### Reconcile missed events with `syncWebhookEvents`
346
+
347
+ Even with retries and a dead-letter queue, network or partner outages can
348
+ drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
349
+ is typical) to catch anything your primary receiver missed.
350
+
351
+ This method requires the partner-level credentials (`partnerClientId` /
352
+ `partnerClientSecret`) on `EdgeConnectServer`. It uses the OAuth
353
+ `client_credentials` grant, caches the partner token internally, and
354
+ handles a 401 by refreshing the token once and retrying.
355
+
356
+ ```typescript
357
+ import { EdgeConnectServer } from '@edge-markets/connect-node'
358
+
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!,
365
+ })
366
+
367
+ 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
+ }
378
+ }
379
+
380
+ setInterval(reconcile, 5 * 60 * 1000)
381
+ ```
382
+
383
+ The server enforces a **30-day lookback**. If your cursor is older than 30
384
+ days, you will receive events from 30 days ago — re-bootstrap from a known
385
+ good state via `getTransfer` if you have been offline for longer.
386
+
387
+ ### Best practices
388
+
389
+ 1. **Verify signatures first** — reject mis-signed events with 401 before
390
+ doing any work.
391
+ 2. **Return 200 immediately** — process events asynchronously. EDGE retries
392
+ on non-2xx and on responses slower than 10s.
393
+ 3. **Be idempotent** — the same event may be delivered more than once
394
+ (at-least-once delivery). Key your wallet credit / status update on
395
+ `event.id` or `event.data.transferId`.
396
+ 4. **Run reconciliation as a backstop** — webhooks are the fast path,
397
+ `syncWebhookEvents` is the safety net. They share the same handler.
398
+
221
399
  ## Error Handling
222
400
 
223
401
  ```typescript
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as node_https from 'node:https';
2
- import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens } from '@edge-markets/connect';
3
- export { Balance, CreateVerificationSessionRequest, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferList, TransferListItem, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
2
+ import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens, EdgeWebhookEvent } from '@edge-markets/connect';
3
+ export { Balance, BaseWebhookEvent, ConsentRevokedEventData, CreateVerificationSessionRequest, EDGE_WEBHOOK_EVENT_TYPES, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, EdgeWebhookEvent, EdgeWebhookEventType, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferCompletedEventData, TransferExpiredEventData, TransferFailedEventData, TransferList, TransferListItem, TransferProcessingEventData, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
4
4
 
5
5
  interface RequestInfo {
6
6
  method: string;
@@ -42,6 +42,24 @@ interface EdgeConnectServerConfig {
42
42
  strictResponseEncryption?: boolean;
43
43
  };
44
44
  mtls?: MtlsConfig;
45
+ /**
46
+ * Partner-level OAuth client ID for the `client_credentials` grant.
47
+ *
48
+ * Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
49
+ * This is a different OAuth client than {@link clientId} — it authenticates
50
+ * your backend (not your users) for partner-scoped surfaces like the
51
+ * webhook reconciliation sync endpoint.
52
+ *
53
+ * If omitted, `syncWebhookEvents()` throws on first call but the rest of
54
+ * the SDK (token exchange, user-scoped API calls) continues to work.
55
+ */
56
+ partnerClientId?: string;
57
+ /**
58
+ * Partner-level OAuth client secret paired with {@link partnerClientId}.
59
+ *
60
+ * Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
61
+ */
62
+ partnerClientSecret?: string;
45
63
  }
46
64
  interface MtlsConfig {
47
65
  enabled: boolean;
@@ -89,6 +107,38 @@ declare class EdgeUserClient {
89
107
  getVerificationSessionStatus(transferId: string, sessionId: string): Promise<VerificationSessionStatusResponse>;
90
108
  }
91
109
 
110
+ /**
111
+ * Options for {@link EdgeConnectServer.syncWebhookEvents}.
112
+ */
113
+ interface SyncWebhookEventsOptions {
114
+ /**
115
+ * Cursor token. Pass the `id` of the last event you successfully processed
116
+ * to receive only newer events. Omit on the first call to start from the
117
+ * beginning of the 30-day server-side lookback window.
118
+ */
119
+ afterEventId?: string;
120
+ /**
121
+ * Maximum events per page (1–100). Defaults to 20 server-side.
122
+ */
123
+ limit?: number;
124
+ }
125
+ /**
126
+ * Result of {@link EdgeConnectServer.syncWebhookEvents}. Field names are
127
+ * normalized to camelCase from the wire format (`has_more` → `hasMore`).
128
+ */
129
+ interface SyncWebhookEventsResult {
130
+ /**
131
+ * Events newer than the supplied cursor, in ascending order. Each event
132
+ * is the unwrapped {@link EdgeWebhookEvent} payload (the SDK strips the
133
+ * sync API's outer envelope).
134
+ */
135
+ events: EdgeWebhookEvent[];
136
+ /**
137
+ * `true` if more events are available beyond this page. Loop while this
138
+ * is truthy, advancing your cursor to the `id` of the last processed event.
139
+ */
140
+ hasMore: boolean;
141
+ }
92
142
  declare class EdgeConnectServer {
93
143
  private readonly config;
94
144
  private readonly apiBaseUrl;
@@ -97,6 +147,7 @@ declare class EdgeConnectServer {
97
147
  private readonly retryConfig;
98
148
  private readonly httpsAgent;
99
149
  private readonly dispatcher;
150
+ private partnerTokenCache;
100
151
  static getInstance(config: EdgeConnectServerConfig): EdgeConnectServer;
101
152
  static clearInstances(): void;
102
153
  constructor(config: EdgeConnectServerConfig);
@@ -116,6 +167,67 @@ declare class EdgeConnectServer {
116
167
  refreshTokens(refreshToken: string): Promise<EdgeTokens>;
117
168
  /** @internal Called by {@link EdgeUserClient} — not part of the public API. */
118
169
  _apiRequest<T>(method: string, path: string, accessToken: string, body?: unknown): Promise<T>;
170
+ /**
171
+ * Reconcile webhook events from the partner sync endpoint.
172
+ *
173
+ * Use this from a scheduled cron (every ~5 minutes is typical) to catch
174
+ * events your primary webhook receiver missed — partner downtime, retry
175
+ * exhaustion, dead-letter queue. This is the third reliability layer
176
+ * ("Leg 3") on top of HTTP delivery + DLQ.
177
+ *
178
+ * **Authentication.** This method uses the OAuth `client_credentials`
179
+ * grant against {@link EdgeConnectServerConfig.partnerClientId} and
180
+ * {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
181
+ * cached internally and refreshed automatically.
182
+ *
183
+ * **Pagination.** Pass `afterEventId` with the last `id` you processed.
184
+ * Loop while `hasMore` is true. The server enforces a 30-day lookback;
185
+ * cursors older than 30 days return events from 30 days ago, not an error.
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * let cursor: string | undefined = await loadCursorFromDb()
190
+ *
191
+ * while (true) {
192
+ * const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
193
+ * for (const event of events) {
194
+ * await processEvent(event)
195
+ * cursor = event.id
196
+ * }
197
+ * if (events.length > 0) await saveCursorToDb(cursor!)
198
+ * if (!hasMore) break
199
+ * }
200
+ * ```
201
+ *
202
+ * @throws {EdgeAuthenticationError} If partner credentials are missing
203
+ * or the partner token cannot be obtained / refreshed
204
+ * @throws {EdgeNetworkError} On network failure
205
+ * @throws {EdgeApiError} For other non-2xx responses after retries
206
+ */
207
+ syncWebhookEvents(options?: SyncWebhookEventsOptions): Promise<SyncWebhookEventsResult>;
208
+ /**
209
+ * Builds the sync URL with normalized query parameters.
210
+ */
211
+ private buildSyncUrl;
212
+ /**
213
+ * Issues a GET to the sync endpoint with the partner Bearer token. Kept
214
+ * separate from `_apiRequest` because partner tokens have a different
215
+ * lifecycle (process-wide, client_credentials) than user tokens.
216
+ */
217
+ private partnerFetch;
218
+ /**
219
+ * Fetches (or returns the cached) partner token via the
220
+ * `client_credentials` grant. Caches with a 60s safety margin so the
221
+ * token is renewed *before* the server-side expiry, never racing it.
222
+ *
223
+ * Side-effects: writes `this.partnerTokenCache` on success.
224
+ */
225
+ private getPartnerToken;
226
+ /**
227
+ * @internal Test-only: clears the cached partner token so the next call
228
+ * to `syncWebhookEvents` re-fetches it. Not part of the public API.
229
+ */
230
+ _resetPartnerTokenCache(): void;
119
231
  private getRetryDelay;
120
232
  private sleep;
121
233
  private fetchWithTimeout;
@@ -124,4 +236,68 @@ declare class EdgeConnectServer {
124
236
  private handleApiErrorFromBody;
125
237
  }
126
238
 
127
- export { EdgeConnectServer, type EdgeConnectServerConfig, EdgeUserClient, type MtlsConfig, type RequestInfo, type ResponseInfo, type RetryConfig, type TransferOptions };
239
+ /**
240
+ * Webhook Signature Verification (SDK-001)
241
+ *
242
+ * HMAC-SHA256 verification for EDGE Connect webhook payloads.
243
+ *
244
+ * Signature header format: `t={timestamp},v1={hex_hmac}`
245
+ * Signed message: `{timestamp}.{raw_json_body}`
246
+ *
247
+ * The timestamp prefix prevents replay attacks — events older than the
248
+ * tolerance window (default 5 minutes) are rejected. The HMAC is verified
249
+ * with constant-time comparison to prevent timing attacks.
250
+ */
251
+ /**
252
+ * Options for {@link verifyWebhookSignature}.
253
+ */
254
+ interface VerifyWebhookSignatureOptions {
255
+ /**
256
+ * Maximum age of the signature timestamp, in seconds.
257
+ *
258
+ * Signatures older than this are rejected as potential replays.
259
+ * Defaults to 300 seconds (5 minutes), matching the producer.
260
+ */
261
+ toleranceSeconds?: number;
262
+ }
263
+ /**
264
+ * Verifies an EDGE Connect webhook signature.
265
+ *
266
+ * **Always pass the raw request body bytes** — never `JSON.stringify(req.body)`.
267
+ * Re-stringifying after the framework parses JSON will reorder keys, change
268
+ * whitespace, and silently break verification with no diagnostic. Capture
269
+ * the raw body via your framework's raw-body middleware:
270
+ *
271
+ * - Express: `express.raw({ type: 'application/json' })`
272
+ * - Fastify: built-in (use `request.rawBody`)
273
+ * - NestJS: `{ rawBody: true }` on `NestFactory.create` + `RawBodyRequest<Request>`
274
+ *
275
+ * @param header - The `X-Edge-Signature` header value (`t=...,v1=...`)
276
+ * @param body - The raw request body as a UTF-8 string (NOT a parsed object)
277
+ * @param secret - The partner's webhook signing secret
278
+ * @param options - Optional overrides
279
+ * @returns `true` if the signature is valid and within the tolerance window,
280
+ * `false` otherwise. Never throws — malformed input returns `false`.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * import { verifyWebhookSignature } from '@edge-markets/connect-node'
285
+ *
286
+ * app.post('/webhooks/edge', express.raw({ type: 'application/json' }), (req, res) => {
287
+ * const ok = verifyWebhookSignature(
288
+ * req.headers['x-edge-signature'] as string,
289
+ * req.body.toString('utf8'),
290
+ * process.env.EDGE_WEBHOOK_SECRET!,
291
+ * )
292
+ *
293
+ * if (!ok) return res.status(401).send('invalid signature')
294
+ *
295
+ * const event = JSON.parse(req.body.toString('utf8'))
296
+ * // ... process event
297
+ * res.status(200).send('ok')
298
+ * })
299
+ * ```
300
+ */
301
+ declare function verifyWebhookSignature(header: string, body: string, secret: string, options?: VerifyWebhookSignatureOptions): boolean;
302
+
303
+ export { EdgeConnectServer, type EdgeConnectServerConfig, EdgeUserClient, type MtlsConfig, type RequestInfo, type ResponseInfo, type RetryConfig, type SyncWebhookEventsOptions, type SyncWebhookEventsResult, type TransferOptions, type VerifyWebhookSignatureOptions, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as node_https from 'node:https';
2
- import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens } from '@edge-markets/connect';
3
- export { Balance, CreateVerificationSessionRequest, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferList, TransferListItem, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
2
+ import { EdgeEnvironment, TransferCategory, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, Transfer, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSession, VerificationSessionStatusResponse, EdgeTokens, EdgeWebhookEvent } from '@edge-markets/connect';
3
+ export { Balance, BaseWebhookEvent, ConsentRevokedEventData, CreateVerificationSessionRequest, EDGE_WEBHOOK_EVENT_TYPES, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, EdgeWebhookEvent, EdgeWebhookEventType, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferCompletedEventData, TransferExpiredEventData, TransferFailedEventData, TransferList, TransferListItem, TransferProcessingEventData, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
4
4
 
5
5
  interface RequestInfo {
6
6
  method: string;
@@ -42,6 +42,24 @@ interface EdgeConnectServerConfig {
42
42
  strictResponseEncryption?: boolean;
43
43
  };
44
44
  mtls?: MtlsConfig;
45
+ /**
46
+ * Partner-level OAuth client ID for the `client_credentials` grant.
47
+ *
48
+ * Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
49
+ * This is a different OAuth client than {@link clientId} — it authenticates
50
+ * your backend (not your users) for partner-scoped surfaces like the
51
+ * webhook reconciliation sync endpoint.
52
+ *
53
+ * If omitted, `syncWebhookEvents()` throws on first call but the rest of
54
+ * the SDK (token exchange, user-scoped API calls) continues to work.
55
+ */
56
+ partnerClientId?: string;
57
+ /**
58
+ * Partner-level OAuth client secret paired with {@link partnerClientId}.
59
+ *
60
+ * Required ONLY when calling {@link EdgeConnectServer.syncWebhookEvents}.
61
+ */
62
+ partnerClientSecret?: string;
45
63
  }
46
64
  interface MtlsConfig {
47
65
  enabled: boolean;
@@ -89,6 +107,38 @@ declare class EdgeUserClient {
89
107
  getVerificationSessionStatus(transferId: string, sessionId: string): Promise<VerificationSessionStatusResponse>;
90
108
  }
91
109
 
110
+ /**
111
+ * Options for {@link EdgeConnectServer.syncWebhookEvents}.
112
+ */
113
+ interface SyncWebhookEventsOptions {
114
+ /**
115
+ * Cursor token. Pass the `id` of the last event you successfully processed
116
+ * to receive only newer events. Omit on the first call to start from the
117
+ * beginning of the 30-day server-side lookback window.
118
+ */
119
+ afterEventId?: string;
120
+ /**
121
+ * Maximum events per page (1–100). Defaults to 20 server-side.
122
+ */
123
+ limit?: number;
124
+ }
125
+ /**
126
+ * Result of {@link EdgeConnectServer.syncWebhookEvents}. Field names are
127
+ * normalized to camelCase from the wire format (`has_more` → `hasMore`).
128
+ */
129
+ interface SyncWebhookEventsResult {
130
+ /**
131
+ * Events newer than the supplied cursor, in ascending order. Each event
132
+ * is the unwrapped {@link EdgeWebhookEvent} payload (the SDK strips the
133
+ * sync API's outer envelope).
134
+ */
135
+ events: EdgeWebhookEvent[];
136
+ /**
137
+ * `true` if more events are available beyond this page. Loop while this
138
+ * is truthy, advancing your cursor to the `id` of the last processed event.
139
+ */
140
+ hasMore: boolean;
141
+ }
92
142
  declare class EdgeConnectServer {
93
143
  private readonly config;
94
144
  private readonly apiBaseUrl;
@@ -97,6 +147,7 @@ declare class EdgeConnectServer {
97
147
  private readonly retryConfig;
98
148
  private readonly httpsAgent;
99
149
  private readonly dispatcher;
150
+ private partnerTokenCache;
100
151
  static getInstance(config: EdgeConnectServerConfig): EdgeConnectServer;
101
152
  static clearInstances(): void;
102
153
  constructor(config: EdgeConnectServerConfig);
@@ -116,6 +167,67 @@ declare class EdgeConnectServer {
116
167
  refreshTokens(refreshToken: string): Promise<EdgeTokens>;
117
168
  /** @internal Called by {@link EdgeUserClient} — not part of the public API. */
118
169
  _apiRequest<T>(method: string, path: string, accessToken: string, body?: unknown): Promise<T>;
170
+ /**
171
+ * Reconcile webhook events from the partner sync endpoint.
172
+ *
173
+ * Use this from a scheduled cron (every ~5 minutes is typical) to catch
174
+ * events your primary webhook receiver missed — partner downtime, retry
175
+ * exhaustion, dead-letter queue. This is the third reliability layer
176
+ * ("Leg 3") on top of HTTP delivery + DLQ.
177
+ *
178
+ * **Authentication.** This method uses the OAuth `client_credentials`
179
+ * grant against {@link EdgeConnectServerConfig.partnerClientId} and
180
+ * {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
181
+ * cached internally and refreshed automatically.
182
+ *
183
+ * **Pagination.** Pass `afterEventId` with the last `id` you processed.
184
+ * Loop while `hasMore` is true. The server enforces a 30-day lookback;
185
+ * cursors older than 30 days return events from 30 days ago, not an error.
186
+ *
187
+ * @example
188
+ * ```typescript
189
+ * let cursor: string | undefined = await loadCursorFromDb()
190
+ *
191
+ * while (true) {
192
+ * const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
193
+ * for (const event of events) {
194
+ * await processEvent(event)
195
+ * cursor = event.id
196
+ * }
197
+ * if (events.length > 0) await saveCursorToDb(cursor!)
198
+ * if (!hasMore) break
199
+ * }
200
+ * ```
201
+ *
202
+ * @throws {EdgeAuthenticationError} If partner credentials are missing
203
+ * or the partner token cannot be obtained / refreshed
204
+ * @throws {EdgeNetworkError} On network failure
205
+ * @throws {EdgeApiError} For other non-2xx responses after retries
206
+ */
207
+ syncWebhookEvents(options?: SyncWebhookEventsOptions): Promise<SyncWebhookEventsResult>;
208
+ /**
209
+ * Builds the sync URL with normalized query parameters.
210
+ */
211
+ private buildSyncUrl;
212
+ /**
213
+ * Issues a GET to the sync endpoint with the partner Bearer token. Kept
214
+ * separate from `_apiRequest` because partner tokens have a different
215
+ * lifecycle (process-wide, client_credentials) than user tokens.
216
+ */
217
+ private partnerFetch;
218
+ /**
219
+ * Fetches (or returns the cached) partner token via the
220
+ * `client_credentials` grant. Caches with a 60s safety margin so the
221
+ * token is renewed *before* the server-side expiry, never racing it.
222
+ *
223
+ * Side-effects: writes `this.partnerTokenCache` on success.
224
+ */
225
+ private getPartnerToken;
226
+ /**
227
+ * @internal Test-only: clears the cached partner token so the next call
228
+ * to `syncWebhookEvents` re-fetches it. Not part of the public API.
229
+ */
230
+ _resetPartnerTokenCache(): void;
119
231
  private getRetryDelay;
120
232
  private sleep;
121
233
  private fetchWithTimeout;
@@ -124,4 +236,68 @@ declare class EdgeConnectServer {
124
236
  private handleApiErrorFromBody;
125
237
  }
126
238
 
127
- export { EdgeConnectServer, type EdgeConnectServerConfig, EdgeUserClient, type MtlsConfig, type RequestInfo, type ResponseInfo, type RetryConfig, type TransferOptions };
239
+ /**
240
+ * Webhook Signature Verification (SDK-001)
241
+ *
242
+ * HMAC-SHA256 verification for EDGE Connect webhook payloads.
243
+ *
244
+ * Signature header format: `t={timestamp},v1={hex_hmac}`
245
+ * Signed message: `{timestamp}.{raw_json_body}`
246
+ *
247
+ * The timestamp prefix prevents replay attacks — events older than the
248
+ * tolerance window (default 5 minutes) are rejected. The HMAC is verified
249
+ * with constant-time comparison to prevent timing attacks.
250
+ */
251
+ /**
252
+ * Options for {@link verifyWebhookSignature}.
253
+ */
254
+ interface VerifyWebhookSignatureOptions {
255
+ /**
256
+ * Maximum age of the signature timestamp, in seconds.
257
+ *
258
+ * Signatures older than this are rejected as potential replays.
259
+ * Defaults to 300 seconds (5 minutes), matching the producer.
260
+ */
261
+ toleranceSeconds?: number;
262
+ }
263
+ /**
264
+ * Verifies an EDGE Connect webhook signature.
265
+ *
266
+ * **Always pass the raw request body bytes** — never `JSON.stringify(req.body)`.
267
+ * Re-stringifying after the framework parses JSON will reorder keys, change
268
+ * whitespace, and silently break verification with no diagnostic. Capture
269
+ * the raw body via your framework's raw-body middleware:
270
+ *
271
+ * - Express: `express.raw({ type: 'application/json' })`
272
+ * - Fastify: built-in (use `request.rawBody`)
273
+ * - NestJS: `{ rawBody: true }` on `NestFactory.create` + `RawBodyRequest<Request>`
274
+ *
275
+ * @param header - The `X-Edge-Signature` header value (`t=...,v1=...`)
276
+ * @param body - The raw request body as a UTF-8 string (NOT a parsed object)
277
+ * @param secret - The partner's webhook signing secret
278
+ * @param options - Optional overrides
279
+ * @returns `true` if the signature is valid and within the tolerance window,
280
+ * `false` otherwise. Never throws — malformed input returns `false`.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * import { verifyWebhookSignature } from '@edge-markets/connect-node'
285
+ *
286
+ * app.post('/webhooks/edge', express.raw({ type: 'application/json' }), (req, res) => {
287
+ * const ok = verifyWebhookSignature(
288
+ * req.headers['x-edge-signature'] as string,
289
+ * req.body.toString('utf8'),
290
+ * process.env.EDGE_WEBHOOK_SECRET!,
291
+ * )
292
+ *
293
+ * if (!ok) return res.status(401).send('invalid signature')
294
+ *
295
+ * const event = JSON.parse(req.body.toString('utf8'))
296
+ * // ... process event
297
+ * res.status(200).send('ok')
298
+ * })
299
+ * ```
300
+ */
301
+ declare function verifyWebhookSignature(header: string, body: string, secret: string, options?: VerifyWebhookSignatureOptions): boolean;
302
+
303
+ export { EdgeConnectServer, type EdgeConnectServerConfig, EdgeUserClient, type MtlsConfig, type RequestInfo, type ResponseInfo, type RetryConfig, type SyncWebhookEventsOptions, type SyncWebhookEventsResult, type TransferOptions, type VerifyWebhookSignatureOptions, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ EDGE_WEBHOOK_EVENT_TYPES: () => import_connect4.EDGE_WEBHOOK_EVENT_TYPES,
33
34
  EdgeApiError: () => import_connect3.EdgeApiError,
34
35
  EdgeAuthenticationError: () => import_connect3.EdgeAuthenticationError,
35
36
  EdgeConnectServer: () => EdgeConnectServer,
@@ -49,7 +50,8 @@ __export(index_exports, {
49
50
  isEdgeError: () => import_connect3.isEdgeError,
50
51
  isIdentityVerificationError: () => import_connect3.isIdentityVerificationError,
51
52
  isNetworkError: () => import_connect3.isNetworkError,
52
- isProductionEnvironment: () => import_connect4.isProductionEnvironment
53
+ isProductionEnvironment: () => import_connect4.isProductionEnvironment,
54
+ verifyWebhookSignature: () => verifyWebhookSignature
53
55
  });
54
56
  module.exports = __toCommonJS(index_exports);
55
57
 
@@ -58,6 +60,14 @@ var import_connect2 = require("@edge-markets/connect");
58
60
 
59
61
  // src/validators.ts
60
62
  var import_connect = require("@edge-markets/connect");
63
+ var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
64
+ function validateTransferId(transferId) {
65
+ if (typeof transferId !== "string" || !UUID_V4_REGEX.test(transferId)) {
66
+ throw new import_connect.EdgeValidationError("transferId must be a UUID v4", {
67
+ transferId: ["Must be a valid UUID v4"]
68
+ });
69
+ }
70
+ }
61
71
  function validateVerifyIdentityOptions(options) {
62
72
  const errors = {};
63
73
  const firstName = typeof options.firstName === "string" ? options.firstName.trim() : "";
@@ -135,6 +145,7 @@ var EdgeUserClient = class {
135
145
  return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
136
146
  }
137
147
  async getTransfer(transferId) {
148
+ validateTransferId(transferId);
138
149
  return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
139
150
  }
140
151
  async listTransfers(params) {
@@ -155,6 +166,7 @@ var EdgeUserClient = class {
155
166
  * The handoff token is single-use and expires in 120 seconds.
156
167
  */
157
168
  async createVerificationSession(transferId, options) {
169
+ validateTransferId(transferId);
158
170
  return this.server._apiRequest(
159
171
  "POST",
160
172
  `/transfer/${encodeURIComponent(transferId)}/verification-session`,
@@ -167,6 +179,7 @@ var EdgeUserClient = class {
167
179
  * Use as an alternative to postMessage events.
168
180
  */
169
181
  async getVerificationSessionStatus(transferId, sessionId) {
182
+ validateTransferId(transferId);
170
183
  return this.server._apiRequest(
171
184
  "GET",
172
185
  `/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
@@ -319,18 +332,8 @@ function environmentRequiresMtls(environment, apiBaseUrl) {
319
332
  }
320
333
  }
321
334
  var EdgeConnectServer = class _EdgeConnectServer {
322
- static getInstance(config) {
323
- const key = getInstanceKey(config);
324
- const existing = instances.get(key);
325
- if (existing) return existing;
326
- const instance = new _EdgeConnectServer(config);
327
- instances.set(key, instance);
328
- return instance;
329
- }
330
- static clearInstances() {
331
- instances.clear();
332
- }
333
335
  constructor(config) {
336
+ this.partnerTokenCache = null;
334
337
  if (!config.clientId) {
335
338
  throw new Error("EdgeConnectServer: clientId is required");
336
339
  }
@@ -359,6 +362,17 @@ var EdgeConnectServer = class _EdgeConnectServer {
359
362
  );
360
363
  }
361
364
  }
365
+ static getInstance(config) {
366
+ const key = getInstanceKey(config);
367
+ const existing = instances.get(key);
368
+ if (existing) return existing;
369
+ const instance = new _EdgeConnectServer(config);
370
+ instances.set(key, instance);
371
+ return instance;
372
+ }
373
+ static clearInstances() {
374
+ instances.clear();
375
+ }
362
376
  /**
363
377
  * Returns the `node:https.Agent` configured with mTLS client certificates.
364
378
  * Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
@@ -529,6 +543,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
529
543
  lastError
530
544
  );
531
545
  }
546
+ /**
547
+ * Reconcile webhook events from the partner sync endpoint.
548
+ *
549
+ * Use this from a scheduled cron (every ~5 minutes is typical) to catch
550
+ * events your primary webhook receiver missed — partner downtime, retry
551
+ * exhaustion, dead-letter queue. This is the third reliability layer
552
+ * ("Leg 3") on top of HTTP delivery + DLQ.
553
+ *
554
+ * **Authentication.** This method uses the OAuth `client_credentials`
555
+ * grant against {@link EdgeConnectServerConfig.partnerClientId} and
556
+ * {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
557
+ * cached internally and refreshed automatically.
558
+ *
559
+ * **Pagination.** Pass `afterEventId` with the last `id` you processed.
560
+ * Loop while `hasMore` is true. The server enforces a 30-day lookback;
561
+ * cursors older than 30 days return events from 30 days ago, not an error.
562
+ *
563
+ * @example
564
+ * ```typescript
565
+ * let cursor: string | undefined = await loadCursorFromDb()
566
+ *
567
+ * while (true) {
568
+ * const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
569
+ * for (const event of events) {
570
+ * await processEvent(event)
571
+ * cursor = event.id
572
+ * }
573
+ * if (events.length > 0) await saveCursorToDb(cursor!)
574
+ * if (!hasMore) break
575
+ * }
576
+ * ```
577
+ *
578
+ * @throws {EdgeAuthenticationError} If partner credentials are missing
579
+ * or the partner token cannot be obtained / refreshed
580
+ * @throws {EdgeNetworkError} On network failure
581
+ * @throws {EdgeApiError} For other non-2xx responses after retries
582
+ */
583
+ async syncWebhookEvents(options = {}) {
584
+ if (!this.config.partnerClientId) {
585
+ throw new import_connect2.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientId` in EdgeConnectServer config");
586
+ }
587
+ if (!this.config.partnerClientSecret) {
588
+ throw new import_connect2.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientSecret` in EdgeConnectServer config");
589
+ }
590
+ const url = this.buildSyncUrl(options);
591
+ let token = await this.getPartnerToken();
592
+ let response = await this.partnerFetch(url, token);
593
+ if (response.status === 401) {
594
+ this.partnerTokenCache = null;
595
+ token = await this.getPartnerToken();
596
+ response = await this.partnerFetch(url, token);
597
+ if (response.status === 401) {
598
+ const body = await response.json().catch(() => ({}));
599
+ throw new import_connect2.EdgeAuthenticationError(
600
+ body.message || "Partner token rejected after refresh \u2014 check partnerClientId/Secret",
601
+ body
602
+ );
603
+ }
604
+ }
605
+ if (!response.ok) {
606
+ const body = await response.json().catch(() => ({}));
607
+ throw new import_connect2.EdgeApiError(
608
+ body.error || "sync_failed",
609
+ body.message || `syncWebhookEvents failed with status ${response.status}`,
610
+ response.status,
611
+ body
612
+ );
613
+ }
614
+ const wire = await response.json().catch(() => ({}));
615
+ const events = (wire.events ?? []).map((entry) => entry.payload).filter((p) => !!p);
616
+ return {
617
+ events,
618
+ hasMore: !!wire.has_more
619
+ };
620
+ }
621
+ /**
622
+ * Builds the sync URL with normalized query parameters.
623
+ */
624
+ buildSyncUrl(options) {
625
+ const url = new URL(`${this.apiBaseUrl}/partner/webhooks/events/sync`);
626
+ if (options.afterEventId) {
627
+ url.searchParams.set("after_event_id", options.afterEventId);
628
+ }
629
+ if (options.limit !== void 0) {
630
+ url.searchParams.set("limit", String(options.limit));
631
+ }
632
+ return url.toString();
633
+ }
634
+ /**
635
+ * Issues a GET to the sync endpoint with the partner Bearer token. Kept
636
+ * separate from `_apiRequest` because partner tokens have a different
637
+ * lifecycle (process-wide, client_credentials) than user tokens.
638
+ */
639
+ async partnerFetch(url, token) {
640
+ try {
641
+ return await this.fetchWithTimeout(
642
+ url,
643
+ {
644
+ method: "GET",
645
+ headers: {
646
+ Authorization: `Bearer ${token}`,
647
+ "Content-Type": "application/json",
648
+ "User-Agent": USER_AGENT
649
+ }
650
+ },
651
+ this.dispatcher
652
+ );
653
+ } catch (error) {
654
+ throw new import_connect2.EdgeNetworkError("syncWebhookEvents network failure", error);
655
+ }
656
+ }
657
+ /**
658
+ * Fetches (or returns the cached) partner token via the
659
+ * `client_credentials` grant. Caches with a 60s safety margin so the
660
+ * token is renewed *before* the server-side expiry, never racing it.
661
+ *
662
+ * Side-effects: writes `this.partnerTokenCache` on success.
663
+ */
664
+ async getPartnerToken() {
665
+ const cached = this.partnerTokenCache;
666
+ if (cached && Date.now() < cached.expiresAt) {
667
+ return cached.token;
668
+ }
669
+ const tokenUrl = `${this.oauthBaseUrl}/token`;
670
+ let response;
671
+ try {
672
+ response = await this.fetchWithTimeout(
673
+ tokenUrl,
674
+ {
675
+ method: "POST",
676
+ headers: {
677
+ "Content-Type": "application/x-www-form-urlencoded",
678
+ "User-Agent": USER_AGENT
679
+ },
680
+ body: new URLSearchParams({
681
+ grant_type: "client_credentials",
682
+ client_id: this.config.partnerClientId,
683
+ client_secret: this.config.partnerClientSecret
684
+ }).toString()
685
+ },
686
+ this.dispatcher
687
+ );
688
+ } catch (error) {
689
+ throw new import_connect2.EdgeNetworkError("Partner token request network failure", error);
690
+ }
691
+ if (!response.ok) {
692
+ const body = await response.json().catch(() => ({}));
693
+ throw new import_connect2.EdgeAuthenticationError(
694
+ body.message || body.error_description || `Partner token request failed with status ${response.status}`,
695
+ body
696
+ );
697
+ }
698
+ const data = await response.json().catch(() => ({}));
699
+ if (!data.access_token || typeof data.expires_in !== "number") {
700
+ throw new import_connect2.EdgeAuthenticationError("Partner token response missing access_token or expires_in");
701
+ }
702
+ this.partnerTokenCache = {
703
+ token: data.access_token,
704
+ expiresAt: Date.now() + (data.expires_in - 60) * 1e3
705
+ };
706
+ return data.access_token;
707
+ }
708
+ /**
709
+ * @internal Test-only: clears the cached partner token so the next call
710
+ * to `syncWebhookEvents` re-fetches it. Not part of the public API.
711
+ */
712
+ _resetPartnerTokenCache() {
713
+ this.partnerTokenCache = null;
714
+ }
532
715
  getRetryDelay(attempt) {
533
716
  const { backoff, baseDelayMs } = this.retryConfig;
534
717
  if (backoff === "exponential") {
@@ -638,11 +821,41 @@ var EdgeConnectServer = class _EdgeConnectServer {
638
821
  }
639
822
  };
640
823
 
824
+ // src/webhook-signing.ts
825
+ var import_node_crypto = __toESM(require("crypto"));
826
+ function verifyWebhookSignature(header, body, secret, options = {}) {
827
+ if (typeof header !== "string" || typeof body !== "string" || typeof secret !== "string") {
828
+ return false;
829
+ }
830
+ const requestedTolerance = options.toleranceSeconds;
831
+ const toleranceSeconds = typeof requestedTolerance === "number" && Number.isFinite(requestedTolerance) && requestedTolerance >= 0 ? requestedTolerance : 300;
832
+ const parts = header.split(",").map((p) => p.trim());
833
+ const tPart = parts.find((p) => p.startsWith("t="));
834
+ const v1Part = parts.find((p) => p.startsWith("v1="));
835
+ if (!tPart || !v1Part) return false;
836
+ const timestamp = parseInt(tPart.slice(2), 10);
837
+ const signature = v1Part.slice(3);
838
+ if (isNaN(timestamp) || !signature) return false;
839
+ const now = Math.floor(Date.now() / 1e3);
840
+ if (Math.abs(now - timestamp) > toleranceSeconds) return false;
841
+ const signedContent = `${timestamp}.${body}`;
842
+ const expectedHmac = import_node_crypto.default.createHmac("sha256", secret).update(signedContent).digest("hex");
843
+ try {
844
+ const sigBuf = Buffer.from(signature, "hex");
845
+ const expBuf = Buffer.from(expectedHmac, "hex");
846
+ if (sigBuf.length !== expBuf.length) return false;
847
+ return import_node_crypto.default.timingSafeEqual(sigBuf, expBuf);
848
+ } catch {
849
+ return false;
850
+ }
851
+ }
852
+
641
853
  // src/index.ts
642
854
  var import_connect3 = require("@edge-markets/connect");
643
855
  var import_connect4 = require("@edge-markets/connect");
644
856
  // Annotate the CommonJS export names for ESM import in node:
645
857
  0 && (module.exports = {
858
+ EDGE_WEBHOOK_EVENT_TYPES,
646
859
  EdgeApiError,
647
860
  EdgeAuthenticationError,
648
861
  EdgeConnectServer,
@@ -662,5 +875,6 @@ var import_connect4 = require("@edge-markets/connect");
662
875
  isEdgeError,
663
876
  isIdentityVerificationError,
664
877
  isNetworkError,
665
- isProductionEnvironment
878
+ isProductionEnvironment,
879
+ verifyWebhookSignature
666
880
  });
package/dist/index.mjs CHANGED
@@ -21,6 +21,14 @@ import {
21
21
 
22
22
  // src/validators.ts
23
23
  import { EdgeValidationError } from "@edge-markets/connect";
24
+ var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
25
+ function validateTransferId(transferId) {
26
+ if (typeof transferId !== "string" || !UUID_V4_REGEX.test(transferId)) {
27
+ throw new EdgeValidationError("transferId must be a UUID v4", {
28
+ transferId: ["Must be a valid UUID v4"]
29
+ });
30
+ }
31
+ }
24
32
  function validateVerifyIdentityOptions(options) {
25
33
  const errors = {};
26
34
  const firstName = typeof options.firstName === "string" ? options.firstName.trim() : "";
@@ -98,6 +106,7 @@ var EdgeUserClient = class {
98
106
  return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
99
107
  }
100
108
  async getTransfer(transferId) {
109
+ validateTransferId(transferId);
101
110
  return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
102
111
  }
103
112
  async listTransfers(params) {
@@ -118,6 +127,7 @@ var EdgeUserClient = class {
118
127
  * The handoff token is single-use and expires in 120 seconds.
119
128
  */
120
129
  async createVerificationSession(transferId, options) {
130
+ validateTransferId(transferId);
121
131
  return this.server._apiRequest(
122
132
  "POST",
123
133
  `/transfer/${encodeURIComponent(transferId)}/verification-session`,
@@ -130,6 +140,7 @@ var EdgeUserClient = class {
130
140
  * Use as an alternative to postMessage events.
131
141
  */
132
142
  async getVerificationSessionStatus(transferId, sessionId) {
143
+ validateTransferId(transferId);
133
144
  return this.server._apiRequest(
134
145
  "GET",
135
146
  `/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
@@ -282,18 +293,8 @@ function environmentRequiresMtls(environment, apiBaseUrl) {
282
293
  }
283
294
  }
284
295
  var EdgeConnectServer = class _EdgeConnectServer {
285
- static getInstance(config) {
286
- const key = getInstanceKey(config);
287
- const existing = instances.get(key);
288
- if (existing) return existing;
289
- const instance = new _EdgeConnectServer(config);
290
- instances.set(key, instance);
291
- return instance;
292
- }
293
- static clearInstances() {
294
- instances.clear();
295
- }
296
296
  constructor(config) {
297
+ this.partnerTokenCache = null;
297
298
  if (!config.clientId) {
298
299
  throw new Error("EdgeConnectServer: clientId is required");
299
300
  }
@@ -322,6 +323,17 @@ var EdgeConnectServer = class _EdgeConnectServer {
322
323
  );
323
324
  }
324
325
  }
326
+ static getInstance(config) {
327
+ const key = getInstanceKey(config);
328
+ const existing = instances.get(key);
329
+ if (existing) return existing;
330
+ const instance = new _EdgeConnectServer(config);
331
+ instances.set(key, instance);
332
+ return instance;
333
+ }
334
+ static clearInstances() {
335
+ instances.clear();
336
+ }
325
337
  /**
326
338
  * Returns the `node:https.Agent` configured with mTLS client certificates.
327
339
  * Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
@@ -492,6 +504,175 @@ var EdgeConnectServer = class _EdgeConnectServer {
492
504
  lastError
493
505
  );
494
506
  }
507
+ /**
508
+ * Reconcile webhook events from the partner sync endpoint.
509
+ *
510
+ * Use this from a scheduled cron (every ~5 minutes is typical) to catch
511
+ * events your primary webhook receiver missed — partner downtime, retry
512
+ * exhaustion, dead-letter queue. This is the third reliability layer
513
+ * ("Leg 3") on top of HTTP delivery + DLQ.
514
+ *
515
+ * **Authentication.** This method uses the OAuth `client_credentials`
516
+ * grant against {@link EdgeConnectServerConfig.partnerClientId} and
517
+ * {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
518
+ * cached internally and refreshed automatically.
519
+ *
520
+ * **Pagination.** Pass `afterEventId` with the last `id` you processed.
521
+ * Loop while `hasMore` is true. The server enforces a 30-day lookback;
522
+ * cursors older than 30 days return events from 30 days ago, not an error.
523
+ *
524
+ * @example
525
+ * ```typescript
526
+ * let cursor: string | undefined = await loadCursorFromDb()
527
+ *
528
+ * while (true) {
529
+ * const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
530
+ * for (const event of events) {
531
+ * await processEvent(event)
532
+ * cursor = event.id
533
+ * }
534
+ * if (events.length > 0) await saveCursorToDb(cursor!)
535
+ * if (!hasMore) break
536
+ * }
537
+ * ```
538
+ *
539
+ * @throws {EdgeAuthenticationError} If partner credentials are missing
540
+ * or the partner token cannot be obtained / refreshed
541
+ * @throws {EdgeNetworkError} On network failure
542
+ * @throws {EdgeApiError} For other non-2xx responses after retries
543
+ */
544
+ async syncWebhookEvents(options = {}) {
545
+ if (!this.config.partnerClientId) {
546
+ throw new EdgeAuthenticationError("syncWebhookEvents requires `partnerClientId` in EdgeConnectServer config");
547
+ }
548
+ if (!this.config.partnerClientSecret) {
549
+ throw new EdgeAuthenticationError("syncWebhookEvents requires `partnerClientSecret` in EdgeConnectServer config");
550
+ }
551
+ const url = this.buildSyncUrl(options);
552
+ let token = await this.getPartnerToken();
553
+ let response = await this.partnerFetch(url, token);
554
+ if (response.status === 401) {
555
+ this.partnerTokenCache = null;
556
+ token = await this.getPartnerToken();
557
+ response = await this.partnerFetch(url, token);
558
+ if (response.status === 401) {
559
+ const body = await response.json().catch(() => ({}));
560
+ throw new EdgeAuthenticationError(
561
+ body.message || "Partner token rejected after refresh \u2014 check partnerClientId/Secret",
562
+ body
563
+ );
564
+ }
565
+ }
566
+ if (!response.ok) {
567
+ const body = await response.json().catch(() => ({}));
568
+ throw new EdgeApiError(
569
+ body.error || "sync_failed",
570
+ body.message || `syncWebhookEvents failed with status ${response.status}`,
571
+ response.status,
572
+ body
573
+ );
574
+ }
575
+ const wire = await response.json().catch(() => ({}));
576
+ const events = (wire.events ?? []).map((entry) => entry.payload).filter((p) => !!p);
577
+ return {
578
+ events,
579
+ hasMore: !!wire.has_more
580
+ };
581
+ }
582
+ /**
583
+ * Builds the sync URL with normalized query parameters.
584
+ */
585
+ buildSyncUrl(options) {
586
+ const url = new URL(`${this.apiBaseUrl}/partner/webhooks/events/sync`);
587
+ if (options.afterEventId) {
588
+ url.searchParams.set("after_event_id", options.afterEventId);
589
+ }
590
+ if (options.limit !== void 0) {
591
+ url.searchParams.set("limit", String(options.limit));
592
+ }
593
+ return url.toString();
594
+ }
595
+ /**
596
+ * Issues a GET to the sync endpoint with the partner Bearer token. Kept
597
+ * separate from `_apiRequest` because partner tokens have a different
598
+ * lifecycle (process-wide, client_credentials) than user tokens.
599
+ */
600
+ async partnerFetch(url, token) {
601
+ try {
602
+ return await this.fetchWithTimeout(
603
+ url,
604
+ {
605
+ method: "GET",
606
+ headers: {
607
+ Authorization: `Bearer ${token}`,
608
+ "Content-Type": "application/json",
609
+ "User-Agent": USER_AGENT
610
+ }
611
+ },
612
+ this.dispatcher
613
+ );
614
+ } catch (error) {
615
+ throw new EdgeNetworkError("syncWebhookEvents network failure", error);
616
+ }
617
+ }
618
+ /**
619
+ * Fetches (or returns the cached) partner token via the
620
+ * `client_credentials` grant. Caches with a 60s safety margin so the
621
+ * token is renewed *before* the server-side expiry, never racing it.
622
+ *
623
+ * Side-effects: writes `this.partnerTokenCache` on success.
624
+ */
625
+ async getPartnerToken() {
626
+ const cached = this.partnerTokenCache;
627
+ if (cached && Date.now() < cached.expiresAt) {
628
+ return cached.token;
629
+ }
630
+ const tokenUrl = `${this.oauthBaseUrl}/token`;
631
+ let response;
632
+ try {
633
+ response = await this.fetchWithTimeout(
634
+ tokenUrl,
635
+ {
636
+ method: "POST",
637
+ headers: {
638
+ "Content-Type": "application/x-www-form-urlencoded",
639
+ "User-Agent": USER_AGENT
640
+ },
641
+ body: new URLSearchParams({
642
+ grant_type: "client_credentials",
643
+ client_id: this.config.partnerClientId,
644
+ client_secret: this.config.partnerClientSecret
645
+ }).toString()
646
+ },
647
+ this.dispatcher
648
+ );
649
+ } catch (error) {
650
+ throw new EdgeNetworkError("Partner token request network failure", error);
651
+ }
652
+ if (!response.ok) {
653
+ const body = await response.json().catch(() => ({}));
654
+ throw new EdgeAuthenticationError(
655
+ body.message || body.error_description || `Partner token request failed with status ${response.status}`,
656
+ body
657
+ );
658
+ }
659
+ const data = await response.json().catch(() => ({}));
660
+ if (!data.access_token || typeof data.expires_in !== "number") {
661
+ throw new EdgeAuthenticationError("Partner token response missing access_token or expires_in");
662
+ }
663
+ this.partnerTokenCache = {
664
+ token: data.access_token,
665
+ expiresAt: Date.now() + (data.expires_in - 60) * 1e3
666
+ };
667
+ return data.access_token;
668
+ }
669
+ /**
670
+ * @internal Test-only: clears the cached partner token so the next call
671
+ * to `syncWebhookEvents` re-fetches it. Not part of the public API.
672
+ */
673
+ _resetPartnerTokenCache() {
674
+ this.partnerTokenCache = null;
675
+ }
495
676
  getRetryDelay(attempt) {
496
677
  const { backoff, baseDelayMs } = this.retryConfig;
497
678
  if (backoff === "exponential") {
@@ -601,6 +782,35 @@ var EdgeConnectServer = class _EdgeConnectServer {
601
782
  }
602
783
  };
603
784
 
785
+ // src/webhook-signing.ts
786
+ import crypto from "crypto";
787
+ function verifyWebhookSignature(header, body, secret, options = {}) {
788
+ if (typeof header !== "string" || typeof body !== "string" || typeof secret !== "string") {
789
+ return false;
790
+ }
791
+ const requestedTolerance = options.toleranceSeconds;
792
+ const toleranceSeconds = typeof requestedTolerance === "number" && Number.isFinite(requestedTolerance) && requestedTolerance >= 0 ? requestedTolerance : 300;
793
+ const parts = header.split(",").map((p) => p.trim());
794
+ const tPart = parts.find((p) => p.startsWith("t="));
795
+ const v1Part = parts.find((p) => p.startsWith("v1="));
796
+ if (!tPart || !v1Part) return false;
797
+ const timestamp = parseInt(tPart.slice(2), 10);
798
+ const signature = v1Part.slice(3);
799
+ if (isNaN(timestamp) || !signature) return false;
800
+ const now = Math.floor(Date.now() / 1e3);
801
+ if (Math.abs(now - timestamp) > toleranceSeconds) return false;
802
+ const signedContent = `${timestamp}.${body}`;
803
+ const expectedHmac = crypto.createHmac("sha256", secret).update(signedContent).digest("hex");
804
+ try {
805
+ const sigBuf = Buffer.from(signature, "hex");
806
+ const expBuf = Buffer.from(expectedHmac, "hex");
807
+ if (sigBuf.length !== expBuf.length) return false;
808
+ return crypto.timingSafeEqual(sigBuf, expBuf);
809
+ } catch {
810
+ return false;
811
+ }
812
+ }
813
+
604
814
  // src/index.ts
605
815
  import {
606
816
  EdgeApiError as EdgeApiError2,
@@ -619,8 +829,14 @@ import {
619
829
  isIdentityVerificationError,
620
830
  isNetworkError
621
831
  } from "@edge-markets/connect";
622
- import { TRANSFER_CATEGORIES, getEnvironmentConfig as getEnvironmentConfig2, isProductionEnvironment } from "@edge-markets/connect";
832
+ import {
833
+ EDGE_WEBHOOK_EVENT_TYPES,
834
+ TRANSFER_CATEGORIES,
835
+ getEnvironmentConfig as getEnvironmentConfig2,
836
+ isProductionEnvironment
837
+ } from "@edge-markets/connect";
623
838
  export {
839
+ EDGE_WEBHOOK_EVENT_TYPES,
624
840
  EdgeApiError2 as EdgeApiError,
625
841
  EdgeAuthenticationError2 as EdgeAuthenticationError,
626
842
  EdgeConnectServer,
@@ -640,5 +856,6 @@ export {
640
856
  isEdgeError,
641
857
  isIdentityVerificationError,
642
858
  isNetworkError,
643
- isProductionEnvironment
859
+ isProductionEnvironment,
860
+ verifyWebhookSignature
644
861
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edge-markets/connect-node",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Server SDK for EDGE Connect token exchange and API calls",
5
5
  "author": "Edge Markets",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@edge-markets/connect": "^1.5.1"
24
+ "@edge-markets/connect": "^1.6.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsup": "^8.0.0",