External software-licensing integration

Bazaar is FlexGalaxy’s marketplace for edge-deployable software (applets and OS-layer firmware today). Its core loop is Publish → Get → Assign → Install → Run → Pay: a developer publishes an installable with listing, pricing, and rollout rules; a tenant admin subscribes an account and assigns the installable to a specific terminal; the terminal pulls the signed artifact and validates a per-(terminal, installable) license certificate at startup; the developer is paid — without any actor touching the underlying signing, PKI, distribution, or billing systems directly.

Most offers are approved inside Bazaar by the tenant admin. This page documents the alternative: offers whose approval is delegated to an external licensing authority — the use case an outside integrator (an ISV, a compliance desk, or a partner licensing system) implements.

When this applies

Each offer carries an approval policy. When an offer’s approval policy is EXTERNAL, every license request against that offer is routed to a nominated external authority for the approve / refuse decision instead of being decided by the tenant admin. Bazaar remains the system of record and performs issuance after an approval; the external authority owns only the decision.

The authority is a distinct account from the publisher: whoever publishes the software and whoever decides its licensing are separate roles, and an EXTERNAL offer names its authority account explicitly. You may also hand the decision back to the customer for some or all accounts while remaining the authority of record — see Delegating approval to the owner.

Roles

Role

Who

Responsibility

Requester

Tenant / tenant admin

Creates a license request against an EXTERNAL-policy offer.

External licensing authority

The integrator

Polls for pending requests and returns approve / refuse / request-information decisions. This is the role you implement.

Bazaar

Platform

Routes the request, records the decision, issues the signed license grant on approval, and exposes reconciliation feeds.

The external authority is a machine actor. It authenticates with a DotID service-user access key (AK/SK), not a human login.

Lifecycle

request created ──▶ PENDING ──▶ APPROVED  ──▶ (Bazaar issues license grant)
                          │
                          ├──▶ REFUSED
                          └──▶ INFORMATION_REQUIRED ──▶ (requester supplies more) ──▶ PENDING

After issuance, an approved grant can be compensated (revoked or superseded) by the same external authority — for example when a downstream contract lapses.

Authentication — DotID AK/SK (FGAI-HMAC-SHA256)

Requests are signed with a DotID service user’s access key. Bazaar never sees your secret key; it forwards each signature to DotID for verification and derives an account-scoped service principal from the result.

Requirements for the key:

  • It must belong to a service user (machine principal), not a human user.

  • It must be scoped to a single account — the account that owns the license requests you will decide. A key can only see and decide requests in its own account.

  • It must be issued with a credential scope of bazaar or software-licensing. A key with any other scope is rejected.

Signing follows the FGAI-HMAC-SHA256 scheme (a SigV4-shaped derived-key signature). Each request carries:

  • Authorization: FGAI-HMAC-SHA256 Credential=<accessKeyId>/<date>/<region>/<service>/fgai4_request, SignedHeaders=host;x-fgai-content-sha256;x-fgai-date, Signature=<hex>

  • X-FGAI-Date: <YYYYMMDDTHHMMSSZ>

  • X-FGAI-Content-SHA256: <sha256-hex of the request body>

  • Host: api.flexgalaxy.ai

A reference signer is provided; you do not compute the derived-key chain by hand.

Authorization

The service user must hold a permission granting the external-licensing decision actions in its account:

  • bazaar:ExternalLicenseRequest:Read — poll pending requests and the change feed

  • bazaar:ExternalLicenseRequest:Decide — approve / refuse / request-information

  • bazaar:ExternalLicenseRequest:Compensate — revoke / supersede after issuance

  • bazaar:ExternalLicenseRequest:Sandbox — drive the conformance sandbox

  • bazaar:ExternalLicenseDelegation:Manage — delegate approval of an offer, and take it back

  • bazaar:ExternalLicenseWebhook:Manage — register callbacks

These are granted by an account-scoped policy attached to your service user. Without the :Decide grant, decision calls fail closed with 403.

Reading the licensed-subject roster is a separate, independently grantable permission:

  • bazaar:ExternalLicenseMeteringSubject:Read — list the subjects you are the licensor for

