Payment Service · Integration Docs

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.

Important: never send this key from a browser. The pattern is always: 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.

Reliability: if your endpoint fails, the worker retries with backoff: 1s, 2s, 4s, 8s, 16s. After 5 failures the invoice stays 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 + stringify changes 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 updateMany with a status guard, or upsert on a payment_events table keyed by event.id.
  • Status can be paid, refunded, failed, or cancelled. 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/supportedList providers + their credential fields
POST/api/merchantsRegister/update merchant (upsert)
GET/api/merchants/by-business/:source/:businessId/providersList a business's providers (checkout)
POST/api/invoicesCreate invoice → returns checkoutUrl
GET/api/invoices/:idCheck status (polling)
POST/api/invoices/:id/cancelCancel a pending invoice
POST/api/refundsFull or partial refund
GET/api/refunds/by-invoice/:invoiceIdList 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"] }
}
codeMeaning
400Validation error — see data.details
401Missing/invalid x-api-key
404Invoice / merchant / refund not found
429Rate-limited (default 120 req/min per IP)
502Upstream provider failed (Multicard/Click/etc.) — usually transient
500Server 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:

  1. Get a webhook URL from webhook.site — copy the unique URL.
  2. Register the sandbox merchant (see Quickstart).
  3. Create an invoice with notifyUrl = your webhook.site URL.
  4. Open the returned checkoutUrl in a browser → Multicard sandbox page.
  5. 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.

Need to operate the service itself — manage merchants, browse invoices, retry stuck webhooks? Open the admin dashboard (requires Google Authenticator).