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 whenRETAIL_CONFIG_ENC_KEYis 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:
GET /api/public/v1/retail/plans— read the catalogue and
the list of configured providers. Render one button per
provider.POST /api/public/v1/retail/quote— submit the cart, get
backpayment_urlandquote_id. Redirect the buyer to
payment_url.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:
-
Provider-initiated — the customer disputes the charge with
their card or the merchant cabinet issues a refund. The
provider’srefund.succeededwebhook arrives, the licence is
automatically revoked, and aretail_refundedemail goes out. -
Admin-initiated — an owner or admin session POSTs
/api/v1/retail/licenses/<id>/refundwith optional
{"reason": "...", "amount_minor": ...}. The license server
computes the pro-rated unused portion (ifamount_minor
is omitted) and asks the provider to refund. The licence is
revoked after either a complete terminal provider response or a verified
refund.succeededwebhook 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.