Skip to Content
Devnet open Β· mainnet by application β€” these docs ship with the pre-alpha release.
IntegrationsQuickstart

Quickstart β€” your first NinjaPay charge

Audience. Merchants integrating NinjaPay end-to-end. Walks through API-key creation β†’ payment link β†’ webhook reconciliation. Assumes you’ve signed up via the dashboard (/dashboard).

Time: ~15 minutes for a working test integration.

What you’re about to build

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ You β”‚ β”‚ NinjaPay API β”‚ β”‚ Payer's β”‚ β”‚ (server) β”‚ β”‚ β”‚ β”‚ wallet β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ 1. Create API key β”‚ β”‚ β”‚ via dashboard β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ 2. POST /v1/payment_links β”‚ │────────────────────>β”‚ β”‚ β”‚ { amount, currency } β”‚ β”‚ <─── { id, hosted_url } β”‚ β”‚ β”‚ β”‚ β”‚ 3. Share hosted_url with payer β”‚ β”‚ ──────────────────────────────────────────>β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ 4. Payer signs + β”‚ β”‚ β”‚ submits via β”‚ β”‚ β”‚ hosted checkout β”‚ β”‚ β”‚<─────────────────────│ β”‚ β”‚ β”‚ β”‚ 5. payment_intent.paid webhook β”‚ β”‚<────────────────────│ β”‚ β”‚ (HMAC-signed) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ 6. Verify HMAC, mark order paid in your DB β”‚ β”‚ β”‚ β”‚

Prereqs

  • NinjaPay merchant account (sign up at /dashboard, sign in with a Solana wallet).
  • A server you can deploy a small Node.js / Python / Go endpoint to (Vercel, Render, fly.io all work).
  • pnpm add @ninjapay/sdk in your codebase.

Step 1 β€” Create an API key

In the dashboard, hit /dashboard/api-keys and click Create key. Choose a scope:

  • Test mode (nk_test_...) β€” safe defaults; charges hit devnet; no real funds. Use for local dev + staging.
  • Live mode (nk_live_...) β€” real mainnet settlements.

Copy the secret once β€” NinjaPay never shows it again. Store it in an env var:

