Skip to main content
A shop order is an ordinary order. The wallet, promo codes, saved carts, payment providers, refunds, receipts, and history all work the same way they do for a plan. What differs is a cost floor on discounts and the fact that a saved shop purchase never buys itself — both explained under Promotions and saved purchases.

Gifts

A gift is bought by one customer and redeemed by another. It may be a subscription, an add-on, or wallet credit.

Whose history it appears in

The order belongs to the sender and is never copied into the recipient’s history. orders.user_id is the sender; the entitlement or ledger entry the claim produces carries the recipient. That is what keeps a gift out of the recipient’s payment history while still giving them what was bought.

Claim codes

A code is sixteen Crockford base-32 characters — no I, L, O, or U, so nothing in it is visually ambiguous and no accidental word appears in it. Eighty bits, combined with the attempt ceiling and the endpoint’s rate limit, makes guessing one impractical. Only the SHA-256 of the code is stored. A database read never yields a redeemable code. The last four characters are kept separately as a hint, which is enough for a sender to tell two of their own gifts apart and for support to confirm which gift a conversation is about, and not enough to redeem one. Input is normalised before lookup: separators are stripped and the three Crockford substitutions (I/L to 1, O to 0) are applied, because those are the characters a person copying by hand reliably confuses. Anything else is rejected rather than guessed at.

Redemption rules

Redemption is exactly once by construction: the claim statement only matches a gift that is deliverable and unexpired, so a second claim of the same code updates no row. The refusal reasons are checked in a deliberate order:
  1. Attempt ceiling. After ten failed presentations a code stops answering anything at all, rather than continuing to leak which failure it is. The count is durable state on the row, so it survives a restart and cannot be reset by waiting out a rate-limit window.
  2. Status. A gift whose payment has not settled answers “not yet” rather than a refusal. A claimed, revoked, refunded, or expired gift is refused.
  3. Expiry, even if the sweeper has not run yet.
  4. Self-claim. A sender cannot redeem their own gift: routing money through the gift path would otherwise reach a promotion or reward they are not eligible for.
  5. Named recipient. A gift addressed to a Telegram identifier is claimable only by that identifier.

Revocation and refunds

An operator may revoke a gift that is pending, deliverable, or expired. A claimed gift is deliberately not revocable: the recipient already holds what it bought, and taking that back is a refund decision made against their entitlement or ledger, with its own permission and its own record. Only a gift that was never redeemed is refundable to its sender. Refunding a claimed gift would mean the sender’s money back and the recipient’s entitlement intact, which is exactly the abuse this rule prevents. Revoking takes a row lock first, so a revoke and a claim arriving at the same moment serialise: whichever commits first wins and the other sees a state its guarded update refuses.

Digital goods

A digital good is not VPN access. Nothing in this area creates, modifies, reads, or depends on a Remnawave entitlement, and a delivery failure here can never disturb a subscription.

The gateway, and why Omniflow does not talk to Fragment

Fragment has no API. Buying there means driving an authenticated browser session through a stateful multi-step flow and settling the result with an on-chain TON transfer from a wallet derived from a mnemonic. Omniflow deliberately does not hold that wallet. A self-hosted product should not ship a configuration field that can spend real money, and a stateful scraping session is not something a background worker can share safely. Instead the operator runs a gateway that owns the Fragment session, the TON wallet, and the settlement, and Omniflow calls it over HTTP Basic as the fixed omniflow user. The adapter interface stays small — supports, quote, deliver, poll, balance — so a second gateway or a different goods provider is five methods, not a rewrite of the shop.

What the gateway does and does not offer

Delivery is addressed by username. The user_id the gateway also takes is bookkeeping for its own customer records, so Omniflow sends the buyer’s Telegram identifier — it does not know the numeric identifier behind an arbitrary handle, and the handle is what actually receives the goods.
Two gateway limitations affect what Omniflow can promise.There is no idempotency key, so a request whose answer never arrives is genuinely ambiguous: the goods may already have been sent and the operator’s funds already spent.And a success is not proof of delivery. The gateway answers 200 for a purchase its own processor abandoned — insufficient TON, an expired Fragment session, and a rejected purchase are reported to an operator chat and then returned from normally. Until the gateway distinguishes those, an accepted submission means “the gateway took it”.Both are fixed on the gateway side: honour an Idempotency-Key header, and raise on a failed purchase instead of returning. Until then, read “delivered” in the panel as “submitted and accepted”.

