Migration guide
Audience. Merchants currently using Stripe who want to add NinjaPay as a payment rail (or replace Stripe entirely). Covers naming + shape mapping for every resource with a NinjaPay equivalent, the deltas you should know about, and a recipe for re-using your existing handler code.
Stripe is a registered trademark of Stripe, Inc., used here for descriptive purposes only. NinjaPay is an independent project, not affiliated with, endorsed by, or sponsored by Stripe, Inc. References below to specific Stripe resources are intended to help integrators map naming and shapes between platforms.
TL;DR
NinjaPay is Stripe-portable by design. Every resource that has a 1:1 Stripe parallel ships under the same name with the same (or compatible) shape. Your existing Stripe handler code mostly works with two changes:
- Swap the SDK import (
stripe→@ninjapay/sdk). - Swap the webhook signature header name (
Stripe-Signature→X-NinjaPay-Signature); the verification primitive is identical (HMAC-SHA256 over<unix-secs>.<rawBody>).
Beyond that, the deltas are mostly additions — NinjaPay adds Solana-native concepts (UTXO observation, dual-tx settlement, on-chain refund signatures) that have no Stripe equivalent. Your existing handlers ignore the new event types until you choose to subscribe.
Resource mapping
| Stripe resource | NinjaPay resource | Notes |
|---|---|---|
Charge | PaymentIntent (12-state) | NinjaPay merges Charge + PaymentIntent into one row. The 12-state machine surfaces on-chain lifecycle (UTXO observed → settlement confirmed → claimed → paid). |
PaymentIntent | PaymentIntent | Same name; same id/amount/currency/metadata/status fields. NinjaPay adds: mint, umbra_commitment, payer_wallet, settlement_tx_sig, claim_tx_sig. |
Customer | Customer | Same shape. NinjaPay adds optional tax_location for jurisdiction-aware tax. |
PaymentLink | PaymentLink | Same hosted-checkout pattern; the URL is https://checkout.ninjapay.finance/pay/[linkId]. |
Refund | Refund | Same id/amount/currency/reason/status. NinjaPay adds refund_tx_sig (null until the on-chain leg confirms; see refunds guide). |
Dispute | Dispute | Same id/reason/status/amount/evidence/due_by. Reason taxonomy is identical (FRAUDULENT, DUPLICATE, etc.). |
Subscription | Subscription | Same lifecycle (TRIALING → ACTIVE → PAST_DUE → UNPAID → CANCELED). |
Invoice | Invoice | Same status enum (DRAFT/OPEN/PAID/VOID/UNCOLLECTIBLE) + due_date. NinjaPay also exposes overdue rollup (Phase 6.8.Îł.22). |
Connect connected account | ConnectedAccount | Same STANDARD/EXPRESS/CUSTOM types. |
Transfer | Transfer | Same shape; on-chain split routing in v1.x. |
ApplicationFee | PlatformFee | Renamed for clarity (it’s the platform’s fee, not the application’s). |
TaxRate | TaxRate | Same shape; jurisdiction presets via POST /v1/tax_rates/seed. |
Webhook Endpoint | Webhook | Same HMAC scheme (SHA-256 over <unix-secs>.<body>). Header is X-NinjaPay-Signature (t=<unix-secs>,v1=<hex>). |
Resources Stripe has but NinjaPay does not (yet, by design):
Card/BankAccount/Source— payment is wallet-driven, not card-driven. There is no card object.Coupon/PromotionCode— Phase 4.7 candidate; not in v1.Tax Calculation— NinjaPay’sTaxRateis jurisdictional; one-off per-line-item calculation is computed at invoice time.Issuing— out of scope.Identity— KYC is handled per-merchant via the Connect onboarding flow.
Webhook event mapping
Stripe and NinjaPay both follow the <resource>.<verb> convention. Subscribe by exact event name; the envelope shape is identical ({id, type, api_version, created, livemode, data: {object, previous_attributes?}, request?}).
Direct equivalents
| Stripe event | NinjaPay event |
|---|---|
charge.refunded | payment_intent.refunded |
charge.dispute.created | charge.dispute.created |
charge.dispute.updated | charge.dispute.updated |
charge.dispute.closed | charge.dispute.closed |
charge.dispute.funds_reinstated | charge.dispute.funds_reinstated |
charge.dispute.funds_withdrawn | charge.dispute.funds_withdrawn |
refund.created / .updated / .succeeded / .failed / .canceled | same names |
transfer.created / .paid / .failed / .reversed | same names |
application_fee.created / .refunded | same names |
invoice.created / .finalized / .paid / .voided / .marked_uncollectible | same names |
customer.subscription.created / .updated / .deleted | same names |
NinjaPay-specific events (no Stripe equivalent)
These cover the on-chain settlement lifecycle that Stripe doesn’t have. Subscribe only if you want fine-grained intent-state visibility:
payment_intent.created— intent row created (charge would-be-created in Stripe parlance).payment_intent.awaiting_payment— hosted checkout link is live.payment_intent.utxo_observed— scanner saw the shielded UTXO on-chain.payment_intent.settlement_confirmed— router-program tx confirmed; merchant funds reconciled.payment_intent.succeeded/.paid— terminal happy-path (subscribe to one; most merchants pickpaid).payment_intent.expired/.canceled/.settlement_failed— terminal failures.x402.attestation_recorded— sub-pillar event; merchants rarely subscribe.webhook.circuit_tripped— self-referential; subscribe an out-of-band channel for “my primary integration is failing.”
For a merchant porting from Stripe with a charge-only mental model, the minimum-viable subscription is:
['payment_intent.paid', 'payment_intent.refunded',
'charge.dispute.created', 'charge.dispute.closed']Add payment_intent.expired if you fulfill on payment confirmation and need to roll back unfulfilled orders.
Code mapping recipe
SDK import + client construction
// Before — Stripe
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// After — NinjaPay
import { NinjaPayClient } from '@ninjapay/sdk';
const client = new NinjaPayClient({ apiKey: process.env.NINJAPAY_API_KEY! });API keys: NinjaPay uses nk_test_<32+> / nk_live_<32+> instead of sk_test_<32> / sk_live_<32>. Same Bearer-auth pattern.
Charge → PaymentIntent
// Before — Stripe
const charge = await stripe.charges.create({
amount: 1250, // cents
currency: 'usd',
source: 'tok_visa',
description: 'Order #42',
});
// After — NinjaPay (no card source — payer signs from their wallet)
const intent = await client.paymentIntents.create({
amount: '12.500000', // smallest-unit Decimal as string
currency: 'USDC',
description: 'Order #42',
metadata: { order_id: '42' },
});
// Then create a PaymentLink for the hosted checkout:
const link = await client.paymentLinks.create({
paymentIntentId: intent.id,
successUrl: 'https://example.com/order/42/done',
});
// Redirect the payer to link.hostedUrl.Two key differences:
- Amount format. NinjaPay uses
Decimal(20,6)strings (USDC’s six-decimal native precision) instead of integer cents.'12.500000'not1250. The smallest-unit-as-string convention avoids floating-point error in the SDK without locking in a specific minor-unit count. - No card source. The payer signs the transaction from their Solana wallet (via the hosted-checkout
<WalletButton>). Your server doesn’t see card details; you don’t need PCI scope.
Webhook handler — direct port
// Before — Stripe
import { Stripe } from 'stripe';
import express from 'express';
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('Stripe-Signature')!;
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
handleEvent(event);
res.json({ received: true });
});
// After — NinjaPay (parseWebhookEvent mirrors stripe.webhooks.constructEvent)
import {
parseWebhookEvent,
WebhookSignatureError,
WEBHOOK_SIGNATURE_HEADER,
} from '@ninjapay/sdk/webhooks';
app.use('/webhooks/ninjapay', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = parseWebhookEvent({
secret: process.env.NINJAPAY_WEBHOOK_SECRET!,
rawBody: req.body.toString('utf8'),
headerValue: req.get(WEBHOOK_SIGNATURE_HEADER),
});
handleEvent(event); // same dispatch as before
res.json({ received: true });
} catch (e) {
if (e instanceof WebhookSignatureError) {
return res.status(401).json({ error: e.reason });
}
throw e;
}
});The verification primitive is byte-for-byte identical to Stripe’s; the import name (parseWebhookEvent vs stripe.webhooks.constructEvent) and the header (X-NinjaPay-Signature vs Stripe-Signature) differ. See the webhook integration guide for non-Node language samples.
Refunds
// Before — Stripe
const refund = await stripe.refunds.create({
charge: charge.id,
amount: 500, // cents, optional partial
reason: 'requested_by_customer',
});
// After — NinjaPay
const refund = await client.refunds.create(
{
paymentIntentId: intent.id,
amount: '5.000000', // optional partial; ≤ remaining
reason: 'REQUESTED_BY_CUSTOMER', // uppercase enum
},
{ idempotencyKey: `refund_${intent.id}_v1` },
);
// One additional step in NinjaPay vs Stripe: the merchant pushes
// the on-chain refund tx with their refund-delegate keypair, then
// closes the loop:
await client.refunds.markSucceeded(refund.id, { refundTxSig });The refund flow has one extra step: NinjaPay’s off-chain ledger entry decouples from the on-chain transaction. See the refunds integration guide for the full pattern.
Subscriptions
// Before — Stripe
const sub = await stripe.subscriptions.create({
customer: 'cus_xxx',
items: [{ price: 'price_yyy', quantity: 2 }],
});
// After — NinjaPay
const sub = await client.subscriptions.create({
customerId: 'cust_xxx',
items: [{ priceId: 'price_yyy', quantity: 2 }],
});
// Identical lifecycle handlers; identical proration math.Cancel:
// Before — Stripe
await stripe.subscriptions.cancel(sub.id);
await stripe.subscriptions.update(sub.id, { cancel_at_period_end: true });
// After — NinjaPay (one method, mode discriminator)
await client.subscriptions.cancel(sub.id, { mode: 'IMMEDIATE' });
await client.subscriptions.cancel(sub.id, { mode: 'AT_PERIOD_END' });Idempotency
Same Stripe-style Idempotency-Key header on every mutating request:
await client.refunds.create(input, { idempotencyKey: 'refund_for_order_42' });Replay on the same key returns the original row instead of creating a second one. The TTL is 24h (matches Stripe).
Error model
NinjaPay’s NinjaPayApiError carries status, code, requestId, and message — same shape as Stripe’s Stripe.errors.StripeAPIError. Branch on code, never on message:
import { NinjaPayApiError } from '@ninjapay/sdk';
try {
await client.refunds.create(input);
} catch (err) {
if (err instanceof NinjaPayApiError) {
if (err.code === 'amount_exceeds_remaining') {
// surface a clean UX
}
if (err.status === 409) {
// idempotency or state-machine conflict; retry with a fresh key
}
}
throw err;
}What you get for free
- Privacy by default. Every payment is shielded via Umbra; the customer’s wallet doesn’t appear in your dashboard or your downstream BI tools unless you explicitly request a compliance grant.
- Cheaper fees. Solana settlement is < $0.01 per transaction. The merchant rate stack is settlement (chain) + NinjaPay platform fee + optional Connect platform fee — orders of magnitude below the 2.9% + 30¢ Stripe baseline.
- Self-custody by default. Merchant funds settle to the merchant’s own wallet. NinjaPay is non-custodial.
- Mainnet-portable wallets. Customers can pay with any Solana wallet (Phantom, Solflare, Backpack, etc) — no network-account-creation step.
What you have to think about
- Stablecoin volatility. USDC and USDT are pegged but not perfectly. If you price in USD and accept USDC, you carry 0.5–2 bps of pricing risk. The merchant API supports multi-mint accept lists (e.g. accept USDC + USDT + dUSDC) so you can hedge.
- Wallet UX. Card payment is one click; wallet payment is two (connect wallet + sign tx). Test the hosted checkout against your conversion baseline.
- Refund delegate wallet. NinjaPay refunds need the merchant to push the on-chain leg. Set up a refund-delegate wallet with sufficient USDC; monitor it via the stuck-refund triage runbook.
Migration checklist
- Sign up for a NinjaPay merchant account; get a
nk_test_key. - Set up a webhook subscription mirroring your Stripe events. Use the table above for direct equivalents.
- Port your charge-create flow:
stripe.charges.create→client.paymentIntents.create+client.paymentLinks.create. - Port your webhook handler: swap header name + verification helper. Most other code stays.
- Port your refund flow: add the
mark-succeededstep after on-chain confirmation. - Port your subscriptions flow: amount-format + cancel-mode are the only deltas.
- Set up a refund-delegate wallet with USDC.
- Run a few test payments end-to-end; verify webhooks fire + reconcile correctly.
- Move to
nk_live_when ready.
Related
- Quickstart — the from-scratch path; useful as a sanity check.
- Webhook integration guide — full event taxonomy, signature verification recipes, retry semantics.
- Disputes integration guide — Stripe-portable dispute lifecycle.
- Refunds integration guide — the off-chain-ledger / on-chain-tx split that’s the one workflow Stripe doesn’t have a direct equivalent for.