Skip to content

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:

  1. No record for the shop → granted. Fail open.
  2. Record exists, subscription no longer entitling (cancelled, unpaid, incomplete, paused) → refused. past_due still 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.
  3. The stored limits map carries the key → that value decides it. The map is the live source of truth, because a plan's customLimits can override its edition's defaults.
  4. Otherwise → the edition default from the table above.
  5. 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

POST /v1/webhooks/sett/subscriptions

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 in data.toolSlug.
  • Compared in constant time.
  • The older X-Badger-Signature covers only the re-serialised data sub-object, which would mean reproducing JavaScript's JSON.stringify byte-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

badger:
  sett:
    entitlements:
      enforce: ${SETT_ENFORCE_ENTITLEMENTS:false}

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