In-app purchase
This page shows you how an application you distribute — a desktop app, a mobile app, a command-line tool — sells your offerings from inside itself. It covers the flow, the sign-in choice, the .NET client that does the work, and the raw HTTP contract underneath it for any other language.
The short version: the buyer is the credential. Your app signs the buyer in with their Monaiq identity and purchases with the buyer’s bearer token. Nothing of yours ships with the app — no API key, no secret — so there is nothing to extract from the binary.
What it is
Section titled “What it is”The purchase API lives at https://api.monaiq.com/purchase (UAT: https://api.uat.monaiq.com/purchase).
It has four operations, and two of them need no credential at all:
- Two anonymous reads return your storefront — the products and offerings that are live for checkout right now, exactly as your hosted storefront page shows them. Your app reads the catalog live, every time; offering ids baked into a build go stale the day you change a price.
- Two buyer-bearer operations create a checkout as the signed-in buyer and read its result. Only the buyer who made the checkout can read it.
A free or zero-dollar offering completes entirely in-app: the license is minted on the spot. A paid offering returns a Stripe Checkout URL, which your app opens in the system browser. Either way your app then polls the result for the credential. It never depends on a redirect landing anywhere it controls.
The buyer’s Monaiq account is created on their first purchase, inside the sign-in. There is no separate sign-up, and no email address for your app to collect.
This is the path for software you ship. If your product is a web backend that already holds secrets server-side, the key-authenticated embedded checkout in Deliver to your customers is the simpler fit.
The flow
Section titled “The flow”- Your app reads the catalog:
GET /purchase/{issuerClientId}/offerings. No token. - The buyer picks an offering. Your app can load the detail:
GET /purchase/{issuerClientId}/offerings/{offeringId}. No token. - The buyer presses Buy. Your app signs them in (below) and receives an access token for the Monaiq licensing resource.
- Your app creates the checkout:
POST /purchase/checkoutwithAuthorization: Bearer <token>.- Free or zero-dollar: the response has a
SessionIdand noSessionUrl. The license exists. - Paid: the response has a
SessionUrl. Open it in the system browser; the buyer pays on Stripe Checkout.
- Free or zero-dollar: the response has a
- Your app polls
GET /purchase/checkout/{sessionId}with the same bearer until the status iscompleted(the response carries the encoded credential) orfailed(it carries the reason). - Your app stores the credential where the runtime SDK reads it, and the licensing calls you already make now run against the purchased license.
Your seller id — the issuerClientId in those URLs — is the same public identifier your hosted
storefront uses, https://monaiq.com/marketplace/{issuerClientId}. It is safe to embed. It names
you as the seller; it proves nothing, because the buyer pays for what they buy.
Choosing a sign-in
Section titled “Choosing a sign-in”The buyer signs in against Monaiq’s identity directory (Microsoft Entra External ID). Your app is
a public client — it holds no secret — and it asks for one scope, checkout.purchase, on the Monaiq
licensing resource. There are two ways to run the sign-in, and the platform recommends one per
form factor.
Desktop: system-browser PKCE
Section titled “Desktop: system-browser PKCE”Use MSAL (Microsoft.Identity.Client) as a public client with the authorization-code + PKCE flow
in the system browser.
- Authority:
https://<tenant>.ciamlogin.com/<tenantId>/v2.0 - Client id:
<monaiq-app-client id> - Redirect URI:
http://localhost— the loopback redirect. Entra ignores the port and matches the path, so MSAL can pick any free port. - Scopes:
api://<monaiq-licensing audience>/checkout.purchase,openid,offline_access
The sign-in page is Microsoft’s, in the buyer’s own browser. Your app never sees a password or a code; it receives a token, and MSAL’s cache refreshes it silently afterwards.
Mobile: native authentication
Section titled “Mobile: native authentication”Use Entra External ID native authentication. Your app renders the sign-in itself — an email field, then a one-time code — and there is no redirect URI at all. The REST sequence is three calls against the same authority:
POST …/oauth2/v2.0/initiatewithclient_id,usernameandchallenge_type=oob redirect.POST …/oauth2/v2.0/challengewith the continuation token — the code is emailed to the buyer.POST …/oauth2/v2.0/tokenwithgrant_type=oob,oob=<code>, the continuation token, andscope=api://<monaiq-licensing audience>/checkout.purchase openid offline_access.
The token response is a standard access token plus a refresh token; keep them in the platform’s secure store.
The trade-off, plainly
Section titled “The trade-off, plainly”Browser PKCE keeps the sign-in on Microsoft’s page: the buyer types their email and code into a page your app does not control, which is what makes phishing hard. Native authentication puts the sign-in UI inside your app, which is a better experience on a phone and a bigger responsibility — Microsoft’s own guidance is that “with this control comes the responsibility to follow security best practices”. Recommend PKCE on desktop, native authentication on mobile.
Browser SPAs
Section titled “Browser SPAs”A single-page web app cannot use this path in phase one. A third-party SPA would need to register
its own callback on Monaiq’s public client, and it cannot. Link the buyer to your hosted storefront,
https://monaiq.com/marketplace/{issuerClientId}, instead — or, if you have a backend, use the
key-authenticated embedded checkout from it.
The .NET client
Section titled “The .NET client”The purchase client ships in the same package as the runtime SDK.
dotnet add package Sidub.Licensing.ClientRegister it
Section titled “Register it”AddSidubLicensingPurchaseClient binds PurchaseClientOptions and registers IPurchaseClient
as a typed IHttpClientFactory client. It is independent of AddSidubLicensing(): register the
purchase client to buy, the runtime SDK to enforce what was bought.
using Sidub.Licensing.Client;using Sidub.Licensing.Client.Purchase;
services.AddSidubLicensingPurchaseClient(options =>{ options.PurchaseServiceUri = "https://api.monaiq.com/purchase"; options.AccessTokenProvider = ct => signIn.AcquireAccessTokenAsync(ct);});Two options:
PurchaseServiceUriis the purchase API. Required; the app fails at startup, naming the option, if it is unset or relative.AccessTokenProvideris how your sign-in plugs in. It is aFunc<CancellationToken, Task<string>>that returns the buyer’s access token — from MSAL’sAcquireTokenSilenton desktop, from the native-auth token response on mobile. The client calls it once per checkout operation, so a silently refreshed token is used without any extra plumbing. The catalog reads never call it. Leave it unset in an app that only browses; a checkout operation without it throwsInvalidOperationExceptionnaming the option before any request is made.
The client never sends a dub-apiKey. There is no way to give it one.
Read the catalog
Section titled “Read the catalog”var catalog = await purchase.ListOfferingsAsync(issuerClientId, ct);
foreach (var product in catalog.Products){ foreach (var offering in catalog.OfferingsFor(product.Id)) { // offering.Name, offering.Description, offering.BaseRate, offering.Currency, // offering.Interval, offering.IntervalUnit, offering.LicenseClassification, // offering.IsFreeOrZeroDollar — and offering.Raw for anything else the API rendered. }}
var detail = await purchase.GetOfferingAsync(issuerClientId, offeringId, ct);// detail.Offering, detail.Product, detail.ResolvedFeatures (feature key, display name, rate, limits)ListOfferingsAsync returns a PurchaseOfferingList; GetOfferingAsync a PurchaseOfferingDetail.
Both lift the fields an app renders into typed properties and keep the wire JSON reachable as
Raw. IsFreeOrZeroDollar is the server’s own predicate, so your UI can say “Start trial” instead
of “Buy” before the checkout is created. Neither view carries a seller Code — the catalog the
purchase API publishes is a buyer view and a seller’s internal codes are not published — so
render Name.
Buy a free offering
Section titled “Buy a free offering”var session = await purchase.CreateCheckoutAsync(new PurchaseCheckoutRequest{ RequestId = Guid.NewGuid(), // the idempotency key — keep it, reuse it on retry OfferingId = offering.Id, IssuerClientId = issuerClientId, CorrelationId = currentUserId // optional, up to 128 characters, echoed on the result}, ct);
// session.SessionUrl is null: the license is already minted.var result = await purchase.PollResultAsync(session.SessionId, cancellationToken: ct);// result.Status == CheckoutSessionStatus.Completed; result.EncodedCredential is the credential.RequestId matters. Generate it once per purchase attempt and send the same value on every retry:
a repeated request from the same buyer for the same seller returns the checkout that already exists
instead of minting a second one. A request id already used by a different buyer or seller is
refused with RequestIdInUse.
Buy a paid offering
Section titled “Buy a paid offering”var session = await purchase.CreateCheckoutAsync(new PurchaseCheckoutRequest{ RequestId = Guid.NewGuid(), OfferingId = offering.Id, IssuerClientId = issuerClientId, OriginContext = PurchaseOriginContext.MobileApp // from a mobile app; omit on desktop}, ct);
if (session.SessionUrl is not null){ // Open Stripe Checkout in the SYSTEM browser — never an embedded web view. Process.Start(new ProcessStartInfo(session.SessionUrl) { UseShellExecute = true }); // MAUI: await Launcher.OpenAsync(session.SessionUrl);}
var result = await purchase.PollResultAsync(session.SessionId, cancellationToken: ct);PollResultAsync reads the result until its status is no longer pending. It waits one second after
the first pending read, doubles each time up to a fifteen-second cap, makes at most sixty reads by
default, and honours the server’s Retry-After hint when it sends one. Pass a CancellationToken
tied to the purchase screen so leaving it stops the poll. If the result is still pending after the
last read it throws LicensingApiException with Code CheckoutResultTimeout — tell the buyer to
keep the app open or come back later, and poll again with the same SessionId when they do. The
result is durable server-side. PurchasePollOptions adjusts the bounds.
OriginContext is Stripe’s own vocabulary for where the buyer is when they pay: web or
mobile_app. Set mobile_app from a mobile app so the hosted page is optimised for an in-app
purchase; anything else is refused.
SuccessUrl and CancelUrl are optional. Omit them and the buyer lands on your hosted success
page after paying, which tells them to return to the app. Your app is polling, so it does not
need the redirect. If you do set them, they must be absolute http or https URLs.
Errors
Section titled “Errors”Every non-2xx answer is a LicensingApiException carrying the server’s Code, the
CorrelationId to quote to support, the HTTP status and any Retry-After. Branch on Code:
NotFound (the offering is not listed for that seller, or the session is unknown),
RequestIdInUse, CheckoutFailed, BuyerTokenMissing and BuyerTokenInvalid (401 — sign the
buyer in again), CheckoutResultForbidden (403 — the session belongs to another buyer). Bad input
never reaches the wire: the client validates the request to the server’s own rules and throws
ArgumentException naming the field.
Store the credential
Section titled “Store the credential”The EncodedCredential on a completed result is the same SIDUB_LIC_… string the runtime SDK
takes as LicensingServiceOptions.EncodedCredential. Store it in the platform’s secure store —
SecureStorage on MAUI, DPAPI or the Credential Manager on Windows, the Keychain on macOS and iOS —
and read it back from your ILicensingContextProvider. It is a secret; see
Credentials and keys for why.
What it carries is a runtime token minted for the license the buyer just bought: it authorizes and meters that one license and reaches nothing else, and the buyer can revoke it from their license page. It is not an account key, and there is no key of yours anywhere in this flow. Your application never decodes it, never inspects the token inside it, and never has to — configure it and the SDK does the rest.
The raw HTTP contract
Section titled “The raw HTTP contract”Everything above is four HTTP operations, so any language can integrate. Envelopes are JSON with
PascalCase keys. Every response carries an X-Correlation-Id header; failures use the platform’s
ApiError shape described in the HTTP API reference.
GET /purchase/{issuerClientId}/offerings
Section titled “GET /purchase/{issuerClientId}/offerings”Anonymous. Returns the seller’s storefront. Answers are cached for ninety seconds.
{ "ClientId": "…", "IsCheckoutReady": true, "Products": [ { "Id": "…", "ClientId": "…", "Name": "…", "Description": "…", "Status": 1, "Platform": 1 } ], "OfferingsByProduct": { "<productId>": [ { "Id": "…", "ProductId": "…", "ClientId": "…", "Name": "…", "Description": null, "Status": 2, "LicenseClassification": 1, "Category": 1, "BaseRate": 29.00, "Currency": "usd", "Interval": 1, "IntervalUnit": 3 } ] }}The products and offerings here are purpose-built buyer views, not the seller-side entities: the
fields listed above are the whole of what is rendered, keys are PascalCase, and enumerations are
written as their numbers (LicenseClassification: 0 trial, 1 subscription, 2 perpetual). In
particular there is no Code and no Features array on either view — a seller’s internal codes
are not published, and an offering’s features come from the single-offering read below.
Description, BaseRate, Interval and IntervalUnit may be null. IsCheckoutReady says
whether the seller can take a paid checkout right now; a storefront that is not ready still lists
its catalog, and free offerings still complete. An unknown seller is 404 NotFound.
GET /purchase/{issuerClientId}/offerings/{offeringId}
Section titled “GET /purchase/{issuerClientId}/offerings/{offeringId}”Anonymous. Returns one offering’s checkout review.
{ "Offering": { …the offering entity… }, "Product": { …the product entity… }, "IssuerClientId": "…", "IsCheckoutReady": true, "ResolvedFeatures": [ { "FeatureKey": "reports.generate", "DisplayName": "Generate reports", "Description": "…", "Rate": 0.01, "SampleSeconds": 60, "RateLimit": 100, "ServiceAccessLevel": null } ]}ServiceAccessLevel is the enum name (Allowed, Denied) or null; SampleSeconds and
RateLimit are numbers or null. An offering that is not listed for that seller is 404 NotFound.
POST /purchase/checkout
Section titled “POST /purchase/checkout”Authorization: Bearer <buyer token>. Creates a checkout as the signed-in buyer.
{ "RequestId": "…", "OfferingId": "…", "IssuerClientId": "…", "CorrelationId": "user-42", "SuccessUrl": "https://…", "CancelUrl": "https://…", "OriginContext": "mobile_app"}RequestId, OfferingId and IssuerClientId are required. CorrelationId is optional, at most
128 characters. SuccessUrl and CancelUrl are optional, absolute http(s) when present.
OriginContext is optional, web or mobile_app.
The response is { "SessionId": "…", "SessionUrl": "https://checkout.stripe.com/…" }, with
SessionUrl null for a free or zero-dollar offering.
| Code | HTTP status | Meaning |
|---|---|---|
InvalidPayload |
400 | The body could not be read. |
ValidationError |
400 | A required field is missing, OriginContext is not web/mobile_app, a URL is not absolute http(s), or CorrelationId is over 128 characters. |
CheckoutFailed |
400 | The checkout could not proceed; Message states why. |
BuyerTokenMissing |
401 | No bearer on the request. |
BuyerTokenInvalid |
401 | The bearer did not validate. Sign the buyer in again. |
BuyerLinkFailed |
403 | The buyer could not be resolved to an account. |
NotFound |
404 | The offering is not listed for that seller. |
RequestIdInUse |
409 | The RequestId already belongs to a checkout by another buyer or for another seller. |
ServerError |
500 | Retry later; quote the correlation id if it persists. |
A repeated request — same RequestId, same buyer, same seller — returns the existing checkout
with status 200.
GET /purchase/checkout/{sessionId}
Section titled “GET /purchase/checkout/{sessionId}”Authorization: Bearer <buyer token>. Returns the result, only to the buyer who made the checkout.
{ "Status": "pending", "CorrelationId": null, "EncodedCredential": null, "LicenseId": null, "OfferingId": null, "Error": null }Status is pending, completed or failed. While pending the response carries Retry-After: 3;
poll again after that many seconds. When completed, EncodedCredential, LicenseId, OfferingId
and your CorrelationId are present. When failed, Error carries the reason.
| Code | HTTP status | Meaning |
|---|---|---|
BuyerTokenMissing / BuyerTokenInvalid |
401 | As above. |
CheckoutResultForbidden |
403 | The session exists but belongs to another buyer. |
NotFound |
404 | No such session. |
Where to find the identifiers
Section titled “Where to find the identifiers”Two identifiers are yours to fill in, and neither is a secret:
<monaiq-app-client id>— Monaiq’s public client,monaiq-app-client, which your app presents as itsclient_id.<monaiq-licensing audience>— the Monaiq licensing resource,api://<monaiq-licensing appId>, whosecheckout.purchasescope your app requests.
Both are per-environment values, published in two places: your Get connected page in the portal, under “Sell from inside your app”, shows the live values for the environment you are signed in to (with the sign-in authority and the scope spelled out ready to paste); and the Endpoints reference lists them per environment. Production and UAT have different ones, and the authority is the Entra External ID tenant for that environment. Do not guess them — an app presenting the wrong client id gets a sign-in error, not a purchase.
Your own issuerClientId is on your seller storefront URL and on the
Credentials page. It is public.
- Credentials and keys for what the buyer receives and why it is a secret.
- .NET quickstart for enforcing the license your app just sold.
- HTTP API reference for the
ApiErrorshape and correlation ids.