@edge-markets/connect-node
v1.7.0
Published
Server SDK for EDGE Connect token exchange and API calls
Readme
@edge-markets/connect-node
Server SDK for EDGE Connect token exchange and API calls.
Features
- 🔐 Secure token exchange - Exchange codes for tokens with PKCE
- 🔄 Token refresh - Automatic refresh token handling
- 📡 Full API client - User, balance, transfers via
forUser()pattern - 🛡️ Typed errors - Specific error classes for each scenario
- 📝 TypeScript first - Complete type definitions
- 🪝 Webhook signature verification - Constant-time HMAC-SHA256 with replay protection
- 🔁 Webhook reconciliation - Cursor-based sync for catching missed events
Installation
npm install @edge-markets/connect-node
# or
pnpm add @edge-markets/connect-node
# or
yarn add @edge-markets/connect-nodeQuick Start
import { EdgeConnectServer } from '@edge-markets/connect-node'
// Create instance once (reuse for all requests)
const edge = new EdgeConnectServer({
clientId: process.env.EDGE_CLIENT_ID!,
clientSecret: process.env.EDGE_CLIENT_SECRET!,
environment: 'staging',
})
// Exchange code from EdgeLink for tokens
const tokens = await edge.exchangeCode(code, codeVerifier)
// Create a user-scoped client and make API calls
const client = edge.forUser(tokens.accessToken)
const user = await client.getUser()
const balance = await client.getBalance()⚠️ Security
This SDK requires your client secret. Use it ONLY on your backend server!
Never expose your client secret to browsers or client-side code.
Configuration
interface EdgeConnectServerConfig {
clientId: string // Your user-facing OAuth client ID
clientSecret: string // Your user-facing OAuth client secret (keep secret!)
environment: EdgeEnvironment // 'production' | 'staging' | 'sandbox'
// Optional
apiBaseUrl?: string // Custom API URL (dev only)
oauthBaseUrl?: string // Custom OAuth URL (dev only)
timeout?: number // Request timeout (default: 30000ms)
retry?: RetryConfig // Retry configuration
onRequest?: (info) => void // Hook called before each request
onResponse?: (info) => void // Hook called after each response
// Optional: Partner-level credentials for non-user-scoped endpoints.
// Required ONLY when calling syncWebhookEvents — the rest of the SDK
// works without them. These are a DIFFERENT OAuth client than clientId
// (machine-to-machine, not user-facing).
partnerClientId?: string
partnerClientSecret?: string
// Optional: Message Level Encryption (Connect endpoints only)
mle?: {
enabled: boolean
edgePublicKey: string // EDGE public encryption key (PEM)
edgeKeyId: string // EDGE key ID (kid) used for requests
partnerPrivateKey: string // Your private key (PEM) to decrypt responses
partnerKeyId: string // Your key ID (kid) expected in response headers
strictResponseEncryption?: boolean // default true
}
}Message Level Encryption (MLE)
const edge = new EdgeConnectServer({
clientId: process.env.EDGE_CLIENT_ID!,
clientSecret: process.env.EDGE_CLIENT_SECRET!,
environment: 'staging',
mle: {
enabled: true,
edgePublicKey: process.env.EDGE_MLE_EDGE_PUBLIC_KEY!,
edgeKeyId: process.env.EDGE_MLE_EDGE_KEY_ID!,
partnerPrivateKey: process.env.EDGE_MLE_PARTNER_PRIVATE_KEY!,
partnerKeyId: process.env.EDGE_MLE_PARTNER_KEY_ID!,
},
})When enabled, the SDK sends X-Edge-MLE: v1, encrypts request bodies, and decrypts encrypted Connect responses.
Token Exchange
After EdgeLink completes, exchange the code for tokens:
// In your /api/edge/exchange endpoint
export async function POST(req: Request) {
const { code, codeVerifier } = await req.json()
try {
const tokens = await edge.exchangeCode(code, codeVerifier)
// Store tokens securely (encrypted in database)
await db.edgeConnections.upsert({
userId: req.user.id,
accessToken: encrypt(tokens.accessToken),
refreshToken: encrypt(tokens.refreshToken),
expiresAt: new Date(tokens.expiresAt),
})
return Response.json({ success: true })
} catch (error) {
if (error instanceof EdgeTokenExchangeError) {
// Code expired or already used
return Response.json({ error: 'Please try again' }, { status: 400 })
}
throw error
}
}Token Refresh
Refresh tokens before they expire:
async function getValidAccessToken(userId: string): Promise<string> {
const connection = await db.edgeConnections.get(userId)
// Refresh 5 minutes before expiry
const BUFFER = 5 * 60 * 1000
if (Date.now() > connection.expiresAt.getTime() - BUFFER) {
const newTokens = await edge.refreshTokens(
decrypt(connection.refreshToken)
)
await db.edgeConnections.update(userId, {
accessToken: encrypt(newTokens.accessToken),
refreshToken: encrypt(newTokens.refreshToken),
expiresAt: new Date(newTokens.expiresAt),
})
return newTokens.accessToken
}
return decrypt(connection.accessToken)
}API Methods
All user-scoped API methods live on EdgeUserClient, created via edge.forUser(accessToken):
const client = edge.forUser(accessToken)User & Balance
const user = await client.getUser()
// Returns: { id, email, firstName, lastName, createdAt }
const balance = await client.getBalance()
// Returns: { userId, availableBalance, currency, asOf }Transfers
// 1. Initiate the transfer — returns a pending transfer that requires verification.
const transfer = await client.initiateTransfer({
type: 'debit', // 'debit' = pull from user, 'credit' = push to user
amount: '100.00',
idempotencyKey: `txn_${userId}_${Date.now()}`,
})
// Returns: { transferId, status: 'pending_verification', otpMethod }
// 2. Create an EDGE-hosted verification session. The returned `verificationUrl`
// is a single-use, short-lived URL you embed in an iframe or popup on your
// frontend. The user enters their OTP inside the EDGE-hosted UI — OTP secrets
// never touch your infrastructure.
const session = await client.createVerificationSession(transfer.transferId, {
origin: 'https://your-site.example.com',
})
// Returns: { sessionId, verificationUrl, expiresAt }
// 3. Listen for the postMessage event from the iframe (or poll
// getVerificationSessionStatus) to learn when verification completes.
// A `transfer.completed` webhook will be delivered to your configured
// webhook URL once the transfer settles. For browser-crash recovery,
// your backend should also reconcile against
// GET /v1/partner/webhooks/events/sync using a dashboard
// client_credentials token from POST /connect/oauth/token.
// Get transfer status
const status = await client.getTransfer(transfer.transferId)
// List transfers
const { transfers, total } = await client.listTransfers({
status: 'completed',
limit: 10,
offset: 0,
})Consent
// Revoke consent (disconnect user)
await client.revokeConsent()
// Clean up stored tokens
await db.edgeConnections.delete(userId)Webhooks
EDGE Connect delivers webhook events to your server when transfers complete, fail, expire, or when a user revokes consent. The SDK provides two helpers: signature verification for the live HTTP delivery channel, and a reconciliation sync method for the cron-driven safety net.
Verify webhook signatures
Every webhook arrives with an X-Edge-Signature header in t=...,v1=...
format — HMAC-SHA256 over ${timestamp}.${rawBody} keyed by your webhook
secret. Use verifyWebhookSignature to check it. The function does
constant-time comparison and rejects events older than 5 minutes by default
to prevent replay attacks.
⚠️ The
bodyargument MUST be the raw request bytes, not a parsed object that you re-stringified.JSON.stringify(req.body)reorders keys and silently breaks verification with no diagnostic. Always capture the raw body via your framework's raw-body middleware.
import { verifyWebhookSignature, type EdgeWebhookEvent } from '@edge-markets/connect-node'
import express from 'express'
const app = express()
app.post(
'/webhooks/edge',
// 👇 Critical: this gives you req.body as a Buffer, not a parsed object
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyWebhookSignature(
req.headers['x-edge-signature'] as string,
req.body.toString('utf8'),
process.env.EDGE_WEBHOOK_SECRET!,
)
if (!ok) return res.status(401).send('invalid signature')
const event: EdgeWebhookEvent = JSON.parse(req.body.toString('utf8'))
// Process asynchronously and return 200 immediately — see "Best Practices"
void processEdgeEvent(event)
res.status(200).send('ok')
},
)NestJS
// main.ts
const app = await NestFactory.create(AppModule, { rawBody: true })
// edge-webhook.controller.ts
import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
import type { Request } from 'express'
import { verifyWebhookSignature } from '@edge-markets/connect-node'
@Controller('webhooks')
export class EdgeWebhookController {
@Post('edge')
@HttpCode(200)
handle(@Req() req: RawBodyRequest<Request>) {
const ok = verifyWebhookSignature(
req.headers['x-edge-signature'] as string,
req.rawBody?.toString('utf8') ?? '',
process.env.EDGE_WEBHOOK_SECRET!,
)
if (!ok) throw new UnauthorizedException('invalid signature')
// ...
}
}Type-safe event handling with EdgeWebhookEvent
EdgeWebhookEvent is a discriminated union over event.type. Switching
narrows event.data automatically — no casts required.
import type { EdgeWebhookEvent } from '@edge-markets/connect-node'
function processEdgeEvent(event: EdgeWebhookEvent) {
switch (event.type) {
case 'transfer.completed':
// event.data is { transferId, status: 'completed', type, amount }
creditWallet(event.data.transferId, event.data.amount)
break
case 'transfer.failed':
// event.data.reason is `string | undefined`
logFailure(event.data.transferId, event.data.reason ?? 'unknown')
break
case 'transfer.expired':
cancelPending(event.data.transferId)
break
case 'transfer.processing':
// @experimental — reserved for ledger dual-write hand-off
break
case 'consent.revoked':
deactivateLink(event.data.userId, event.data.clientId)
break
default: {
const _exhaustive: never = event
return _exhaustive
}
}
}event.data.amount is intentionally a string (e.g. '100.00'), not a
number, to preserve decimal precision — parse it with a decimal-aware
library on your side.
Reconcile missed events with syncWebhookEvents
Even with retries and a dead-letter queue, network or partner outages can
drop webhook events. Run syncWebhookEvents from a cron (every ~5 minutes
is typical) to catch anything your primary receiver missed.
This method requires the partner-level credentials (partnerClientId /
partnerClientSecret) on EdgeConnectServer. It uses the OAuth
client_credentials grant, caches the partner token internally, and
handles a 401 by refreshing the token once and retrying.
import { EdgeConnectServer } from '@edge-markets/connect-node'
const edge = new EdgeConnectServer({
clientId: process.env.EDGE_CLIENT_ID!,
clientSecret: process.env.EDGE_CLIENT_SECRET!,
environment: 'staging',
partnerClientId: process.env.EDGE_PARTNER_CLIENT_ID!,
partnerClientSecret: process.env.EDGE_PARTNER_CLIENT_SECRET!,
})
async function reconcile() {
let cursor = await db.cursors.get('edge') // your stored watermark
while (true) {
const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
for (const event of events) {
await processEdgeEvent(event) // same idempotent handler as live webhooks
cursor = event.id
}
if (events.length > 0) await db.cursors.set('edge', cursor!)
if (!hasMore) break
}
}
setInterval(reconcile, 5 * 60 * 1000)The server enforces a 30-day lookback. If your cursor is older than 30
days, you will receive events from 30 days ago — re-bootstrap from a known
good state via getTransfer if you have been offline for longer.
Best practices
- Verify signatures first — reject mis-signed events with 401 before doing any work.
- Return 200 immediately — process events asynchronously. EDGE retries on non-2xx and on responses slower than 10s.
- Be idempotent — the same event may be delivered more than once
(at-least-once delivery). Key your wallet credit / status update on
event.idorevent.data.transferId. - Run reconciliation as a backstop — webhooks are the fast path,
syncWebhookEventsis the safety net. They share the same handler.
Error Handling
import {
EdgeError,
EdgeAuthenticationError,
EdgeTokenExchangeError,
EdgeConsentRequiredError,
isEdgeError,
} from '@edge-markets/connect-node'
try {
const client = edge.forUser(accessToken)
const balance = await client.getBalance()
} catch (error) {
if (error instanceof EdgeAuthenticationError) {
// Token expired - try refresh or reconnect
return { error: 'session_expired' }
}
if (error instanceof EdgeConsentRequiredError) {
// User revoked consent - need to reconnect
return { error: 'reconnect_required' }
}
if (isEdgeError(error)) {
// Some other SDK error
console.error(`Edge Error [${error.code}]: ${error.message}`)
return { error: error.code }
}
// Unknown error
throw error
}Error Types
| Error | When | What to do |
|-------|------|------------|
| EdgeAuthenticationError | Token invalid/expired | Refresh token or reconnect |
| EdgeTokenExchangeError | Code exchange failed | Ask user to try again |
| EdgeConsentRequiredError | User hasn't granted consent | Open EdgeLink |
| EdgeInsufficientScopeError | Missing required scopes | Request more scopes |
| EdgeNotFoundError | Resource not found | Check ID |
| EdgeApiError | Other API error | Check error.code |
| EdgeNetworkError | Network failure | Retry request |
NestJS Example
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { EdgeConnectServer, EdgeConsentRequiredError } from '@edge-markets/connect-node'
@Injectable()
export class EdgeService {
private readonly edge: EdgeConnectServer
private readonly logger = new Logger(EdgeService.name)
constructor(private config: ConfigService) {
this.edge = new EdgeConnectServer({
clientId: this.config.getOrThrow('EDGE_CLIENT_ID'),
clientSecret: this.config.getOrThrow('EDGE_CLIENT_SECRET'),
environment: this.config.get('EDGE_ENVIRONMENT', 'staging'),
})
}
async exchangeCode(code: string, codeVerifier: string) {
return this.edge.exchangeCode(code, codeVerifier)
}
async getBalance(accessToken: string) {
try {
const client = this.edge.forUser(accessToken)
return await client.getBalance()
} catch (error) {
if (error instanceof EdgeConsentRequiredError) {
this.logger.warn('User consent required')
throw error
}
this.logger.error('Failed to get balance', error)
throw error
}
}
}Express Example
import express from 'express'
import { EdgeConnectServer, isEdgeError } from '@edge-markets/connect-node'
const edge = new EdgeConnectServer({
clientId: process.env.EDGE_CLIENT_ID!,
clientSecret: process.env.EDGE_CLIENT_SECRET!,
environment: 'staging',
})
const app = express()
app.use(express.json())
// Exchange code for tokens
app.post('/api/edge/exchange', async (req, res) => {
try {
const { code, codeVerifier } = req.body
const tokens = await edge.exchangeCode(code, codeVerifier)
// Store tokens for user...
res.json({ success: true })
} catch (error) {
if (isEdgeError(error)) {
res.status(400).json({ error: error.code, message: error.message })
} else {
res.status(500).json({ error: 'internal_error' })
}
}
})
// Get balance
app.get('/api/edge/balance', async (req, res) => {
try {
const accessToken = await getAccessTokenForUser(req.user.id)
const client = edge.forUser(accessToken)
const balance = await client.getBalance()
res.json(balance)
} catch (error) {
// Handle errors...
}
})Related Packages
@edge-markets/connect- Core types and utilities@edge-markets/connect-link- Browser SDK for popup authentication
License
MIT
