This website uses cookies to ensure you get the best experience
OK
Getting Started!
Integration of the Bestbridge Payments SDK into a Unity game. Below is the minimum set of requirements for the correct integration of the payment system.
Overview
BestBridge is for the RU region. Route through it only when the store currency requires it, and in all other cases, switch to the native store (Google Play / App Store). The currency code comes from the game; the SDK never reads it.
// With Unity IAP, present when com.unity.purchasing is installed:
if (BestBridgeStoreUtilityIAP.IsStoreInRuRegion(product)) { /* route through BestBridge */ }

// Or pass the ISO currency code directly:
if (BestBridgeStoreUtility.IsStoreInRuRegion("RUB")) { /* route through BestBridge */ }
IsStoreInRuRegion is true for RUB, and by default also true in the Editor, so the flow stays testable without a RU store. forceTrueInEditor: false disables that.
Requirements

Unity

2021.3 or newer

Dependencies

none (UnityWebRequest + JsonUtility only)

Credentials

a project API key, issued at onboarding

Environment

development and production are both built into the SDK; isDevBuild picks one, environment forces one

Install
The SDK is distributed as a .unitypackage file, handed over together with the API key.
  1. Download bestbridge-<version>.unitypackage.
  2. In the Unity Editor: Assets → Import Package → Custom Package…, then pick that file.
  3. Leave everything selected in the import dialog and press Import.
The sample is a second, nested package. The main import also drops bestbridge-samples.unitypackage next to the SDK folder. That file holds the Basic integration sample: reference UGUI/TextMeshPro popups (SampleWaitPopup, SampleConfirmationPopup, SampleInfoPopup, SamplePopupController), the fastest way to a working purchase.
Updating. Import the new .unitypackage on top of the old one. Delete the SDK folder before importing only when its files were moved or renamed by hand.
Integration in five steps
Step 1: Build a config
using BestBridge.Payments;

var config = new BestBridgeConfig(
    apiKey:   "your-project-api-key",
    isDevBuild: Debug.isDebugBuild,          // selects the built-in development / production environment
    identities: IdentitiesListCreator.Create(
        new DeviceIdIdentityProvider()
        // , new GooglePlayIdentityProvider(googlePlayId)  // a custom IIdentityProvider
    ));
identities is optional: with null the SDK uses the device id. An identity resolves to a player account, created on first use, so a stable identity is what keeps a player’s purchases.
environment is the escape hatch from isDevBuild: BestBridgeEnvironment.Development or .Production forces that environment whatever the build type is, so a release-candidate build can stay on development and a development build can take a real payment. The API key must belong to the forced environment. The default Auto follows isDevBuild.
Step 2: Describe the products
var products = new[]
{
    new BestBridgeProduct("coins_small", "sku_coins_500",  BestBridgeProductType.Consumable),
    new BestBridgeProduct("remove_ads",  "sku_remove_ads", BestBridgeProductType.NonConsumable),
    new BestBridgeProduct("premium",     "sku_premium_1m", BestBridgeProductType.Subscription),
};
  • productId — the game’s own id. It is passed to Purchase(...) and comes back in OnPurchase, and never leaves the game.
  • sku — the id in the BestBridge catalog. Prices, titles and orders resolve from it.
Every lookup (TryFindProduct, GetProductInfo, IsNonConsumablePurchased, GetSubscriptionState, …) accepts either. When there is no separate game-side id, pass the same string twice.
Step 3: Implement the purchase listener
This is the one contract that decides whether a paid reward can be lost. Grant, persist, then return true.
public class GamePurchaseListener : IBestBridgePurchaseListener
{
    public async Task<bool> OnPurchase(BestBridgePurchaseInfo purchase)
    {
        // _wallet, _ui and _profile belong to the game, not to the SDK:
        //   _wallet.Grant           — adds the product to the player's inventory or balance
        //   _ui.PlayPurchaseCelebration — plays the "purchase successful" animation
        //   _profile.Save           — writes the player profile to disk / cloud, true when it persisted
        _wallet.Grant(purchase.Product, purchase.Quantity);   // × Quantity — multi-buy!
        if (!purchase.IsRestored)
            _ui.PlayPurchaseCelebration(purchase.Product);    // skip celebration on restores

        bool saved = await _profile.Save();
        return saved;                                         // false → SDK retries later
    }
}

