Files
soroush.asadi ec51e87d2d feat(payment): standalone ZarinPal broker on pay.flatrender.ir
A generic multi-client payment gateway so FlatRender, meezi.ir and
bargevasat.ir can all pay through ZarinPal's single verified callback
domain (pay.flatrender.ir).

New Go service services/payment (clones the notification skeleton +
vendored deps):
- migration 31_payment_broker.sql — `payment` schema: client_apps,
  transactions, webhook_deliveries.
- ZarinPal v4 client ported from the proven identity PaymentService
  (request.json -> StartPay -> verify.json; codes 100/101).
- client API: POST /v1/pay/request + /v1/pay/inquiry, authed by
  X-Api-Key + HMAC body signature; GET /callback/zarinpal (the single
  verified endpoint) verifies, then 302s the user back to the site's
  return_url (signed) and fires a signed, retried webhook.
- per-client ZarinPal merchant override (default = shared merchant);
  amount stored canonically in Rial, unit to ZarinPal env-configurable.
- admin API /v1/admin/* (FlatRender admin JWT): client-app CRUD +
  key issue/rotate + transactions list.

Deploy wiring: payment-svc in docker-compose.v2.yml (host port 1607),
pay.flatrender.ir server block in mirror-nginx conf, ENV_FILE +
README updates (cert SAN + manual migration note).

Admin UI: src/components/admin/PaymentsAdmin.tsx (client apps with
one-time key reveal + rotate, transactions table) + /admin/payments
page + nav link + fa/en strings; pay-admin proxy route to payment-svc.

Docs/SDK: deploy/PAYMENTS.md (integration contract) + deploy/sdk/flatpay.js
(zero-dep Node client + webhook verifier) for meezi/any site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:59:54 +03:30

4.7 KiB

FlatRender Pay — ZarinPal Broker Integration Guide

pay.flatrender.ir is a standalone, multi-client ZarinPal gateway. Any site (FlatRender, meezi.ir, bargevasat.ir, …) routes payments through it, because ZarinPal only accepts callbacks on the single verified domain pay.flatrender.ir.

your site ──POST /v1/pay/request──►  pay.flatrender.ir  ──►  ZarinPal request.json
   ▲  (api key + HMAC)                      │                       │
   │                                        ▼                  authority
   └──◄ 302 return_url (signed) ◄── /callback/zarinpal ◄── user pays on ZarinPal
   └──◄ POST webhook_url (signed) ◄────────┘  (verify.json → ref_id)

You get a client app from the FlatRender admin (Admin → پرداخت → اپلیکیشن‌ها):

  • api_key — public id, sent as X-Api-Key (e.g. pk_…)
  • secret — shown once; signs your requests AND verifies broker callbacks (sk_…)
  • webhook_url — optional server-to-server result endpoint
  • allowed_return_origins — the origins your return_url may use (empty = any)

1. Create a payment

POST https://pay.flatrender.ir/v1/pay/request

Headers:

Header Value
Content-Type application/json
X-Api-Key your api_key
X-Signature hex( HMAC_SHA256(secret, <raw request body bytes>) )

Body:

{
  "amount": 50000,
  "currency": "IRT",
  "description": "خرید اشتراک طلایی",
  "client_ref": "order-1234",
  "return_url": "https://meezi.ir/payment/return",
  "mobile": "09120000000",
  "email": "user@example.com",
  "metadata": { "user_id": "42", "plan": "gold" }
}
  • amount — integer. currency is "IRR" (Rial, default) or "IRT" (Toman). The broker stores the canonical Rial value and converts for ZarinPal.
  • client_ref — your own order id (echoed back everywhere).
  • return_url — where the user's browser is sent after payment. Must match an allowed_return_origins entry if you configured any.
  • metadata — arbitrary JSON, echoed back in the redirect signature scope + webhook.

Response 200:

{
  "id": "9b2c…",            // broker transaction id
  "status": "Pending",
  "payment_url": "https://www.zarinpal.com/pg/StartPay/A000…",
  "authority": "A000…",
  "amount_rial": 500000
}

Redirect the user's browser to payment_url.


2. The user comes back (browser redirect)

After ZarinPal, the broker verifies the payment and 302-redirects the browser to your return_url with a signed result appended:

https://meezi.ir/payment/return?status=Paid&id=9b2c…&ref_id=123456789&sign=<hex>
  • statusPaid | Failed | Cancelled
  • ref_id — ZarinPal receipt (only when paid)
  • signhex( HMAC_SHA256(secret, "{id}.{status}.{ref_id}.{amount_rial}") )

⚠️ The redirect is not proof of payment on its own (a user can craft a URL). Treat it as a UX hint, then confirm with the webhook (§3) or the inquiry API (§4).

To verify the redirect signature you need amount_rial; fetch it via the inquiry API, or just rely on the webhook / inquiry as the source of truth.


If webhook_url is set, the broker POSTs a signed JSON body to it when a payment finishes (with retry + exponential backoff up to ~1h):

Headers: X-FlatPay-Signature: <hex>, X-FlatPay-Event: payment

{
  "event": "payment.paid",
  "id": "9b2c…",
  "status": "Paid",
  "amount_rial": 500000,
  "currency": "IRR",
  "client_ref": "order-1234",
  "ref_id": "123456789",
  "authority": "A000…",
  "card_pan": "6037********1234",
  "metadata": { "user_id": "42", "plan": "gold" },
  "paid_at": "2026-06-15T14:00:00Z",
  "ts": 1750000000
}

Verify: HMAC_SHA256(secret, <raw body bytes>) == X-FlatPay-Signature. Respond 2xx to acknowledge (anything else is retried). Make handling idempotent (keyed on id or client_ref) — duplicate deliveries are possible.


4. Inquiry (authoritative pull)

POST https://pay.flatrender.ir/v1/pay/inquiry (same X-Api-Key + X-Signature as §1)

{ "id": "9b2c…" }

Returns the full transaction (status, ref_id, amount_rial, …). Use this from your return_url handler to confirm before granting the user anything.


Reference signature recipe

signature = hex( HMAC_SHA256(client_secret, message_bytes) )
  • request / inquiry: message_bytes = the exact raw JSON body you send.
  • return redirect: message_bytes = UTF-8 of "{id}.{status}.{ref_id}.{amount_rial}".
  • webhook: message_bytes = the exact raw JSON body received.

See sdk/flatpay.js for a drop-in Node client + Express webhook verifier.