Skip to Content
Devnet open · mainnet by application — these docs ship with the pre-alpha release.
IntegrationsMigration guide

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:

  1. Swap the SDK import (stripe → @ninjapay/sdk).
  2. 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 resourceNinjaPay resourceNotes
ChargePaymentIntent (12-state)NinjaPay merges Charge + PaymentIntent into one row. The 12-state machine surfaces on-chain lifecycle (UTXO observed → settlement confirmed → claimed → paid).
PaymentIntentPaymentIntentSame name; same id/amount/currency/metadata/status fields. NinjaPay adds: mint, umbra_commitment, payer_wallet, settlement_tx_sig, claim_tx_sig.
CustomerCustomerSame shape. NinjaPay adds optional tax_location for jurisdiction-aware tax.
PaymentLinkPaymentLinkSame hosted-checkout pattern; the URL is https://checkout.ninjapay.finance/pay/[linkId].
RefundRefundSame id/amount/currency/reason/status. NinjaPay adds refund_tx_sig (null until the on-chain leg confirms; see refunds guide).
DisputeDisputeSame id/reason/status/amount/evidence/due_by. Reason taxonomy is identical (FRAUDULENT, DUPLICATE, etc.).
SubscriptionSubscriptionSame lifecycle (TRIALING → ACTIVE → PAST_DUE → UNPAID → CANCELED).
InvoiceInvoiceSame status enum (DRAFT/OPEN/PAID/VOID/UNCOLLECTIBLE) + due_date. NinjaPay also exposes overdue rollup (Phase 6.8.Îł.22).
Connect connected accountConnectedAccountSame STANDARD/EXPRESS/CUSTOM types.
TransferTransferSame shape; on-chain split routing in v1.x.
ApplicationFeePlatformFeeRenamed for clarity (it’s the platform’s fee, not the application’s).
TaxRateTaxRateSame shape; jurisdiction presets via POST /v1/tax_rates/seed.
Webhook EndpointWebhookSame 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’s TaxRate is 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 eventNinjaPay event
charge.refundedpayment_intent.refunded
charge.dispute.createdcharge.dispute.created
charge.dispute.updatedcharge.dispute.updated
charge.dispute.closedcharge.dispute.closed
charge.dispute.funds_reinstatedcharge.dispute.funds_reinstated
charge.dispute.funds_withdrawncharge.dispute.funds_withdrawn
refund.created / .updated / .succeeded / .failed / .canceledsame names
transfer.created / .paid / .failed / .reversedsame names
application_fee.created / .refundedsame names
invoice.created / .finalized / .paid / .voided / .marked_uncollectiblesame names
customer.subscription.created / .updated / .deletedsame 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 pick paid).
  • 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' not 1250. 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-succeeded step 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.