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 URL | https://entitlehub.com/v1 |
| Auth | Authorization: Bearer sk_live_…, a secret key on every call, reads included |
| Format | JSON in, JSON out. Creates return 201. |
| Rate limit | 120 requests / minute / project (X-RateLimit-Limit, -Remaining, -Reset on every response; 429 + Retry-After past it) |
| Price | Included on every plan, free tier 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.
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:
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:
| Segment | Accepts |
|---|---|
{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 & path | Does |
|---|---|
GET /v1/entitlements | List. Add ?include_archived=true for archived ones. |
POST /v1/entitlements | Create: 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}/products | The 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 · /unarchive | Retire or restore |
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 & path | Does |
|---|---|
GET /v1/products | List. Filters: ?store=, ?entitlement=, ?include_archived=true |
POST /v1/products | Create 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 · /unarchive | Retire or restore a SKU |
| Create field | |
|---|---|
store | required: app_store · play · stripe · web · amazon |
store_product_id | required: exactly as registered with the store (Stripe: the Price id) |
type | required: subscription · non_consumable · consumable |
duration | required for subscriptions: P1W · P1M · P3M · P6M · P1Y. Never guessed, since it drives MRR. |
price_micros or price | integer micros (4990000) or a decimal (4.99), not both |
currency | ISO 4217, default USD |
display_name | defaults to the SKU |
entitlements | keys this product grants. An unknown key is 400 unknown_entitlements naming it, never a product that silently grants nothing. |
# 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:
entitlementson a new product, orproductson 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.
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 originalarchived_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 & path | Does |
|---|---|
POST /v1/offerings | Create: identifier, display_name, is_current, metadata, packages. The first offering becomes current automatically. |
GET /v1/offerings/all?include_archived=true | List 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 · /unarchive | Hide from / return to the SDK |
GET · POST /v1/offerings/{offering}/packages | List, 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.
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.
{ "error": "Product stripe/price_1Q... already exists.",
"code": "product_exists",
"existing": { "object": "product", "id": "prod_b2…", ... } }| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request · invalid_json | A field is missing or malformed; the message names it |
| 400 | unknown_entitlements | unknown lists the keys that don't exist |
| 400 | field_immutable | Tried to change a key, SKU, store, type, duration or identifier |
| 401 | unauthorized | Missing, invalid or revoked key |
| 403 | secret_key_required | Sent a pk_ key |
| 404 | *_not_found · unknown_action | No such entitlement, product, offering or package (missing lists them in bulk calls) |
| 409 | entitlement_exists · product_exists · offering_exists · package_exists | Already there; existing carries it |
| 409 | ambiguous_product · ambiguous_package | Name matches several; candidates lists them |
| 409 | product_in_use · entitlement_in_use | Has grant history; grants is the count. Archive instead. |
| 409 | offering_is_current · offering_in_experiment · offering_archived · product_archived | A guard rail above |
| 429 | rate_limited | Wait Retry-After seconds |
Coming from RevenueCat API v2
| RevenueCat | EntitleHub |
|---|---|
POST /projects/{id}/entitlements then …/actions/attach_products | POST /v1/entitlements with products: one call |
POST /projects/{id}/products, then attach from the entitlement | POST /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 ids | Paths take ids or the key / SKU / identifier you already have |
| Project config: 60 requests / minute | 120 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.
