Skip to main content

Overview

When a cardholder swipes their card, the card network sends an authorization webhook to your backend. You have ~2 seconds to check credit, execute an on-chain draw, and respond.
Card swipe → Visa → Card issuer → Your webhook endpoint
  ├─ Validate signature
  ├─ Look up wallet from card ID
  ├─ GET /credit/accounts/{account}/info   → check credit
  ├─ GET /credit/accounts/{account}/draw   → build draw calldata
  ├─ Execute on-chain via delegated signer
  └─ Respond: approved / declined
This example uses Rain as the card issuer. Adapt signature validation and response format for your card program.

Implementation

1

Set Up Configuration

import express from "express";
import crypto from "crypto";
import { ethers } from "ethers";

const app = express();
app.use(express.json({ verify: (req: any, _res, buf) => { req.rawBody = buf; } }));

const SPRINTER_API = "https://api.sprinter.tech";
const RAIN_WEBHOOK_SECRET = process.env.RAIN_WEBHOOK_SECRET!;
const SETTLEMENT_ADDRESS = process.env.SETTLEMENT_ADDRESS!;
const RPC_URL = process.env.RPC_URL!;
const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY!;

const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(SIGNER_PRIVATE_KEY, provider);
2

Define Types and Helpers

interface ContractCall {
  to: string;
  data: string;
  value: string;
}

interface CreditInfo {
  data: {
    USDC: {
      totalCollateralValue: string;
      principal: string;
      interest: string;
      healthFactor: string;
      dueDate: string | null;
    };
  };
}

// Use Redis or a database in production
const processed = new Map<string, { approved: boolean; txHash?: string }>();

