Architecture · Long-form

How PayBridge is built

A deep, opinionated walkthrough of every architectural decision behind PayBridge — what we chose, what we rejected, and why. Written for the kind of reviewer who reads source before reading slides.

TL;DR

What problem PayBridge actually solves

Payment integrations are a tax that every product-led team pays again and again — once per provider, per geography. The work is repetitive (auth, checkout, tokenize, charge, webhook, idempotency), high-stakes (one missed gate and you are a credit-card breach away from a front-page incident), and almost entirely undifferentiated. PayBridge collapses that work into a single workflow:

  1. A provider-agnostic SPI so your application code never changes shape when you swap providers.
  2. A real correctness harnessthat drives each adapter end-to-end against the provider’s actual sandbox — auth, checkout, tokenize, charge, webhook signature round-trip, idempotency replay — and persists every step.
  3. An AI co-pilot (Pulse) that generates the integration code on demand, then calls the harness and the guardrails on your behalf.
  4. Deterministic guardrails (G1–G5) that scan the generated artefact and block a Go-Live badge until every PCI-aligned check passes.
  5. An MCP server that re-exposes the same six tools so any AI agent — Claude Desktop, Cursor, an autonomous build pipeline — can drive PayBridge from outside the UI.
  6. Four ways to prove the generated code actually runs — a preconfigured Live Sandbox, a downloadable runnable project, a one-click Vercel preview deploy, and an in-browser WebContainer — each anchored to a SHA-256 content hash and culminating in a real live ₹1 Razorpay payment. See §14.

PayBridge ships five providers today — Braintree, Cashfree, PayPal, a self-hosted Blackbaud-mock, and Razorpay(India’s live INR rail, taking real money against api.razorpay.com). Everything below is the architecture that makes that work without becoming a knot of provider-specific exceptions.

§1

Monorepo layout — package boundaries do the work

PayBridge is a structured monolithinside a pnpm + Turborepo workspace. Logical service boundaries are enforced by package boundaries, not by network boundaries — there is exactly one Vercel deployment. Crossing package lines requires an explicit import, which is exactly the kind of friction that prevents an “adapter” from sneakily reaching into the harness, or the harness from reaching into the AI co-pilot.

