Licensing & Billing API
This page is the integrator reference for everything around money: reading catalog prices, querying a business’s licence and owned products, taking payment through the checkout rail, reading invoices, and following the renewal cycle. It builds on the conventions in API Fundamentals (base URL, auth realms, branch scope, the error envelope, idempotency, rate limits).
The model: the core product is free forever. Paid capabilities are bought
individually from the catalog, with the annual licence
(license_annual) as the prerequisite. All amounts are TRY and
VAT-inclusive; collection runs through PayTR, TRY only. There are no
plans, tiers, bundles, trials, or auto-renewals.
Auth & roles
These endpoints use the Staff JWT realm (Authorization: Bearer <jwt> — see
API Fundamentals). Role requirements differ by
endpoint:
| Capability | Required role |
|---|---|
| Read the catalog price list | Public (@Public, no auth) |
| Read licence state / invoices | Any authenticated user |
Price a cart (/checkout/quote) | Any authenticated user |
| Checkout intent / confirm | ADMIN, MANAGER |
| Cancel a product | ADMIN only |
POST /v1/checkout/quote writes nothing — it prices a hypothetical cart — so
it carries no role gate. The first step that creates state is
/checkout/start; the first step that moves money is /checkout/intent. Both
require ADMIN/MANAGER.
Endpoint summary
| Method | Path | Realm / role | Purpose |
|---|---|---|---|
GET | /api/v1/catalog/pricing | Public | Published catalog + prices (?locale=en) |
GET | /api/v1/me/licensing | Staff JWT | Licence state, owned products, credits, renewal, offers, purchasability |
GET | /api/v1/me/invoices | Staff JWT | This tenant’s itemized à-la-carte invoices |
GET | /api/v1/entitlements/me | Staff JWT | The folded entitlement set |
POST | /api/v1/checkout/quote | Staff JWT | Price a mixed cart (no writes) |
POST | /api/v1/checkout/start | ADMIN, MANAGER | Lock in a quote before redirecting (re-prices) |
POST | /api/v1/checkout/intent | ADMIN, MANAGER | Mint a PayTR iframe token + paymentRef |
POST | /api/v1/checkout/confirm | ADMIN, MANAGER | Turn a settled intent into provisioning (idempotent) |
POST | /api/webhooks/paytr | PayTR (HMAC + IP allowlist) | Payment-result callback |
DELETE | /api/v1/marketplace/addons/:id | ADMIN | Cancel an owned product |
GET | /api/subscriptions/plans | Public | Permanently an empty array (see below) |
GET | /api/subscriptions/tenant/invoices | ADMIN, MANAGER | Legacy subscription-invoice archive (paginated) |
GET | /api/invoices/:invoiceNumber | ADMIN, MANAGER | One legacy invoice (/download for the PDF) |
GET /api/subscriptions/plans is still mounted as @Public, but it now
permanently returns []: the endpoint lists only isActive && isPublic
plans, and the v3.3.0 migration (20260811120000_free_core) set every
subscription_plans row to isActive=false, isPublic=false. The rows survive
only for the subscriptions.planId Restrict FK and the legacy tax invoices
Turkish law requires retaining. For the price list use
GET /v1/catalog/pricing.
Reading the price list
The catalog is public — marketing pages read it too, so a price change made in the superadmin panel cannot leave the website advertising a stale amount.
curl "https://app.hummytummy.com/api/v1/catalog/pricing?locale=en"Fields returned per product:
| Field | Description |
|---|---|
code | Immutable product code (e.g. module_inventory) |
name / description | Localised for locale (falls back to the TR text) |
kind | license | module | integration | capacity | credit | service |
billing | annual | oneTime |
priceCents | VAT-inclusive cents — the full annual list price |
currency | Always TRY |
creditKind / creditUnits | Credit packs only |
requiresLicense | Whether a live licence is needed to use it |
sortOrder | Storefront ordering |
For what each product code grants, see the Entitlement Matrix.
Licence state and owned products
GET /v1/me/licensing lets the SPA render the whole licence screen in one
request:
curl https://app.hummytummy.com/api/v1/me/licensing \
-H "Authorization: Bearer $JWT"| Field | Contents |
|---|---|
entitlements | features, limits, integrations, computedAt |
license | status, anchorAt, anniversaryAt, daysRemaining |
credits | Credit kind → remaining balance |
owned | Per owned row: code, kind, quantity, pendingQuantity, status, periodEnd, chargedCents, renewalCents, origin |
renewal | The open renewal cycle: cycleId, anniversaryAt, graceEndsAt, totalCents, daysLeft |
offers | Every grant key → the cheapest product providing it, priced for this tenant today |
purchasability | Product code → { ok } or { ok: false, reason, message } |
license.status is one of four values:
| Value | Meaning |
|---|---|
none | No licence has ever been bought |
active | The licence is live |
grace | The licence row is past_due — inside the grace window |
expired | The anniversary passed unpaid |
offers and purchasability are produced by the same functions checkout
uses (LicensingService.price, evaluatePurchasability). That is what keeps
the storefront price identical to the charged price, and the “Buy” button
identical to what checkout will accept.
The anniversary and proration
The day the licence is bought becomes the account’s immutable anniversary
(the tenant-local calendar date; default Europe/Istanbul). Every annual
item bought afterwards is prorated to the days remaining until that
anniversary — so the whole account renews on ONE date with ONE itemized invoice.
| Mode | When | Price |
|---|---|---|
full | Remaining days = cycle days (first purchase, or on the anniversary itself) | Full list price |
prorated | More than 14 days to the anniversary | list × remainingDays / cycleDays |
rollForward | ≤ 14 days to the anniversary | The remainder plus one whole next cycle |
- Rounding happens per unit, then multiplies:
unitCents × qty === subtotalCentsholds exactly. - No priced line ever falls below ₺1 (
MIN_LINE_CENTS = 100); a genuinely free item stays at 0. - The cycle length is computed as 365 or 366, never hardcoded; a 29 Feb anniversary clamps to 28 Feb in common years.
The 14-day threshold is deliberate: a ₺990 module bought 2 days before the anniversary would cost ₺5.42, land on the renewal cart 48 hours later, and sit under PayTR’s minimum charge — a support ticket, not a sale.
VAT-inclusive pricing internals
Every catalog price is a gross (VAT-inclusive) amount, and the customer is charged exactly that. The quote engine derives the tax out of the gross; it never adds it on top:
netCents = round(grossLineSum / (1 + rate))
taxCents = grossLineSum - netCentsFor TRY carts the rate is 20%. In the quote response subtotalCents is the
net, taxCents is the embedded VAT, and totalCents is the gross amount
that will be charged (including shipping when the cart holds hardware).
The checkout rail
The only way to buy a product is the checkout (PayTR) rail. No paid line is ever
provisioned without proof of payment (paymentRef).
Price the cart
curl -X POST https://app.hummytummy.com/api/v1/checkout/quote \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "type": "addon", "code": "license_annual", "qty": 1 },
{ "type": "addon", "code": "module_inventory", "qty": 1 },
{ "type": "addon", "code": "extra_branch", "qty": 2 }
]
}'Cart item types: addon (a catalog product — code), hardware (sku),
service (code). The legacy plan type is still accepted by the DTO, but the
pricer produces no line for it. A cart holds at most 50 items, each with a
quantity of at most 999.
Create a payment intent
curl -X POST https://app.hummytummy.com/api/v1/checkout/intent \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"cart": { "items": [ { "type": "addon", "code": "license_annual", "qty": 1 } ] },
"buyer": {
"email": "[email protected]",
"name": "Ali Veli",
"phone": "+905551112233",
"address": "..."
},
"returnUrl": "https://app.hummytummy.com/checkout/done",
"acceptedDocumentIds": ["<KVKK>", "<DISTANCE_SALES>", "<REFUND>"]
}'| Field | Required | Notes |
|---|---|---|
cart | Yes | At least 1 item |
buyer | Yes | What PayTR sees and fraud-scores; the phone is normalised to E.164 |
acceptedDocumentIds | Yes | Exactly 3: KVKK, distance-sales, refund policy. Ids come from /legal/documents/:kind/current |
returnUrl | No | Must be an absolute http(s) URL (open-redirect guard) |
branchId | No | A tenant-owned active branch, for hardware shipping |
referralCode | No | Resolved and frozen at intent time |
Response: { paymentRef, iframeToken, paymentLink }. The paymentRef has the
form CK-<uuid7>.
In this step the server, in order: blocks the demo tenant, records the consent
rows, runs every addon line through the purchasability gate, freezes the
pricing instant (pricedAt) and re-prices the cart (it never trusts client
totals), checks hardware stock summed per product, and freezes the cart onto a
CheckoutIntent row.
Pay via PayTR
The buyer pays on the PayTR hosted iframe. Each cart line is shown as a separate basket entry (e.g. “Inventory & Cost Management (yıllık)”).
Settlement via webhook
The PayTR callback carries only merchant_oid + total_amount. The dispatcher
sees the CK- prefix and routes it to CheckoutSettlementService, which finds
the CheckoutIntent, reads the frozen cart and pricedAt, and runs
CheckoutService.confirmAndProvision.
Provisioning
For each catalog line, TenantMarketplaceService.purchase() is called with the
settled paymentRef inside the checkout transaction: a TenantAddOn row is
created (or a lapsed row is reactivated in place), an AddOnPurchased event is
appended to the outbox, and credit packs mint a CreditLot. The entitlement
projector consumes the event and folds the product’s grants map into the
tenant’s entitlement set.
At settlement the cart is re-quoted with pricedAt, and provisioning is
refused if the total moved by more than one cent. That is what stops an intent
created just before midnight from re-pricing a day cheaper and stranding a
cart the buyer already paid for; the tolerance is left doing its real job —
catching catalog price edits in flight.
The purchasability gate (before any charge)
Every addon line is checked before payment. A rejected line returns 409
and stops the whole cart — no intent row is written and PayTR is never called.
The envelope carries { code, message, addOnCode }:
code | Meaning |
|---|---|
LICENSE_REQUIRED | The product needs a licence and the cart has none either |
ADDON_ALREADY_OWNED | This product (or the licence) is already active |
ADDON_ALREADY_GRANTED | Everything the product grants is already in the entitlement set |
ADDON_REQUIRES_DEPENDENCY | Its dependency is neither owned nor in the cart |
ADDON_LIMIT_REDUNDANT | The capacity it adds is already unlimited |
ADDON_MAX_QUANTITY | The catalog ceiling would be exceeded (e.g. 100 for extra_branch) |
The gate is cart-aware: a prerequisite can be satisfied by a sibling line rather than by something already owned. An opening cart necessarily contains both the licence and the first module it unlocks — without this, a first-time buyer’s only possible cart would be rejected. On a generated renewal cart the ownership checks are switched off, because re-paying for what you already hold is exactly what a renewal is.
Other pre-charge rejections:
409HARDWARE_OUT_OF_STOCK— not enough real stock for a hardware line (quantities for the same product are summed across lines before checking).400— the cart total is0; PayTR rejects zero-value baskets, and free provisioning goes through the superadmin comp path.
Idempotency
PayTR retries aggressively (it retries even after a 200 OK if the body isn’t
"OK"/"FAIL"). Three layers guard against double provisioning:
CheckoutIntent.status check
If the intent is already provisioned/failed, nothing is touched.
confirmAndProvision is independently idempotent
It is idempotent on (tenantId, paymentRef) and requires a settled
CheckoutIntent for that pair before provisioning anything — calling
/checkout/confirm with a forged paymentRef provisions nothing.
purchase() returns the existing row
For the same paymentRef it returns the existing TenantAddOn row without
re-emitting AddOnPurchased.
Invoices
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/me/invoices | The à-la-carte tenant_invoices records — what is issued today |
GET | /api/subscriptions/tenant/invoices | Legacy subscription-invoice archive (paginated: page, pageSize) |
GET | /api/subscriptions/:id/invoices | Invoices of one legacy subscription |
GET | /api/invoices/:invoiceNumber | One legacy invoice; /download returns the PDF |
The two tables coexist deliberately: the legacy invoices table holds tax
records Turkish law requires retaining for years and carries a NOT NULL
subscriptionId. The new tenant_invoices writer draws from the same
number sequence (invoice_counters) — two independent counters over one
number format would eventually collide at settlement, after the card was
charged.
Renewal (manual)
There is no card vault and no auto-charge. A renewal is paid by hand through the same checkout rail.
| Step | Timing | What happens |
|---|---|---|
| Cycle generation | 30 days before the anniversary, daily 06:00 cron | A RenewalCycle is frozen with one line per owned row; prices are read live from the catalog at generation time and then fixed |
| Reminders | Daily 09:00 cron | Once each at 30 / 7 / 1 days left; each send is appended to remindersSent, so no reminder goes out twice |
| Grace | Anniversary + 7 days | Unpaid rows go past_due but keep granting |
| Lapse | Daily 00:30 cron | When grace ends, unpaid rows go expired: access goes dark, data is not deleted |
The renewal cart is at full list price: it is quoted as of the anniversary, so
remaining days equal cycle days and proration returns the whole price. When it
is paid, the same TenantAddOn row is reactivated in place — no duplicate row,
the ownership identity survives, and any scheduled capacity reduction
(pendingQuantity) is honoured.
If the licence line goes unpaid, it is not only that product that goes dark:
the grants of every requiresLicense: true product are suppressed.
Ownership rows and business data stay put, and paying the licence re-lights
everything on the next projection. See the
Entitlement Matrix.
Cancellation
Product cancellation lives on the marketplace rail and is ADMIN-only:
DELETE /api/v1/marketplace/addons/:id (at period end by default,
?immediate=true to revoke right away). Details in the
Marketplace API.
Retired rails
The following endpoints were removed in v3.3.0; a legacy integration calling
them gets a 404:
| Removed | Replacement |
|---|---|
POST /api/subscriptions/:id/change-plan | None — there are no tiers to move between; products are bought and dropped individually |
POST /api/payments/create-intent | POST /api/v1/checkout/intent |
GET/POST /api/payments/bank-transfer/* | None — collection runs on the PayTR rail |
POST /api/v1/marketplace/addons/purchase | POST /api/v1/checkout/intent (comps live only on the superadmin surface) |
GET /api/subscriptions/usage/snapshot | GET /api/v1/me/licensing |
GET /api/subscriptions/effective-features (still mounted, but plan-table bound: 404 without a currentPlan) | GET /api/v1/entitlements/me |
The TRIAL_ENDED lock / PLAN_SELECTION_REQUIRED envelope | None — with a free core the global subscription-status guard is gone; gates are per-route and return ENTITLEMENT_REQUIRED |
Related
- Entitlement Matrix — the free core, product
grants, fold rules, and the
ENTITLEMENT_REQUIREDenvelope. - Marketplace API — catalog listing, owned products, and cancellation.
- API Fundamentals — base URL, auth realms, branch scope, error envelope, rate-limit tiers.
- Webhooks — the payment and checkout events that fire when these rails confirm.