HTTP API
This page is for developers who call Monaiq without an SDK, or need to know exactly what a call returned. It documents the HTTP surface an integrator can call directly. The SDKs make these calls for you, so most integrations never need it, until a request fails and you want to know exactly what came back. Base URLs are in Endpoints.
Authentication
Section titled “Authentication”There are two credentials and two sets of paths, and each credential reaches only its own.
The account API key rides in the dub-apiKey header. It is your server-side identity as a
seller, and it reaches the checkout operations and the management surfaces:
POST /licensing/checkout/embedded HTTP/1.1Host: api.monaiq.comContent-Type: application/jsondub-apiKey: <your API key>A runtime token (the RuntimeToken inside a SIDUB_LIC_ credential) rides as a bearer and
reaches the runtime paths under /licensing/runtime and /consumption/runtime. Those are the
paths a distributed application calls:
POST /licensing/runtime/GenerateLicenseAuthorization HTTP/1.1Host: api.monaiq.comContent-Type: application/jsonAuthorization: Bearer <RuntimeToken>The runtime paths take no dub-apiKey, no Ocp-Apim-Subscription-Key and no subscription at all;
the gateway strips both key headers before the request reaches the backend. A runtime credential
carries no account authority, and the account key reaches no runtime path.
Headers are the one documented way to present either value. Never put a credential in a URL: URLs
land in access logs, proxies and browser history. Where each comes from, and the encoded
SIDUB_LIC_ credential the SDKs use, are covered in
Credentials and keys.
Correlation ids
Section titled “Correlation ids”Every response carries an X-Correlation-Id header. Send your own x-correlation-id request
header and the platform adopts your value, so a failing call stitches into your own traces;
otherwise one is generated. The same value appears in the error body below; quote it when you
raise a support request.
Failures: the ApiError shape
Section titled “Failures: the ApiError shape”Every endpoint returns one JSON shape on failure. This is the product-wide contract across the platform’s HTTP surface:
{ "Code": "ValidationError", "Message": "RequestId, OfferingId, CorrelationId, and CustomerEmail are required.", "CorrelationId": "0f8fad5bd9cb469fa16570867728950e", "Details": [ { "Code": "ValidationError", "Message": "CustomerEmail is required.", "Field": "CustomerEmail" } ]}Codeis machine-readable and PascalCase. Branch on it, never onMessage.Messageis for humans; its wording may change without notice.CorrelationIdrepeats theX-Correlation-Idheader value.Detailsis optional: per-field entries on validation failures, the denial reason onAuthorizationDenied, and the period end onAllowanceExhausted.
| Code | HTTP status | Meaning |
|---|---|---|
ApiKeyMissing |
401 | No dub-apiKey header on the request. |
ApiKeyInvalid |
403 | The key does not resolve to an account. |
LicenseNotActive |
409 | The license is paused, suspended or cancelled, so its seats cannot move. |
SeatsNotSupported |
409 | This license’s seats cannot be changed by this call: a Monaiq plan, a plan sold one seat at a time, or a one-time purchase. On a one-time license the Message reads that the buyer adds seats to a one-time license from their Monaiq license page, because your server cannot pay for them. |
InvalidFilter |
400 | A filter the route needs is missing, or one it was given is not a shape it serves, is one of two sent at once, or is not a valid value. |
InvalidPaging |
400 | top is not a whole number from 1 to 500, or continuationToken is not one this list issued, was issued for other filters, or no longer points at a row. Restart without the token. See Paging. |
LastSeat |
409 | The seat is the license’s last live seat. Cancel the license instead of retiring it. |
SeatRetired |
409 | The seat is retired, so no license code is issued for it. |
InvalidState |
409 | The lifecycle change does not fit the license’s state: a cancelled license is neither suspended nor resumed. |
InvalidReason |
400 | A suspend or cancel named a Reason that is not one of that route’s codes. Message lists them. |
NoteRequired |
400 | A suspend or cancel gave Other as its Reason with no Note. Nothing was changed. |
InvalidRefund |
400 | A cancel named a Refund that is not one of that route’s words. Message lists them. |
RefundNotOffered |
409 | The license no longer offers the Refund the cancel asked for. Message says why; nothing was changed. |
NotRefundable |
409 | A seat’s cancel asked for a refund the license cannot give: no payment holds the seat’s price, or the processor credits it instead. Nothing was retired. |
ConcurrencyConflict |
409 | Another request changed the license or seat while this one was being made. Read it again and retry; repeating the request is safe. |
SubscriptionUpdateFailed |
502 | The payment processor did not change the subscription, so nothing was changed. |
CredentialSurfaceUnavailable |
503 | Runtime credentials cannot be issued right now. Retry, and contact support if it persists. |
RuntimeTokenMissing |
401 | A runtime path was called with no bearer. |
RuntimeTokenInvalid |
401 | The bearer did not validate: signature, issuer, audience or scope. |
CredentialUnknown |
403 | The token is genuine, but the credential it names is not known to this license issuer. |
CredentialRevoked |
403 | The credential was revoked. Issue a new one. |
IssuerMismatch |
403 | The body asserts an issuer that is not the key’s account. |
AuthorizationDenied |
403 | The seat exists but authorization is refused. Details[0].Code is the reason: Expired, LicenseSuspended, LicenseCancelled, SeatRetired or ProductMismatch (the credential was not issued for the seller or product the application declares). |
AllowanceExhausted |
403 | The seat’s allowance has no units left to grant in the current billing period. Details[1].Message is when the period resets. |
FeatureUnknown |
404 | The license has no allowance feature with that key. |
NotFound |
404 | Nothing here for that identifier. |
InvalidPayload |
400 | The request body could not be read at all. |
PayloadTooLarge |
413 | The request body is larger than the route reads: 64 KB on the license, checkout and runtime routes, 4 KB on the seat, credential and lifecycle routes. Nothing is changed. |
ValidationError |
400 | The body was read but a field fails validation; see Details. |
CheckoutFailed |
400 | The checkout could not proceed; Message states why. |
RequestIdInUse |
409 | The RequestId was already used for something else: a checkout by another buyer or seller, a seat add on this license with a different Count, or a different allowance adjustment of the seat. |
SeatRetired |
409 | The seat is retired, so its allowance cannot be adjusted. |
RequestInProgress |
409 | An earlier call with the same RequestId is still completing. Repeat the call in a moment and it answers what that call did. |
LedgerBusy |
429 | The seat’s allowance ledger was written by other runtimes on every attempt. Retry the same request, with the same RequestId, after Retry-After; the SDKs do. |
StoreUnavailable |
503 | The platform’s store is still connecting. Nothing was changed; retry after Retry-After (5 seconds). The SDKs do. |
ServerError |
500 | An unexpected platform failure. Retry later, and quote the correlation id if it persists. |
A bearer the gateway itself refuses never reaches the backend, so it answers with the gateway’s own
401 and a WWW-Authenticate: Bearer realm="monaiq-runtime", scope="license.runtime" header rather
than an ApiError body.
Embedded checkout
Section titled “Embedded checkout”POST /licensing/checkout/embedded creates a checkout session so a customer can buy an
offering without leaving your application. The seller-side story is in
Deliver to your customers; this is the wire contract underneath it.
{ "RequestId": "5f2d0c44-0000-0000-0000-000000000000", "OfferingId": "b3b1f6e0-0000-0000-0000-000000000000", "CorrelationId": "order-8842", "CustomerEmail": "buyer@example.com", "SuccessUrl": "https://app.example.com/thanks", "CancelUrl": "https://app.example.com/pricing", "Quantity": 25, "ReturnUrl": "https://app.example.com/team"}| Field | Required or optional | Meaning |
|---|---|---|
OfferingId |
Required | The offering being bought. |
CorrelationId |
Required | Your own reference for this checkout, up to 128 characters. It comes back with the completed result so you can match the sale to your side. |
CustomerEmail |
Required | The address the offer is for. It is validated for shape and never looked up, so an address with a Monaiq account and one without do identical work and return the same shape. Nothing is provisioned for it until the buyer is proven: by the claim on a free offering, by the payment on a paid one. |
IssuerClientId |
Optional | Your seller client id. The platform resolves the issuer from the API key, so you may omit this; supply it only as a cross-check, because a mismatch is refused with 403 IssuerMismatch. |
SuccessUrl |
Optional | Absolute HTTP(S) URL to send the buyer to after payment. A session_id query parameter is appended for you. |
CancelUrl |
Optional | Absolute HTTP(S) URL for a buyer who backs out. |
Quantity |
Optional | How many seats the purchase is for, a whole number from 1 to 100. Omit it for one seat. The purchase issues one license with that many seats, under one subscription. A Monaiq platform plan is always one and is refused in multiples, and so is an offering whose AllowsMultiple is false (every free plan and trial, and a paid offering the seller set to one seat): 400 CheckoutFailed. |
ReturnUrl |
Optional | Absolute HTTP(S) URL to send the buyer to when they are finished, up to 2048 characters and carrying no user information. It is recorded on the checkout and is yours, unlike SuccessUrl, which is where the payment provider returns a payer. |
RequestId |
Optional | A GUID identifying this checkout attempt, generated for you when the field is absent. Send the same value on a retry and the existing checkout comes back instead of a second one. Sending the empty GUID explicitly is refused with 400 ValidationError. |
BuyerIdToken |
Optional | An OpenID Connect ID token that proves you already verified CustomerEmail. Free and trial offerings only. See Buyer already signed in. |
A malformed CustomerEmail is 400 ValidationError. There is no 404 for an address without an
account: that refusal is gone.
A Quantity outside 1 to 100, and a ReturnUrl that is relative, uses a scheme other than
HTTP or HTTPS, carries user information such as https://good.example.com@evil.example.com, or
runs past 2048 characters, are each 400 ValidationError. There is no list of allowed return
hosts: you choose where your own buyer lands.
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 409 RequestIdInUse, the same answer a request id
already used by another buyer gets.
Monaiq bills for the seats and issues one license with a seat for each, every seat with its own credentials and its own allowance, sharing the license’s features. The completed result names the license and every seat, and carries a credential only for a purchase of a single seat; for more than one, issue a credential per seat as you assign it. You decide which user holds which seat, in your own directory.
An offering carries these fields, in the buyer views and in the seller entity: AllowsMultiple
(true when a buyer may take more than one seat; always false for a free plan or a trial),
AddOn (true when the offering is held alongside a plan rather than as one), both always true or
false and never null, and TrialDays (the trial length in days, 1 to 90, present on Trial
offerings only). A buyer holds one plan
of a product at a time and one license of an offering; the checkout refusals that enforce it are
listed with in-app purchase.
A successful create returns 200 with the session. A paid offering carries the Stripe URL:
{ "SessionId": "cs_a1B2...", "SessionUrl": "https://checkout.stripe.com/c/pay/cs_a1B2..." }Redirect the buyer there to complete payment. The typed address is passed to Stripe as
customer_email; the account is resolved or created when the payment settles, and the delivery
email deep-links the buyer to their license page, where their first sign-in links the account.
A free or trial offering carries a claim URL instead, and nothing has been provisioned yet:
{ "SessionId": "free-9c1e...", "SessionUrl": null, "ClaimUrl": "https://monaiq.com/claim/..." }| Field | Meaning |
|---|---|
SessionId |
What you poll, on both paths. |
SessionUrl |
Stripe Checkout URL; null on the free path. |
ClaimUrl |
Where the buyer accepts the offer. Present only on the free typed-address path, absent everywhere else. |
Send the buyer to ClaimUrl now, in the same browser they pressed your button in. The page
names you and the product, asks them to confirm, proves the address with a one-time code, and
returns them to your ReturnUrl with the session id. The license is created on that confirmation,
not before. Do not make them wait for an email: the same link is emailed to the address only as
a fallback, some minutes later, for a buyer who closed the tab, and it is good for seven days.
Once they land on ReturnUrl?session_id=..., read the result once: it is
already completed. Poll only when you set no ReturnUrl.
Buyer already signed in
Section titled “Buyer already signed in”If your app signs the buyer in with OpenID Connect before the sale, send their ID token as
BuyerIdToken. The claim page then skips the one-time code: it says you verified the address and
asks for one confirmation. That confirmation creates the buyer’s Monaiq account, or adds the
license to the one they have, and returns them to your ReturnUrl.
First register your issuer under Account settings → Customer sign-in in the portal: the iss of
your ID tokens, exactly as it appears, and your app’s client ID. The token is accepted when:
issis the registered issuer, and its signature verifies against the keys that issuer’s/.well-known/openid-configurationpublishes (RS, PS or ES algorithms).audis the registered client ID.exphas not passed, allowing two minutes of clock skew, andiatis present.emailisCustomerEmail(case does not matter). Request theemailscope. If the token carriesemail_verified, it istrue.
The page honours the proof for one hour from the token’s iat. After that, or with a token that
fails any check, the checkout behaves exactly as it does without the field: the response is the
same, and the buyer proves the address with the emailed code. A bad token is never an error.
The claim email is still sent as the fallback. A proof creates no portal session: a buyer with no
ReturnUrl lands on their license page and signs in there as usual.
A signed assertion under your API key is not accepted as a proof. Your API key already authenticates the request, so a signature with the same key would add nothing a third party could check.
A repeated create with the same RequestId returns the existing session; on the free path that
includes a freshly issued ClaimUrl, and no second email is sent. A RequestId that belongs to a
different buyer or seller is 409 RequestIdInUse.
A create with a new RequestId for the same seller, offering and address while the claim is still
open returns the same session with a new ClaimUrl, and sends the email again with that link.
Every link issued for an open claim keeps working until the buyer accepts through one of them;
the others then read “already accepted”.
Claim notices are capped at five per address per hour across all sellers, re-sends included, and one open intent is kept per seller, offering and address. Over the cap the checkout still succeeds and the link is still returned; only the email is withheld.
Polling the result
Section titled “Polling the result”POST /licensing/checkout/session/result with the session id:
{ "SessionId": "cs_a1B2..." }A known session always answers 200, and Status tells you where it stands:
{ "Status": "completed", "CorrelationId": "order-8842", "EncodedCredential": "SIDUB_LIC_...", "LicenseId": "7b0e5d10-0000-0000-0000-00000000a001", "SeatIds": [ "c9d2a7e1-0000-0000-0000-000000000000" ], "OfferingId": "b3b1f6e0-0000-0000-0000-000000000000"}| Status | Meaning |
|---|---|
pending |
Nothing terminal has happened yet: payment has not settled, or the buyer has not accepted the claim. Poll again; the response carries a Retry-After: 3 header (seconds). |
completed |
The license exists. LicenseId is the license and SeatIds its live seats, oldest first. For a purchase of one seat, EncodedCredential is the buyer’s credential; treat it as the secret it is. For more than one seat it is null: issue a credential per seat. |
failed |
The checkout did not complete; an Error field carries the reason. A claim whose seven days lapsed reads "Claim expired." |
The status vocabulary is exactly these three values and does not change with the claim path.
An unknown or expired session id answers 404 NotFound.
Manage licenses from your backend
Section titled “Manage licenses from your backend”The management API is at /licensing-mgmt and takes the account API key in dub-apiKey. Your
account is the issuer of every license it sells, and the key is what says so: the platform
resolves the issuer from the key alone. A license or seat another account issued answers
404 NotFound, the same answer an id that does not exist gets, so this surface never tells you
what exists outside your own account.
A license is created by a purchase (a checkout on the marketplace, in the portal or in your own app), never by this API, and invoices are portal work: there is no invoice operation here. A refund is made as part of a cancel (below) or from the invoice in the portal. The portal’s Customers page reads the same licenses and seats, and makes the same suspend, resume, cancel and allowance changes, without code.
A license is what a customer bought: its term, price, features, and how it is billed. A seat is one unit of a license: its own keys, its own license codes, its own allowance. A license of 25 seats has 25 seats naming it, and the seat count is the number of live seats.
Paging
Section titled “Paging”Every list on this API answers one page at a time, in the same shape:
{ "Items": [...], "Count": 100, "ContinuationToken": "MS5..." }| Query | Meaning |
|---|---|
?top=<n> |
How many rows the page holds at most: a whole number from 1 to 500. Omitted, 100. |
?continuationToken=<token> |
Resumes the list after the page that issued the token. Omitted, the first page. |
Items is the page’s rows in the list’s order, Count is how many rows this page holds, and
ContinuationToken is what resumes the list, or null when nothing follows the page. Read every
page by repeating the call with the token until it is null; each row arrives exactly once. The
license list may end with an empty page when its row count is a multiple of top; the seat and
credential lists never do. Send the token URL-encoded and treat it as opaque:
it is bound to the list it came from and to every filter in force, and one replayed on another
list, with other filters or by another account is 400 InvalidPaging, as is a top out of bounds.
top may change between pages.
A caller that sends neither parameter still gets the first page: up to 100 rows. A list of fewer
than 100 rows is therefore one page, as before, with ContinuationToken: null.
Finding a license
Section titled “Finding a license”GET /licensing-mgmt/licenses lists the licenses you issued, paged, in a stable order
by id. Optional filters narrow it, in storage, and combine:
| Query | Finds |
|---|---|
?correlationId=<value> |
The licenses carrying your own id. |
?clientId=<guid> |
Every license one customer holds from you. |
?status=<Active | Suspended | Cancelled> |
The licenses in that state. |
?productId=<guid> |
The licenses made against one of your products. |
?offeringId=<guid> |
The licenses made against one offering. |
correlationId is matched exactly, after trimming, and is data: a quote or a percent sign in it is
matched as the character it is. Send the value URL-encoded. clientId and correlationId at
once, an issuerClientId parameter, a correlationId past 128 characters, a status that is not
one of the three, or a productId or offeringId that is not a GUID is 400 InvalidFilter.
GET /licensing-mgmt/licenses/{licenseId} reads one license. Each license carries:
| Field | Meaning |
|---|---|
Id |
The license id: a GUID. |
ClientId |
The customer. |
DisplayName |
What was bought. |
CorrelationId |
Your own id for it, or null. |
LicenseClassification |
Trial, Subscription or Perpetual. |
Status |
Active, Suspended or Cancelled. |
Standing |
Where the license stands now: Active, Ending (a trial, a scheduled cancellation or a term that ends within two weeks), Suspended, PaymentFailed or Ended. |
SuspendedBy |
Who suspended it: Seller, Monaiq or Payment. A buyer never suspends a license. null while it is not suspended. |
SuspensionReason |
The reason code, a LicenseEventReason name. A seller’s suspension carries Misuse, PaymentDispute, BuyerRequest or Other, and a failed payment PaymentFailed. null while it is not suspended. |
SuspensionReasonLabel |
The seller-facing words for the code. null while it is not suspended. |
TermEndUtc |
When the license’s term ends, for every seat alike, or null when it does not end on its own. |
PaidThroughUtc |
How far it has been paid. A paid renewal moves it to the end of the service period that payment bought. Access runs to the later of the term and this, plus a 72-hour renewal grace. |
ScheduledCancellationUtc |
When it is scheduled to end, or null. Access stops then. |
ProductId, OfferingId, BaseRate, Currency, Interval, IntervalUnit |
What was bought and how it is billed. |
Features |
What every seat of the license is entitled to (license features). |
A 404 NotFound at any of these routes reads "License '{id}' not found.".
Updating a license
Section titled “Updating a license”PATCH /licensing-mgmt/licenses/{licenseId} changes the license’s display name, its features, or
both: the one body a seat’s rename cannot also do. Features is the license’s complete new
set: a feature the license holds that the body omits is removed, and every seat reads the new set
at its next authorization. Nothing else about a license moves through this route: its seats move
on their own routes below, and its status on the lifecycle routes.
{ "DisplayName": "Acme Pro (renewed)", "Features": [] }Sending neither field is 400 ValidationError, “Nothing to change: send DisplayName, Features, or
both.”, and a blank DisplayName is refused the same way.
To change one feature, read the license, edit that entry, and send the whole list back. Each entry
is matched to a feature the license holds by its id, or, when it has none or names one this license
does not hold, by its type and FeatureKey; an entry that matches nothing is added, and a held feature
no entry matches is removed. A feature whose values are unchanged keeps its id; a changed feature is
answered with a new id. Two entries with the same id, or two of one type with the same
FeatureKey, are 400 ValidationError.
License features
Section titled “License features”A feature is one entitlement, of one of four types, named by __sidub_entityType. A seat’s
signed authorization carries features in this same shape. The MCP catalog tools use the same
field for a product feature’s type, with product type names such as
ProductFeature.ProductQuotaFeature. A license’s features look like this:
"Features": [ { "__sidub_entityType": "ServiceAccessLicenseFeature", "FeatureKey": "reports", "ServiceAccessLevel": "Allowed" }, { "__sidub_entityType": "RateLimitLicenseFeature", "FeatureKey": "api-calls", "SampleSeconds": 60, "RateLimit": 100 }, { "__sidub_entityType": "QuotaLicenseFeature", "FeatureKey": "exports", "Allowance": 1000 }, { "__sidub_entityType": "LimitLicenseFeature", "FeatureKey": "projects", "Limit": 10 }]| Type | Fields you send | Grants |
|---|---|---|
ServiceAccessLicenseFeature |
FeatureKey, ServiceAccessLevel (Allowed or Denied) |
Access to a capability, or not. |
RateLimitLicenseFeature |
FeatureKey, SampleSeconds, RateLimit |
At most RateLimit uses in any SampleSeconds window, in one process. |
QuotaLicenseFeature |
FeatureKey, Allowance |
Allowance uses per billing period, for each seat. An Allowance of 0 has no cap: every use is counted and reported, and none is refused. |
LimitLicenseFeature |
FeatureKey, Limit (a whole number of 0 or more, or null for unlimited) |
A number the application holds to, such as 10 projects or 90 days of history. The application counts its own data and asserts the count; the platform never counts it. |
A LimitLicenseFeature is read by SDK 2.4.0 or later. An older .NET SDK (before 2.4.0) fails the
whole authorization for a license carrying a Limit, not just that feature, so give a Limit only to
offerings whose application runs SDK 2.4.0 or later.
A response names the type in its qualified form (default:LicenseFeature.QuotaLicenseFeature),
which a request may also send. It adds fields the platform owns: id (the current version of that
feature: it changes when the feature’s values change, and a feature is identified by its type and
FeatureKey), and LicenseId and IssuerClientId (set from the route and your key; values sent are
ignored). An allowance carries the entitlement alone: what a seat has used is on the platform’s
ledger, and a runtime spends it through allowance grants.
Adding seats
Section titled “Adding seats”POST /licensing-mgmt/licenses/{licenseId}/seats adds seats to a license. It is a call of its own
because it charges your customer, and an operation that moves money should never be
indistinguishable from renaming something.
POST /licensing-mgmt/licenses/7b0e5d10-0000-0000-0000-00000000a001/seats HTTP/1.1Host: api.monaiq.comdub-apiKey: <your API key>Content-Type: application/json
{ "Count": 15, "RequestId": "5f2d0c44-0000-0000-0000-00000000a0d1" }Count is how many seats to add (a change, not a total) and must be a whole number of at least
one. The subscription moves first and the seats are minted after it, so what is issued is always
what is billed. Adding seats charges your customer’s card now, with the payment processor’s
default proration for the remainder of the current period. There is no confirmation step, so ask
for one in your own application before you send this.
RequestId is optional and is how you repeat the call safely. Generate a GUID per request and
send the same value on every retry: the first call records how many seats the license is to hold
once it is done, and a later call with the same id completes that number instead of adding again,
so a retry after a lost answer never bills or mints the seats twice. A repeat answers the seats
the license then holds, with Seats listing only what that call minted (usually nothing). Without
a RequestId a retried add adds again. The empty GUID, or a value that is not a GUID, is refused
with 400 ValidationError.
The response is 200:
{ "Previous": 25, "Current": 40, "Seats": [ { "Id": "…", "LicenseId": "7b0e5d10…", "RetiredUtc": null, "…": "…" } ]}| Field | Meaning |
|---|---|
Previous |
Live seats before the call. |
Current |
Live seats after it. |
Seats |
The new seats, in the seat shape below. Each has no credential yet; issue one per seat as you assign it. |
A refusal changes nothing at all, at the processor or here:
| Refusal | Meaning |
|---|---|
400 ValidationError |
Count is missing, not a whole number, below one, or would take the license past 100 seats. |
404 NotFound |
No license you issued has that id. |
409 LicenseNotActive |
The license is paused, suspended or cancelled. Resume it first. |
409 SeatsNotSupported |
A Monaiq platform plan, a plan sold one seat at a time (the offering’s AllowsMultiple is false, as on every free plan and trial), or a one-time purchase: the buyer adds seats to a one-time license from their Monaiq license page. |
409 RequestIdInUse |
The RequestId was already used on this license with a different Count. This call changed nothing; the earlier request stands, so list the seats before sending another. |
409 RequestInProgress |
The same RequestId is still being completed by an earlier call that has not answered yet. Repeat the call in a moment and it answers what that call did. |
502 SubscriptionUpdateFailed |
The processor did not make the change. Nothing was written. |
Reading, renaming and listing seats
Section titled “Reading, renaming and listing seats”GET /licensing-mgmt/licenses/{licenseId}/seats lists the seats of one license, live and
retired, oldest first, paged:
{ "Items": [ { "Id": "c9d2a7e1-0000-0000-0000-000000000000", "LicenseId": "7b0e5d10-0000-0000-0000-00000000a001", "ClientId": "a1b2c3d4-0000-0000-0000-000000000000", "Name": "Acme Pro", "RetiredUtc": null, "LicenseStatus": "Active", "IssueDate": "2026-01-04T10:15:00Z", "ServiceKeyId": "3f8e1c22-0000-0000-0000-000000000000", "ServiceKeyPublic": "MFkwEwYHKoZ...", "Allowances": [ { "FeatureKey": "exports", "Allowance": 100, "Uncapped": false, "Used": 12, "Reserved": 18, "Remaining": 70, "PeriodEndUtc": "2026-11-01T00:00:00Z" } ] } ], "Count": 1, "ContinuationToken": null}| Field | Meaning |
|---|---|
LicenseId |
The license this seat belongs to. Every seat of one license carries the same value. |
LicenseStatus |
The seat’s license’s status, or null when it could not be read. |
RetiredUtc |
When the seat was retired, or null while it is live. A retired seat is refused at its next authorization and keeps no license codes. |
Allowances |
The seat’s figures for each allowance of its license in the current billing period. |
A seat carries no features: it is entitled to its license’s, read at GET /licensing-mgmt/licenses/{licenseId}.
What it has used of each allowance is its own:
Allowances[] field |
Meaning |
|---|---|
FeatureKey |
The allowance feature. |
Allowance |
The uses per billing period. Zero or less is no cap. |
Uncapped |
true when the allowance has no cap: every use is in Used, none is refused, and Remaining is 0 because there is nothing to count down. |
Used |
The uses recorded for the seat in the period. |
Reserved |
Units the seat’s runtimes hold and have not yet recorded as used. |
Remaining |
The uses neither recorded nor held: Allowance plus Credited, less Used and Reserved, never below zero. 0 when the allowance has no cap. |
Credited |
Units you credited to the seat for the period (see Adjusting a seat’s allowance). 0 when there are none. |
PeriodEndUtc |
When the period resets. |
The figures are read from the platform’s count on each call, for every live seat, whether or not
any application has run under the seat this period. Allowances is empty for a retired seat, for
a license with no allowance, and for an allowance whose figures could not be read. It is filled
on the two reads only: the responses of add, rename and retire carry it empty.
GET /licensing-mgmt/licenses/{licenseId}/seats/{seatId} reads one seat in the same shape. A seat
named under a license it does not belong to answers 404 NotFound, the same answer an unknown
seat gets.
PATCH /licensing-mgmt/licenses/{licenseId}/seats/{seatId} renames a seat. The body carries only
{ "Name": "..." }; any other field is ignored, so sending a whole seat object back never moves
its license, its retirement or its keys, however stale it has become. A missing or blank Name is
400 ValidationError, “Name is required: the seat’s new name.”
Retiring a seat
Section titled “Retiring a seat”POST /licensing-mgmt/licenses/{licenseId}/seats/{seatId}/retire retires one seat of a license you
issued. With no body it retires the seat and nothing more.
POST /licensing-mgmt/licenses/7b0e5d10-0000-0000-0000-00000000a001/seats/c9d2a7e1-0000-0000-0000-000000000000/retire HTTP/1.1Host: api.monaiq.comdub-apiKey: <your API key>On a license billed through a Stripe subscription, the subscription moves first and the seat
follows it: your customer is credited with the processor’s default proration for the rest of the
current period. Every retire revokes the seat’s license codes and refuses the seat at its next
authorization. The response is 200 with the retired seat, its RetiredUtc set. A retired seat
cannot be brought back; add a seat instead. Retiring a seat that is already retired changes nothing
and answers 200 again, so a retry cannot credit or refund your customer twice.
A body makes it your cancel of the seat, which the customer is told about:
{ "Refund": "Full", "Reason": "BuyerRequest", "Note": "As you asked on the phone." }Refund is None (the default) or Full, by name in any case. Full is for a license bought
outright: the seat’s price, as the payment that bought it paid it, goes back to the card that
paid, never more than the license’s seat rate, and the response carries a Refund block
(Amount, Currency, InvoiceId, RefundId, Failure). A seat on a subscription is credited by
the processor instead, and one on a license Monaiq invoices is billed one seat fewer from then on,
so Full is refused on both. Reason takes the cancel’s codes (below), Other with a Note, and
Note up to 500 characters. With a body, the customer is
emailed which seat stopped, your reason and note, and the refund. A refund the processor refuses
after the seat is retired leaves the seat retired; Refund.Failure says why, and you refund it
from the invoice in the portal.
The license’s last live seat cannot be retired. To end the license, cancel it.
| Refusal | Meaning |
|---|---|
404 NotFound |
No seat you issued has that id under that license. |
400 InvalidRefund |
Refund is not None or Full. |
400 InvalidReason |
Reason is not one of the cancel’s codes. |
400 NoteRequired |
Reason is Other and there is no Note. |
400 InvalidPayload |
The body cannot be read as JSON. |
413 PayloadTooLarge |
The body is past 4 KB. |
409 LicenseNotActive |
The license is paused, suspended or cancelled. Resume it first. |
409 LastSeat |
This is the license’s last live seat. Cancel the license instead. |
409 NotRefundable |
Full was asked and the license cannot give it. Nothing was retired. |
502 SubscriptionUpdateFailed |
The processor did not make the change. Nothing was written. |
Adjusting a seat’s allowance
Section titled “Adjusting a seat’s allowance”POST /licensing-mgmt/licenses/{licenseId}/seats/{seatId}/allowance-adjustments corrects one
seat’s position in an allowance for the current billing period: give back units for work that
failed on your side, grant a goodwill top-up, or reset the period’s count. Each adjustment is
recorded with its reason. Nothing is billed, and Used stays the uses recorded; the seat’s
Credited rises instead, and Remaining with it.
POST /licensing-mgmt/licenses/7b0e5d10-0000-0000-0000-00000000a001/seats/c9d2a7e1-0000-0000-0000-000000000000/allowance-adjustments HTTP/1.1Host: api.monaiq.comdub-apiKey: <your API key>Content-Type: application/json
{ "FeatureKey": "documents", "Credit": 20, "Reason": "Our outage failed 20 extractions", "RequestId": "5d6f1c2a-0000-0000-0000-000000000001" }| Field | Required | Meaning |
|---|---|---|
FeatureKey |
Yes | The allowance feature, a capped allowance of the license. |
Credit |
One of the two | Whole units to credit the seat for the period, 1 to 1,000,000. A credit can take the seat past its allowance for this period. |
Reset |
One of the two | true credits what the seat has used this period and not already had credited, so its count starts again from zero. |
Reason |
Yes | Why, up to 500 characters. Stored with the adjustment. |
RequestId |
Yes | A GUID that names this adjustment. Repeat it on a retry and the adjustment is made once. |
The response is 200 with Adjustment (Id, FeatureKey, Kind (Credit or Reset),
Units, Reason, RequestId, PeriodStartUtc, PeriodEndUtc, CreatedUtc) and Allowance,
the seat’s figures for the allowance afterwards, in the Allowances[] shape above. A credit
applies to the current period only; the next period starts from the allowance again. Runtimes see
the credited units on their next grant.
GET on the same path lists the current period’s adjustments of the seat’s allowances, oldest
first, under Items.
| Refusal | Meaning |
|---|---|
400 ValidationError |
A field is missing or out of range, both or neither of Credit and Reset are stated, or FeatureKey is not a capped allowance of the license. |
404 NotFound |
No seat you issued has that id under that license. |
409 SeatRetired |
The seat is retired. |
409 RequestIdInUse |
The RequestId was already used for a different adjustment of this seat. Nothing was changed. |
Cancelling, suspending and resuming
Section titled “Cancelling, suspending and resuming”POST /licensing-mgmt/licenses/{licenseId}/cancel ends a license, and gives your customer’s money
back as part of it. Refund says how much, and that sets when access stops:
Refund |
On a Stripe subscription | On a license bought outright | On a free plan, a trial, or a license Monaiq invoices |
|---|---|---|---|
None (the default) |
Ends at the end of the period already paid for. | Ends now. | A free plan or trial ends now; an invoiced license ends at its period end. |
Prorated |
Ends now; the unused share of the current period’s charge goes back. | Not offered. | Not offered. |
Full |
Ends now; the current period’s charge goes back in full. | Ends now; every payment for the license and its added seats goes back, never a plan change’s. | Not offered. |
On a subscription the refund is of what the period cost: usage billed on the same invoice was
consumed and is not refunded, and a license’s usage so far is still billed. A license that ends at
its period end keeps working until then and reads Active with a ScheduledCancellationUtc; your
customer cannot undo a cancel you scheduled. A cancel with no refund of a subscription your
customer already set to end keeps their date and makes the cancel yours: they are emailed your
reason and can no longer undo it. Any cancelled license stops working, a license bought outright
included. The subscription moves first; a processor refusal is 502 SubscriptionUpdateFailed and
nothing is written. A refund the license no longer offers (it was refunded meanwhile, or the
period ran out) is 409 RefundNotOffered, and nothing is written. The refund is asked for after
the license ends; the response carries a Refund block (Amount, Currency, InvoiceId,
RefundId, Failure). A refund the processor refuses leaves the license cancelled with Failure
saying why, for you to refund from the invoice in the portal; one whose answer never came says so
in Failure, because it may have gone through: check the invoice before refunding again. A
license already cancelled answers 200 as it stands and refunds nothing. An API key can refund
your customer’s own payments only as part of a cancel, and only back to the card that paid.
POST …/suspend stops access without ending the license, and POST …/resume restores it. The
response to all three is 200 with the license. A cancelled license is neither suspended nor
resumed: 409 InvalidState. The body of cancel and suspend is optional, but one that is present
and cannot be read as JSON is 400 InvalidPayload, and one past 4 KB is 413 PayloadTooLarge;
neither is silently defaulted.
Suspend and cancel each take an optional Reason and Note, which the customer sees:
{ "Refund": "Prorated", "Reason": "Refunded", "Note": "Refunded the rest of October." }| Route | Reason codes |
|---|---|
…/suspend |
PaymentDispute, Misuse, BuyerRequest, Other |
…/cancel |
BuyerRequest, Refunded, PaymentDispute, Misuse, Other |
A code is matched by name, in any case, and so is a Refund word; one not in the route’s list
is 400 InvalidReason or 400 InvalidRefund. Other says nothing on its own, so it needs a
Note; without one the call is 400 NoteRequired. A call without a reason records none, and its
email carries no reason line. Note is up to 500 characters; a longer one is cut. The customer is
emailed once for suspend, resume and cancel: what changed, when access ends, your reason and
note, what goes back to their card, and your support contact.
Issuing and revoking a license code
Section titled “Issuing and revoking a license code”Three operations hang off a seat of a license you issued. All three answer 404 NotFound for a
seat another account issued, or one named under the wrong license.
POST /licensing-mgmt/licenses/{licenseId}/seats/{seatId}/credentials issues a runtime credential
(a license code) and returns 201 with the encoded string. An optional body carries a Label of
up to 60 characters, which is a note to you and appears in the listing, and an optional
RequestId (a GUID of your own) that makes the call safe to repeat: the same seat and RequestId
answer the one credential already issued, re-encoded, rather than a second live credential. A
longer label is trimmed rather than refused, a body that is not JSON is 400 InvalidPayload, a
RequestId that is not a GUID is 400 ValidationError, and a request body larger than 4 KB is
413 PayloadTooLarge; in each case nothing is issued. A retired seat is issued no license code:
409 SeatRetired.
{ "CredentialId": "8b7a6c55-0000-0000-0000-000000000000", "SeatId": "c9d2a7e1-0000-0000-0000-000000000000", "ClientId": "a1b2c3d4-0000-0000-0000-000000000000", "IssuedUtc": "2026-01-04T10:15:00Z", "Source": "api", "Label": "build server", "EncodedCredential": "SIDUB_LIC_..."}EncodedCredential is shown once. The token inside it is not stored and cannot be derived
again, so keep it when you receive it. The response carries Cache-Control: no-store for the same
reason. If you lose one, issue another and revoke the old one.
GET /licensing-mgmt/licenses/{licenseId}/seats/{seatId}/credentials lists what the seat has,
newest first, paged. Each item carries CredentialId, SeatId,
ClientId, IssuedUtc, RevokedUtc, Source, Label and IsActive. A listing never carries an
encoded credential. Revoked credentials stay on the list, because removing them would leave you
unable to tell a revoked credential from one that never existed.
DELETE /licensing-mgmt/licenses/{licenseId}/seats/{seatId}/credentials/{credentialId} revokes one
and returns 204. It is idempotent: revoking one that is already revoked answers 204 again. A
credential that belongs to a different seat answers 404 NotFound, even when both seats are
yours.
What revoking does, and when, is in Credentials and keys.
License authorization
Section titled “License authorization”POST /licensing/runtime/GenerateLicenseAuthorization is the call behind the SDKs’
getAuthorization / GetAuthorization. It takes the runtime token as a bearer, and the body names
the seat:
{ "RequestId": "5f2d0c44-0000-0000-0000-000000000000", "SeatId": "c9d2a7e1-0000-0000-0000-000000000000", "IssuerClientId": "a1b2c3d4-0000-0000-0000-000000000000", "ProductId": "d4c3b2a1-0000-0000-0000-000000000000", "SignatureLayout": 1}| Field | Required or optional | Meaning |
|---|---|---|
RequestId |
Required | A GUID, new for every request and repeated only when the same request is retried. |
SeatId |
Required | The seat the token names. |
IssuerClientId |
Optional | The seller the application declares itself to belong to. A seat of another seller is refused 403 AuthorizationDenied with the reason ProductMismatch. The SDKs send IssuerClientId from their options. |
ProductId |
Optional | The product the application declares itself to be. A seat of another product is refused the same way. |
SignatureLayout |
Optional | The newest signature layout the caller reads (1 today). The answer is signed in that layout or an older one and names it in its own SignatureLayout, beside the signature. A caller that states none is answered as before 2.2.0, with no layout field. |
The three optional fields are the same on AcquireAllowanceGrant and ReadAllowance below. A
request that declares neither seller nor product is answered as before.
A successful call returns 200 with the signed authorization: authorization id, seat id, license
id, classification (numeric on the wire: 0 trial, 1 subscription, 2 perpetual), issue and
expiry dates, the feature list and, when the request stated a SignatureLayout, the layout the
answer was signed in. The SDKs verify its signature before trusting it. Each allowance feature
carries its Allowance only: what the seat has used is on the platform’s ledger, not on the
authorization. An
enforcement denial answers 403 AuthorizationDenied with the reason as Details[0].Code:
Expired, LicenseSuspended, LicenseCancelled, SeatRetired or ProductMismatch (the credential
was not issued for the seller or product the application declares). The .NET SDK raises it as
LicenseAuthorizationDeniedException with that Reason; the JavaScript SDK exposes it as
LicensingError.denialReason.
The body’s SeatId must be the seat the token names, or the answer is 404 NotFound (never
a 403, which would confirm the id exists elsewhere). A credential authorizes its own seat and no
other.
Allowance grants
Section titled “Allowance grants”A runtime spends an allowance from a grant: a signed, one-hour reservation of units taken from
the seat’s ledger for the billing period. The SDKs take and return grants for you;
Enforcing an allowance covers the model. Every
operation here takes the runtime token as a bearer, and the body’s SeatId must be the seat the
token names, as on GenerateLicenseAuthorization.
Taking a grant
Section titled “Taking a grant”POST /licensing/runtime/AcquireAllowanceGrant reserves units of one allowance feature:
{ "RequestId": "8d1f6a2e-0000-0000-0000-000000000000", "SeatId": "c9d2a7e1-0000-0000-0000-000000000000", "FeatureKey": "exports", "Amount": 1, "PriorGrantId": "41c7e9b0-0000-0000-0000-000000000000", "PriorSpent": 100}| Field | Required or optional | Meaning |
|---|---|---|
RequestId |
Required | A GUID, new for every request and repeated only when the same request is retried. The grant’s id is derived from it, so a retried request answers the grant it already took. |
SeatId |
Required | The seat to take units from. |
FeatureKey |
Required | The allowance feature. |
Amount |
Optional | The size of the use the grant must cover. Omit it for one. |
PriorGrantId |
Optional | A grant the runtime has finished spending. Its unspent units return to the ledger in the same call. |
PriorSpent |
Optional | The uses the runtime spent of PriorGrantId. |
IssuerClientId, ProductId, SignatureLayout |
Optional | As on GenerateLicenseAuthorization. |
A grant is a tenth of what the period has not yet granted, at least one unit and at least
Amount, and never more than the period has left. The first grant of a period is a tenth of the
allowance; each later grant, while earlier ones are held, is a tenth of the rest, so runtimes
sharing a seat take many grants before an unspent period is refused. A successful call returns 200 with the signed grant:
| Field | Meaning |
|---|---|
id |
The grant id. |
IssuerClientId, SeatId, LicenseId, FeatureKey |
The seller, the seat the units were taken from, its license, and the allowance feature. |
PeriodStartUtc, PeriodEndUtc |
The billing period the units belong to. The start is inclusive and the end exclusive. |
Allowance |
The feature’s allowance when the grant was issued. |
Units |
The uses the grant covers. |
IssuedUtc, ExpiresUtc |
When the grant was issued, and when it stops covering uses: one hour after issue, or the period end if sooner. |
PeriodGranted |
The units handed out in the period when the grant was issued, this grant included. |
PeriodUsed |
The uses recorded in the period when the grant was issued. |
Spent |
The uses the platform has attributed to the grant from consumption reports. |
ReportedSpent |
The uses the runtime said it spent when it returned the grant. |
Released |
Whether the unspent units have returned to the ledger. |
SignatureLayout |
The layout the grant was signed in, present when the request stated one. Not covered by the signature. |
__sidub_entitySignature |
The signature, made with the seat’s service key. The SDKs verify it before spending from the grant. |
A feature whose Allowance is zero or less has no cap: its grant carries Units of 0, covers
any number of uses and reserves nothing.
| Refusal | Meaning |
|---|---|
403 AllowanceExhausted |
The period has no units left. Details[0] is { "Code": "AllowanceExhausted", "TargetId": "<feature key>" }, and Details[1] is { "Code": "PeriodEndUtc", "Message": "<ISO-8601 UTC instant>" }: when the period resets. |
403 AuthorizationDenied |
The seat’s authorization is refused, with the reason as Details[0].Code, as on GenerateLicenseAuthorization. A suspended, cancelled or expired license, or a retired seat, is granted nothing from that moment. |
404 FeatureUnknown |
The license has no allowance feature with that key. |
404 NotFound |
The seat is not the one the token names. |
400 ValidationError |
RequestId is missing or the empty GUID. |
400 InvalidPayload |
The body could not be read. |
429 LedgerBusy |
The seat’s ledger was written by other runtimes on every attempt. Retry the same request after Retry-After (1 second). |
503 StoreUnavailable |
The store is still connecting. Retry after Retry-After (5 seconds). |
Returning a grant
Section titled “Returning a grant”POST /licensing/runtime/ReleaseAllowanceGrant returns a grant’s unspent units to the ledger:
{ "RequestId": "2b7c4f10-0000-0000-0000-000000000000", "SeatId": "c9d2a7e1-0000-0000-0000-000000000000", "GrantId": "41c7e9b0-0000-0000-0000-000000000000", "Spent": 37}Spent is the uses the runtime spent of the grant. The response is 200 with the grant, its
Released set to true. The call is idempotent: returning a grant twice changes nothing and
answers 200 again. A grant the seat does not hold answers 404 NotFound, and a missing or empty
RequestId answers 400 ValidationError. A lost ledger race is 429 LedgerBusy with Retry-After,
as on acquire. A credential revoked since the grant was taken may still return it: the seat gets
its units back at once rather than when the grant expires.
The units of a grant that is not returned stay reserved until the platform reclaims the grant: on the seat’s next acquire, once the grant has been expired for five minutes.
Reading the figures
Section titled “Reading the figures”POST /licensing/runtime/ReadAllowance reads a seat’s figures for one allowance feature in the
current billing period. It reserves nothing and changes nothing:
{ "RequestId": "5e0a9c31-0000-0000-0000-000000000000", "SeatId": "c9d2a7e1-0000-0000-0000-000000000000", "FeatureKey": "exports"}A successful call returns 200 with the signed figures:
| Field | Meaning |
|---|---|
SeatId, FeatureKey |
The seat and the allowance feature read. |
PeriodStartUtc, PeriodEndUtc |
The billing period. The start is inclusive and the end exclusive. |
Allowance |
The feature’s allowance. Zero or less is no cap. |
Used |
The uses recorded in the period. |
Granted |
The units handed to the seat’s runtimes in the period and not returned; never below Used. What is above Used is held by runtimes and not yet recorded as used. |
AsOfUtc |
When the figures were read. |
SignatureLayout |
The layout the reading was signed in, present when the request stated one. |
__sidub_entitySignature |
The signature, made with the seat’s service key. The SDKs verify it before showing the figures. |
The SDKs call it when the state is read and the runtime holds no figures for the period, or
figures more than a minute old: Showing what is left.
It answers 403 AuthorizationDenied, 404 FeatureUnknown, 404 NotFound, 400 ValidationError
and 400 InvalidPayload as AcquireAllowanceGrant does. It never answers AllowanceExhausted: a
spent period reads Granted equal to Allowance.
Consumption
Section titled “Consumption”POST /consumption/runtime/messages is the ingestion endpoint for metered usage. It takes the
runtime token as a bearer. Both SDKs batch and send consumption for you once
ConsumptionServiceUri / consumptionServiceUri is configured. It is not designed for
hand-rolled calls; Consumption and metering covers the model.
A use spent from an allowance grant carries the grant’s id as a top-level
GrantId, and the platform attributes the use to that grant.
The gateway binds the batch to the token: a batch whose SeatId is not the seat the
credential names is refused 400 before it reaches ingestion, and the credential id is stamped
onto the batch so usage reported under a revoked credential is dropped rather than billed.
Runtime discovery
Section titled “Runtime discovery”Monaiq issues the runtime tokens, so it publishes its own discovery documents. Both are anonymous and both are public by construction: an issuer string, a URL, and the elliptic-curve public points:
GET /licensing/runtime/.well-known/openid-configurationGET /licensing/runtime/.well-known/jwks
Signing key versions are additive and never disabled, so the JWKS serves every version that has ever signed. You do not need either document to integrate; the SDKs never fetch them, but they are there for a proxy or a gateway of your own that wants to verify a token itself. The per-environment URLs are in Endpoints.
Rate limits on the runtime paths
Section titled “Rate limits on the runtime paths”Per seat, keyed on the seat the token names:
| Path | Limit |
|---|---|
POST /licensing/runtime/GenerateLicenseAuthorization |
600 requests per minute, one bucket shared by the four runtime operations |
POST /licensing/runtime/AcquireAllowanceGrant |
(the same bucket) |
POST /licensing/runtime/ReleaseAllowanceGrant |
(the same bucket) |
POST /licensing/runtime/ReadAllowance |
(the same bucket) |
POST /consumption/runtime/messages |
2400 requests per minute |
A coarser per-IP limit runs ahead of token validation as a volume guard: 6000 requests per
minute per caller address across the four /licensing/runtime/ operations together, and 12000
per minute across /consumption/runtime/messages. Each API keeps its own counters, so metering
traffic never draws on the authorization budget, per seat or per address. A refused request
answers 429 with Retry-After; the SDKs honour it.
The seller API (/licensing/checkout/* and /licensing-mgmt) is limited to 300 requests a
minute per account API key. The anonymous purchase reads under /purchase are limited to 60
requests a minute per caller address, as are the discovery documents under
/licensing/runtime/.well-known/. The MCP endpoint is limited to 1200 requests a minute per
caller address ahead of sign-in and 300 a minute per signed-in person. Every limit is set per
environment, not per seller.
Related
Section titled “Related”- Endpoints for the base URLs these paths hang off.
- Credentials and keys for where each credential comes from.
- .NET client reference and JavaScript SDK reference for the SDK surfaces that wrap these calls.