pnpm-workspace.yaml · yaml
packages:
  - apps/*
  - packages/*
  - packages/adapters/*

# apps/web              — Next.js 16 App Router (the only deployed surface)
# packages/core         — PaymentProvider SPI, idempotency wrapper, ProviderError
# packages/db           — Prisma client + DEMO_TENANT_ID + DB enum re-exports
# packages/codegen      — provider × pattern × language template engine
# packages/guardrails   — pure G1–G5 deterministic scanners
# packages/pulse        — Pulse system prompt + six tool descriptors + zod schemas
# packages/mcp          — stdio binary that proxies stdin↔HTTP to /api/mcp
# packages/adapters/braintree, /cashfree, /paypal, /blackbaud-mock, /razorpay
# apps/web/features/isv   — Sample-ISV proof surface: 4 modes that run the
#                           generated code (sandbox / download / deploy / in-browser)
# scripts               — smoke-* binaries that exercise adapters from the CLI

The split lets a single change ripple deterministically. For example: adding a new provider means a new packages/adapters/<name>/ directory implementing the SPI, plus one line in the workspace catalogue. The harness, the codegen, the guardrails, and Pulse pick it up without modification. The interface dividend is real, not aspirational.

§2

The PaymentProvider SPI — the contract that everything hangs from

Every adapter implements PaymentProvider. The contract is deliberately narrow — five required methods, two optional. Anything more would leak provider-specific concepts into the contract; anything less and the harness could not run uniformly.

packages/core/src/provider.ts · ts
export interface PaymentProvider {
  readonly id: ProviderId;
  readonly capabilities: Capabilities;

  ping(): Promise<{ ok: boolean; latencyMs: number }>;

  createCheckoutSession(
    input: CheckoutInput,
  ): Promise<CheckoutSession>;

  tokenizeTestCard(
    input: TokenizeInput,
  ): Promise<TokenizeOutput>;

  charge(input: ChargeInput): Promise<ChargeOutput>;

  verifyWebhook(input: WebhookInput): Promise<WebhookOutput>;

  // Optional — only providers with a deferred-capture model
  // (PayPal Smart Buttons) implement this.
  completePendingCharge?(input: CompleteInput): Promise<ChargeOutput>;

  // Optional — used by the harness to fabricate a webhook the
  // provider's own dashboard refuses to resend (B7 step 5).
  createSyntheticWebhook?(
    runId: string,
    secret: string,
  ): Promise<{ headers: Record<string,string>; body: string }>;
}

Two design decisions are worth flagging.

Errors are typed. Adapters throw ProviderError with a code from a closed set (AUTH_FAILED, CARD_DECLINED, NETWORK, etc.). The harness translates these into RunStep rows; the API envelope translates them into structured error responses. No adapter ever throws a raw fetch error past its boundary — the seam absorbs provider noise.

Optional methods are signals.Rather than every adapter stubbing every method (which trains the harness to ignore returns), the optionality says “this provider works differently and the harness must branch.” PayPal’s hosted-checkout is approve-then-capture, not synchronous charge; completePendingCharge? tells the harness to poll. The runner reads typeof adapter.completePendingCharge === ‘function’ instead of guessing.

§3

The six-step harness — a deterministic state machine over the SPI

runHarness() is the centerpiece. It drives an adapter through a fixed step plan, records every step to Postgres, and emits SSE events for the live UI. Each step is wrapped in timed(), which captures latency and writes a RunStep row whether the step passes or throws.

apps/web/features/harness/runner.ts · ts
const STEP_PLAN = [
  { key: 'auth',        ordinal: 1, label: 'Authenticate' },
  { key: 'checkout',    ordinal: 2, label: 'Create checkout session' },
  { key: 'tokenize',    ordinal: 3, label: 'Tokenize test card' },
  { key: 'charge',      ordinal: 4, label: 'Charge (with idempotency)' },
  { key: 'webhook',     ordinal: 5, label: 'Webhook signature round-trip' },
  { key: 'idempotency', ordinal: 6, label: 'Idempotency replay' },
] as const;

export async function runHarness(input: RunInput): Promise<RunResult> {
  const adapter = getAdapter(input.providerId);
  const run = await prisma.integrationRun.create({ /* ... */ });

  try {
    const pong = await timed(run.id, 'auth', () => adapter.ping());
    /* ...steps 2..6, each writing markPassed / markFailed on the
       way through... */
  } catch (err) {
    // ProviderError → fail the current step + finish the run.
  } finally {
    await prisma.integrationRun.update({ where: { id: run.id }, data: { /* ... */ } });
  }
  return { run, steps };
}

Three things make this approach more than a glorified for-loop.

Step persistence is in the same transaction as step execution. We do not buffer step results in memory and flush them at the end. If the process dies after step 3, the database has the first three steps with their timings and the run is left in running — a janitor can later decide whether to fail-forward or replay. (For the demo we just let it expire; a real deployment cancels after maxDuration.)

Webhook signature round-trip is real, not simulated. Step 5 asks the adapter for a synthetic webhook ( createSyntheticWebhook) — properly HMAC-signed using the configured secret — then POSTs it to the public webhook route on the same deployment, then asserts that the inbound route accepted it. This catches the most common production bug we see: a webhook handler that parses the body before verifying the signature.

Idempotency replay is bit-exact. Step 6 re-issues the original charge with the original Idempotency-Key. The expectation is not“a successful charge” — it is “the byte-for-byte same response as step 4.” If the adapter accidentally retries the provider call (rather than replaying from cache), step 6 fails even when step 4 passed. This is the test that separates well-built idempotency wrappers from ones that kinda work.

§4

Two-layer idempotency — Upstash hot path, Postgres durable record

Idempotency is not just a header. The contract a caller expects is: if I retry with the same key, I get the same response — even if the provider call actually happened, even across pod restarts, even across regions. PayBridge implements that contract in two layers.