Pricing

What the customer pays is derived from what the provider charges: a markup in basis points, then a rounding rule, both operator-configured per product. Every mode rounds up, and the markup itself rounds up rather than truncating. A shop that loses a minor unit on every sale is a shop whose configured margin is not the margin it earns. Stars are quoted from the gateway’s published per-star value, rounded to the minor unit per star and then multiplied — the same arithmetic the gateway charges against. Premium has no published cost. The operator sets the sale price directly with fixed_amount_minor, and Omniflow records that the cost is unknown rather than zero, so the finance view reports the margin as unknown instead of as a hundred per cent. Both sides of a quote are stored on the order: what the provider asked and what the customer was shown. Their difference is the realised margin, which is why the finance view can report it without recomputing a markup that may since have changed. Because a gateway rate can move, a quote carries an expiry; quote_ttl_seconds sets it, and a stale quote produces a new one rather than a charge at the old number.

Recipients

The recipient is a Telegram username and nothing else. No display name, no numeric identifier, no profile data: delivery needs the username and support needs to be able to answer “where did it go”, and neither needs more. Input is normalised from the forms people actually paste — a bare handle, an @ prefix, a t.me/ link, a link with a query string — and validated against Telegram’s own rule of five to thirty-two characters beginning with a letter. The gateway exposes no pre-payment check, so a handle that is well-formed but unreachable is caught at delivery and refunded.

Delivery and failure classification

One delivery row per order, enforced by the primary key. That constraint stops a replayed job, a duplicated webhook, or a retried worker from submitting a second time. It cannot undo a purchase that already happened, which is exactly why the ambiguous class below exists. A failure is classified, and the class decides what happens next: Every class resolves in exactly one way, which is asserted by a unit test. Retrying an ambiguous purchase could deliver and charge twice; refunding one could give money back for goods the recipient received. Both answers can be wrong in a way that costs somebody money, so the delivery stops and waits. A server error from the gateway is ambiguous rather than retryable, because the gateway settles on-chain and writes its own records afterwards — a 5xx can be raised from code that runs after the goods were paid for. A lost connection is ambiguous for the same reason. Backoff doubles from a minute to a ceiling of an hour, and six attempts — a little over two hours — is where a retryable failure becomes a refund. The refund is an ordinary wallet credit, so it appears in the customer’s wallet history exactly like any other.

Paying with wallet balance

A shop purchase is an ordinary order, so wallet-first payment applies to it unchanged: the balance covers what it can and the remainder becomes the external amount a provider settles. A customer with enough balance pays for Premium or Stars without a payment provider being involved at all.

Spend controls

The provider record carries a low-balance threshold and a rolling spend ceiling measured in provider cost rather than customer price — the ceiling protects the operator’s funding source, and that is what the provider actually draws down. A zero ceiling means no ceiling. The gateway publishes no balance, so the low-balance alert has nothing to fire on for it today; an exhausted TON wallet instead surfaces as deliveries the gateway accepts and silently abandons. Exposing a balance endpoint is the single change that would fix that. Credentials — the gateway URL and its shared token — are sealed with APP_DATA_ENCRYPTION_KEY, never returned by the API after they are written, and excluded from diagnostics.

Promotions and saved purchases

A shop price is not a plan price, and three decisions follow from that.

A promotion applies to plans or to goods, never both

An unscoped plan promotion means “every plan”. If digital goods had simply joined that set, every promotion an operator had ever written would have started discounting shop orders the moment they upgraded — which nobody agreed to, and which does not mean what it looks like: a plan price is a number the operator chose, and a shop price is a provider quote plus a markup. “Fifty percent off” written for plans is a different instruction against a product whose margin might be eight percent. So a promotion names its catalogue. Existing promotions default to plans and keep meaning exactly what they meant. A shop promotion is scoped to specific products or, left unscoped, applies to every visible one.

