Skip to content

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.

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.

  1. Your app reads the catalog: GET /purchase/{issuerClientId}/offerings. No token.
  2. The buyer picks an offering. Your app can load the detail: GET /purchase/{issuerClientId}/offerings/{offeringId}. No token.
  3. The buyer presses Buy. Your app signs them in (below) and receives an access token for the Monaiq licensing resource.
  4. Your app creates the checkout: POST /purchase/checkout with Authorization: Bearer <token>.
    • Free or zero-dollar: the response has a SessionId and no SessionUrl. The license exists.
    • Paid: the response has a SessionUrl. Open it in the system browser; the buyer pays on Stripe Checkout.
  5. Your app polls GET /purchase/checkout/{sessionId} with the same bearer until the status is completed (the response carries the encoded credential) or failed (it carries the reason).
  6. 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.

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.

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.

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:

  1. POST …/oauth2/v2.0/initiate with client_id, username and challenge_type=oob redirect.
  2. POST …/oauth2/v2.0/challenge with the continuation token; the code is emailed to the buyer.
  3. POST …/oauth2/v2.0/token with grant_type=oob, oob=<code>, the continuation token, and scope=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.

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.

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 purchase client ships in the same package as the runtime SDK.

Terminal window
dotnet add package Sidub.Licensing.Client

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:

  • PurchaseServiceUri is the purchase API. Required; the app fails at startup, naming the option, if it is unset or relative.
  • AccessTokenProvider is how your sign-in plugs in. It is a Func<CancellationToken, Task<string>> that returns the buyer’s access token: from MSAL’s AcquireTokenSilent on 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 throws InvalidOperationException naming the option before any request is made.

The client never sends a dub-apiKey. There is no way to give it one.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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 its client_id.
  • <monaiq-licensing audience>: the Monaiq licensing resource, api://<monaiq-licensing appId>, whose checkout.purchase scope 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.