packages/core/src/idempotency.ts · ts
export async function withIdempotency<T>(
  key: string,
  body: unknown,
  exec: () => Promise<T>,
): Promise<T> {
  const hash = sha256(stableStringify(body));
  const cacheKey = `idem:${tenantId}:${providerId}:${key}`;

  // 1. Hot path — Upstash KV. ~10ms global.
  const hit = await kv.get<{hash:string; res:T}>(cacheKey);
  if (hit) {
    if (hit.hash !== hash) throw new ProviderError(
      'IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY',
      providerId,
      'A different request body was sent for the same key.',
    );
    return hit.res;
  }

  // 2. Slow path — Postgres outcome cache. Survives KV eviction.
  const row = await prisma.idempotencyCache.findUnique({ /* ... */ });
  if (row) {
    if (row.requestHash !== hash) throw /* same error */;
    await kv.set(cacheKey, { hash, res: row.responseJson }, { ex: TTL });
    return row.responseJson as T;
  }

  // 3. Miss — execute, persist, return.
  const res = await exec();
  await prisma.$transaction([
    prisma.idempotencyCache.create({ /* ... */ }),
  ]);
  await kv.set(cacheKey, { hash, res }, { ex: TTL });
  return res;
}

Several properties fall out of this shape.

  • Hash-mismatch detection is a feature, not an accident.A common bug pattern is “same key, different body” — a retry that replays the original idempotency key but with subtly different fields (different tip amount, different shipping address). Most providers will silently accept the first body and ignore the second. PayBridge throws IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_BODY so the bug surfaces on first retry rather than two weeks later in reconciliation.
  • The Postgres layer is the source of truth. Upstash is a performance cache; KV evictions, regional outages, or a hot-cache flush do not cause a duplicate provider call. Worst case the second request is slower.
  • Some providers have native idempotency (PayPal Orders v2 takes a PayPal-Request-Id header).The adapter forwards the header AND the wrapper still caches the outcome. Belt and braces — if the adapter is misconfigured we still don't double-charge.

§5

Webhook signature verification — one inbound surface, five scheme variants

Every provider lies a little differently about webhook signatures. Braintree signs form-encoded body fields (not headers); Cashfree v2025-01-01 uses millisecond timestamps (not seconds); PayPal verifies through their verify-webhook-signature API; Blackbaud-mock uses standard HMAC-SHA256 of <timestamp>.<body>; Razorpay signs the raw body with HMAC-SHA256 (hex) in X-Razorpay-Signature. The job of the inbound surface is to absorb every one of those quirks before any DB write happens. The single route at /api/v1/providers/[id]/webhook dispatches to the right adapter, so adding Razorpay added zero new webhook plumbing — its account-level events land here and annotate the matching run in /runs.

apps/web/app/api/v1/providers/[id]/webhook/route.ts · ts
export async function POST(request: NextRequest) {
  // 1. Raw body — never JSON.parse() first.
  const raw = await request.text();
  const headers = Object.fromEntries(request.headers);

  // 2. Adapter-specific verify. Returns { valid, eventId, eventType,
  //    parsed } or throws.
  const verdict = await adapter.verifyWebhook({
    rawBody: raw,
    headers,
    secret: env.WEBHOOK_SECRET,
  });
  if (!verdict.valid) {
    await prisma.webhookEvent.create({ /* validity: 'invalid' */ });
    return fail({ code: 'INVALID_SIGNATURE', httpStatus: 200 });
  }

  // 3. Dedupe by (providerId, eventId). Upserts are atomic.
  const dup = await prisma.webhookEvent.findUnique({ /* ... */ });
  if (dup) return ok({ deduped: true });

  // 4. NOW we are allowed to touch business state.
  await prisma.$transaction([
    prisma.webhookEvent.create({ /* ... */ }),
    matchToRun(verdict.eventId),
  ]);
  return ok({ accepted: true });
}

