Subscription Entitlements¶
Overview¶
Badger Commerce is sold through Badger Sett as two editions:
| Edition | Sett tool slug | What it is |
|---|---|---|
| Badger Commerce Lite | commerce-lite |
Keyword search, hand-curated collections, no taxonomy engine |
| Badger Commerce Enterprise | commerce-enterprise |
Badger Search, faceted navigation, autocomplete, the taxonomy engine, dynamic collections |
Same platform, same code, same database — the difference is a configuration change, not a replatform. Which means the platform has to know which edition each shop is on. Badger Sett tells it, by webhook, and Badger Commerce writes that down per shop.
Enforcement ships switched off
The gates are built and running, but they refuse nothing until
badger.sett.entitlements.enforce is turned on. Until then they evaluate, log what they
would have refused, and let the request through. See
Enforcement.
Two rules that govern everything¶
Fail open. A shop with no entitlement record has full access. Every shop on the platform predates this system, and none of them may quietly lose a feature because Badger Sett has not got round to telling us about them. "We have never heard of this shop" is not the same as "this shop is entitled to nothing", and the code treats them differently.
Enforcement is a switch, and it starts off. Recording the tier is the immediate goal. Withholding features on the strength of it comes later, once the recorded data has been checked against reality.
The capability vocabulary¶
The capability keys are Badger Sett's, mirrored here verbatim from lib/config/tools.ts in the
Sett repo. They arrive on the wire in the subscription's limits map.
| Capability | Lite | Enterprise |
|---|---|---|
search |
keyword |
badger-search |
facetedNavigation |
false |
true |
autocomplete |
false |
true |
searchSuggestions |
false |
true |
collections |
manual |
dynamic |
productVariants |
true |
true |
taxonomyEngine |
(absent) | true |
taxonomyAttributes |
false |
true |
merchandisingRules |
(absent) | true |
largeCatalogue |
false |
true |
Two things to notice.
taxonomyEngine and merchandisingRules are absent from Lite rather than set to false.
Absent reads as disabled — the two spellings mean the same thing here.
search and collections are string-valued, not booleans. For gating purposes only the
enterprise value counts as enabled (badger-search, dynamic); the raw string is still readable
for code that needs to tell keyword apart from "no value at all".
Where a capability's answer comes from¶
In order:
- No record for the shop → granted. Fail open.
- Record exists, subscription no longer entitling (cancelled, unpaid, incomplete, paused) →
refused.
past_duestill entitles, deliberately: a failed renewal starts the dunning chase, and switching a live shop's search off the moment a card expires is worse than carrying it through the grace period. - The stored
limitsmap carries the key → that value decides it. The map is the live source of truth, because a plan'scustomLimitscan override its edition's defaults. - Otherwise → the edition default from the table above.
- Absent from both → refused.
Step 5 has one exception: if the stored tool slug is not a Badger Commerce edition at all — a corrupt row, or a tool the platform does not model — there is no edition to fall back to, and refusing on the strength of a row we cannot interpret would break the fail-open promise. That case grants, and logs a warning.
Linking a shop to a Badger Sett organisation¶
Sett identifies a customer by organisation, never by shop. The join key is a per-shop setting:
| Setting | Where | Default |
|---|---|---|
Badger Sett Organisation ID (sett-organisation-id) |
Admin → Site Configuration → entitlement-config |
(unset) |
Set it to the Sett organisation that owns the shop's subscription. One organisation may run several shops — set the same value on each, and a subscription change applies to all of them.
Leave it blank and the shop is treated as unsubscribed, which — per the fail-open rule — means it keeps every feature.
The webhook¶
Served by the REST app (api.bdgr.co.uk). Configure this URL as the webhook URL on both the
commerce-lite and commerce-enterprise Tools in Badger Sett.
Authentication is the signature¶
The endpoint is unauthenticated at the security layer. The HMAC is the authentication:
X-Badger-Signature-V2— HMAC-SHA256, hex, over the exact raw bytes of the request body, keyed by the signing secret of the Sett Tool named indata.toolSlug.- Compared in constant time.
- The older
X-Badger-Signaturecovers only the re-serialiseddatasub-object, which would mean reproducing JavaScript'sJSON.stringifybyte-for-byte from Java. It is ignored — accepting a weaker signature as a fallback would just move the attack to the weaker one.
The two editions are separate Tools in Sett with separate signing secrets, so the secret is
looked up per tool slug. A delivery naming a tool we hold a secret for is bound to that secret,
which is what stops a leaked commerce-lite secret being used to assert an enterprise
subscription. A delivery naming no tool at all (Sett's user and organisation provisioning events
are not tied to one) falls back to trying every configured secret.
The signature covers the body, so the id and type inside the envelope are authenticated
while the X-Badger-Event-ID and X-Badger-Event-Type headers are not. The signed values win; the
headers are only a fallback for a payload that omits them.
Payload¶
{
"id": "evt_01J8Z4Q0000000000000000000",
"type": "ORGANISATION_SUBSCRIPTION_UPDATED",
"occurredAt": "2026-08-24T10:30:00Z",
"data": {
"organisationId": "org_abc",
"toolSlug": "commerce-enterprise",
"subscription": {
"status": "active",
"plan": "growth",
"currentPeriodEnd": "2026-09-24T10:30:00Z",
"limits": { "search": "badger-search", "facetedNavigation": true }
}
}
}
What gets acted on¶
| Event | Behaviour |
|---|---|
ORGANISATION_SUBSCRIPTION_CREATED |
Recorded |
ORGANISATION_SUBSCRIPTION_UPDATED |
Recorded |
ORGANISATION_SUBSCRIPTION_CANCELLED |
Recorded — status only; the plan and capability map we already had are kept, so the record still shows what the shop used to have |
ORGANISATION_PROVISIONED |
Acknowledged, ignored |
USER_PROVISIONED / USER_DEPROVISIONED / USER_ROLE_UPDATED |
Acknowledged, ignored |
| Anything Sett adds later | Acknowledged, ignored |
Why almost everything answers 2xx¶
Badger Sett retries a non-2xx delivery five times — after 1m, 5m, 15m, 1h and 4h — and then drops it permanently. So anything other than "we cannot authenticate you" or "we cannot read this" is acknowledged:
| Response | When |
|---|---|
200 APPLIED |
Recorded against at least one shop |
200 DUPLICATE |
A retry of something already handled |
200 IGNORED |
An event type this platform does not act on |
200 NOT_A_COMMERCE_TOOL |
A subscription for another Badger tool |
200 UNMAPPED_ORGANISATION |
No shop has that sett-organisation-id. Logged as a warning — it is a configuration gap here, and retrying cannot fix it. Once the organisation id is set on the shop, use the refresh below to pick the subscription up; the missed webhook will not be redelivered |
401 |
Signature missing, malformed, or not verifying |
400 |
Body unreadable as an event envelope |
Idempotency¶
Deliveries are deduplicated on the signed event id, which is stored as the primary key of a receipt document. The insert is the lock: two nodes handed the same retry at the same moment both try, and exactly one wins on the primary key. Receipts expire after 30 days.
If processing fails midway, the claim is released so Sett's retry can still work — a transient database blip must not be recorded as "handled".
Where the gates are¶
Each gate sits at the single chokepoint everything else funnels through, and returns something callers already handle. That is the point: the "not entitled" path is an existing, tested path, not a new error case.
| Capability | Chokepoint | Not entitled → |
|---|---|---|
taxonomyEngine |
TaxonomyService.getActiveTaxonomy |
null — the same as a shop with no active taxonomy. Facet defaults, the search indexer's node resolution, the admin panels and the MCP tools all read through here |
facetedNavigation |
MerchandisingTreeService.resolveFacetConfig |
An empty facet config — the facet rail does not render; products, sorting and pagination are untouched |
autocomplete |
MerchandisingQueryService.suggest and MerchandisingTreeService.suggestNodes |
An empty list — the type-ahead dropdown stays shut, the search box still submits |
Not yet gated: the search engine itself
Lite's search: 'keyword' says it does not integrate Badger Search at all, but
MerchandisingQueryService.search is deliberately left ungated. Refusing it outright would
leave Lite shops with no search rather than keyword search, and the keyword fallback does not
exist yet. Gating it needs that fallback built first.
Enforcement is a switch, and it starts off¶
Set in the web, worker and rest apps — the storefront renders gated features, and the worker's search indexer resolves merchandising nodes through the taxonomy, so they have to agree. Keep the three in step or indexing and rendering will disagree about what a shop is entitled to.
With enforcement off (the default), a gate that would have refused something logs it and allows it anyway:
Entitlement shadow-mode: would have refused taxonomyEngine for site acme
(enforcement is off, so it is being allowed)
That log is the point. It lets the recorded entitlement data be checked against what shops are actually using before anything is taken away. Note that a shop with no entitlement record never produces one of these lines at all — it is granted outright, not shadow-granted.
With enforcement on, the same decision is logged as a refusal and takes effect.
This is the same shape as badger.auth.sett.enforce-federated-only in the auth module: build the
enforcement, ship it inert, turn it on per deployment once the shadow log says it is safe.
Drift, and how to repair it¶
A webhook can be lost for good — five failed retries and Sett stops. A shop upgraded during an outage on this side would then sit on a stale tier indefinitely, with nothing to signal it.
Two admin endpoints cover it. Both require an admin or superadmin token and act on the shop in the request's tenant context.
GET /v1/admin/entitlement # what we last heard, and when
POST /v1/admin/entitlement/reconcile # go and ask Badger Sett again
GET returns entitled: false for a shop no subscription has ever been recorded for — which,
again, means it keeps everything rather than nothing. It also reports whether enforcement is
currently on.
POST .../reconcile asks Badger Sett what the organisation is currently subscribed to
(GET /api/internal/subscriptions/by-tool) and rewrites the record from the answer. It tries the
recorded edition first, then the other one — because the drift being repaired might be exactly an
upgrade that was missed.
| Result | Meaning |
|---|---|
REFRESHED |
Sett answered and something changed |
UNCHANGED |
Sett answered and we were already up to date |
NOT_MAPPED |
The shop has no sett-organisation-id |
NO_SUBSCRIPTION |
Sett has no Badger Commerce subscription for that organisation |
UNAVAILABLE |
No Sett API configuration, or Sett could not be reached |
Why admin-triggered rather than a scheduled sweep
Sett's internal API answers one organisation and one tool at a time, so a fleet-wide sweep would
be a call per shop per edition against a partner's API, to repair something that should almost
never be wrong — and it would have to run in the worker, which would then need its own copy of
the Sett API keys. Drift is rare, visible through the GET above, and fixable with one request
by whoever spots it. If that stops being true, a scheduled job would call exactly the same
SettSubscriptionService.reconcile method.
There is no Thymeleaf admin panel for this yet — the endpoints are the interface today.
Deployment configuration¶
badger:
sett:
webhook:
signing-secrets:
commerce-lite: ${SETT_WEBHOOK_SECRET_COMMERCE_LITE:}
commerce-enterprise: ${SETT_WEBHOOK_SECRET_COMMERCE_ENTERPRISE:}
api:
base-url: ${SETT_API_BASE_URL:}
keys:
commerce-lite: ${SETT_API_KEY_COMMERCE_LITE:}
commerce-enterprise: ${SETT_API_KEY_COMMERCE_ENTERPRISE:}
entitlements:
enforce: ${SETT_ENFORCE_ENTITLEMENTS:false}
These are deployment secrets rather than per-shop configuration, for a structural reason: an inbound webhook has no tenant. It identifies the shop by a Sett organisation id, which is the thing being looked up — so there is no shop whose configuration the secret could be read from.
Sett enforces that an API key may only query its own tool, which is why the keys are per slug too.
Everything is optional. A deployment with no secrets configured refuses every delivery with a 401 and cannot reconcile — which is correct for a dev box with no Sett behind it, and is why local development works without one.
What is stored¶
One document per shop:
| Field | |
|---|---|
siteId |
The shop |
organisationId |
The Badger Sett organisation |
toolSlug |
The edition — commerce-lite or commerce-enterprise |
plan |
Plan slug within the edition |
status |
Subscription status as Sett spelled it |
currentPeriodEnd |
End of the billing period |
capabilities |
The limits map, verbatim |
sourceEventId |
The event (or reconcile: marker) that last set this |
updatedAt |
When we last wrote it |
The capability map is stored whole rather than projected onto known keys, so a capability Sett
adds tomorrow is already on disk by the time this platform learns to read it — and the numeric plan
limits (maxProducts, perOrderFee, …) stay available without a second round trip.
See also¶
- Badger Sett Federation — operator sign-in federating to the same platform
- Search and Discovery
- Multi-Tenancy