Situation

Return

Granted and persisted

true

Could not persist (network or error)

false — roll back the local grant

A subsystem is not ready yet

false

Exception thrown

treated as false

The SDK commits the delivery (records the transaction, consumes the consumable) only after a true, and dedups by purchase.TransactionId, so a delivery the game already confirmed is never repeated. false: the entitlement stays in the purchase state and is retried on the next sync.
Never use IsRestored to decide whether to grant. A purchase recovered after an app kill also arrives as a restore. It exists solely to skip celebratory UI.
OnPurchase is called for fresh purchases, restores, and purchases recovered after an app kill — one call per delivery, on the Unity main thread.
Step 4: Provide popup UI
Implement IBestBridgePopupController; it is mandatory. The service creates and closes the popups at the right moments, and the game only renders them.

Interface

What it is

IBestBridgePopupController

Factory: ShowWaitPopup(bool canCancel), ShowConfirmationPopup(), ShowInfoPopup()

IBestBridgeWaitPopup

“Please wait” while the order is created and polled. Its CancellationToken cancels the purchase; Close() is called by the SDK.

IBestBridgeConfirmationPopup

RequestConfirmation (request) → confirmed + chosen quantity. Shown before any order exists.

IBestBridgeInfoPopup

ShowSuccess, ShowError, ShowRestoreResult. Every failure the SDK reports arrives at ShowError with a ready message.

The sample implementations are meant to be taken and restyled. Body copy comes from service.Messages (see §9); ShowError receives its message as an argument.
The SDK does all purchase messaging through these popups, so the game never shows a “purchase failed” window of its own — success, “still processing”, “purchases are unavailable”, “already owned”, “already subscribed”, an abandoned payment, any request failure. Good popups are the whole job. show PurchaseMessages: false in the config moves all of it to the game instead; the confirmation and wait popups still run, since those are the flow itself and not messages.
What the sample popups look like. Three windows cover the whole purchase. The screenshots below are the shipped sample on UGUI/TextMeshPro — a visual starting point, not a required look.

Confirmation popup
IBestBridgeConfirmationPopup. Shown before the order exists. Body: ConfirmPaymentPrompt; a consumable also gets a quantity selector (QuantityLabel / PurchaseTotalFormat). Proceed → the order is created and the payment page opens; cancel → CanceledByUser, no order.

Wait popup
`IBestBridgeWaitPopup`. One instance for order creation and polling. Body: `PleaseWait`. Its **Cancel** button trips the `CancellationToken`, which stops polling and ends the purchase as `CanceledByUser`. The SDK closes the popup itself.

Info / error popup
IBestBridgeInfoPopup. Every outcome that needs a message lands here. This one is a connection failure while polling (ConnectionProblem); a generic failure shows PurchaseError, and success shows PurchaseSuccess.
Step 5: Construct and initialize
IBestBridgePaymentsService service = new BestBridgePaymentsService(
    products,
    new GamePurchaseListener(),
    popupController,      // the game's IBestBridgePopupController implementation; required, non-null
    config);

BestBridgeInitializationStatus status = await service.Initialize();
if (status.IsSuccess)             Debug.Log("BestBridge ready");
else if (status.IsConnectionError) Debug.LogWarning("No connection — the SDK will retry itself");
else if (status.IsCancelled)       Debug.Log("Initialization cancelled");
else                               Debug.LogError(status.Exception);
Check IsConnectionError and IsCancelled before IsError: a connection error sets both IsConnectionError and IsError. A connection error carries no Exception.
`Initialize()` authenticates the user, loads the catalog, and syncs the purchase state to rebuild owned non-consumables and subscriptions. It is safe to call repeatedly — concurrent and repeated calls share one in-flight task. It has a 45s time budget, so a dead network fails the start instead of hanging it, and with `autoRetryFailedInitialization` (default on) a connection failure is retried in the background after 5 s / 15 s / 45 s.