Three invariants this code enforces:

  1. Verify before write. No prisma.X.create fires before verifyWebhook returns valid. This is the property guardrail G3 looks for in generated code — the gate is a literal scan of every webhook handler file for the relative order of verifyWebhook-shaped calls vs. prisma.X.create|update|upsert|delete calls.
  2. 200 even on invalid signature. Returning 4xx tells the provider to retry, which floods the route. Logging an invalid event and returning 200 stops the retry storm. The webhook_events table is the audit trail.
  3. Dedupe by event id. Providers will re-send the same event. The (providerId, eventId) unique index is the cheap dedupe; everything downstream assumes at-most-once.

§6

Deterministic guardrails (G1–G5) — heuristics that say “ship” or “don't”

Guardrails are not a security audit. They are a sanity check against the most common regressions reviewers (and a real PCI assessor) flag first. The whole package is pure functions over file contents — no DB, no network, no clocks, no randomness. Same files → same verdict, always.

The five shipped gates:

  • G1No secret-shaped literals in client-bundled files. Regex over Stripe-style sk_live_*, hex secrets ≥32 chars, AWS access-key pairs, PayPal token literals. Files classified as client (anything ending in .tsx or /page.tsx) are scanned; .env.example placeholders are not.
  • G2No raw PAN/CVV field references on server routes. Scans for cardNumber, card_number, pan, cardCvc, cvv outside comments on any route handler file.
  • G3Webhook signature verified before any DB write. Flow-order check inside webhook handler files: index of the first verifyWebhook|verifyHmac|createHmac|timingSafeEqual match must precede the index of the first prisma.X.create|update|upsert|delete match.
  • G4Idempotency primitive on every mutating route. Any file with export async function POST|PUT|PATCH|DELETE must reference idempotencyKey, Idempotency-Key, withIdempotency, or PayPal-Request-Id somewhere.
  • G5TLS-only configuration. No http:// URLs in the artefact except localhost / 127.x.
packages/guardrails/src/gates.ts · ts
const VERIFY_RE =
  /\b(verifyWebhook|verifyHmac|createHmac|timingSafeEqual|verify_webhook_signature)\b/;
const MUTATE_RE =
  /\bprisma\.\w+\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\b/;

export function runG3(files: Classified[]): GateResult {
  const failures: Evidence[] = [];
  for (const f of files) {
    if (f.kind !== 'webhook') continue;
    const verifyIdx = f.contents.search(VERIFY_RE);
    const mutateIdx = f.contents.search(MUTATE_RE);
    if (mutateIdx === -1) continue; // no mutations — nothing to gate.
    if (verifyIdx === -1 || verifyIdx > mutateIdx) {
      failures.push({ path: f.path, line: lineOf(f.contents, mutateIdx) });
    }
  }
  return failures.length === 0
    ? { id: 'G3', status: 'passed', detail: 'Signature verified before DB mutation.' }
    : { id: 'G3', status: 'failed', detail: '...', evidence: failures };
}

The output is a structured verdict per gate (passed / failed / skipped) with evidence (path, line number, snippet). The generated_artefacts table holds the snapshot the gates ran against; the guardrail_results table holds every verdict ever computed. Re-running on the same artefact is therefore O(1) lookup, but we re-run anyway — the scanners are fast and the freshness signal matters.

§7

Codegen — provider × pattern × language as a template lattice

packages/codegen is a template engine keyed by a tuple. Each supported tuple — (braintree, hosted-checkout, ts), (paypal, hosted-checkout, ts), and so on — has a renderer that emits a coherent set of files (server route, client component, webhook receiver, .env.example) plus ordered setup steps.

packages/codegen/src/generator.ts · ts
export function generateCode(input: CodegenInput): CodegenResult {
  const target = SUPPORTED_TARGETS.find(
    (t) =>
      t.providerId === input.providerId &&
      t.pattern === input.pattern &&
      t.language === (input.language ?? 'ts'),
  );
  if (!target) throw new Error('Unsupported (provider, pattern, language).');

  const ctx: TemplateContext = {
    appName: input.appName ?? 'paybridge-demo',
    currency: input.currency ?? defaultCurrencyFor(input.providerId),
    amountMinor: input.amount ?? 1234,
  };
  return TEMPLATES[target.key](ctx);
}