It is deliberately not part of the decision policy: you can be given the roster without decision authority, or decision authority without the roster.

API surface

All paths are product-neutral and served from https://api.flexgalaxy.ai.

Purpose

Call

Poll your work list

GET /external-software-licensing/v1/requests?limit=50

Read other states

GET /external-software-licensing/v1/requests?state=DRAFT,ISSUED

Submit a decision

POST /external-software-licensing/v1/requests/{requestId}/decisions

Delegate an offer’s approval

POST /external-software-licensing/v1/offers/{offerId}/approval-delegation

Change feed (reconcile)

GET /external-software-licensing/v1/changes?cursor=<cursor>

Licensed-subject roster

GET /external-software-licensing/v1/metering-subjects?limit=50

Register a webhook

POST /external-software-licensing/v1/webhooks

Compensate a grant

POST /external-software-licensing/v1/requests/{requestId}/compensations

Conformance sandbox

POST /external-software-licensing/v1/sandbox/scenarios/{scenario}

GET …/requests returns your work list: the requests you can still decide — PENDING, INFORMATION_REQUIRED, and the APPROVED device requests still inside the override window. Requests nobody has submitted are not on it, because a DRAFT permits no decision and answers 409 decision_not_allowed. Pass state with one name, or several separated by commas, to read any other part of your book; unknown names are rejected rather than quietly matching nothing.

Submitting a decision

POST /external-software-licensing/v1/requests/{requestId}/decisions requires an Idempotency-Key header (safe to retry) and a body:

{
  "decision": "APPROVE",
  "expectedRevision": 1,
  "externalReference": "WHITE-CASE-4711",
  "reasonCode": "APPROVED_BY_POLICY",
  "safeExplanation": "License approved per contract terms.",
  "quantity": 3,
  "validityDays": 90
}
  • decision is one of APPROVE, REFUSE, REQUEST_INFORMATION (→ request state APPROVED, REFUSED, INFORMATION_REQUIRED).

  • expectedRevision is the request’s current revision, read from the poll response — optimistic concurrency. A stale value returns 409 (revision conflict); re-read and retry.

  • For a refusal, send "decision": "REFUSE" with a reasonCode and a safeExplanation (user-safe text). The term fields are for an approval only and are rejected with 400 on a REFUSE or a REQUEST_INFORMATION, which grant nothing.

Approving different terms than were asked for. An approval may narrow a request, and the narrowed terms are what the platform issues:

  • quantity — the seats you approve. Between 1 and the quantity the request asked for; outside that range is 400. Omit it to approve the full request. The issued grant carries this number, and it is the number the customer can assign: an approved 3 of 5 yields exactly three assignable seats, and the fourth assignment is refused.

  • validityDays — the window you approve, 13650. Omit it for the platform default of 365 days. The issued grant’s validTo, and the validTo claim inside the signed licence certificate, are computed from this value.

Both are also recorded with the decision as your evidence of the terms you approved and are readable back on the request’s ledger.

scope and constraints are not accepted. Sending either is rejected with 400 decision_term_not_supported, naming the field. Issuance has no defined behaviour for them — there is no rule for what narrowing a scope would mean and nothing that enforces one — so rather than accept a term and ignore it, this API refuses it. If your licensing policy needs a term these fields would carry, refuse the request with a reasonCode and have it re-filed on terms you can grant.

  • externalReference is unique per account. Re-using one for a different decision is refused with 409 external_reference_conflict — which is what makes it a safe key to look a case up by in both systems.

Response:

{ "decisionId": "…", "eventId": "…", "state": "APPROVED", "revision": 2, "issuanceState": "…" }

Whose account a request lands in

A request names its subject — for a robot, subjectFrn is that robot. The account the request is held by is not taken from whoever filed it: the platform resolves the robot’s owner from its own device inventory at request time, and the request, the resulting license and the bill all land in that owner’s account.

Two consequences for your integration:

  • ownerAccountId on a row is the tenant, not the requester. A head office, a partner, or a service organization with delegated authority can file the request; the owner you address about that robot is still the account on the row.

  • An unknown or unowned robot is refused, not guessed at. There is no fallback to the caller’s own account for a robot the platform cannot resolve an owner for.