Fire-and-forget variant, subscribing before the call:
service.OnInitialized += status => { if (status.IsSuccess) Debug.Log("ready"); };
service.Initialize().Forget();
A shop does not have to wait for initialization: GetProductsAsync() needs no signed-in user.
Consumables
Coins, gems, boosters — bought repeatedly, consumed once delivered.
var result = await service.Purchase("coins_small");                 // one unit
var result = await service.Purchase("coins_small", quantity: 3);    // preselect x3

// RedrawShop() is a method of the game, not of the SDK: it repaints the shop screen
// (prices, "owned" marks, currency counter) after the balance changed.
if (result.IsDelivered) RedrawShop();   // the SDK has already told the player the outcome
Multi-buy. quantity must be within 1..99, and above 1 only for consumables. For consumables the passed value is only preselected: the confirmation popup shows a quantity selector (BestBridgeConfirmationRequest.AllowQuantitySelection), and the order is created for whatever the player picks. One payment covers the whole order, and OnPurchase delivers once with that Quantitygrant × Quantity.
Purchase needs no try/catch. Payment, network and server failures come back in BestBridgePurchaseResult.Status, never as exceptions.
Consumables are exactly-once and need nothing extra. The SDK consumes a delivered consumable, after which the entitlement leaves the purchase state and can never be re-delivered.
Non-consumables
For example: “Remove ads”, a permanent unlock of any in-game item.
// MarkOwnedInShop() is a method of the game, not of the SDK: it switches the shop item
// to the "owned" look — no price, no buy button.
if (service.IsNonConsumablePurchased("remove_ads")) MarkOwnedInShop();
IsNonConsumablePurchased reflects the last synced purchase state and is always false for consumables. Buying one the player already owns returns NonConsumableAlreadyPurchased without creating an order.

A non-consumable’s payload must be idempotent by nature: a flag such as level unlocked, ads removed or sword owned. Re-delivery is not a bug but a requirement — a reinstalled player has to get the purchase back, and re-delivery is exactly how that happens. Non-consumables have no consume step, so the only record of a completed handover is the SDK’s local ledger, which a reinstall or an identity change legitimately erases.
Subscriptions
A subscription is bought with the same Purchase(productId) call. Because cancelling is the only way to stop charges, the game must expose a subscription screen where the player sees the state and can unsubscribe.

The short way — one button, one handler, no state machine:
// button, label, dateRow and warningRow are the game's own UI objects on the subscription
// screen; LabelFor() is the game's own method that turns an action kind into localized text.
var action = service.GetSubscriptionAction("premium");

button.interactable = action.IsEnabled;
label.text          = LabelFor(action.Kind);          // e.g. Subscribe → "Подписаться"
dateRow.SetActive(action.ShowsExpiration);            // render action.Data.ExpirationDate
warningRow.SetActive(action.HasPaymentProblem);

button.onClick.AddListener(async () => await service.InvokeSubscriptionActionAsync("premium"));
Kind can be Loading, Subscribe, Unsubscribe, Resume, Expiring, PaymentProblem, Switch or `Scheduled`. Use IsEnabled to enable or disable the button, not Kind. It is already false every time there is nothing to press: Loading, PaymentProblem, Scheduled, Expiring, and a Switch that the platform cannot accept yet. Because of this, the game cannot offer the product to a player who is already subscribed.

InvokeSubscriptionActionAsync runs the action the button offers and returns whether it worked. For a purchase, true means the product was delivered, so a paid but not delivered purchase returns false.

The explicit way — render from the joined state directly:

BestBridgeSubscriptionState

Meaning

What to show

Unknown

Nothing synced yet

Loading or disabled. Never “Subscribe”

NotSubscribed

No live subscription

“Subscribe” → Purchase(productId)

Active

Subscribed, auto-renew on

“Unsubscribe” → Unsubscribe(productId); optionally NextBillingDate

CanceledUntilPeriodEnd

Auto-renew already off

“Active until ExpirationDate”, not an unsubscribe button

Paused

Paused, access removed

“Resume” → ResumeSubscription(productId)

Grace

A renewal charge failed, access kept for now

Nothing required: the SDK shows its own popup

