npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-node

Quick 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 body argument 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

  1. Verify signatures first — reject mis-signed events with 401 before doing any work.
  2. Return 200 immediately — process events asynchronously. EDGE retries on non-2xx and on responses slower than 10s.
  3. Be idempotent — the same event may be delivered more than once (at-least-once delivery). Key your wallet credit / status update on event.id or event.data.transferId.
  4. Run reconciliation as a backstop — webhooks are the fast path, syncWebhookEvents is 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