Catalog API

Create and manage entitlements, products, the mappings between them, and paywall offerings from code. Everything the dashboard's catalog screens do, scriptable, and re-runnable without side effects.

Base URLhttps://entitlehub.com/v1
AuthAuthorization: Bearer sk_live_…, a secret key on every call, reads included
FormatJSON in, JSON out. Creates return 201.
Rate limit120 requests / minute / project (X-RateLimit-Limit, -Remaining, -Reset on every response; 429 + Retry-After past it)
PriceIncluded on every plan, free tier too
Why reads need a secret key too

Catalog responses include business data such as how many subscribers hold each entitlement. A publishable key ships inside your app binary, where anyone can extract it. For the paywall-facing view of your catalog, GET /v1/offerings and /v1/offerings/current still accept a pk_ key.

Set up a catalog in three calls

One entitlement, the products that grant it, and the paywall that sells them. Each call is safe to re-run: a duplicate answers 409 with the row that already exists, so a setup script can treat that as done.

bash
KEY="sk_live_..."

# 1. The entitlement your app checks with isEntitled("hd_export")
curl -X POST https://entitlehub.com/v1/entitlements \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"key":"hd_export","name":"HD Export","description":"Export at full resolution"}'

# 2. A product per store, each mapped to the entitlement at creation
curl -X POST https://entitlehub.com/v1/products \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"store":"app_store","store_product_id":"com.example.unlock","type":"non_consumable",
       "display_name":"Unlock","price":4.99,"entitlements":["hd_export"]}'

curl -X POST https://entitlehub.com/v1/products \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"store":"stripe","store_product_id":"price_1Q...","type":"non_consumable",
       "display_name":"Unlock (web)","price":4.99,"entitlements":["hd_export"]}'

# 3. The offering your paywall fetches, with its packages
curl -X POST https://entitlehub.com/v1/offerings \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"identifier":"default","is_current":true,"metadata":{"headline":"Go HD"},
       "packages":[{"identifier":"unlock","store":"app_store","store_product_id":"com.example.unlock"},
                   {"identifier":"unlock","store":"stripe","store_product_id":"price_1Q..."}]}'

From Node, the same thing through @entitlehub/sdk (0.3.0 or later). On entitlements and products, ensure returns the existing row instead of a 409:

typescript
import { EntitleHubServer } from "@entitlehub/sdk";
const eh = new EntitleHubServer({ apiKey: process.env.ENTITLEHUB_SECRET_KEY! });

await eh.entitlements.ensure({ key: "hd_export", name: "HD Export" });
await eh.products.ensure({ store: "app_store", storeProductId: "com.example.unlock",
  type: "non_consumable", priceMicros: 4_990_000, entitlements: ["hd_export"] });
await eh.offerings.create({ identifier: "default", isCurrent: true,
  packages: [{ identifier: "unlock", product: { store: "app_store", storeProductId: "com.example.unlock" } }] });

Addressing

Every path segment accepts the id or the name you already know:

SegmentAccepts
{entitlement}its id (ent_…) or its key (pro)
{product}its id (prod_…) or its store_product_id. The same SKU on two stores is ambiguous: add ?store=play, or you get 409 ambiguous_product listing the candidates.
{offering}its id or its identifier (default)
{package}its id, or its identifier when that identifier is used on only one store

In request bodies, a product reference is an id, a unique SKU, or {"store":"play","store_product_id":"…"}. Entitlement references are keys.

Entitlements

Method & pathDoes
GET /v1/entitlementsList. Add ?include_archived=true for archived ones.
POST /v1/entitlementsCreate: key (required), name, description, products (attach in the same call)
GET /v1/entitlements/{entitlement}Retrieve
PATCH /v1/entitlements/{entitlement}Update name, description; products replaces the set of products that grant it
DELETE /v1/entitlements/{entitlement}Delete one nobody has ever been granted (else 409, archive it)
GET /v1/entitlements/{entitlement}/productsThe products that grant it
POST /v1/entitlements/{entitlement}/attach_products{"products":[…]}, up to 100
POST /v1/entitlements/{entitlement}/detach_products{"products":[…]}
POST /v1/entitlements/{entitlement}/archive · /unarchiveRetire or restore
http
GET /v1/entitlements/hd_export
Authorization: Bearer sk_live_…

200 OK
{
  "object": "entitlement",
  "id": "ent_4f1c9a0b2d3e5f67",
  "key": "hd_export",
  "name": "HD Export",
  "description": "Export at full resolution",
  "products": [
    { "id": "prod_9a…", "store": "app_store", "store_product_id": "com.example.unlock" },
    { "id": "prod_b2…", "store": "stripe", "store_product_id": "price_1Q..." }
  ],
  "active_grants": 128,
  "created_at": "2026-09-19T06:02:11Z",
  "archived_at": null
}

Keys are 1-100 characters of letters, digits, _, - or ., matched exactly (so keys imported from RevenueCat keep working as-is). A key can't be changed after creation: shipped apps check that exact string. A PATCH that tries is refused with 400 field_immutable, never silently ignored.

Products