// Every Show*() below is a method of the game, not of the SDK: each one puts the subscription
// screen into one visual state (subscribe button, unsubscribe button with the next charge date,
// "active until <date>", resume button, payment-problem notice, loading spinner).
switch (service.GetSubscriptionState("premium", out var sub))
{
    case BestBridgeSubscriptionState.NotSubscribed:          ShowSubscribeButton();                      break;
    case BestBridgeSubscriptionState.Active:                 ShowUnsubscribeButton(sub.NextBillingDate); break;
    case BestBridgeSubscriptionState.CanceledUntilPeriodEnd: ShowActiveUntil(sub.ExpirationDate);        break;
    case BestBridgeSubscriptionState.Paused:                 ShowResumeButton();                         break;
    case BestBridgeSubscriptionState.Grace:                  ShowPaymentProblem(sub.ExpirationDate);     break;
    default:                                                 ShowSpinner();                              break;  // keep this
}
Keep the default: branch: the enum may report a state the screen does not handle, and the fallback has to be loading, never “Subscribe”.
Keep it fresh. Both reads are synchronous cache reads, so the screen that renders them owns a refresh call and a redraw on change:

Moment

What to do

The subscription screen or shop opens (OnEnable)

Draw from the cache at once, subscribe to OnSubscriptionsChanged, then await service.RefreshSubscriptionStatusAsync() and redraw.

Return to the foreground while such a screen is open

await service.RefreshSubscriptionStatusAsync(). It picks up an expiry, a renewal or a cancellation made on another device.

The screen closes (OnDisable / OnDestroy)

Unsubscribe from OnSubscriptionsChanged.

After Unsubscribe, ChangeSubscriptionPlan or InvokeSubscriptionActionAsync

Nothing. These re-sync themselves and raise OnSubscriptionsChanged.

// Redraw() is the game's own method: it re-reads GetSubscriptionAction / GetSubscriptionState
// and rebuilds the whole subscription screen from what it finds.
private void OnEnable()
{
    _service.OnSubscriptionsChanged += Redraw;
    Redraw();                     // cached state, drawn without waiting for the network
    RefreshAsync().Forget();      // BestBridgeTaskExtensions.Forget()
}

private void OnDisable() => _service.OnSubscriptionsChanged -= Redraw;

private async Task RefreshAsync()
{
    await _service.RefreshSubscriptionStatusAsync();
    Redraw();
}
RefreshSubscriptionStatusAsync() runs a full state sync plus a subscriptions refresh, swallows its own errors, and delivers no purchases. OnSubscriptionsChanged is raised only when the picture actually changed, and one management call can raise it more than once, so the handler has to re-read the state and rebuild the whole screen instead of applying an incremental change. Polling per frame is never needed.

Management. Unsubscribe (auto-renew off, access until period end, irreversible and idempotent), PauseSubscription (pause from the end of the period), ResumeSubscription (immediate charge and a new period, or cancelling a scheduled pause), ChangeSubscriptionPlan(current, new) within one group (an upgrade is prorated immediately, a downgrade takes effect next period). All re-sync state and raise OnSubscriptionsChanged on success.

Several plans in one subscription. Plans of one subscription group (monthly / quarterly / yearly, or bronze / silver / gold) are separate products. The platform allows only one live subscription per group, and it checks this only after the payment. A second order is paid and gives nothing.
To move a player to another plan, call ChangeSubscriptionPlan, not Purchase. The SDK blocks such a purchase itself: it returns SubscriptionPlanChangeRequired and creates no order.
GetSubscriptionAction helps to draw a plan card:
  • Switch instead of Subscribe when another plan of the group is live. CurrentPlanProductId says which plan it is.
  • Scheduled when the change to this plan is already set for the end of the period (see BestBridgeSubscriptionData.PendingProductId).
  • Switch is disabled while the live plan is paused or cancelled. The platform accepts a plan change only from a subscription that renews.
  • SwitchTiming says when the change happens: now, with a charge (the new plan costs more), or at the end of the period (the same price or cheaper).
InvokeSubscriptionActionAsync runs the right action, so one handler is enough for a plan card. To cancel a scheduled change, call ChangeSubscriptionPlan and pass the current plan as both arguments. ChangeSubscriptionPlan shows no popups, so the screen shows its own progress and errors.