export NINJAPAY_API_KEY=nk_test_...
import { NinjaPayClient } from '@ninjapay/sdk'; const client = new NinjaPayClient({ baseUrl: 'https://api.ninjapay.finance', jwt: process.env.NINJAPAY_API_KEY!, }); const link = await client.paymentLinks.create({ amount: '5.000000', // Decimal(20,6) β€” the wire format is a string, not a number currency: 'USDC', description: 'Test purchase', metadata: { order_id: 'ord_42', // your internal order id; flows back via the webhook }, }); console.log(link.hostedUrl); // https://checkout.ninjapay.finance/pay/plink_2c4e8f1a9b3d456

The metadata object echoes back on every webhook event for this link’s intents β€” use it to thread your own DB ids through.

Post link.hostedUrl to your customer:

  • Email: plain anchor.
  • Invoice: include in the β€œpay now” CTA.
  • QR code: standard URL β†’ QR; the hosted-checkout page is mobile-first and renders the QR on desktop too.

When the payer hits the URL, they:

  1. Pick a currency (USDC default; cross-currency quotes come from Jupiter).
  2. Sign with their wallet (any Solana wallet supported by the standard wallet-adapter).
  3. Settlement happens via the Umbra Privacy SDKΒ  β€” amounts are shielded by default, your transactions don’t spray to block explorers.

Step 4 β€” Handle the webhook

You need a publicly-reachable HTTPS endpoint that NinjaPay will POST to when the intent transitions through its lifecycle. The endpoints + sample payloads are in webhooks.md. For most merchants, only payment_intent.paid matters.

4a. Create a webhook subscription

In the dashboard, /dashboard/webhooks β†’ Create subscription. Enter your URL + select events. Copy the whsec_... secret β€” same one-time-show pattern as API keys.

4b. Implement the handler

Use parseWebhookEvent from the SDK β€” one-shot verify + JSON.parse, returning a typed WebhookEvent<T> envelope. Throws WebhookSignatureError (with a typed .reason discriminant) on bad signatures. Same primitive as the delivery worker, byte-for-byte; Β±5min default tolerance window. Hand-rolled HMAC sample lives in webhooks.md for non-Node runtimes.

import { parseWebhookEvent, WebhookSignatureError, WEBHOOK_SIGNATURE_HEADER, } from '@ninjapay/sdk/webhooks'; import express from 'express'; const app = express(); 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), }); switch (event.type) { case 'payment_intent.paid': { const orderId = (event.data.object as { metadata?: { order_id?: string } }).metadata ?.order_id; // Mark your order paid. Your handler MUST be idempotent on event.id. markOrderPaid(orderId, event.id); break; } case 'payment_intent.expired': case 'payment_intent.canceled': // Optional: free up reserved inventory. break; } res.status(200).end(); } catch (e) { if (e instanceof WebhookSignatureError) { return res.status(401).json({ error: e.reason }); } throw e; } });

4c. Test the webhook locally

pnpm tsx scripts/test-webhook.ts (or your equivalent) β€” compose a fake event with signWebhookPayload from @ninjapay/sdk:

import { signWebhookPayload, WEBHOOK_SIGNATURE_HEADER } from '@ninjapay/sdk/webhooks'; const body = JSON.stringify({ id: 'evt_local_001', type: 'payment_intent.paid', api_version: '2026-04-01', created: Math.floor(Date.now() / 1000), livemode: false, data: { object: { id: 'pi_local_001', amount: '5.000000', currency: 'USDC', status: 'PAID', metadata: { order_id: 'ord_42' }, }, }, }); const { headerValue } = signWebhookPayload({ secret: process.env.NINJAPAY_WEBHOOK_SECRET!, rawBody: body, }); await fetch('http://localhost:3000/webhooks/ninjapay', { method: 'POST', headers: { 'Content-Type': 'application/json', [WEBHOOK_SIGNATURE_HEADER]: headerValue, }, body, });

Step 5 β€” Watch the lifecycle (optional)

If you want to drive a one-shot intent end-to-end without a webhook (useful for tests), the SDK ships a polling helper:

const intent = await client.paymentIntents.create({ amount: '5.000000', currency: 'USDC', }); console.log('Pay at:', intent.hostedUrl); const final = await client.paymentIntents.waitForSettlement(intent.id, { timeoutMs: 5 * 60 * 1000, // wait up to 5 minutes }); if (final.status === 'PAID' || final.status === 'CLAIMED') { console.log('Settled.'); } else { console.error(`Intent ${final.id} ended in ${final.status}`); }

For production, prefer webhooks β€” the polling helper is a fit for tests, smoke scripts, and small one-shot integrations.

Common gotchas

SymptomLikely cause
NinjaPayApiError: rate_limited (429)Default rate limits on nk_test_ keys are intentionally low. Move to nk_live_ for production volume.
Webhook delivery returns 200 but payment_intent.paid never firesCheck /dashboard/webhooks/[id] deliveries inspector β€” your endpoint may have returned 200 only on the first redirect, with the actual handler on a different path.
Amount mismatchThe wire format is Decimal(20,6) as a string β€” '5.000000', not 5. Round-tripping through Number will lose precision.
HMAC verification failsThe most common cause is parsing the body as JSON before computing the HMAC. Compute the HMAC over the raw body bytes, then parse.
payment_intent.paid arrives twiceYour handler must be idempotent on event.id β€” see webhooks.md for the pattern.

Going to production

Before flipping to nk_live_:

  1. Rotate webhook secrets β€” the test webhooks point to your dev endpoint; production wants a separate subscription.
  2. Enable IP allowlist (/dashboard/settings/security) β€” restricts which IPs can hit your nk_live_ keys.
  3. Subscribe to webhook.circuit_tripped on a separate channel (PagerDuty / a different webhook integration) so you find out when your primary endpoint starts failing.
  4. Set up retention for webhook delivery logs in your own system. NinjaPay keeps the delivery history for 90 days hot in WebhookDelivery.