Method & pathDoes
GET /v1/productsList. Filters: ?store=, ?entitlement=, ?include_archived=true
POST /v1/productsCreate and map in one call (fields below)
GET /v1/products/{product}Retrieve
PATCH /v1/products/{product}Update display_name, price_micros or price, currency; entitlements replaces what it grants
DELETE /v1/products/{product}Delete one nobody has ever bought (else 409, archive it)
POST /v1/products/{product}/attach_entitlements{"entitlements":["pro"]}
POST /v1/products/{product}/detach_entitlements{"entitlements":["pro"]}
POST /v1/products/{product}/archive · /unarchiveRetire or restore a SKU
Create field
storerequired: app_store · play · stripe · web · amazon
store_product_idrequired: exactly as registered with the store (Stripe: the Price id)
typerequired: subscription · non_consumable · consumable
durationrequired for subscriptions: P1W · P1M · P3M · P6M · P1Y. Never guessed, since it drives MRR.
price_micros or priceinteger micros (4990000) or a decimal (4.99), not both
currencyISO 4217, default USD
display_namedefaults to the SKU
entitlementskeys this product grants. An unknown key is 400 unknown_entitlements naming it, never a product that silently grants nothing.
bash
# Same SKU on two stores: say which one
curl "https://entitlehub.com/v1/products/pro_monthly?store=play" -H "Authorization: Bearer $KEY"

# Remap: the Play product now grants pro AND team
curl -X PATCH "https://entitlehub.com/v1/products/pro_monthly?store=play" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"entitlements":["pro","team"]}'

Mapping products to entitlements

Three ways, pick whichever fits the script you're writing:

  • At creation: entitlements on a new product, or products on a new entitlement.
  • Declaratively: PATCH with the full desired set; EntitleHub works out what to attach and detach.
  • Incrementally: attach_* / detach_* from either side. Idempotent, and all-or-nothing: one unknown reference fails the call and names every miss.
Remapping never takes access away

A mapping change applies to futurepurchases. Grants already issued keep the entitlement they were issued for. To end someone's access, use revoke.

Archive vs delete

Delete is for mistakes: a product nobody bought, an entitlement nobody was granted. Once anything references it, even an expired or revoked grant, deleting would rewrite purchase history, so it's refused with 409 product_in_use / entitlement_in_use and the grant count.

Archive is how you retire a row with history:

  • An archived product leaves listings and every paywall, so nobody new buys it. Renewals, restores and store notifications for it still ingest: existing subscribers keep their access.
  • An archived entitlement leaves listings. check() keeps answering for everyone who holds it.
  • Both are reversible with /unarchive, and archiving twice keeps the original archived_at.

Offerings & packages

An offering is the set of packages a paywall shows; the SDK fetches the current one. A package points at one store product, so offer monthly on iOS and Android by adding it once per store.

Method & pathDoes
POST /v1/offeringsCreate: identifier, display_name, is_current, metadata, packages. The first offering becomes current automatically.
GET /v1/offerings/all?include_archived=trueList every offering, archived included (secret key; without the flag it's the SDK's view)
GET /v1/offerings/{offering}Retrieve, with package ids
PATCH /v1/offerings/{offering}display_name, metadata (null clears), is_current: true (the old current steps down in the same transaction)
DELETE /v1/offerings/{offering}Delete, with its packages
POST /v1/offerings/{offering}/archive · /unarchiveHide from / return to the SDK
GET · POST /v1/offerings/{offering}/packagesList, or add: identifier, product (or store + store_product_id), position, display_name
GET · PATCH · DELETE /v1/offerings/{offering}/packages/{package}display_name, position, or product to repoint it at a new price

metadata is any JSON object up to 16 KB, returned with the offering by /v1/offerings/current: paywall copy, colors, flags. Change it and every paywall updates without an app release.

Guard rails

The current offering can't be deleted, archived, or set to is_current: false: apps would be left with no paywall. Make another offering current first. Offerings a running experiment serves are protected the same way. Archived products can't be added to a package. all and current are reserved identifiers.

Errors

Every error has the same shape: a readable error, a stable code to branch on, and sometimes context.

json
{ "error": "Product stripe/price_1Q... already exists.",
  "code": "product_exists",
  "existing": { "object": "product", "id": "prod_b2…", ... } }
StatuscodeMeaning
400invalid_request · invalid_jsonA field is missing or malformed; the message names it
400unknown_entitlementsunknown lists the keys that don't exist
400field_immutableTried to change a key, SKU, store, type, duration or identifier
401unauthorizedMissing, invalid or revoked key
403secret_key_requiredSent a pk_ key
404*_not_found · unknown_actionNo such entitlement, product, offering or package (missing lists them in bulk calls)
409entitlement_exists · product_exists · offering_exists · package_existsAlready there; existing carries it
409ambiguous_product · ambiguous_packageName matches several; candidates lists them
409product_in_use · entitlement_in_useHas grant history; grants is the count. Archive instead.
409offering_is_current · offering_in_experiment · offering_archived · product_archivedA guard rail above
429rate_limitedWait Retry-After seconds

Coming from RevenueCat API v2

RevenueCatEntitleHub
POST /projects/{id}/entitlements then …/actions/attach_productsPOST /v1/entitlements with products: one call
POST /projects/{id}/products, then attach from the entitlementPOST /v1/products with entitlements: one call
…/entitlements/{id}/actions/attach_products · detach_products/v1/entitlements/{key}/attach_products · detach_products, plus the product-side equivalents
…/actions/archive · unarchive/archive · /unarchive on entitlements, products and offerings
Offerings, packages, metadata/v1/offerings and /v1/offerings/{offering}/packages
Paths take RevenueCat's generated idsPaths take ids or the key / SKU / identifier you already have
Project config: 60 requests / minute120 requests / minute

Moving an existing RevenueCat catalog? Dashboard → Import copies its entitlements, products and mappings in one step with a RevenueCat v2 secret key. See Migrating from RevenueCat.