Each template is a TypeScript module that returns a list of { path, language, contents }. The output is intentionally not Markdown wrapped — Pulse's generate_codetool publishes the same list to the UI's CodePreview pane and to a GeneratedArtefact row, so guardrails later have the exact bytes the user saw.

Two opinions worth calling out:

  • Templates are TypeScript, not EJS / Handlebars. Template strings inside .ts files are typed end-to-end. Refactoring a field name in the SPI tells you which templates need updates because they fail to compile, not because a test eventually catches it.
  • Each template encodes the provider's quirks.The Braintree template hardcodes form-body webhook parsing because that's what Braintree actually does; the PayPal template hardcodes body: {} on the capture POST because Orders v2 rejects empty bodies. These are not configuration knobs — they are facts of the provider that the template asserts in compiled-in form.

§8

Pulse — Claude with six tools and one job

Pulse is the chat surface in the integration workspace. It is streamText() from AI SDK v6 against @ai-sdk/anthropic directly — we deliberately do not route through Vercel AI Gateway, because the project has prepaid Anthropic credits and a second billing surface is a non-goal.

apps/web/app/api/v1/pulse/route.ts · ts
const anthropic = createAnthropic({
  apiKey: env.ANTHROPIC_API_KEY,
  // Pin baseURL — the shell sometimes carries ANTHROPIC_BASE_URL
  // without the /v1 suffix, which the SDK respects, which 404s.
  baseURL: 'https://api.anthropic.com/v1',
});

const result = streamText({
  model: anthropic(PULSE_MODEL_ID),
  system: PULSE_SYSTEM_PROMPT,
  messages: modelMessages,
  tools,
  maxOutputTokens: 4096,
  stopWhen: stepCountIs(6),
  onFinish: async ({ text, finishReason, totalUsage, steps }) => {
    const allToolCalls = steps.flatMap((s) =>
      s.toolCalls.map((tc) => ({
        id: tc.toolCallId, name: tc.toolName, input: tc.input,
      })),
    );
    await prisma.$transaction([
      prisma.pulseMessage.create({ /* user turn  */ }),
      prisma.pulseMessage.create({ /* assistant turn — text + toolCalls + usage */ }),
    ]);
  },
});
return result.toUIMessageStreamResponse({
  headers: { 'X-Pulse-Conversation-Id': conversationId },
});

Pulse's tool registry is intentionally declared in a shared package (@paybridge/pulse), with zod-typed inputs and a stub execute that throws. The route layer in apps/web swaps the stub for the real implementation. This is the boundary that lets the MCP server reuse the same six tool descriptors with a different host runtime (see §9).

Three behaviours worth noting:

  • Tool calls aggregate across steps.AI SDK v6's top-level toolCalls in onFinishis the final step's calls only — empty when the model ends in plain text. Pulse flattens across steps[] so the audit row is complete.
  • Generated code is snapshotted, not regenerated. When generate_code fires, the files are written to generated_artefacts with an artefactId returned to the model. A later run_harness call attaches the artefactId to the IntegrationRun, so guardrails always run against the exact bytes the user saw — not whatever the templates would regenerate today.
  • Cross-step propagation is server-side, not prompt-engineered. The model does not need to pass the artefactId from generate_code to run_harness in its tool arguments. The route keeps a per-request recentArtefactId in a closure and auto-attaches.

§9

MCP — the same six tools, callable from any AI agent

The Model Context Protocol surface sits at POST /api/mcpand speaks JSON-RPC 2.0 over plain HTTP — no SSE transport, no Server-Sent message stream. The justification is short: PayBridge's tools are request-response, not server-initiated. SSE adds keepalive complexity that buys nothing.