Requests filed by the robot itself

A robot can request a license for its own identity. You will see these on your work list like any other request, with two differences worth handling:

  • They arrive already PENDING. There is no DRAFT step, because no person is going to press submit, so a self-request is decidable the moment you first see it.

  • Nobody filed it on a human’s behalf. The subject is derived by the platform from the device, never taken from the request body, so the subject on the row is an attested fact rather than a claim to check.

Everything else is identical: a self-request is an ordinary approval-gated request. It is never auto-approved, and it carries the same states, the same revision concurrency, and the same idempotency rules.

Delegating approval to the owner

You can hand the approve/refuse decision for one of your offers back to the customer — their own administrators then decide in the console, with no round trip to you — while remaining the authority of record.

POST /external-software-licensing/v1/offers/{offerId}/approval-delegation
{ "ownerAccountId": "acct-4711", "delegated": true }

or, scoped to a branch of the customer’s organization rather than one account:

{ "organizationId": "…", "subtreeOuId": "…", "delegated": true }

Field

Meaning

ownerAccountId

delegate for one owner account. Omit it (and the organization fields) to delegate for every account that takes this offer.

organizationId + subtreeOuId

delegate for an organizational unit and everything beneath it. Accounts added to that branch later inherit the delegation.

delegated

true to delegate, false to take it back. Required.

Send either ownerAccountId or both organization fields — mixing the two, or sending only one of the pair, is 400 approval_delegation_scope_invalid. Setting a delegation twice is idempotent and returns the existing one.

Response:

{ "delegationId": "…", "offerId": "…", "scopeType": "ORGANIZATION_SUBTREE",
  "ownerAccountId": null, "organizationId": "…", "subtreeOuId": "…",
  "delegated": true, "lifecycleVersion": 2 }

Only the offer’s external authority may delegate it — the publisher of the software cannot, and neither can anyone else. An offer you are not the authority for answers 404 software_offer_not_found rather than 403, so the call cannot be used to discover that an offer exists.

You keep the last word

Delegating is not giving the decision away:

  • Delegated requests stay on your work list. They carry "decisionAuthority": "BAZAAR" (a direct one carries "EXTERNAL"), so you can tell the two apart. Deciding a delegated request overrides the owner, and is recorded as an override.

  • A robot-held license has a veto window. After the owner approves, you may still refuse — APPROVEDREFUSE — for as long as issuance has not begun. Once the request reaches issuing, the window has closed and the refusal is 409. That race is settled by the same expectedRevision check as every other decision.

  • Revoking a delegation fails in-flight work closed. Send "delegated": false, and the next decision or issuance on a request that was routed under it is refused instead of quietly proceeding. The same happens if the owner account leaves the delegated branch of the organization. The delegation record is kept either way, so the provenance of an already-decided request survives.

  • Membership has to be resolvable. An organization-subtree delegation is evaluated against live organization membership. If that cannot be read, the request fails closed with 503 approval_delegation_membership_unavailable — retry it rather than treating it as “not delegated”.

Licensed-subject roster

GET /external-software-licensing/v1/metering-subjects lists the subjects — robots — currently under your license authority. Use it to answer “which terminals am I the licensor for right now”, and to reconcile against your own records.

A subject is on the roster while its license actually confers authority. When a grant is revoked, or a seat is unassigned, the subject leaves. Reconciliation is therefore a set difference: anything in your records that is not on the roster is no longer licensed by you.

Each row:

{
  "cursor": "1408:",
  "subjectFrn": "frn:acct-4711:thingmake:thing/robot-0912",
  "ownerAccountId": "acct-4711",
  "offerId": "…",
  "licenseRequestId": "…",
  "licenseState": "ISSUED",
  "licenseHolder": "DEVICE",
  "grantId": "…",
  "grantState": "ACTIVE",
  "assignmentId": null,
  "licensedQuantity": 1,
  "validFrom": "…",
  "validTo": "…"
}
  • ownerAccountId is the tenant that owns the robot — not your account and not the publisher’s. It is the account you address for anything about that robot.

  • licenseHolder is DEVICE or ACCOUNT. A DEVICE-held license is borne by the robot itself, so it contributes exactly one row and assignmentId is null. An ACCOUNT-held license is a pool of seats the tenant assigns, so it contributes one row per assigned terminal, each with its own assignmentId.

  • licensedQuantity is the licensed entitlement (seats), not a measurement.