A discount cannot take an order below what the provider charges

The provider still charges its cost whatever the customer pays, so a discount past the markup is the installation paying to make a sale. That is a legitimate thing to choose and an illegitimate thing to arrive at by accident. Markup is where an operator sets the price; a promotion is a discount off the price they set. A code that would breach cost is refused with that reason, not quietly capped — a percentage that silently becomes a different percentage is the mismatch that generates a support ticket. Two exceptions follow from the same reasoning:
  • A product sold at a fixed price the operator configured has no provider quote behind it. That number is theirs, exactly like a plan price, and it can be discounted to zero.
  • A product whose provider will not say what it charges has no floor to check against, so it cannot be discounted at all. Discounting a number nobody can bound is how an installation sells under cost without knowing.
The floor is enforced in the table as well as in the application, so a script or a backfill cannot write what the checkout refuses.
The realised margin on a discounted order is price − discount − cost. The discount is stored on the shop order for exactly this reason: without it the finance view would report the full markup on a sale that earned less.

A saved shop purchase never buys itself

A saved plan charges unattended when a wallet top-up covers it. That is safe because the price it re-quotes against is the operator’s own and changes rarely. A shop price is a provider quote that expires in minutes. Charging one unattended would mean charging a number the customer last saw days ago, against a rate that has moved since. So a shop purchase is saved and the customer confirms it when they come back. What is shared is the property that matters: the price is re-quoted before any charge. A price that fell is applied without comment — nobody objects to paying less. A price that rose is refused, and the customer is shown both numbers. A customer has one open cart. Saving a shop item therefore replaces a saved plan rather than sitting beside it: keeping both would mean the auto-purchase sweep charging for something the customer believed they had replaced.

Buying in the bot

Gifts

A sender chooses a subscription or an amount of wallet credit, optionally writes a note, and receives a claim code. The code exists in exactly that one message: only its SHA-256 is stored, so a database read — a backup, a dump, a compromised replica — never yields a redeemable code, and a sender who loses it cannot have it recovered. The stored hint is the last four characters, which is enough to tell two of your own gifts apart and not enough for anybody to redeem one. A gift becomes claimable when the sender’s order is paid, and the code is shown before then with a note saying so — a recipient trying early would otherwise be refused with no explanation. Every refusal renders the same message. A recipient learning the difference between “no such code”, “already claimed”, and “expired” would also be an attacker learning which codes exist. A gifted subscription extends what the recipient already has rather than replacing it, and is provisioned through the same fulfillment pipeline a purchase uses, in the claim’s own transaction. The gift cannot be marked claimed without the recipient actually getting something.

The shop

The catalogue separates Premium from Stars, because one is answered with a duration and the other with a quantity. The price is quoted when the product is opened, not at confirmation, so the number on the screen is the number that will be charged and its expiry is stated. A quote that has expired by the time the customer confirms is re-taken rather than honoured. A product with a fixed price never touches the provider at all: the operator has already decided what the customer pays and absorbs the variance. The recipient review step is a separate screen. Digital goods cannot be recalled once a gateway has sent them, so a mistyped username is unrecoverable, and the handle is restated in full immediately above the button that spends the money. The recipient’s Telegram handle is never stored on the customer record. A handle can be changed or dropped at any time, and keeping a copy would mean holding a stale identifier for somebody who has moved on. It is stored on the shop order, where delivery and support genuinely need it.

Delivery, polling, and the ambiguous outcome

goods_deliveries is the source of truth about what is owed, not the job queue. A scheduler translates “this row is due” into “run the worker”, so a lost job, a purged queue, or a restarted process costs a delay rather than a delivery. A row that already carries a provider reference has been submitted and has not finished. Asking again would be asking for a second purchase, so the worker polls instead — that is what delayed delivery means in practice. An adapter that cannot be polled leaves the outcome genuinely unknown, and unknown is neither a failure to retry nor a success to record. The gateway that fronts Fragment is exactly that case: it honours no idempotency key and exposes no way to poll a submission. Such a delivery is classified ambiguous and parks in the panel’s review queue until an operator confirms with the provider what happened. Retrying could deliver twice; refunding could give money back for goods the recipient received.