Skip to Content

Flow: recurring payments

A contract is a one-time consent from the subscriber, after which charges go through without a PIN for every payment. The subscriber confirms once, and from then on you create invoices referencing the contract.

  1. Create the contract: POST /contracts with customer and merchant_contract_id. The response is 201 with contract_id, authorization_type and expires_at; it has no status field.
  2. Confirm it: POST /contracts/{contract_id}/authorize with "pin_code": "1234". The response carries contract_id and status: active, followed by a contract.authorized callback.
  3. Charge against the contract: an ordinary POST /invoices, but with a contract: {"merchant_contract_id": "…"} block. No PIN is requested at this step.
  4. Check the outcome: with SuccessSequencePayment in the sandbox knob the invoice reaches Processed / PAID, with FailedSequencePayment the transaction fails (see “Sandbox knobs”). In the payment.updated callback the outcome is read from invoice.status, invoice.payment_status and transaction.status; in the legacy envelope (payment.completed / payment.failed) the value you set also arrives as the billing_result field.
  5. Close the contract when the subscription ends: POST /contracts/{contract_id}/cancel with an empty body. The response is status: cancelled, followed by a contract.closed callback.

Where merchant_contract_id goes depends on the method. In POST /contracts it sits at the top level of the body, in POST /invoices it sits inside the contract block. The mock does not validate the body: put the field in the wrong place and you get 201 with an empty value in the echoed fields of the response.

The sandbox stores mobile contracts, as production does: a repeated merchant_contract_id gets 409, actions on an unknown or cancelled contract get 404 CONTRACT_NOT_FOUND (see “JSON v3 errors: HTTP codes”). The authorization block comes back even for a charge against a contract — that is a quirk of the mock, not a request to confirm the payment. Details in “Sandbox ↔ Production differences”.

Step 1 — creating the contract
// POST /api/v3/projects/1001/contracts { "customer": { "type": "mobile_phone", "msisdn": "79600000000" }, "merchant_contract_id": "CONTRACT-599160" // top level! } // 201 Created { "success": true, "data": { "contract_id": "85ff8da2-2b75-44f9-b012-49927da62fde", "authorization_type": "SMS_PIN", "expires_at": "2026-09-21T12:00:00Z", "merchant_contract_id": "CONTRACT-599160", "customer": { "type": "mobile_phone", "msisdn": "79600000000" } } }
Step 2 — confirmation
// POST /contracts/{contract_id}/authorize { "pin_code": "1234" } // 200 OK → { "contract_id": "85ff8da2-…", "status": "active" } // callback (excerpt): { "type": "contract.authorized", "data": { "customer": { "type": "mobile_phone", "msisdn": "79600000000" }, "contract": { "contract_id": "85ff8da2-…", "merchant_contract_id": "CONTRACT-599160" } } }
Step 3 — charging against the contract
// POST /api/v3/projects/1001/invoices { "customer": { "type": "mobile_phone", "msisdn": "79600000000" }, "order": { "merchant_order_id": "SEQ-a9e56f", "price": { "amount": "199.00", "currency": "RUB", "vat_included": true } }, "contract": { "merchant_contract_id": "CONTRACT-599160" }, // inside contract! "test_info": { "expected_billing_result": "SuccessSequencePayment" } }
Step 4 — the payment callback
{ "type": "payment.updated", "data": { "invoice": { "invoice_id": "efa72de6-…", "merchant_order_id": "SEQ-a9e56f", "status": "Processed", "payment_status": "PAID" }, "transaction": { "status": "SUCCESS", "result_code": 0 } // excerpt: customer and the other transaction fields are omitted } }