Developer docs

Integrate stablecoin QR payments

Accept USDC & USDT on Solana with a single API call. Non-custodial, no smart contract, confirmed on-chain in about a second. This guide takes you from zero to a live payment — copy, paste, ship.

Base URLhttps://api.duitq.com

Overview

DuitQ turns any invoice into a Solana Pay QR code. A customer scans it from their wallet, sends stablecoins straight to your wallet, and you get a confirmed payment webhook seconds later. There is no custody and no smart contract — matching happens through protocol-native Solana Pay reference keys.

Non-custodial

Funds settle payer → merchant directly. DuitQ never holds your money.

~1s confirmation

Payments detected on-chain and pushed to you over WebSocket or webhook.

USDC & USDT

6-decimal stablecoins on Solana. Price in USD or Indonesian Rupiah.

Authentication

Programmatic requests use an API key. You get one at registration — it is shown only once, so store it securely. Pass it as a bearer token:

http
Authorization: Bearer sk_live_a1b2c3d4e5f6...

Or with the dedicated header:

http
X-API-Key: sk_live_a1b2c3d4e5f6...
Keep keys server-side. Never embed a live key in a browser or mobile app. Create scoped, per-terminal keys for POS setups and revoke any that leak.

Quick start

Five steps from a fresh account to a paid invoice.

1

Register a merchant

bash
curl -X POST https://api.duitq.com/api/merchants \
  -H 'Content-Type: application/json' \
  -d '{"name":"Kopi Kenangan","email":"ops@kopi.example"}'

The response includes your apiKey (shown once) and a WhatsApp verification link.

2

Verify via WhatsApp

Open the returned whatsappUrl and send the prefilled code. Your account activates automatically.

3

Link a settlement wallet

bash
# a) Get the challenge message to sign
curl "https://api.duitq.com/api/merchants/settlement/challenge?address=$WALLET" \
  -H "Authorization: Bearer $API_KEY"

# b) Sign it (ed25519), base58-encode, then submit
curl -X PUT https://api.duitq.com/api/merchants/settlement \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"address":"<WALLET>","signature":"<base58-sig>"}'
4

Provision token accounts (ATA)

bash
# Auto-provision any missing ATAs (subsidized by the platform)
curl -X POST https://api.duitq.com/api/merchants/settlement/ata \
  -H "Authorization: Bearer $API_KEY"
5

Create your first invoice

Jump to Create an invoice — you are ready to take money.

SDK (Node / TypeScript)

The official @duitq/sdk wraps the REST API and webhook signature verification so you can integrate in a few lines. Works in Node 18+ and modern browsers (uses the global fetch).

bash
npm install @duitq/sdk

Create & track an invoice

typescript
import { DuitQ } from '@duitq/sdk';

const duitq = new DuitQ({ apiKey: process.env.DUITQ_API_KEY });

// Create an invoice
const invoice = await duitq.createInvoice({
  orderId: `ORD-${Date.now()}`,
  amount: 12.5,
  description: '2x Americano',
  acceptedTokens: ['USDC', 'USDT'],
});
console.log(invoice.payments?.[0].qr); // data:image/png;base64,...

// Poll status
const latest = await duitq.getInvoice(invoice.id);
if (latest.status === 'PAID') {
  // fulfil the order
}

Verify webhooks

typescript
import { parseWebhook } from '@duitq/sdk';
import express from 'express';

const app = express();

// Use the RAW body so the HMAC matches
app.post('/webhooks/duitq', express.raw({ type: '*/*' }), (req, res) => {
  try {
    const event = parseWebhook({
      secret: process.env.DUITQ_WEBHOOK_SECRET!,
      timestamp: req.header('X-DuitQ-Timestamp')!,
      rawBody: req.body.toString('utf8'),
      signature: req.header('X-DuitQ-Signature')!,
    });
    if (event.event === 'invoice.paid') {
      // mark order as paid — event.invoiceId, event.signature
    }
    res.json({ ok: true });
  } catch {
    res.status(400).end(); // invalid signature
  }
});

Configuration

typescript
new DuitQ({
  apiKey: 'sk_live_...',              // required for authenticated calls
  baseUrl: 'https://api.duitq.com',   // override per environment (e.g. staging)
});

Exposes DuitQ (client), verifyWebhookSignature / parseWebhook, and fully-typed Invoice / WebhookPayload types.

Create an invoice

