Docs Retail Platform

Retail Platform

Mockarty’s retail platform lets individual customers buy a licence
from your landing page. After payment, the buyer receives an activation key
by email. When the payment provider confirms a refund or chargeback, the
corresponding licence is revoked.

This guide is for license-server administrators. End-user purchase
flow is described from the customer’s perspective in
Quick Start.


Supported services

Hosted checkout can use YooKassa, CloudPayments, Т-Банк, Robokassa, or Stripe.
Set up the provider you use on the Payment Providers page. Fiscal receipts
can be handled by the acquirer or АТОЛ. The retail service also supports refunds,
licence revocation, email delivery with retries, and multiple instances backed
by shared PostgreSQL and Redis.

The retail subsystem is off by default. Configure providers in the admin
UI (Payment Providers page) or via the env vars below; either way the
buyer-facing endpoints activate only for configured providers. If none is
configured, those endpoints reply 503 so nothing is silently exposed.


Step 1 — pick your payment provider(s)

Two ways to configure providers, mix freely:

  • Admin UI (recommended) — open the Payment Providers page. Each
    acquirer (YooKassa, CloudPayments, Т-Банк, Robokassa, Stripe) and the fiscal
    cash register (АТОЛ) shows a card with its credential fields; fill, Test,
    Enable, and optionally Make default. Credentials are stored in the
    database, encrypted at rest when RETAIL_CONFIG_ENC_KEY is set. Changes apply
    immediately and propagate to every cluster node within
    RETAIL_PROVIDER_RELOAD_SECONDS (default 60).
  • Env vars — YooKassa and Stripe can also be wired with the env vars below
    (used as a fallback only when no provider is configured in the UI). The other
    acquirers and the fiscal provider are configured exclusively in the UI.

The /api/public/v1/retail/plans response advertises which providers are active
so the landing page renders the right buttons.

YooKassa (Russian market)

Required env vars for the license server:

RETAIL_YOOKASSA_SHOP_ID=123456
RETAIL_YOOKASSA_SECRET_KEY=live_********
# Optional: a shared HMAC secret your reverse proxy adds via
# X-Mockarty-Signature for belt-and-braces webhook protection.
RETAIL_YOOKASSA_WEBHOOK_HMAC=any-long-random-string

YooKassa’s webhooks have no built-in payload signature; the
license server protects against forgery by re-fetching every
referenced payment from /v3/payments/{id} and rejecting the
webhook if the body’s amount or status disagrees with the server-
side record. Optional X-Mockarty-Signature adds a second layer.

Stripe (international)

RETAIL_STRIPE_SECRET_KEY=sk_live_********
RETAIL_STRIPE_WEBHOOK_SECRET=whsec_********

The webhook secret is the value Stripe shows under
Developers → Webhooks → your endpoint → Signing secret. The
license server validates Stripe-Signature with HMAC-SHA256 and a
5-minute clock-skew window — this matches the official Stripe SDK
default.

Other acquirers (configured in the admin UI)