Renewal problems are reported by the SDK.
OnPurchase fires once per subscription lifetime (deduped by subscription id). Renewals are silent.

Buying a subscription the player already has returns AlreadyPurchased and creates no order. This is true for every live status: auto-renew off, paused, and grace. The platform allows only one live subscription per group and checks it only after the payment, so a second order would be paid and give nothing. The plan can be bought again only after it expires. Before that, a paused subscription comes back with ResumeSubscription, and a subscription in grace needs a working payment method.
Prices and the shop
IReadOnlyList<BestBridgeProductInfo> catalog = await service.GetProductsAsync();  // no auth needed
This seeds an internal price cache, which is afterwards read synchronously by productId or sku:
BestBridgeProductInfo info = service.GetProductInfo("coins_small");
string price = service.GetLocalizedItemPrice("coins_small");   // "" if not cached yet
GetProductInfo returns a default struct on a miss; TryGetProductInfo separates “not loaded / unknown id” from a genuine zero price. BestBridgeProductInfo carries Title, LocalizedPrice, Sku, Type, AmountMinorUnits and Price. Subscription products add BillingPeriodUnit, BillingPeriodCount and SubscriptionGroup for rendering “N ₽ / period”.

AmountMinorUnits is the price as an integer in the currency’s minor units — its smallest whole denomination, kopecks for RUB and cents for USD, so 199 ₽ is 19900.

Price is the same amount as a decimal (AmountMinorUnits / 100), and LocalizedPrice is the ready display string.
Offline start. When the app starts with no connection the price cache stays empty. RefreshCatalogAsync() belongs wherever the shop opens or prices are shown: it needs no auth, swallows its own errors, and succeeds the moment connectivity returns. Prices then have to be re-read, since the getters are synchronous cache reads.
Restore and recovery
Restore belongs on a “Restore purchases” button. It re-reads the purchase state and re-grants everything owned through OnPurchase:
await service.RestorePurchases(showPopupOnResult: true);
Recovery runs itself. With autoRecoverPurchases (default true) the SDK delivers purchases that settled while the app was gone: once after a successful Initialize() and again on every return to the foreground.

Automatic and manual runs are deduplicated, so an extra call is harmless:
await service.TryProcessUnfinishedPurchaseAsync();   // true if at least one product was delivered
Support: show the user id
// settingsUserIdLabel is the game's own UI label on the settings screen, not part of the SDK.
settingsUserIdLabel.text = $"User ID: {service.UserId}";   // add a copy button next to it
This is required. A player writing to support about a purchase quotes this id, and the team finds the account and its orders by it. It is populated on Initialize(), refreshed on every state sync, and persisted locally. It is empty until the first successful sync, which the UI has to tolerate. The same id is attached to the SDK error diagnostics (BestBridgeException.UserId), so a support screenshot of an error popup already carries it.

One way to surface it — the id on a popup the player can reach and copy:
Texts and localization
Every player-facing string lives behind IBestBridgeMessages. The defaults are ready Russian strings (BestBridgeMessagesDefaults). To change a few, derive and override:
public sealed class MyTexts : BestBridgeMessagesDefaults
{
    public override string PurchaseSuccess   => "Спасибо за покупку!";
    public override string ConnectionProblem => "Нет соединения";
}

var service = new BestBridgePaymentsService(products, listener, popupController, config, new MyTexts());
The SDK hands the error text to ShowError directly and exposes the whole set as service.Messages for the popups to read. Assign that instance once (the sample does it through SamplePopupController.Messages) so every window renders from one source.
Never display BestBridgePurchaseResult.ErrorText. It is raw English server diagnostic text, meant for logs.
Checklist before release
  • BestBridge is gated on RU currency; other regions use the native store.
  • OnPurchase persists before returning true, grants × Quantity, and never uses IsRestored to decide whether to grant.
  • Non-consumable payloads are idempotent; any one-time bonus on a non-consumable is marked on the project’s own server or in cloud save, keyed by UserId.
  • Purchase outcomes read result.IsDelivered / IsPaidDeliveryPending / IsFailure rather than the raw enum, and the game shows no purchase messages of its own (the SDK does that).
  • RefreshCatalogAsync() on shop open, with prices re-read afterwards.
  • A subscription screen exists with an unsubscribe path, refreshed via RefreshSubscriptionStatusAsync() + OnSubscriptionsChanged.
  • UserId is visible somewhere the player can reach.
  • The production build uses the production API key (isDevBuild: false), no leftover environment override, and no leftover BestBridgeLog.ForceLogging(true) (a release build otherwise logs ids in full).
  • Tested: buy, cancel in the browser, kill the app mid-payment then relaunch, restore, launch offline then go online.