apps/web/app/api/mcp/route.ts · ts
export async function POST(request: NextRequest) {
  if (!authOk(request)) return unauthorized();

  const body = (await request.json()) as JsonRpcRequest;
  if (body.jsonrpc !== '2.0' || typeof body.method !== 'string') {
    return rpcError(body.id, -32600, 'Invalid Request');
  }

  switch (body.method) {
    case 'initialize':
      return rpcResult(body.id, {
        protocolVersion: '2025-03-26',
        capabilities: { tools: {} },
        serverInfo: { name: 'paybridge-mcp', version: '0.1.0' },
      });
    case 'notifications/initialized':
      return new Response(null, { status: 204 });
    case 'tools/list':
      return rpcResult(body.id, listToolsResult());
    case 'tools/call':
      return rpcResult(body.id, await callTool(body.params));
    case 'ping':
      return rpcResult(body.id, {});
    default:
      return rpcError(body.id, -32601, `Method not found: ${body.method}`);
  }
}

Auth is a single bearer token (MCP_DEMO_TOKEN). The endpoint is closed by default — if the env var is unset, every request returns 401. The stdio fallback in packages/mcp/bin/stdio.ts is a thin proxy that forwards stdin frames to HTTP, so Claude Desktop (which only speaks stdio) can drive the deployed PayBridge without a second implementation. The contract — six tools, identical JSON-RPC shape — has exactly one source of truth.

§10

Stack choices — what we picked and why

Every dependency is a constraint. The bias here is heavy: prefer platform defaults; do not bring in machinery you cannot delete in an afternoon.

  • Next.js 16 App Router on Vercel Fluid Compute. One deployment, full Node.js (not Edge — Edge is fast but has compat issues with Prisma/cron/dotenv). Server components for the dashboards, client components only where state is reactive (Pulse, RunPanel, palette).
  • pnpm + Turborepo for the workspace, with turbo.json caching the four gates (typecheck, lint, build, test). Adding a package is a one-line addition to pnpm-workspace.yaml; the path aliases live in per-app tsconfigs so the workspace stays trivially navigable in editors.
  • Prisma 6 with Supabase Postgres. Two URLs from the Vercel Marketplace integration: POSTGRES_PRISMA_URL (pooled, used at runtime) and POSTGRES_URL_NON_POOLING (direct, used for migrations). RLS is preserved in the schema but disabled for the demo (single seeded tenant).
  • Upstash Redis for KV. KV_REST_API_URL + KV_REST_API_TOKEN from the Marketplace. Only the idempotency hot path uses it — webhook dedupe goes through Postgres because the read frequency does not justify the network hop.
  • Biome for lint + format. One config, zero plugins. The alternative (ESLint + Prettier + their dozen rules) costs more time than it saves at this scale.
  • AI SDK v6 + @ai-sdk/anthropic + cmdk + framer-motion + recharts. Lazy-imported where the bundle cost matters (jszip, framer animations, recharts).
  • shadcn-style hand-rolled components. Not the install script — the components live in apps/web/components/ui/ and are class-variance-authority + tailwind primitives so we own the variants outright. The brand gradient (indigo → blue → cyan → mint → gold) is declared once in globals.css and consumed everywhere via the --vx-* custom properties.

§11

Data model — every interesting thing is a row

Twelve tables, one tenant column on every interesting one. The schema is normalised but the surfaces always JOIN through tenantId first so RLS lands without a migration. Every schema change this project ships goes through a tracked Prisma migration applied with migrate deploy — additive only, never a reset (the Razorpay enum value, the contentHash column, and the isv_sites table all landed this way).

schema, abridged · prisma
Tenant
ProviderConfig            (per-tenant adapter config)
IdempotencyCache          (durable outcome cache; KV is the fast path)
IntegrationRun            ── steps ──▶ RunStep
                          └─ webhookEvents ──▶ WebhookEvent
                          └─ artefact ──▶ GeneratedArtefact (+ contentHash: sha256)
                          └─ guardrailResults ──▶ GuardrailResult
PulseMessage              (one row per assistant or user turn)
AuditLog                  (every privileged action — incl. harness.run / harness.replay)
BbMockTransaction         (self-hosted Blackbaud mock state)
IsvSite                   (Sample-ISV sites created from scratch in the UI)