CloudPayments, Т-Банк (Tinkoff) Касса and Robokassa are connected from
the Payment Providers page (no env vars). Each is a Russian-market acquirer
with card + SBP; Т-Банк and Robokassa also support self-employed (НПД). Fill the
credentials shown on the card (CloudPayments: Public ID + API secret; Т-Банк:
Terminal key + password; Robokassa: merchant login + Password #1/#2), press
Test, then Enable.

Robokassa refunds are performed in the merchant cabinet and do not
auto-revoke the licence — revoke it manually from the admin.

54-FZ fiscal receipts (чек)

Pick a fiscal provider on the Payment Providers page (Fiscalization section):

  • Inline — the acquirer issues the чек itself (Mockarty passes the receipt
    in the payment request). Available with YooKassa today.
  • АТОЛ Онлайн — a standalone cloud cash register / OFD that issues the чек
    server-side; works with any acquirer. Fill login, password, group code,
    company INN + email.
  • None — no чек (e.g. B2B bank transfer, which is 54-FZ exempt).

The VAT rate on the чек comes from your seller profile. Fiscal outcomes are
recorded against each payment and are idempotent across webhook retries.

Webhook URLs to register

Provider URL
YooKassa https://<your-license-server>/api/public/v1/retail/checkout/yookassa
CloudPayments https://<your-license-server>/api/public/v1/retail/checkout/cloudpayments
Т-Банк https://<your-license-server>/api/public/v1/retail/checkout/tbank
Robokassa https://<your-license-server>/api/public/v1/retail/checkout/robokassa
Stripe https://<your-license-server>/api/public/v1/retail/checkout/stripe

Both URLs are anonymous on purpose — the provider’s signature is
the auth.


Step 2 — configure outgoing email

The license server queues email rows into an outbox table on
purchase, refund, and resend. Set the SMTP env vars to drain the
outbox:

RETAIL_SMTP_HOST=smtp.example.com
RETAIL_SMTP_PORT=587            # 465 for implicit TLS
RETAIL_SMTP_USERNAME=mockarty@example.com
RETAIL_SMTP_PASSWORD=********
RETAIL_SMTP_FROM=licences@example.com
RETAIL_BASE_URL=https://app.example.com   # used in email body links

If RETAIL_SMTP_HOST is unset, the outbox stays idle — purchase
emails accumulate in the database until you either wire SMTP or
forward them manually. The licence is still issued and the buyer
can fetch it from the admin panel; only the auto-email is
deferred.

The outbox runs under a Postgres advisory lock so multi-replica
deployments do not double-send. Failed deliveries back off
exponentially (1m → 2m → 4m → … capped at 1h) for up to 10
attempts, then surface in the email_deliveries table with
failed_at and last_error populated for ops triage.


Step 3 — verify the catalogue

The first run creates a retail billing plan. Open
Billing Plans → retail → components to review and set the prices for your
installation before showing offers to buyers. Use the values in your admin
panel as the source of truth: they can differ between installations and can
change over time.

The retail quote currently accepts RUB prices only. Omit currency or send
"RUB"; a request for another currency returns market_unavailable.


Step 4 — wire the landing

The landing page calls three endpoints:

  1. GET /api/public/v1/retail/plans — read the catalogue and
    the list of configured providers. Render one button per
    provider.
  2. POST /api/public/v1/retail/quote — submit the cart, get
    back payment_url and quote_id. Redirect the buyer to
    payment_url.
  3. GET /api/public/v1/retail/licenses/validate?key=… — a public
    revocation check for your own integrations: it answers whether a
    licence is active, expired or refunded. The Mockarty desktop and CLI
    verify licences locally and never call it, so an air-gapped
    installation needs nothing from here. Always returns 200 to avoid
    leaking licence existence.

Sample quote request:

POST /api/public/v1/retail/quote

{
  "customer_email": "buyer@example.com",
  "customer_name": "John Buyer",
  "feature_seats": { "mock": 3, "api-tester": 2, "security": 1 },
  "duration_days": 365,
  "provider": "yookassa",
  "currency": "RUB",
  "return_url": "https://app.example.com/checkout/done"
}

Use security for new integrations. Requests created before the security-module
rename may still send the legacy fuzz or security_agent key; the server
canonicalizes it to security before calculating and freezing the quote. Do
not send more than one of these equivalent keys in one cart—the request is
rejected as ambiguous instead of charging twice.

Response:

{
  "quote_id": "q_3f2c…",
  "total_amt": 560000,
  "currency": "RUB",
  "expires_at": "2026-05-08T12:30:00Z",
  "payment_url": "https://yookassa.ru/checkouts/3f2c…",
  "provider": "yookassa",
  "payment_reference": "py_3f2c…"
}

HTTP 200 means the license server has recorded the provider’s exact payment
identity, not merely sent a request to the provider. If the endpoint returns
provider_error or db_error, do not submit a new cart automatically: first
check the provider dashboard for an existing payment and let an operator
resolve an ambiguous attempt.

Quotes expire after 30 minutes. After that the buyer must start
over with a fresh price (so promo codes can change without
trapping carts at stale rates).

The initial retail market profile has an active RUB price list.
The quote endpoint rejects another currency before it calls a
payment provider. Add a reviewed price list for that market before
offering another currency on a landing page.


Step 5 — refunds

Two paths land in your inbox:

  1. Provider-initiated — the customer disputes the charge with
    their card or the merchant cabinet issues a refund. The
    provider’s refund.succeeded webhook arrives, the licence is
    automatically revoked, and a retail_refunded email goes out.

  2. Admin-initiated — an owner or admin session POSTs
    /api/v1/retail/licenses/<id>/refund with optional
    {"reason": "...", "amount_minor": ...}. The license server
    computes the pro-rated unused portion (if amount_minor
    is omitted) and asks the provider to refund. The licence is
    revoked after either a complete terminal provider response or a verified
    refund.succeeded webhook confirms the same refund identity and economics.

The partner API also keeps
/api/public/v1/retail/licenses/<id>/refund and the matching
.../<id>/email resend endpoint for automation. A partner API key can
use them only for a licence linked to that same partner’s paid order.
Direct retail licences and licences owned by another partner return
403 without calling the payment or email provider.

A synchronous provider response revokes the licence only when its refund ID,
payment reference, currency, and amount exactly match the requested refund.
An incomplete or mismatched response returns 502, keeps the licence active,
and records the operation for manual review.

Pro-rated formula:

refund = paid × (total_days − elapsed_days) ÷ total_days

A licence refunded the day after issuance returns ~99% of the
price; one refunded a week before expiry returns ~2%.


Operations — observability

Set LICENSE_SERVER_METRICS_TOKEN to a dedicated random bearer token to enable
GET /metrics. If the variable is empty, the route is not registered. Keep it
on a private monitoring network and scrape it with
Authorization: Bearer <token>.

Metric What to watch
mockarty_license_retail_provider_dispatches Current payment/refund attempts by provider and durable status.
mockarty_license_retail_provider_dispatch_attempts Attempt growth that may indicate provider or network instability.
mockarty_license_retail_provider_operator_required Money effects that automatic recovery refused to repeat. Alert on any value above zero.
mockarty_license_retail_provider_oldest_due_age_seconds Age of the oldest attempt waiting for recovery. Alert when it exceeds the normal recovery interval.

These metrics deliberately use only the bounded provider, operation, and
status labels. They never expose licence, quote, payment, refund, dispatch, or
idempotency identifiers.

When a provider call has an ambiguous outcome, the license server retries only
if that provider guarantees exact idempotent replay. Otherwise it stops the
attempt for operator review. Check the provider dashboard against the original
payment/refund, resolve it there, and do not create a second charge or refund
until the result is known.

Licence abuse signals

The license server records, and never acts on, three signals about a licence:
a machine beyond the licence’s device slots asked to be activated, an unusual
number of new devices appeared within a day, or more instances report in than
the licence allows. Each signal appears once per day per licence under
KMS → Violations and stays there until an operator resolves it.

Set LICENSE_ABUSE_ALERT_EMAIL to an operators’ mailbox to receive every new
signal by e-mail as well (the outgoing e-mail from Step 2 is used). The mail
names the licence and the signal; nothing is blocked automatically — blocking
a licence remains a manual operator action.


Troubleshooting

Symptom Action
503 retail_not_configured on /retail/quote No provider env vars set. Add YooKassa or Stripe credentials and restart.
503 pricing_unavailable on /retail/quote The active price list is missing or inconsistent. Do not retry payment; ask an operator to restore pricing.
400 market_unavailable on /retail/quote The requested currency or product is not available in the active retail market.
400 zero_price on /retail/quote Discounts reduced the payable total to zero. Use a supported paid checkout configuration.
502 provider_error or 500 db_error on /retail/quote Do not create another payment automatically. Check the provider dashboard and the operator_required metric for an ambiguous attempt.
503 dispatch_unavailable on /retail/quote The durable payment attempt could not be claimed. Retry only after the operator confirms no payment is already active.
401 invalid_signature on /retail/checkout/... Webhook secret rotated and the provider hasn’t picked up the new value yet. Re-save in the dashboard.
Email queued but never delivered Check the delivery failure shown to the operator. Most often SMTP auth or DNS on the relay.
Buyer paid but didn’t get a key Verify payment and email delivery status, then resend the key through the authenticated admin flow.
Licence still works after refund The provider’s refund.succeeded webhook hasn’t arrived yet — give it a few minutes. Verify via the provider’s dashboard.

For deeper architecture details, contact your Mockarty distribution.