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/sdkin 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_...Step 2 β Create your first payment link
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_2c4e8f1a9b3d456The metadata object echoes back on every webhook event for this linkβs intents β use it to thread your own DB ids through.
Step 3 β Share the link
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:
- Pick a currency (USDC default; cross-currency quotes come from Jupiter).
- Sign with their wallet (any Solana wallet supported by the standard wallet-adapter).
- 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
| Symptom | Likely 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 fires | Check /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 mismatch | The wire format is Decimal(20,6) as a string β '5.000000', not 5. Round-tripping through Number will lose precision. |
| HMAC verification fails | The 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 twice | Your handler must be idempotent on event.id β see webhooks.md for the pattern. |
Going to production
Before flipping to nk_live_:
- Rotate webhook secrets β the test webhooks point to your dev endpoint; production wants a separate subscription.
- Enable IP allowlist (
/dashboard/settings/security) β restricts which IPs can hit yournk_live_keys. - Subscribe to
webhook.circuit_trippedon a separate channel (PagerDuty / a different webhook integration) so you find out when your primary endpoint starts failing. - Set up retention for webhook delivery logs in your own system. NinjaPay keeps the delivery history for 90 days hot in
WebhookDelivery.
Related docs
webhooks.mdβ full event reference + HMAC verification deep-dive.packages/sdk/README.mdβ SDK resource map + error model + testing pattern.- Dashboard
/dashboard/api-keysβ API key management. - Dashboard
/dashboard/webhooksβ webhook subscription management.