A single POST /api/invoices returns the invoice plus a ready-to-render QR (base64 PNG) and a solana: pay URL for each accepted token.

curl -X POST https://api.duitq.com/api/invoices \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "orderId": "ORD-20240115-001",
    "amount": 12.50,
    "description": "2x Americano, 1x Croissant",
    "acceptedTokens": ["USDC", "USDT"],
    "ttlMinutes": 15
  }'

Response (trimmed):

json
{
  "id": "b1c2d3e4-...",
  "orderId": "ORD-20240115-001",
  "amount": 12.50,
  "amountToken": 12.50,
  "acceptedTokens": ["USDC", "USDT"],
  "status": "PENDING",
  "reference": "base58-reference-key",
  "expiresAt": "2024-01-15T10:30:00.000Z",
  "payments": [
    { "token": "USDC", "url": "solana:...", "qr": "data:image/png;base64,..." },
    { "token": "USDT", "url": "solana:...", "qr": "data:image/png;base64,..." }
  ]
}

Prefer a standalone image? Fetch GET /api/invoices/:id/qr?token=USDC&format=png (or format=svg).

Track payment status

Three ways to know the moment a payment lands.

WebSocket (recommended)

javascript
import { io } from "socket.io-client";

const socket = io("wss://api.duitq.com/ws");
socket.emit("subscribe", { invoiceId: "b1c2d3e4-..." });
socket.on("payment", (data) => {
  if (data.status === "PAID") console.log("Confirmed!", data.signature);
});

Polling

bash
curl https://api.duitq.com/api/invoices/$INVOICE_ID
# => { "status": "PENDING" | "PAID" | "EXPIRED" | "NEEDS_REVIEW" }

For server-to-server reliability, use webhooks — they retry until acknowledged.

IDR pricing

Price in Indonesian Rupiah and DuitQ locks the token amount from a live CoinGecko rate (with a platform spread) at invoice creation.

bash
# Live FX quote
curl "https://api.duitq.com/api/fx/quote?fiat=IDR&token=USDT"
# => { "midRate": 15850.25, "effectiveRate": 15691.75, "spreadBps": 100 }

# IDR-priced invoice
curl -X POST https://api.duitq.com/api/invoices \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"orderId":"ORD-IDR-001","amount":150000,"pricingCurrency":"IDR","acceptedTokens":["USDT"]}'

The amountToken in the response is the exact USDT the payer sends.

Webhooks

Register a URL to receive real-time payment events.

bash
curl -X PUT https://api.duitq.com/api/merchants/webhook \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://your-app.com/webhooks/duitq"}'
# => { "webhookSecret": "whsec_abc123..." }  (shown once)

Events

EventTrigger
invoice.paidPayment confirmed on-chain
invoice.expiredTTL elapsed without payment
invoice.needs_reviewDouble payment or amount mismatch

Verify the signature

Always verify before processing. Each delivery is signed with HMAC-SHA256 over `${timestamp}.${rawBody}` using your webhookSecret.
import crypto from "node:crypto";

function verify(req, rawBody, secret) {
  const ts = req.headers["x-duitq-timestamp"];
  const sig = req.headers["x-duitq-signature"];
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}

Failed deliveries retry with exponential backoff (5s base, 1h cap) up to 8 attempts. Deduplicate with the X-DuitQ-Idempotency-Key header.

Prepaid credits

A non-custodial prepaid model: 50 free credits on signup. Creating an invoice costs 1 credit; a settled invoice costs 1 more (2 total per paid invoice).

bash
# Check balance
curl https://api.duitq.com/api/merchants/credits -H "Authorization: Bearer $API_KEY"

# Buy a pack (creates a Solana Pay invoice to the platform wallet)
curl -X POST https://api.duitq.com/api/merchants/credits/order \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"packs":1}'

Invoice lifecycle

StatusMeaning
PENDINGAwaiting payment, QR is active
PAIDPayment confirmed on Solana
EXPIREDTTL elapsed without payment
NEEDS_REVIEWDouble payment or amount mismatch
REFUNDEDManually marked refunded by the merchant

Errors & limits

Errors follow a consistent shape:

json
{ "statusCode": 400, "message": "Descriptive error", "error": "Bad Request" }
StatusMeaning
400Validation error or bad request
401Missing or invalid authentication
402Insufficient credits
404Resource not found
429Rate limit exceeded (120 req/min per IP)
500Internal server error

Ready to build?

Create a merchant account and grab your API key — 50 free credits included.

Get started