payment_intents record rather than any one provider’s
object model.
Declaring capabilities rather than special-casing providers is what lets a screen explain why
something is not offered instead of merely refusing it. A plan priced only in RUB will not show
Telegram Stars, and a CryptoBot payment shows no refund button, because both facts come from the
adapter itself.
This page is the setup guide. The order state machine, the settlement classifications, and what a
paid order produces are in orders and payments.
The four adapters
How an adapter becomes available
Adapters are built from environment variables at process start, in both the API and the worker, so the two processes cannot end up with different provider sets — a renewal the worker cannot charge through the provider the customer paid with would be the failure that produces.manualandtelegram_starsare always registered.cryptobotis registered whenAPP_CRYPTOBOT_TOKENis set.yookassais registered whenAPP_YOOKASSA_SHOP_IDorAPP_YOOKASSA_SECRETis set, and then refuses to build unless both are present.
422 provider_unavailable.
Which methods a customer is offered
A method appears only when the installation configured it and the plan carries a price in a currency that method settles. Selection never falls back to an incompatible adapter, and an order that the wallet already covers is offered no provider at all. Wallet top-ups have no plan price list, so they offer every enabled adapter that supports the currency. The list a customer sees is sorted by provider slug —cryptobot, manual, telegram_stars,
yookassa — and both surfaces use the same sort, so a method never appears in one position in the
bot and another in the browser. The panel’s display-order field does not change it.
/admin/settings has a Payment providers card that stores enabled, a display order, sealed
credentials, and a webhook secret per (provider, merchantId). None of it configures a running
adapter: ConfigureProviders reads environment variables only, and the customer-facing list uses
the compiled-in display order. The stored credentials have no reader. Configure providers in
.env and restart. Installation settings covers what the
settings screens do and do not affect.Telegram Stars
Settles in Telegram Stars, a zero-decimal currency with the codeXTR, through the authenticated
Bot API. It is the only rail that needs no merchant account and no external onboarding, and the only
one whose money arrives entirely inside Telegram.
Why its settlement path is different. Telegram does not sign Bot API updates for a public HTTP
route. Accepting a “Stars webhook” on /v1/payments/webhooks/telegram_stars would be accepting
unauthenticated money, so the adapter declares no webhook capability and the public route refuses
that provider before parsing anything. Settlement enters only through the bot’s own authenticated
update stream.
1
Set the bot token
APP_TELEGRAM_TOKEN is the same token the bot process uses. On the API it enables the Stars
adapter and Telegram sign-in; on the worker it enables Stars for renewals. See
Telegram for obtaining it.2
Price a plan version in XTR
A plan with no
XTR price can never be bought with Stars, because provider selection matches
the plan’s price currencies. APP_DEFAULT_CURRENCY=XTR makes a Stars-only installation
consistent everywhere.3
Buy in the bot
The order screen shows an invoice button. The bot re-reads the order, refuses unless the
currency is
XTR and there is an external amount left to pay, and sends a Telegram invoice with
the order ID as its payload and forwarding protection on.4
Watch the pre-checkout answer
Telegram sends
pre_checkout_query. Omniflow approves only when the referenced order belongs to
that customer, the currency matches, the total equals the external amount exactly, and the order
is still open. Anything else is answered with a refusal and a “stale” message rather than
silently accepted.successful_payment the update is recorded as a provider event keyed on
charge:<telegram_payment_charge_id>, which makes a redelivered update a no-op that classifies as
duplicate. What is stored as the raw body is Omniflow’s own projection —
{updateId, orderId, chargeId, amountMinor, currency} — not the Telegram update: no message
content, no account data. The charge identifier is written to the payment intent before
settlement, so a retry can find the intent again and a refund has the reference it needs. A charge
whose intent belongs to a different order is refused with order_mismatch.
Refunds. refundStarPayment needs both the charge ID and the Telegram user who paid. The payer
is stored as non-secret receipt metadata when the payment is started from a surface that knows the
Telegram account. Refunds are full-charge only: a partial request is refused rather than reported as
settled.
Testing. There is no sandbox. Price a cheap plan version in XTR and buy it from your own
Telegram account; Telegram’s own test environment is out of scope here.
CryptoBot
Creates fiat-denominated crypto invoices throughpay.crypt.bot. The operator prices in a fiat
currency and the customer pays in crypto, which is why it is the rail most Telegram-native shops
already run.
Currencies: USD, EUR, RUB, BYN, UAH, GBP, CNY, KZT, UZS, GEL, TRY, AMD,
INR, AZN, AED, PLN. Telegram Stars are deliberately absent — they never settle through
CryptoBot.
1
Create the app in @CryptoBot
Open @CryptoBot, go to Crypto Pay, create an app, and copy its API
token. CryptoBot runs a separate testnet bot with its own tokens; a testnet token only works
while
APP_CRYPTOBOT_TESTNET is true, and a mainnet token only while it is false.2
Configure Omniflow
APP_CRYPTOBOT_TESTNET defaults to true, which points the adapter at
https://testnet-pay.crypt.bot/api. Set it to false for production; leaving it at the default
means real invoices are never created.3
Point the webhook at Omniflow
In the app’s settings, enable webhooks and set the URL to
https://<your-public-host>/v1/payments/webhooks/cryptobot. The route is unauthenticated by
necessity and protected by the signature check below.4
Buy something small
On testnet, create an order and pay the invoice. The order should settle within seconds; if it
does not, check
/admin → System → Webhooks for the intake record, and remember that the
one-minute reconciler will poll the invoice anyway.crypto-pay-api-signature header is hex-decoded and compared with a
constant-time equality against HMAC-SHA256(key = SHA256(apiToken), body) over the exact raw body. A
tampered body is rejected. The deduplication ID is {updateType}:{invoiceID}:{updateID}, which is
stable across replays of the same body.
Recovery and polling. If CreateIntent is retried after a lost response, the adapter lists the
last 100 invoices and adopts the one whose payload matches the order — no second invoice for one
order. Polling uses getInvoices?invoice_ids=… and backs both the background reconciler and the
customer’s Refresh button. Invoices are created with expires_in=3600.
YooKassa
The Russian acquirer, overhttps://api.yookassa.ru/v3 with HTTP Basic authentication. It is the
only adapter that supports partial refunds, and the only one that declares recurring support.
1
Get the shop ID and secret key
In the YooKassa merchant account, create a secret key for the API. You need the shop identifier
and the key together.
2
Configure Omniflow
APP_PUBLIC_URL is required
whenever YooKassa is configured, because a hosted checkout needs an absolute link to come back
to; the bot refuses to start without it.3
Set the notification URL
In the merchant account, point HTTP notifications at
https://<your-public-host>/v1/payments/webhooks/yookassa.4
Pay a test order
Use a YooKassa test shop and its test cards. After confirmation the customer lands on
{APP_PUBLIC_URL}/account/orders/{orderID}, which is the return URL Omniflow supplies on every
intent it creates.POST /payments with capture: true, a redirect confirmation carrying the return URL,
metadata.order_id, and Omniflow’s idempotency key in the Idempotence-Key header. YooKassa
honours that key for 24 hours, which is what makes a retried worker safe.
Webhook verification — the part that matters. YooKassa does not sign its notifications. Omniflow
therefore treats a notification as an untrusted hint: verification only parses the envelope and
requires a notification type with a non-empty object ID, and then HandleWebhook immediately
re-fetches the referenced payment over the authenticated API and replaces the notification’s status
and amount with the polled values before any financial state changes. If that fetch fails, the
webhook row completes as failed with reconciliation_failed and nothing is applied. An attacker
who can post to the webhook route cannot make a payment succeed.
The webhook event ID is {event}:{objectID}:{first 8 bytes of sha256(body)}, which is what
deduplicates a redelivery.
Refunds. POST /refunds with the payment ID, the amount, and the caller’s idempotency key. Both
full and partial are supported — YooKassa is the only adapter where a partial refund reaches the
provider.
Recurring. ChargeSaved posts a payment with a stored payment_method_id and capture: true
in one step, because nobody is present to confirm a two-phase authorisation.
Manual payments
An adapter with no external system.Create returns an intent whose provider reference is the order
ID and whose status is requires_action; there is nothing to poll and no webhook to verify.
Settlement happens when an operator approves it, which is what makes it usable for bank transfers,
cash, and any rail Omniflow does not integrate.
Because the money moved outside the system, the approval carries the accountability: it refuses
without a named operator, a reason, and a valid idempotency key.
manual_payment_approvals, unique per payment intent, so a second
decision that contradicts the first is refused with “manual payment already has a different final
decision”. An approval settles the full intent amount through the same settlement path every
other provider uses. A rejection records the decision and settles nothing.
The customer sees the manual handoff: the panel tells them an operator will confirm it, and the
Telegram order screen says the same while the order is pending.
Webhook intake
One public route serves every adapter that declares webhook support:POST /v1/payments/webhooks/{provider}. It carries no credential — authenticity is the adapter’s
signature check, not a token on the URL — and the body is capped at 64 KiB (413 payload_too_large
above that).
What happens to a request, in order:
- Refuse unless the named adapter is registered and declares webhook support. This is what closes
the route for
telegram_starsandmanual. - Digest the body and run the adapter’s verification. A failure is still recorded — a row with
signature_valid = falseand the event IDinvalid:<digest>— and answered400 webhook_rejected. A rejected webhook that left no trace would be indistinguishable from one that never arrived. - Insert the event row. If the stored row is already processed or ignored, answer
{"status": "duplicate"}and stop. This is the deduplication guard, keyed on the provider’s own event ID rather than on any Omniflow header. - For YooKassa only, re-fetch the payment over the authenticated API.
- A verified but non-successful event completes as
ignored, notfailed. An expired invoice is not an error. - Find the intent, settle through the shared idempotent path, and answer
200with the classification.
content-type and user-agent only.
Retaining authorization or signature headers would put a provider credential in the database.
Rows are deleted 30 days after intake.
Operators read the intake history at /admin → System → Webhooks (system.read). The raw body is
never returned there: it is the provider’s own payload and can contain payment details, and “did
this arrive and was it accepted” needs only the status.
The Replay action on a webhook event sets its status back to
received and writes an audit
row, but nothing consumes received rows — there is no reprocessing worker. Recovering a missed
settlement is done by reconciliation, not by replay.Reconciliation: when a webhook never arrives
A webhook can be lost, delayed, or blocked. Reconciliation asks the provider what actually happened and applies the answer through the same idempotent settlement, so it can run beside a webhook without double-crediting.- Automatically. The worker’s
payment_reconcilepass runs every minute and polls up to 100 intents that arependingorprocessing, belong tocryptobotoryookassa, have a provider reference, and have not been updated for a minute. - By the customer.
POST /v1/account/orders/{orderID}/refresh, and the Telegram order screen on open. It acts only while the order is still awaiting payment and silently does nothing for an adapter that cannot be polled. - By an operator.
POST /v1/admin/payments/{paymentID}/reconcilewith the operator token.
/admin/finance → Stuck lists intents in flight longer than any supported provider should take.
Refunds, by provider
A refund is never a rewrite of payment history: it is a newrefunds row plus a new balanced ledger
transaction, so the record of what was charged survives.
succeeded, the currency differs, the amount is not positive or exceeds
the intent, the adapter does not declare refunds, the intent has no provider reference, or the
already-reserved refunds plus this amount would exceed what the provider took. Refund mutations are
serialised per intent by an advisory lock, and re-using a key with different parameters is refused.
The wallet portion of an order can never be refunded to a provider: the total is bounded by what the
provider actually took. A full refund moves the order to
refunded, a partial one to
partially_refunded, and either queues an operator notice.
Verifying a provider is really working
There is no reliable in-panel connection test.POST /v1/panel/settings/providers/{provider}/test
does run a real, read-only probe — getMe for CryptoBot, one listed payment for YooKassa, getMe
for Stars, nothing for manual — with its own 20-second deadline, and the probe cannot create a
payment. But the outcome it tries to store (passed, failed, unsupported) is outside the values
the column accepts (unknown, healthy, failing), so the write fails and the request answers
500 operation_failed whether or not the credentials worked. The probe result is in the API server
log; the panel’s webhook column likewise always reads unknown, because nothing records webhook
health.
What to do instead:
- Put a plan version on sale at the smallest price you can and buy it yourself, on testnet where the provider has one.
- Watch
/admin→ System → Webhooks for the intake row and its status. - Watch
/admin/finance/{orderId}for the payment intent’s event timeline —created,status_changed, and any mismatch classification are all there. - Refund it back through the operator-token surface, which exercises the other half of the integration.
Money handling that applies to every provider
- Amounts are integer minor units everywhere, with the exponent taken from the currency:
XTR,JPY, andKRWare zero-decimal; most others are two. - Every provider mutation carries an Omniflow idempotency key. The customer key is
order-<orderID>-<provider>— scoped to both, because the order’s key alone would bind a second attempt with a different method to the first adapter’s intent. Renewals userenewal:<cycleKey>:<attempt>. On/v1/adminthe key is yours, 8 to 128 characters. - A payment that does not match its order still commits an event. Currency mismatch, duplicate, underpayment, overpayment, and late arrival each have their own classification, and none of them can grant an entitlement. A top-up is the exception: it owes nothing beyond the money, so an over- or underpayment credits exactly what arrived.
wallet_unavailableis worth recognising: the provider took the money but the wallet portion the order relied on is no longer there, so the order stays pending. Nothing resolves it automatically — it appears in the order timeline and needs an operator.- Provider HTTP calls are bounded: a 15-second client timeout and a 1 MiB response limit.