enum ProviderId          { braintree, cashfree, paypal, blackbaud_mock, razorpay }
enum PatternKind         { hosted_checkout, tokenized_card_vault, payouts }
enum RunStatus           { pending, running, passed, failed, cancelled }
enum StepKey             { auth, checkout, tokenize, charge, webhook, idempotency, failure }
enum WebhookValidity     { valid, invalid, deduped }
enum GuardrailGateId     { G1, G2, G3, G4, G5 }
enum GuardrailGateStatus { passed, failed, skipped }

The indexes are conservative — every table has (tenantId, createdAt DESC), every FK is covered, no composite indexes on speculative future queries. Postgres' planner is sharp enough that adding indexes for queries that don't exist yet costs more than it saves.

§12

Performance, observability, and cost

The default function timeout is 300 s on Vercel; PayBridge routes set their own maxDuration based on what they actually need. /api/v1/harness/runssets 60 s because the harness blocks on Cashfree UPI's ~50 s poll loop. /api/v1/pulsesets 60 s for tool-call rounds. The rest stay at the default and finish in <200 ms.

  • Server components do the work. Dashboards, runs, settings, and providers are all server components that read live Prisma counts on every request. export const dynamic = ‘force-dynamic’ + runtime = ‘nodejs’ skip prerendering — there is no useful static version of these pages.
  • Streaming where it pays off.Pulse streams via AI SDK's UI message stream. The harness streams via SSE so the RunPanel updates per step rather than at the end. Other routes are vanilla JSON.
  • Observability is built into the data model. Every run writes to integration_runs; every step to run_steps; every webhook to webhook_events; every Pulse turn to pulse_messages. The dashboard's KPI strip and the runs page are just SQL aggregations over these tables. No third-party APM ingestion required for the live demo to be auditable.
  • AI cost is bounded. Pulse runs claude-sonnet-4-5 with stopWhen: stepCountIs(6) and maxOutputTokens: 4096. A typical turn is ~6 500 input tokens + ~2 400 output tokens — about $0.05 on the published Sonnet rate. Worst-case runaway is bounded at six steps; a full demo day stays under $5.

§13

Lessons, non-goals, and what we'd do differently

The boring parts of this section are often the most useful, so:

  • Provider quirks live in adapters, period.Every time we pulled a quirk into the core SPI “just for now”, we paid for it twice — once in the next provider, once in the test. Braintree's form-body webhook parsing, PayPal's body: {} requirement, Cashfree's ms timestamps — all of them now live only in their adapter or webhook route.
  • Vercel-provisioned env names are not negotiable. The Marketplace integrations write POSTGRES_PRISMA_URL, not DATABASE_URL. PayBridge reads what Vercel writes, rather than maintaining a translation layer that breaks when integration versions roll.
  • Cashfree v2025-01-01 signature scheme is still TODO.Three HMAC input variants tested, none match the docs' example. The provider webhook arrives and is logged with validity: ‘invalid’, but the harness still runs end-to-end via the synthetic-webhook path. Real-webhook badge stays grey until we find the fourth variant. This is on docs/pending.md.
  • Explicit non-goals. No PCI Level 1 attestation, no SOC 2, no multi-region deploy, no white-label theming, no PayPal Vault, no MCP server-initiated messages. Each of those is a perfectly valid next investment, but each one carries a specific cost that does not pay back inside the prototype window.

github.com/manizvlabs/paybridge — the source. Pull requests welcome; opinions sharper than ours, even more welcome.

§14

Proving the generated code works — four independent modes

Generating code is the easy half. The hard, trust-winning half is proving the generated code actually runs end-to-end — without asking anyone to take our word for it. PayBridge proves it four different ways, all surfaced under /isv (sidebar → Sample ISV), and all anchored to the same evidence primitive.

