Third-party SDK
Integration guide
So that your SaaS product knows what each of your customers has bought through Mitnix Commerce, verifies it without intermediaries and stops granting it when Commerce revokes it.
For the team of a third party that sells its SaaS product through the Mitnix Commerce platform and wants that product to know, without intermediaries, what each of its customers has bought (capabilities, limits, quotas and validity) and to stop granting it when Commerce revokes it. Spanish original (the Spanish text is the reference if they differ).
Everything described here is implemented in the five @mitnix-ai-solutions/platform-* 1.0.2 packages and runs end to end in the tercero-saas example, which Mitnix compiles and tests against the packages as they are published.
1. How it fits together
your SaaS product ──OIDC token from your platform──▶ Commerce (/installation/v1/*)
│ │
│ 1 trust/bundle trust bundle ◀────────────┤ (your vendor's licensing authority)
│ 2 tenant-grants encrypted Grants (JWE) ◀──┤ (one per customer, signed by your authority)
│ 3 trust/operations content key ◀─────────────┤ (open-grant: Trust opens it; the private key never leaves the HSM)
│ 4 tenant-license-status revocations ◀──────────┘
▼
verify against the anchor ▶ grant capabilities, limits and quotas per customer (tenant)
- Your product holds no secret. It authenticates with the OIDC token that your runtime platform issues for your workload; Commerce verifies it against what you registered.
- You verify, offline. Commerce delivers the Grant, but what makes it trustworthy is its signature (ES256,
mitnix.signed-artifact/v3) checked against an anchor that you pin. A license from Mitnix or from another third party is rejected. - Commerce works out who you are from the token (vendor, product and environment). The SDK never sends your
vendorId.
2. Installation
The five packages are on npmjs.com and install without any token:
npm install @mitnix-ai-solutions/platform-common@1.0.2 @mitnix-ai-solutions/platform-artifacts@1.0.2 \
@mitnix-ai-solutions/platform-trust@1.0.2 @mitnix-ai-solutions/platform-licensing@1.0.2 \
@mitnix-ai-solutions/platform-commerce-client@1.0.2
Node 22 or later (platform fetch and WebCrypto; the SDK uses no Node or DOM types). The SDK license is proprietary and free, valid only for use with Commerce (LICENSE). The provenance of what is published can be verified without credentials with the manifest signed by Mitnix (verification). Versions and their compatibility with /installation/v1 are on mitnix.net/sdk.
3. Register your product in the Commerce console
Done by your vendor's administrator in your console (https://<your host>/consola/#/canal, "Identidad de tus productos"). For each product and environment, register:
| Field | What it is |
|---|---|
| Product | The one in your catalog, shaped <vendor>:<product> (after the colon: lowercase letters, digits and inner hyphens, up to 32). |
| Environment | development, staging or production. It must be the environment in which the Commerce installation sells (the console shows it and proposes it). |
| Issuer | The exact iss of your platform's token. |
| Audience | The exact aud: the one you choose is the one your product will request when it obtains the token. Use the Commerce URL. |
| Subject | The exact sub of your workload's token. |
What Commerce does when you register:
- Discovers the issuer (
<issuer>/.well-known/openid-configuration): the document'sissuermust be exactly the registered one, and it pins thejwks_uri. It never discovers again afterwards. - Requires an
https://issuer with a public name (no literal IP, port, credentials orlocalhost) that resolves only to public addresses. - An identity (issuer and subject) belongs to one product and environment across the whole platform.
- Requests the custody identity
tercero.<vendor>.<product>for the environment from Trust. A Mitnix security officer approves it. With "Comprobar custodia", once approved and active, Commerce registers your SaaS installationtercero.<vendor>.<product>.<environment>(for exampletercero.prueba.excerptia.staging): the recipient of your Grants and theinstallationIdyour product uses.
Changing the identity of an already registered product and environment (another issuer, audience or subject) replaces it without losing the custody: it does not need approving again.
4. Your workload token
Your runtime platform issues an OIDC token (JWT) for your service. Commerce accepts it only if:
- it is signed with RS256 or ES256 (P-256) using a key from the pinned
jwks_uri, and carries akid; iss,subandaudare exactly the registered ones;- it carries
expandiat, lives 24 hours or less (exp−iat), has not expired and was not issued (nor starts, if it hasnbf) in the future, with 30 s of clock tolerance.
How to obtain it on each platform is explained by the platform's own documentation; what Commerce demands is the above. Verified in Commerce today: Cloud Run (how Mitnix's own applications identify themselves): GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=<audience> with the Metadata-Flavor: Google header; it lasts one hour. On Kubernetes (projected service-account token with the requested audience), AWS, Azure or a CI with OIDC the same rules apply: your issuer must be public and discoverable by Commerce from the Internet. A private issuer (a cluster without a public discovery document) cannot be registered.
The SDK does not store the token or cache it: it calls your provider on every request (tokenProvider({ forceRefresh })) and puts the token only in Authorization: Bearer. The token never appears in a URL, a body, an error or a log. After a 401 it asks for a new one (forceRefresh: true) and retries once.
const commerce = createCommerceChannelClient({
commerceUrl: "https://<the Commerce URL Mitnix gives you>",
tokenProvider: async ({ forceRefresh }) => requestTokenFromThePlatform({ audience, bypassCache: forceRefresh }),
});
For development and tests without a runtime platform you can use an OIDC issuer of your own with client_credentials (what the example's staging test does); the example ships it as one more token source (MITNIX_TOKEN_URL).
5. The trust anchor
To verify licenses without asking anyone, your product pins an anchor (public keys only, nothing secret):
const anchor: VendorTrustAnchor = {
vendorId: "acme", // your vendor identifier
environment: "production", // the `environment` of the bundle Trust signs (see note)
trustUpdatePublicKey: { kty: "EC", crv: "P-256", x: "…", y: "…" }, // public key of Mitnix's trust-update authority
};
- The trust bundle (
mitnix.trust.vendor-bundle/v1) is signed by Mitnix'strust-updateauthority and contains the third-party root and the licensing authorities of your vendor with their public keys, plus their revocations. If someone served you a bundle signed with another key, or for another vendor or environment,applyVendorTrustBundlerejects it (VENDOR_BUNDLE_SIGNATURE_INVALID,…_SCOPE_MISMATCH,…_ENVIRONMENT_MISMATCH). - Note on
environment: it is the environment Trust signs with, not your application's environment. Trust only supportsproductiontoday and uses it in staging too, so the staging bundle carriesenvironment: "production". Check it in the bundle you receive (payload.environment). - Where to get it: in your Commerce console, «Identidad de tus productos» (
https://<your host>/consola/#/canal), Ancla de confianza section. Any member of your team can see it, readers included. It shows yourvendorId, theenvironmentyou must set (the one Trust actually writes into your bundle, with a note when it differs from the sales environment) and thetrust-updatepublic key as a JWK with its SHA-256 thumbprint (RFC 7638). «Copiar ancla» and «Descargar JSON» give you theVendorTrustAnchorobject as is; «Copiar clave (JWK)» gives just the key, which is what the example expects inMITNIX_TRUST_UPDATE_JWKorMITNIX_TRUST_UPDATE_JWK_FILE. Commerce takes it from Trust's trust distribution and only shows it if it verifies the signature of the bundle it serves you. If you have no bundle yet (Trust has not approved your licensing authority), the section says so. - Check the thumbprint before pinning the anchor: it is the base64url SHA-256 of the JSON
{"crv":"P-256","kty":"EC","x":"…","y":"…"}(keys in that order, no whitespace), and it must match the one shown in the console. - Rotation: when Mitnix rotates the
trust-updatekey, the console shows more than one. «En uso» (in use) is the one that signs the bundle Commerce serves you now; «Vigente» (active) or «Siguiente» (next) are the upcoming ones. The SDK anchor holds a single key (trustUpdatePublicKey) and the Grant verifier re-checks the stored bundle with it. So switch like this: get the new key ready and, when the console marks it «En uso», replace the one in the anchor and apply the new bundle right away (applyVendorTrustBundle). Until you change the anchor, the new bundle is rejected withVENDOR_BUNDLE_SIGNATURE_INVALIDand your product keeps the last one it applied.
6. The flow in code
The example implements it in src/licencias.ts (ServicioDeLicencias class). The steps:
// 1. Trust bundle: applied exactly as Commerce serves it (base64url). If it fails, the last valid one is kept.
const delivered = await commerce.trustBundle(); // { vendorId, sequence, bundleId, bundle }
const applied = await applyVendorTrustBundle({ bundle: delivered.bundle, anchor, store: bundleStore });
// 2. Encrypted Grants: one per customer (tenant).
const { grants } = await commerce.tenantGrants(); // [{ grantId, encryptedDocument }]
// 3. Open the JWE. open-grant goes through Commerce to Trust: the SDK extracts the encrypted key, sends it and decrypts locally.
const opened = await openTenantGrantJwe(grant.encryptedDocument, commerce.openGrant, { expectedInstallationId: installationId });
// 4. Verify and apply: your authority's signature, recipient, validity, per-tenant sequence…
const runtime = new TenantGrantRuntime(tenantStore, createAnchoredTenantGrantVerifier({ anchor, bundleStore }));
const r = await runtime.apply(opened.value, { installationId, installationKind: "saas", now: new Date().toISOString(), expectedAuthority: { vendorId: anchor.vendorId } });
// 5. Revocations: what Commerce has revoked after signing.
const status = await commerce.tenantLicenseStatus(); // { tenants: [{ commercialAccountReference, grantId, revokedAddons, revokedAt?, confirmedEntitlements }] }
What each step guarantees:
openTenantGrantJwevalidates the JWE (RSA-OAEP-256+A256GCM, 384-byteencrypted_key), rejects before spending a Key Vault operation if the recipient is not your installation, and wipes the content key bytes after use. The result is the signed document unverified: decrypting does not prove who signed it.TenantGrantRuntime.applyvalidates in the normative order (TENANT_GRANT_VALIDATION_ORDER). WithexpectedAuthority: { vendorId }onlytercero-<vendorId>-licenciasand its-g<n>generations are valid: a license from Mitnix or another third party givesLG-CHAIN-INVALID. The anchored verifier only accepts an authority the latest bundle declares active, not revoked, valid when it signed and at verification time.- An authority revoked in a new bundle makes its Grants return
LG-AUTHORITY-REVOKED.
What your product provides: the stores
The SDK persists nothing (and creates no timers). Your product implements:
VendorTrustBundleStore: the last applied bundle and a monotonic high-water mark (the highest applied sequence) somewhere a restore of an old bundle copy will not drag it back. This is the anti-rollback: an old bundle is still correctly signed, so only the sequence stops it.TenantGrantStateStore: the protected per-tenant state (current Grant, highest accepted sequence, superseded Grants) with conditional atomic replacement.
The example uses the SDK's ephemeral stores (createEphemeral…), which are not for production: they neither persist nor resist a rollback.
7. Capabilities, limits and quotas
With the applied Grant (AppliedTenantGrant, from store.load(tenantRef)):
| You need | Function | Result |
|---|---|---|
| Can it use the capability now? | requireTenantCapability(grant, capabilityId, now) / tenantHasCapability |
CAPABILITY_NOT_GRANTED without a valid Grant, without the capability or with its entitlement expired |
| Contract validity | tenantGrantTermStatus(grant, now) |
not-yet-valid, active or expired (from entry into force to term.validUntil, inclusive) |
| Validity of an add-on | tenantEntitlementStatus / tenantEntitlementValidUntil |
Each entitlement may have its own validUntil; it never exceeds the contract end |
| Grace after expiry | tenantGrantGrace(grant, now, policies) |
You declare the policies (TenantGracePolicy); the SDK ships none |
| Stock limit (e.g. users) | admitStockOperation(grant, limitId, howMany, now) |
STOCK_LIMIT_REACHED if you already reached the maximum |
| Periodic quotas and prepaid lots | computeQuotaBalance, planReservation, closeReservation, replayConsumptionLedger |
Reserve on accept, charge on completion, release on failure; calendar-month-utc period first, then lots by nearest expiry |
The consumption ledger (ConsumptionLedgerEntry) is yours: the balance is rebuilt from the Grant and the ledger; an inconsistent ledger gives LEDGER_INCONSISTENT and produces no balance.
Combine the Grant with Commerce's status. The signed Grant says what was bought and until when; it does not say it was later refunded. Commerce revokes it (refund, lost dispute, non-payment) and announces it in tenant-license-status: revokedAddons (all confirmed ones if the Grant is revoked) and revokedAt. Also, confirmedEntitlements is what Commerce confirms as acquired: a signed capability that is not listed there is not granted. The example (describir in licencias.ts) does this combination and fails closed: without Commerce's status, it grants nothing.
8. Recommended polling frequency
This is an SDK recommendation, not a server requirement (Commerce sends no Retry-After today).
| Query | Cost | Recommendation |
|---|---|---|
tenantLicenseStatus() |
Commerce database only; no signing or Trust | At most every 5 minutes; it is what detects a refund |
trustBundle() |
Commerce database only; never queries Trust or Azure | At startup and hourly; it brings a revocation of your authority |
tenantGrants() |
Commerce database only | At startup and when tenantLicenseStatus shows a grantId different from the applied one (renewal or upgrade) |
openGrant (open-grant) |
One Trust Key Vault operation per Grant | Once per grantId: do not open one already applied, nor repeat one definitively rejected while the bundle has not changed (the example does it this way) |
confirmPurge |
Idempotent | Only when Commerce asks for it (see "Terminations") |
The SDK retries nothing except the 401, does not wait and does not poll: frequency and waiting are your product's decision, and it should only repeat what is idempotent (queries and confirmPurge).
9. Channel errors
The client throws CommerceChannelError with code, retryable, retryAfterSeconds?, status? and commerceCode?. The message is a fixed SDK text: the server's own message never reaches your product.
| What you see | SDK code |
What it means and what to do |
|---|---|---|
| 401 | COMMERCE_AUTHENTICATION_FAILED |
Commerce does not authenticate you, even after requesting another token. Check issuer, audience and subject in #/canal, your clock, and that the token lives 24 h or less. Repeating does not fix it |
503 INSTALLATION_IDENTITY_UNVERIFIABLE |
COMMERCE_UNAVAILABLE (retryable) |
Your issuer's JWKS did not answer Commerce. It is not your product's fault: retry later |
404 NOT_FOUND on trust/bundle |
COMMERCE_REQUEST_REJECTED |
Commerce does not recognise the token as the identity of any third-party product (the other routes answer 401 in that case). Register the exact issuer, audience and subject in #/canal, for the installation's product and environment |
404 INSTALLATION_TRUST_BUNDLE_NOT_AVAILABLE |
COMMERCE_TRUST_BUNDLE_NOT_AVAILABLE |
There is no trust bundle for your vendor yet (the licensing authority is not approved yet). Wait and tell Mitnix; not your product's fault |
403 INSTALLATION_CUSTODY_SCOPE_FORBIDDEN |
COMMERCE_REQUEST_REJECTED |
Custody only operates with your identity's application and environment. Check the registered product and environment |
| 429, other 5xx, network or timeout | COMMERCE_UNAVAILABLE (retryable) |
Retry later; if Retry-After is present, retryAfterSeconds carries it (capped at 3600 s) |
| 400, 422 and other 4xx | COMMERCE_REQUEST_REJECTED |
Integration failure; do not repeat |
| 3xx | COMMERCE_REDIRECT_REFUSED |
Commerce does not redirect the channel; the SDK refuses any redirect so the token never leaves the origin |
| 2xx that violates the contract | COMMERCE_RESPONSE_INVALID |
You never receive half-parsed data |
| Your token provider fails | COMMERCE_TOKEN_UNAVAILABLE |
Retryable if your provider's failure is |
A failure while opening a Grant arrives as TENANT_GRANT_KEY_OPEN_FAILED with details.causeCode, retryable and retryAfterSeconds (only when they have a safe shape). Verification rejections (LG-CHAIN-INVALID, LG-SIGNATURE-INVALID, LG-AUTHORITY-REVOKED, LG-RECIPIENT-MISMATCH, LG-NOT-YET-VALID, LG-SUPERSEDED…) are final for that document. The example maps them to an action in src/errores.ts. The full list of codes is in the types the packages export: CommerceChannelErrorCode in platform-commerce-client, and TenantGrantValidationCode and TenantGrantJweErrorCode in platform-licensing/tenant-grants.
10. Terminations and refunds
- Refund or lost dispute: Commerce revokes the license; you will see it in
tenantLicenseStatus()(revokedAddons,revokedAt). The signed Grant remains valid until itsterm.validUntil, so your product must query the status and stop granting. - End of contract:
tenantDispositions()tells you, per tenant, whether to block and retain the data (block-and-retain) or delete it (purge), withdataRetainedUntil. After a purge,confirmPurge({ commercialAccountReference, grantId, scope? })confirms to Commerce that you did it.
11. Testing your integration
- Unit tests, no network: the example tests the whole flow against a Commerce simulated at HTTP level (
commerceSimuladoinsrc/pruebas/mundo.ts) with real keys (signed bundle, Grant signed by your authority and by another third party's, JWE encrypted with RSA-3072) and the SDK without doubles. You can copy that double to test your product. - End to end, against the staging Commerce with a test vendor: register your test product's identity, buy with a test card and run the example with your token (see its README).
12. What the SDK does not do
It does not store the token, does not persist the bundle or the Grants, opens no connections of its own beyond fetch, creates no timers or polling, retries nothing except a 401, and never sends your vendorId or any secret. All of that belongs to your product, on purpose: the SDK needs no permission in your environment.
Working example
A minimal SaaS product (Node 22, strict TypeScript) that goes through the whole flow of this guide with the packages published on npmjs. Its tests run offline against a simulated Commerce with real keys.
tar -xzf mitnix-ejemplo-tercero-saas.tar.gz
cd mitnix-ejemplo-tercero-saas
npm install
npm testSHA-256: eccf2461e0eb9e0e36eb277c9eb6b9a39f46822cce076172f1d558ad0c0b1e54