Integration Docs
payment-service is a multi-provider payment gateway. Your app calls it for two things: (1) register a business's provider credentials once, (2) create an invoice every time a buyer checks out. The provider does its thing; we send your app a signed webhook when the money lands.
Four providers are wired up: Multicard, Click, Payme, Uzum Bank. A single business can register with as many as they want — at checkout, the marketplace asks payment-service "which providers does this business accept?" and renders one button per result.
Quickstart (5 minutes)
Assuming the service is running locally on http://localhost:4002:
1. Register a sandbox merchant
Use Multicard's public sandbox credentials. Replace SERVICE_API_KEY with the value from your .env.
curl -X POST http://localhost:4002/api/merchants \
-H "x-api-key: $SERVICE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "marketplace",
"businessId": "sandbox",
"provider": "multicard",
"credentials": {
"applicationId": "rhmt_test",
"secret": "Pw18axeBFo8V7NamKHXX",
"storeId": "a1df872e-d5aa-11ee-8de8-005056b4367d"
},
"displayName": "Sandbox merchant"
}'
2. Create an invoice
Amount is in tiyin (1 som = 100 tiyin). Set notifyUrl to a real URL —
webhook.site
gives you one for free.
curl -X POST http://localhost:4002/api/invoices \
-H "x-api-key: $SERVICE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "marketplace",
"businessId": "sandbox",
"provider": "multicard",
"sourceInvoiceId": "sandbox_test_1",
"amount": 100000,
"notifyUrl": "https://webhook.site/<your-id>",
"ofd": [{"qty":1,"price":100000,"total":100000,
"mxik":"06401004002000000","package_code":"1506113",
"name":"Test","vat":0}]
}'
You get back a checkoutUrl. Open it in a browser — that's the page your buyers will see.
3. (After payment) receive the webhook
Once payment settles, payment-service POSTs to your notifyUrl with a signed body.
See webhook receiver below for how to verify and process it.
Authentication
Every call to /api/* (except /api/callbacks/* which providers hit directly)
requires the shared service key in the x-api-key header.
browser → your app's backend → payment-service. If the key shows up in client code or in
DevTools, rotate it immediately.
Get the key from payment-service's .env (line starting with SERVICE_API_KEY=).
Discovering providers
Before you build any UI, two questions you'll have: "what payment providers does the gateway support?" and "which ones has this particular business configured?" — both have endpoints.
What providers does the gateway support? GET /api/merchants/supported
Lists every provider this service knows about plus the credential fields each one needs. The business-dashboard onboarding form uses this to render the provider dropdown and the right credential inputs dynamically — add a new provider and the UI picks it up for free.
curl http://localhost:4002/api/merchants/supported \
-H "x-api-key: $SERVICE_API_KEY"
{
"items": [
{ "name": "multicard", "fields": [
{ "key": "applicationId", "required": true },
{ "key": "secret", "required": true },
{ "key": "storeId", "required": true }
]},
{ "name": "click", "fields": [
{ "key": "serviceId", "required": true },
{ "key": "merchantId", "required": true },
{ "key": "secretKey", "required": true },
{ "key": "merchantUserId", "required": false }
]},
{ "name": "payme", "fields": [
{ "key": "merchantId", "required": true },
{ "key": "key", "required": true }
]},
{ "name": "uzum", "fields": [
{ "key": "serviceId", "required": true },
{ "key": "login", "required": true },
{ "key": "password", "required": true }
]}
]
}
Which providers has THIS business configured? GET /api/merchants/by-business/:source/:businessId/providers
Returns only the providers a specific business has registered and activated. This is what marketplace calls at checkout to decide which payment buttons to render. If the list is empty, this business can't accept payments yet — surface a clear message instead of a broken checkout.
curl http://localhost:4002/api/merchants/by-business/marketplace/biz_42/providers \
-H "x-api-key: $SERVICE_API_KEY"
{
"items": [
{ "provider": "click", "displayName": "Restoran A — Click" },
{ "provider": "multicard", "displayName": "Restoran A — Multicard" }
]
}
See it as a human
- The landing page shows a live status pill — at a glance, every provider currently wired up.
- GET /health returns the same list as JSON with no auth needed — handy for monitoring or for the marketplace to verify the gateway is reachable.
- The admin Merchants page shows every registered (business, provider) pair across the platform — staff view.
Drop-in client
A tiny typed wrapper. Copy this into each consuming app and import from it.
// payment-client.ts
const BASE = process.env.PAYMENT_SERVICE_URL!;
const KEY = process.env.PAYMENT_SERVICE_KEY!;
const SOURCE = "marketplace"; // your app's stable identifier
const call = async <T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> => {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { "Content-Type": "application/json", "x-api-key": KEY },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok || json.status === false) {
throw new Error(json.message || `HTTP ${res.status}`);
}
return json.data as T;
};
export const payments = {
listProvidersFor: (businessId: string) =>
call<{ items: { provider: string; displayName: string | null }[] }>(
"GET", `/api/merchants/by-business/${SOURCE}/${encodeURIComponent(businessId)}/providers`,
),
registerMerchant: (input: {
businessId: string;
provider: string;
credentials: Record<string, string>;
displayName?: string;
}) => call<{ id: string }>("POST", "/api/merchants", { source: SOURCE, isActive: true, ...input }),
createInvoice: (input: {
businessId: string;
provider: string;
sourceInvoiceId: string;
amount: number; // tiyin
notifyUrl: string;
returnUrl?: string;
returnErrorUrl?: string;
lang?: "uz" | "ru" | "en";
ofd?: unknown[];
}) => call<{
id: string;
checkoutUrl: string;
deeplink: string | null;
status: string;
}>("POST", "/api/invoices", { source: SOURCE, currency: "UZS", ...input }),
getInvoice: (id: string) =>
call<{ id: string; status: string; paidAt: string | null }>("GET", `/api/invoices/${id}`),
refund: (input: { invoiceId: string; amount?: number; ofd?: unknown[]; cardPan?: string; reason?: string; idempotencyKey?: string }) =>
call<{ id: string; status: string }>("POST", "/api/refunds", input),
};
Flow 1 — Onboard a business
Called once per (business, provider) pair. Typically lives in your business-dashboard's "Payment settings" page. The business has signed up directly with the provider's portal and pasted their credentials into your form.
// app/api/payments/onboard/route.ts (Next.js)
import { payments } from "@/lib/payment-client";
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return Response.json({ error: "auth" }, { status: 401 });
const { provider, credentials } = await req.json();
// credentials shape depends on the provider — see the Providers section.
const { id } = await payments.registerMerchant({
businessId: session.business.id,
provider,
credentials,
displayName: `${session.business.name} — ${provider}`,
});
return Response.json({ id });
}
Same business can register multiple providers — call registerMerchant once per provider they enable.
Flow 2 — Checkout
Step A — render the provider buttons
Buyer is on the checkout page; you fetch the active providers for the seller.
// Server side — in your checkout route loader
const { items } = await payments.listProvidersFor(order.businessId);
// items: [{ provider: "click", displayName: "..." }, ...]
// Client component
"use client";
export function CheckoutButtons({ items, order }) {
const pay = async (provider: string) => {
const res = await fetch("/api/checkout/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId: order.id, provider }),
});
const { checkoutUrl } = await res.json();
window.location.href = checkoutUrl;
};
return (
<div className="space-y-2">
{items.map((it) => (
<button key={it.provider} onClick={() => pay(it.provider)}>
Pay with {it.provider}
</button>
))}
</div>
);
}
Step B — your backend creates the invoice
// app/api/checkout/start/route.ts
import { payments } from "@/lib/payment-client";
export async function POST(req: Request) {
const { orderId, provider } = await req.json();
const order = await db.order.findUnique({ where: { id: orderId } });
const inv = await payments.createInvoice({
businessId: order.businessId,
provider,
sourceInvoiceId: order.id, // YOUR id
amount: order.totalTiyin, // tiyin
notifyUrl: `${process.env.API_PUBLIC_URL}/api/payments/webhook`,
returnUrl: `${process.env.WEB_PUBLIC_URL}/orders/${order.id}`,
lang: order.locale,
ofd: order.lines.map(toOfdItem), // only Multicard needs this, but safe to send
});
await db.order.update({
where: { id: order.id },
data: {
paymentInvoiceId: inv.id,
paymentProvider: provider,
paymentStatus: "pending",
},
});
return Response.json({ checkoutUrl: inv.checkoutUrl });
}
Step C — the return page (poll for status)
After the buyer pays, the provider redirects them to your returnUrl. The payment may not
have settled in your DB yet (the webhook is racing the redirect). Poll for status.
"use client";
useEffect(() => {
if (order.paymentStatus === "paid") return;
const t = setInterval(async () => {
const o = await fetch(`/api/orders/${order.id}`).then((r) => r.json());
if (o.paymentStatus !== "pending") { setOrder(o); clearInterval(t); }
}, 2000);
return () => clearInterval(t);
}, [order]);
Flow 3 — Webhook receiver
The most important code in your integration. When the provider tells payment-service the buyer paid,
we forward a signed POST to your notifyUrl. This is where you flip your order status.
notify_delivered=false — visible in the Admin → Stuck view.
Headers we send
Content-Type: application/json
X-Payment-Signature: sha256=<hex>
X-Payment-Event: invoice.updated
X-Payment-Id: <our invoice uuid>
Body we send
{
"id": "f6339f31-6a09-11f0-9a1b-00505680eaf6",
"source": "marketplace",
"businessId": "biz_42",
"sourceInvoiceId": "order_123",
"amount": 5000000,
"currency": "UZS",
"status": "paid",
"providerUuid": "f6339f31-...",
"paidAt": "2026-05-12T10:01:23.000Z",
"card": { "pan": "860030******5959", "paymentSystem": "uzcard" },
"receiptUrl": "https://..."
}
Verifying + handling
// app/api/payments/webhook/route.ts
import crypto from "crypto";
const SECRET = process.env.WEBHOOK_SIGNING_SECRET!;
function verify(rawBody: string, header: string): boolean {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function POST(req: Request) {
// CRITICAL: verify the RAW body, not the parsed JSON.
const rawBody = await req.text();
const sig = req.headers.get("x-payment-signature") || "";
if (!verify(rawBody, sig)) return new Response("bad sig", { status: 401 });
const event = JSON.parse(rawBody);
// IDEMPOTENT — running twice on the same event is a no-op.
await db.order.updateMany({
where: { id: event.sourceInvoiceId, paymentStatus: { not: event.status } },
data: {
paymentStatus: event.status,
paidAt: event.paidAt ? new Date(event.paidAt) : null,
cardPan: event.card?.pan ?? null,
paymentSystem: event.card?.paymentSystem ?? null,
},
});
return Response.json({ ok: true });
}
Common pitfalls
- Verify the raw body, not the parsed JSON.
JSON.parse + stringifychanges whitespace and breaks the HMAC. Capture the bytes first. - Respond 2xx within 10 seconds. Queue heavy work (emails, etc.); don't make payment-service wait.
- Be idempotent. Use
updateManywith a status guard, or upsert on apayment_eventstable keyed byevent.id. - Status can be
paid,refunded,failed, orcancelled. Handle each.
Refunds
Issue a full or partial refund. Omit amount for a full refund of the remaining balance.
Partial refunds need a replacement OFD receipt (Multicard's requirement).
await payments.refund({
invoiceId: order.paymentInvoiceId,
amount: 2000000, // tiyin, omit for full refund
reason: "Buyer returned 1 item",
idempotencyKey: `refund_${order.id}_${Date.now()}`,
ofd: [{ qty: 1, price: 2000000, total: 2000000,
mxik: "...", package_code: "...", name: "...", vat: 0 }],
// cardPan: required if paid via Payme/Click
});
When the running total of successful refunds reaches the original amount, the invoice flips to
refunded and a fresh signed webhook fires with status: "refunded" — handle this
in your webhook receiver the same way as paid.
Provider support: Multicard has full refund support. Click / Payme / Uzum refunds are processed via the provider's own portal UI for now (the API endpoints throw a clear error).
Providers
Each provider has a different shape of credentials you ask businesses to paste:
| Provider | Credentials | Buyer flow |
|---|---|---|
| multicard | applicationId · secret · storeId |
Redirect to Multicard hosted checkout |
| click | serviceId · merchantId · secretKey · merchantUserId |
Redirect to my.click.uz/services/pay |
| payme | merchantId · key |
Redirect to base64-encoded Payme URL |
| uzum | serviceId · login · password |
Buyer-initiated from the Uzum app (no redirect) |
Uzum is different
Uzum doesn't have a checkout URL to redirect to. createInvoice returns a
uzum://pay?order=<uuid> deeplink. Render a QR code or instruction screen — the buyer
opens the Uzum app, finds your service, enters the order id, pays. The webhook arrives the same way.
if (provider === "uzum") {
return <div>
<p>Open the Uzum app and pay order <code>{inv.id}</code></p>
<QRCode value={inv.deeplink!} />
</div>;
}
window.location.href = inv.checkoutUrl;
For full per-provider details (callback flows, signature formulas, error codes), see the README's "How each provider works" section in the payment-service repo.
API endpoints
All require x-api-key except /api/callbacks/*.
| Method | Path | Use |
|---|---|---|
| GET | /api/merchants/supported | List providers + their credential fields |
| POST | /api/merchants | Register/update merchant (upsert) |
| GET | /api/merchants/by-business/:source/:businessId/providers | List a business's providers (checkout) |
| POST | /api/invoices | Create invoice → returns checkoutUrl |
| GET | /api/invoices/:id | Check status (polling) |
| POST | /api/invoices/:id/cancel | Cancel a pending invoice |
| POST | /api/refunds | Full or partial refund |
| GET | /api/refunds/by-invoice/:invoiceId | List refunds on an invoice |
Errors
Errors follow the same envelope as successes, with status: false:
{
"status": false,
"code": 400,
"message": "VALIDATION_ERROR",
"data": { "details": ["\"amount\" must be greater than or equal to 100"] }
}
| code | Meaning |
|---|---|
| 400 | Validation error — see data.details |
| 401 | Missing/invalid x-api-key |
| 404 | Invoice / merchant / refund not found |
| 429 | Rate-limited (default 120 req/min per IP) |
| 502 | Upstream provider failed (Multicard/Click/etc.) — usually transient |
| 500 | Server error — check our logs by request id |
Every response carries a X-Request-Id header that's also in our logs — quote it when reporting issues.
Sanity test
End-to-end smoke test using Multicard's public sandbox creds and webhook.site:
- Get a webhook URL from webhook.site — copy the unique URL.
- Register the sandbox merchant (see Quickstart).
- Create an invoice with
notifyUrl= your webhook.site URL. - Open the returned
checkoutUrlin a browser → Multicard sandbox page. - Trigger any payment outcome and watch the payload arrive at webhook.site.
Sub-2-minute round trip if everything's wired right. If the webhook never arrives, check Admin → Stuck.