In-app purchase
This page is for whoever builds the purchase into an app you ship. It 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 store 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 that 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. 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.
Buy several seats
Section titled “Buy several seats”Quantity says how many seats the purchase is for: a whole number from 1 to 100. Omit it and
the purchase is for one seat, which is what every request that predates the field means.
var session = await purchase.CreateCheckoutAsync(new PurchaseCheckoutRequest{ RequestId = Guid.NewGuid(), OfferingId = offering.Id, IssuerClientId = issuerClientId, Quantity = 25}, ct);Monaiq issues one license with a seat for each, under one subscription, every seat with its own
credentials and its own allowance, sharing the license’s features. A purchase of more than one seat
returns no credential: the completed result carries the license’s LicenseId and its seats’
SeatIds, EncodedCredential is null, and your backend lists the seats and issues one credential
per seat as it is put to use. An app that holds one buyer’s own seat is the one-seat case, which
returns the credential as before. Anything outside the range is refused by the client with
ArgumentException before the request leaves it.
A retry is the same purchase only when it asks for the same number of seats. The same RequestId
with a different Quantity is refused with RequestIdInUse, the same answer a request id already
used by another buyer gets.
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 seat the buyer just bought: it authorizes and meters that one seat and reaches nothing else, and the buyer can revoke it from their seat 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, and the anonymous reads are limited to 60 requests a minute per caller address.
{ "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, "AllowsMultiple": true, "AddOn": false } ] }}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. AllowsMultiple says
whether a license of the offering may hold more than one seat: when it is false (a free plan or a
trial, a plan with no base rate the seller did not tick, or a paid offering the seller set to one
seat), show no seat control, and expect 400 CheckoutFailed
for a quantity above one. A buyer who holds a live license of the offering cannot buy it again
whatever the setting; they add seats to the license they have (see the refusals below).
AllowsMultiple and AddOn are always true or false, never null. AddOn is true when the
offering is held alongside a plan rather than instead of one. TrialDays is the trial length in
days (1 to 90) and is present on a trial offering only; every Trial carries one. 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, "Allowance": null, "HasLimit": false, "Limit": null }, { "FeatureKey": "documents", "DisplayName": "Documents", "Description": "…", "Rate": 0, "SampleSeconds": null, "RateLimit": null, "ServiceAccessLevel": null, "Allowance": 500, "HasLimit": false, "Limit": null }, { "FeatureKey": "projects", "DisplayName": "Projects", "Description": "…", "Rate": 0, "SampleSeconds": null, "RateLimit": null, "ServiceAccessLevel": null, "Allowance": null, "HasLimit": true, "Limit": 10 } ]}ServiceAccessLevel is the enum name (Allowed, Denied) or null; SampleSeconds,
RateLimit, Allowance (uses per billing period, on an allowance; 0 is no cap) and Limit are
numbers or null. HasLimit is true on a limit only, so a limit whose Limit is null
(unlimited) is told apart from a feature that is not a limit. 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", "Quantity": 25}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. Quantity is optional, a whole number from 1
to 100, and means one seat when absent; the checkout issues one license with that many seats.
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), CorrelationId is over 128 characters, or Quantity is outside 1 to 100. |
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, for another seller, or for a different number of seats. |
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.
A buyer holds one plan of a product at a time, a license of an offering once, and a free plan or
trial once per account. Checkout refuses what would break that with CheckoutFailed, and
Message says which rule applied:
| Refusal | Meaning |
|---|---|
AlreadyHeld |
The buyer already holds a live license of this offering. “You already have {offering}. Add seats from your license instead.” where the offering allows more than one seat, otherwise “You already have {offering}. Open it from your licenses.” Send them to their Monaiq license page. |
AlreadyUsed |
A free plan or trial the buyer has had before: “You’ve already had {offering}. Free and trial plans are once per account.” |
PlanHeld |
The buyer holds another paid plan of the same product: “You already have {plan} of {product}. Change plan from your license instead.” The buyer changes plan from their Monaiq license page. |
TrialWhileOnPlan |
A free plan or trial requested while the buyer holds a paid plan of the product: “You already have {plan} of {product}. A free plan or trial can’t run alongside it.” |
A buyer on a free plan or a trial who buys a paid plan of the product is not refused: the paid
license replaces the trial, which ends when the paid license starts. Add-ons (AddOn true) sit
outside the plan rule, so a buyer can hold a plan and its add-ons. When the request carries a
CorrelationId, these rules look only at the buyer’s licenses with that same CorrelationId;
without one they look at licenses that have none. That keeps one buyer’s several tenants apart.
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, "SeatIds": 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, LicenseId (the license), SeatIds
(its live seats, oldest first), OfferingId and your CorrelationId are present, and
EncodedCredential is present for a purchase of one seat. 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 Connect your app 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.