This endpoint returns no usage. Bazaar knows what is licensed, not what a robot did. Usage counters are a separate capability that each device owner grants you directly and can revoke at any time, without affecting this roster.

Reconciling the roster against usage

The roster is the denominator of a metering run: it answers “which robots am I the licensor for right now”. The counters are read from the device platform’s own usage-metering capability, which each device owner grants you per scope and can revoke independently — see App-scoped telemetry and usage metering in the FlexGalaxy.AI device-platform developer documentation for the counters, their buckets, and the grant an owner writes for you.

Reconcile in that order, and the failure modes stay distinguishable:

  1. Walk the roster to hasMore: false and take the set of subjectFrn values.

  2. Read usage per owner scope for the robots in that set.

  3. A robot on the roster with no counters is licensed but not being measured — either it has not reported, or that owner has not granted you the usage capability. It is not an unlicensed robot.

  4. A robot with counters but not on the roster is one your license no longer covers. Stop billing for it; the change feed tells you when and why it left.

The two halves are deliberately independent: an owner revoking measurement must not look like a license lapsing, and a license lapsing must not look like a quiet fleet.

Paging the roster

after is an opaque cursor string taken from nextCursor — not the plain number used by …/requests and …/changes. One license request can contribute many subjects, so a row needs more than a single number to address it. Pass the previous response’s nextCursor verbatim.

GET …/metering-subjects?limit=50
GET …/metering-subjects?after=<nextCursor>&limit=50

Keep paging while hasMore is true. limit defaults to 50 and is capped at 100. A cursor that is not a value we handed you is rejected with 400 rather than silently restarting — so a partial read can never be mistaken for a complete one. Because the roster is what you make absence decisions from, always walk to hasMore: false before concluding a subject is gone.

Reconciliation

Two mechanisms keep an external authority consistent with Bazaar:

  • Change feedGET …/changes?cursor=<cursor> returns an ordered, cursor-paged stream of request/decision changes. Persist the cursor and resume from it; this is the source of truth for catch-up after downtime.

  • Webhooks — register an HTTPS callback to be notified of new pending requests. Webhooks are an optimization; the change feed remains authoritative, so a missed webhook is always recoverable by replaying the feed.

Conformance sandbox

Before going live, exercise the integration against POST …/sandbox/scenarios/{scenario} with scenarios covering the paths you must handle: SUCCESS, REFUSAL, CONCURRENCY (revision conflict), RETRY (idempotent replay), MISSED_WEBHOOK, and ISSUANCE_FAILURE.

Integration checklist

  1. Obtain an account-scoped service user and an AK/SK for it, minted with credential scope bazaar or software-licensing.

  2. Ensure the service user holds the bazaar:ExternalLicenseRequest:{Read,Decide, Compensate} and bazaar:ExternalLicenseWebhook:Manage permissions in its account.

  3. Implement the signer for FGAI-HMAC-SHA256.

  4. Poll GET …/requests, or register a webhook.

  5. For each request, POST …/decisions with the matching expectedRevision and an Idempotency-Key.

  6. Consume GET …/changes to reconcile, and handle 409 by re-reading revision.

  7. Handle requests that arrive already PENDING with no human requester — a robot asking for its own license — and address the owner named on the row, not the caller who filed it.

  8. If you delegate approval to customers, add bazaar:ExternalLicenseDelegation:Manage, and keep polling: a delegated request still reaches your work list with "decisionAuthority": "BAZAAR", and overriding it is your call to make.

  9. If you meter licensed robots, add bazaar:ExternalLicenseMeteringSubject:Read and walk GET …/metering-subjects to hasMore: false.

  10. Validate every path against the conformance sandbox before production.