FieldCraftPayKitDocsServicesBlogWork With Me →
Back to Blog

Introducing PayKit: One API for Every Payment Provider

Every payment provider has its own SDK, its own types, its own webhook format, and its own quirks. If your app supports more than one — say Stripe for the US and Razorpay for India — you end up maintaining two completely separate integrations. Different charge flows, different refund APIs, different webhook payloads. Your "payment layer" becomes a sprawling mess of provider-specific branches.

PayKit fixes this. It gives you a single, type-safe API that works identically across providers. Write your charge-creation, webhook, and checkout code once. Swap providers by changing one import.

The problem

Here's what multi-provider payment code typically looks like without an abstraction layer:

import Stripe from 'stripe';
import Razorpay from 'razorpay';

async function charge(provider: string, amount: number) {
if (provider === 'stripe') {
const intent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
payment_method_types: ['card'],
});
return { id: intent.id, status: intent.status };
} else if (provider === 'razorpay') {
const order = await razorpay.orders.create({
amount: amount * 100,
currency: 'INR',
receipt: `receipt_${Date.now()}`,
});
return { id: order.id, status: order.status };
}
// ... more providers, more branches
}

Every new provider doubles the surface area. Every webhook handler needs provider-specific parsing. Your tests need mocks for each SDK. It doesn't scale.

The PayKit approach

PayKit sits between your application and the provider SDKs. It uses the adapter pattern — each provider gets an adapter that maps its native API to PayKit's unified interface.

Your App  →  PayKit Core  →  Stripe Adapter  →  Stripe API
                           →  Razorpay Adapter  →  Razorpay API
                           →  PayPal Adapter  →  PayPal API

The same charge code works regardless of which adapter you plug in:

import { PayKit } from '@squaredr/paykit'
import { StripeAdapter } from '@squaredr/paykit/stripe'

const paykit = new PayKit({
adapter: new StripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
}),
})

const charge = await paykit.charges.create({
amount: 5000,
currency: 'usd',
metadata: { orderId: 'order_123' },
})

console.log(charge.id) // "ch_..."
console.log(charge.clientSecret) // Send to frontend

Switch to Razorpay? Change one line:

import { RazorpayAdapter } from '@squaredr/paykit/razorpay'

const paykit = new PayKit({
adapter: new RazorpayAdapter({
keyId: process.env.RAZORPAY_KEY_ID!,
keySecret: process.env.RAZORPAY_KEY_SECRET!,
}),
})

// Everything else stays identical

Unified types

Every adapter maps provider responses into shared TypeScript interfaces. The most important one is UnifiedCharge:

interface UnifiedCharge {
id: string
amount: number
currency: string
status: 'pending' | 'succeeded' | 'failed' | 'refunded'
clientSecret: string
metadata: Record<string, string>
_raw: unknown // Escape hatch for provider-specific data
}

The _raw field is there for advanced use cases. If you need something PayKit doesn't normalise, cast _raw to the provider's native type and access it directly.

Webhook normalisation

Different providers send different event names. Stripe fires payment_intent.succeeded. Razorpay fires payment.captured. PayKit maps them into a single set of event types:

PayKit EventStripeRazorpay
payment.succeededpayment_intent.succeededpayment.captured
payment.failedpayment_intent.payment_failedpayment.failed
refund.createdcharge.refundedrefund.processed

Your webhook handler is always the same shape:

const event = paykit.webhooks.construct({
payload: body,
signature: req.headers['stripe-signature'],
secret: process.env.STRIPE_WEBHOOK_SECRET!,
})

switch (event.type) {
case 'payment.succeeded':
await fulfillOrder(event.data.metadata.orderId)
break
case 'payment.failed':
await notifyCustomer(event.data.metadata.orderId)
break
}

Frontend support

PayKit ships with React components and a headless vanilla JS SDK for collecting payments in the browser:

import { PayKitProvider, CheckoutForm } from '@squaredr/paykit-react'
import { StripeClientAdapter } from '@squaredr/paykit/stripe/client'

const stripe = new StripeClientAdapter({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PK!,
})

function Checkout({ clientSecret }: { clientSecret: string }) {
return (
<PayKitProvider adapter={stripe} clientSecret={clientSecret}>
<CheckoutForm
onSuccess={(result) => console.log('Paid:', result.id)}
onError={(err) => console.error(err.message)}
/>
</PayKitProvider>
)
}

Server adapters and client adapters are separate entry points, so your bundler tree-shakes them independently. Your secret key never ends up in the client bundle.

What ships today

  • @squaredr/paykit — Core SDK with unified types, adapter system, and subpath exports for all adapters
  • @squaredr/paykit/stripe — Stripe adapter (charges, refunds, customers, subscriptions, webhooks)
  • @squaredr/paykit/razorpay — Razorpay adapter (charges, refunds, customers, subscriptions, UPI, webhooks)
  • @squaredr/paykit-react — React components and hooks
  • @squaredr/paykit-js — Vanilla JS/TS frontend SDK

All packages are MIT-licensed and published on npm. Install and start accepting payments:

npm install @squaredr/paykit stripe

What's next

PayPal is next, followed by PhonePe, Cashfree, Square, Adyen, and more — one or two new providers every week. The goal is 25 provider adapters within 11 weeks.

PayKit Pro (coming soon) will extend the free adapters with refund management, payouts, subscription lifecycle, and analytics dashboards. It will be available as a one-time purchase — no recurring fees.

Get started

Give it a try and let us know what you think. Open an issue if you hit anything, or star the repo if you find it useful.