Paid checkout flow via Platform API and confirmation URL.

Payments

Flow

1. User selects a paid plan_price on the paywall
2. App calls createPayment(plan_price_id, return_url)
3. API creates provider payment + pending Platform payment row
4. App opens confirmation_url
5. Provider webhook → Platform marks succeeded → grants entitlements / subscription period
   (if webhook missed: worker ~1 min, or getMyEntitlements / syncPayment)
6. App refreshes entitlements (and updates local cache)

If the webhook never arrived but YooKassa already shows succeeded, Platform closes pending on its own:

  • worker every minute (POST /v1/internal/payments/reconcile)
  • getMyEntitlements() after return
  • SDK syncPayment(payment_id) / POST /v1/payments/{id}/sync

Same path as the webhook: verify at YooKassa → finalize_yookassa_payment. Admin Payments → Sync with YooKassa remains for debugging.

Client rules

  • Require auth JWT + public key
  • Never send provider secrets from the client
  • Do not call createPayment for free prices
  • After return — getMyEntitlements() (reconciles pending). Optional syncPayment(payment_id)

Webhook (production)

YooKassa cabinet URL: YOUR_API_URL/v1/webhooks/yookassa (API host, not marketing).

Finalize reads the token from platform_config.yookassa_webhook_token (≥24 chars in production). Env YOOKASSA_WEBHOOK_TOKEN is a fallback if the DB token is still weak. The YooKassa cabinet webhook URL must get a non-2xx until our payment row exists, so YooKassa retries.

Reconcile (admin)

POST /v1/payments/{paymentId}/sync
POST /v1/payments/sync-pending

Canceled in YooKassa → canceled. If the plan was already granted via admin/license, sync only closes the pending row (no second grant).

Response (paid)

{
  "data": {
    "payment_id": "uuid",
    "provider_payment_id": "…",
    "status": "pending",
    "confirmation_url": "https://…",
    "amount": 1490,
    "currency": "RUB",
    "requires_purchase": true
  }
}

Free attempt

{
  "error": "free_plan_offline",
  "is_free": true,
  "requires_purchase": false,
  "works_offline": true
}

Display discount

discount_percent is cosmetic. Charged amount is always amount. Strike-through uses compare_at_amount.