function validateSignature(rawBody: Buffer, signature: string): boolean {
  const expected = crypto
    .createHmac("sha256", RAIN_WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

async function getWalletForCard(cardId: string): Promise<string | null> {
  // TODO: query your database
  throw new Error(`Implement getWalletForCard for cardId: ${cardId}`);
}

async function getCreditInfo(account: string): Promise<CreditInfo> {
  const res = await fetch(`${SPRINTER_API}/credit/accounts/${account}/info`);
  if (!res.ok) throw new Error(`Credit info failed: ${res.status}`);
  return res.json();
}

async function buildDrawCalls(
  account: string,
  amountUsdc: string,
  receiver: string
): Promise<ContractCall[]> {
  const url = new URL(`${SPRINTER_API}/credit/accounts/${account}/draw`);
  url.searchParams.set("amount", amountUsdc);
  url.searchParams.set("receiver", receiver);
  const res = await fetch(url.toString());
  if (!res.ok) throw new Error(`Draw calldata failed: ${res.status}`);
  const data = await res.json();
  return data.calls;
}

async function executeCalls(calls: ContractCall[]): Promise<string> {
  let lastTxHash = "";
  for (const call of calls) {
    const tx = await signer.sendTransaction({
      to: call.to,
      data: call.data,
      value: call.value || "0",
    });
    const receipt = await tx.wait();
    if (!receipt || receipt.status !== 1) {
      throw new Error(`Transaction reverted: ${tx.hash}`);
    }
    lastTxHash = tx.hash;
  }
  return lastTxHash;
}

/** $1.00 = 100 cents = 1_000_000 USDC units (6 decimals) */
function centsToUsdcUnits(cents: number): string {
  return (cents * 10_000).toString();
}
3

Handle the Webhook

app.post("/webhooks/rain/authorization", async (req, res) => {
  try {
    // 1. Validate webhook signature
    const signature = req.headers["x-rain-signature"] as string;
    if (!signature || !validateSignature((req as any).rawBody, signature)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const { id, cardId, amount } = req.body;

    // 2. Idempotency — return cached result if already processed
    const cached = processed.get(id);
    if (cached) return res.json(cached);

    // 3. Look up user wallet
    const wallet = await getWalletForCard(cardId);
    if (!wallet) {
      processed.set(id, { approved: false });
      return res.json({ approved: false, reason: "unknown_card" });
    }

    // 4. Check available credit and health factor
    const info = await getCreditInfo(wallet);
    const available = parseFloat(info.data.USDC.totalCollateralValue);
    const healthFactor = parseFloat(info.data.USDC.healthFactor);

    if (amount / 100 > available) {
      processed.set(id, { approved: false });
      return res.json({ approved: false, reason: "insufficient_credit" });
    }

    if (healthFactor < 1.3) {
      processed.set(id, { approved: false });
      return res.json({ approved: false, reason: "low_health_factor" });
    }

    // 5. Build and execute the credit draw
    const calls = await buildDrawCalls(wallet, centsToUsdcUnits(amount), SETTLEMENT_ADDRESS);
    const txHash = await executeCalls(calls);

    processed.set(id, { approved: true, txHash });
    return res.json({ approved: true, txHash });
  } catch (error) {
    // Fail closed — always decline on error
    console.error("Authorization error:", error);
    return res.json({ approved: false, reason: "internal_error" });
  }
});

app.listen(3001, () => console.log("Webhook handler running on :3001"));

Delegated Credit Draws

Card authorizations must complete without user interaction. Your backend needs to draw credit on behalf of users. There are two on-chain approaches:

Option A: Non-Custodial (Smart Accounts)

Users deploy a smart account (ERC-4337) and grant your backend a scoped session key or module that can only call the credit draw function. The user retains full custody.

ERC-7579 Session Keys

User grants a scoped session key that can only call the credit draw function. Expires automatically.

Pre-signed Permits

User pre-signs EIP-2612 permits for USDC transfers up to a spending limit.

Smart Account Modules

User’s smart wallet delegates draw authority to your backend via a custom module.

Option B: Operator Contract (Server-Side)

Deploy an ExclusiveOperator contract that lets your backend draw credit on behalf of opted-in users. This is the pattern used by card programs that need server-side control over credit draws. Setup (one-time):
// Deploy with your backend address as the authorized caller
ExclusiveOperator operator = new ExclusiveOperator(
    creditHubAddress,    // Sprinter Credit Hub
    controllerAddress,   // Credit Hub Controller
    adminAddress,        // Contract admin (your multisig)
    callerAddress,       // Your backend's signing address
    7 days               // Revoke delay — users must wait 7 days to revoke
);
User onboarding:
// 1. User sets the operator on their credit position
creditHub.setOperator(address(operator));

// 2. User whitelists your settlement address as a credit receiver
operator.addCreditReceiver(settlementAddress);
Drawing credit at swipe time:
// Your backend calls this directly — no user signature needed
operator.openCreditLine(
    borrowerAddress,     // User's wallet
    settlementAddress,   // Whitelisted receiver
    amount               // USDC amount in wei
);
Safety guarantees:
  • withdraw() is disabled — the operator can never touch collateral
  • Credit can only go to whitelisted receivers the user has explicitly approved
  • Revocation has a time delay (e.g. 7 days) — prevents users from revoking mid-billing-cycle while the operator still has outstanding credit exposure
  • Users can schedule revocation at any time via revoke(), then finalize after the delay with finalizeRevoke()
The caller address can draw credit from any opted-in user account. Secure the private key with HSM or KMS in production — never store it in environment variables on shared infrastructure.

Latency Budget

Card networks expect a response within ~2 seconds:
StepTargetNotes
Signature validation< 1msLocal crypto
Database lookup (card → wallet)< 10msIndex on card ID
GET /credit/accounts/{account}/info< 100msSprinter API
GET /credit/accounts/{account}/draw< 100msSprinter API
On-chain execution< 1000msDedicated Base RPC node
Total< 1300ms~700ms buffer
Use a dedicated RPC node on Base. Public endpoints will exceed the time budget under load.

Production Checklist

Infrastructure

  • Replace in-memory idempotency map with Redis or database
  • Use a dedicated Base RPC node (Alchemy, QuickNode, or self-hosted)
  • Store signer key in HSM or cloud KMS (AWS KMS, GCP Cloud KMS)
  • Implement getWalletForCard() against your user database
  • Log all authorization decisions for audit trail
  • Load test under expected peak authorization volume
  • Monitor authorization latency (p99 < 2s)
  • Alert on elevated decline rates
  • Track on-chain execution success rate

Environment Variables

VariableDescription
RAIN_WEBHOOK_SECRETWebhook signing secret from your card issuer
SETTLEMENT_ADDRESSYour card program’s USDC settlement address on Base
RPC_URLBase RPC endpoint (dedicated node recommended)
SIGNER_PRIVATE_KEYDelegated signer private key (HSM/KMS in production)