Frequently Asked Questions
Does TryProcessUnfinishedPurchaseAsync()
have to be called manually?
No. It runs automatically after init and on every foreground return. An explicit call is only for a moment the SDK cannot know about, and extra calls are deduplicated.
The player paid and Purchase
returned PaidDeliveryPending. Is the money lost?
No. The entitlement is in the purchase state and is delivered by the next recovery sync. The product must not be granted and no success screen shown — the SDK has already told the player that the payment is still processing. This status is reached when the follow-up state read failed, when OnPurchase deferred, or when the granted SKU is not registered in this build.
Does the game need its own “purchase failed” message?
No. The SDK messages every outcome that needs one, so a second window would land on top of it. The cases it stays quiet about need nothing: the player closed the window, buy was tapped twice while the wait popup was up, or the product id is not registered in the build (a bug for the log, not for the player). showPurchaseMessages: false hands all of it to the game for a different look.
Does purchase code need a try/catch?
No. Payment, network and server failures arrive in BestBridgePurchaseResult.Status, not as exceptions. An exception out of Purchase means an integration bug: an invalid quantity, an invalid quantity returned by the confirmation popup, or an exception thrown inside a popup implementation. A player cannot trigger those, and they are fixed at the call site rather than caught.
Can a player be charged twice, or get an entitlement twice?
No. The SDK makes order creation idempotent, so a retry never charges twice, and the purchase state is a statement of ownership, so reading it any number of times yields the same entitlements.
Is returning false from OnPurchase safe?
Yes, always. The SDK does not commit, the purchase stays in the purchase state, and it is redelivered on the next recovery or restore. Returning true before the reward is persisted is the one unsafe move.
A purchase returned Error
with “still processing” after ~60 s. What now?
Nothing. Polling gave up in the foreground; the order lives on outside the app and is delivered whenever it settles. The player has already seen the “still processing” popup.
Two purchases at once?
Not possible. A re-entrant Purchase while one is in flight returns Error (“Purchase already in progress”) immediately.
Does the SDK open an in-app browser?
No. SDK opens the payment page in the external system browser, automatically, right after the order is created. The wait popup stays up and re-polls the moment the app regains focus.
Can a shop render before initialization finishes?
Yes. GetProductsAsync() / RefreshCatalogAsync() need no signed-in user, so a shop can render immediately, and Purchase(...) auto-initializes when needed.
Why does GetSubscriptionState return Unknown right after Initialize() returned success?
IsInitialized is deliberately set before the first state and subscriptions round-trips complete. Unknown means “no sync has landed yet” — render loading, never “Subscribe”, and redraw on OnSubscriptionsChanged.
Is there an OnPurchase call for each subscription renewal?
No. Once per subscription lifetime, deduped by subscription id. Renewals are silent; access is read from IsSubscriptionActive / GetSubscriptionState.
A player reinstalled and lost a non-consumable.
That cannot happen while the identity is stable: the purchase state does not live on the device, and the auto-restore after a fresh install re-delivers everything owned. If it did happen, the identity changed — check what is passed as identities (a device-id-only identity on a platform without one is storage-bound).
Nothing works and the log is nearly empty.
A release build keeps critical logs only and strips them down to the failing operation, with no ids and no exception text. Build with isDevBuild: true and logLevel: BestBridgeLogLevel.Info, or call BestBridgeLog.ForceLogging(true) to get full logs out of a release build.
Let's Talk!
Address: IFZA Business Park, Dubai Silicon Oasis, Dubai