The evidence layer — content hashing. Every generate_code output is hashed: a SHA-256 per file plus a stable manifest hash over the set (packages/core/src/hash.ts). The hash shown when Pulse generates equals the hash the running storefront declares equals the hash in the downloaded project’s README — a three-way match anyone can verify. The pure order-creation unit, paybridge/createOrder.cjs, is the canonical hashed artefact: the same bytes are executed, shipped, and booted across the modes below.

  1. Mode 1 — Live Sandbox (preconfigured).The storefront’s checkout route doesn’t hand-roll the order; it executes the generated createOrder.cjs verbatim inside a locked-down node:vm (env-whitelist, egress restricted to api.razorpay.com, hard timeout). So the Razorpay order behind the live ₹1 is created by the generated bytes. Zero setup — the safe, instant on-stage demo. This is the same generated code, just preconfigured.
  2. Mode 2 — Download & Build / Run it yourself. One click emits a complete runnable Next.js project (pinned to the battle-tested Next 14.2 + React 18.3, with lint, typecheck, and build scripts that all pass). The developer runs pnpm i && pnpm devwith their own keys and takes a real payment — “don’t trust us, run it yourself.”
  3. Mode 2b — Build & deploy a live preview. PayBridge can take that same project, build it, and deploy a fresh Vercel preview you pay on — deployment-as-proof: a brand-new app, built from the generated code, taking real money.
  4. Mode 3 — In-browser (WebContainer).The generated frontend boots live in a StackBlitz WebContainer right in the browser. Because a browser sandbox can’t hold a payment secret, order creation is delegated to PayBridge’s CORS-enabled order service (which runs the same generated code server-side) — no secret ever reaches the browser. This is exactly the production-correct split Razorpay mandates.

Razorpay is the live rail. All of the above culminate in a real ₹1 captured to a live Razorpay account against api.razorpay.com — Standard Checkout, Basic-auth Orders API, HMAC-SHA256 (hex) payment + webhook signatures. The account-level webhook lands on PayBridge’s inbound surface and annotates the matching run in /runs with real-delivery evidence; Replay re-runs it with a fresh idempotency key and writes a distinct harness.replay audit entry.

Security posture throughout. PAN never touches a PayBridge server (provider-hosted iframe → PCI SAQ-A lane); secrets live only in server env, never in code, the DB, git, or the browser; signatures are verified with constant-time HMAC; the sandbox runs with an env whitelist + domain allowlist; and the Sample-ISV pages are cookie-gated (constant-time access code, httpOnly cookie, no token in the URL). The one deliberate exception — putting a live key in an ephemeral browser — was engineered out by delegating order creation in Mode 3.

§15

Why VyaptIX — and why partner with us

The point of everything above is a simple promise to Blackbaud’s partner and ISV developers: stop worrying about payment-integration code. Describe the integration to Pulse — in the UI or from any MCP-aware agent — and you get back working, hash-verified, multi-mode-proven code for any supported gateway, in minutes, with the PCI-aligned guardrails already green. The undifferentiated tax disappears; your developers spend their time on the product, not the plumbing.

VyaptIX wants to be Blackbaud’s technology partner and vendor for this. We build production AI and we understand payments at the architecture level — not as a buzzword, but as systems we have shipped.

  • Manish Singh, Co-founder & CTO — ~20 years in IT with deep, hands-on payments-domain expertise. Has architected and built payment gateway solutions, merchant onboarding systems, ATM automation testing solutions, and bank cheque-clearing systems. PayBridge is that experience, expressed as an AI-assisted, provider-agnostic accelerator.
  • Fintech fluency + AI execution. We know how gateways, webhooks, idempotency, settlement, and PCI scope actually behave — and we pair that with shipping LLM tool-calling and MCP surfaces that real agents drive end-to-end.

Our ask is straightforward: give VyaptIX the chance to be a Blackbaud Tech Partner— to bring this AI + fintech acumen to Blackbaud’s social-good ISV ecosystem and make “integrate any payment provider” a few minutes of generated, proven code instead of weeks of bespoke work.

Full partnership details, the team, traction, and the engagement plan are in the Blackbaud pitch deck. This page is the engineering evidence behind the ask.