Developer / APILicensing & Billing API

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:

CapabilityRequired role
Read the catalog price listPublic (@Public, no auth)
Read licence state / invoicesAny authenticated user
Price a cart (/checkout/quote)Any authenticated user
Checkout intent / confirmADMIN, MANAGER
Cancel a productADMIN 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

MethodPathRealm / rolePurpose
GET/api/v1/catalog/pricingPublicPublished catalog + prices (?locale=en)
GET/api/v1/me/licensingStaff JWTLicence state, owned products, credits, renewal, offers, purchasability
GET/api/v1/me/invoicesStaff JWTThis tenant’s itemized à-la-carte invoices
GET/api/v1/entitlements/meStaff JWTThe folded entitlement set
POST/api/v1/checkout/quoteStaff JWTPrice a mixed cart (no writes)
POST/api/v1/checkout/startADMIN, MANAGERLock in a quote before redirecting (re-prices)
POST/api/v1/checkout/intentADMIN, MANAGERMint a PayTR iframe token + paymentRef
POST/api/v1/checkout/confirmADMIN, MANAGERTurn a settled intent into provisioning (idempotent)
POST/api/webhooks/paytrPayTR (HMAC + IP allowlist)Payment-result callback
DELETE/api/v1/marketplace/addons/:idADMINCancel an owned product
GET/api/subscriptions/plansPublicPermanently an empty array (see below)
GET/api/subscriptions/tenant/invoicesADMIN, MANAGERLegacy subscription-invoice archive (paginated)
GET/api/invoices/:invoiceNumberADMIN, MANAGEROne 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:

FieldDescription
codeImmutable product code (e.g. module_inventory)
name / descriptionLocalised for locale (falls back to the TR text)
kindlicense | module | integration | capacity | credit | service
billingannual | oneTime
priceCentsVAT-inclusive cents — the full annual list price
currencyAlways TRY
creditKind / creditUnitsCredit packs only
requiresLicenseWhether a live licence is needed to use it
sortOrderStorefront 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"
FieldContents
entitlementsfeatures, limits, integrations, computedAt
licensestatus, anchorAt, anniversaryAt, daysRemaining
creditsCredit kind → remaining balance
ownedPer owned row: code, kind, quantity, pendingQuantity, status, periodEnd, chargedCents, renewalCents, origin
renewalThe open renewal cycle: cycleId, anniversaryAt, graceEndsAt, totalCents, daysLeft
offersEvery grant key → the cheapest product providing it, priced for this tenant today
purchasabilityProduct code → { ok } or { ok: false, reason, message }

license.status is one of four values:

ValueMeaning
noneNo licence has ever been bought
activeThe licence is live
graceThe licence row is past_due — inside the grace window
expiredThe 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.

ModeWhenPrice
fullRemaining days = cycle days (first purchase, or on the anniversary itself)Full list price
proratedMore than 14 days to the anniversarylist × remainingDays / cycleDays
rollForward≤ 14 days to the anniversaryThe remainder plus one whole next cycle
  • Rounding happens per unit, then multiplies: unitCents × qty === subtotalCents holds 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 - netCents

For 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>"]
  }'
FieldRequiredNotes
cartYesAt least 1 item
buyerYesWhat PayTR sees and fraud-scores; the phone is normalised to E.164
acceptedDocumentIdsYesExactly 3: KVKK, distance-sales, refund policy. Ids come from /legal/documents/:kind/current
returnUrlNoMust be an absolute http(s) URL (open-redirect guard)
branchIdNoA tenant-owned active branch, for hardware shipping
referralCodeNoResolved 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 }:

codeMeaning
LICENSE_REQUIREDThe product needs a licence and the cart has none either
ADDON_ALREADY_OWNEDThis product (or the licence) is already active
ADDON_ALREADY_GRANTEDEverything the product grants is already in the entitlement set
ADDON_REQUIRES_DEPENDENCYIts dependency is neither owned nor in the cart
ADDON_LIMIT_REDUNDANTThe capacity it adds is already unlimited
ADDON_MAX_QUANTITYThe 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:

  • 409 HARDWARE_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 is 0; 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

MethodPathNotes
GET/api/v1/me/invoicesThe à-la-carte tenant_invoices records — what is issued today
GET/api/subscriptions/tenant/invoicesLegacy subscription-invoice archive (paginated: page, pageSize)
GET/api/subscriptions/:id/invoicesInvoices of one legacy subscription
GET/api/invoices/:invoiceNumberOne 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.

StepTimingWhat happens
Cycle generation30 days before the anniversary, daily 06:00 cronA RenewalCycle is frozen with one line per owned row; prices are read live from the catalog at generation time and then fixed
RemindersDaily 09:00 cronOnce each at 30 / 7 / 1 days left; each send is appended to remindersSent, so no reminder goes out twice
GraceAnniversary + 7 daysUnpaid rows go past_due but keep granting
LapseDaily 00:30 cronWhen 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:

RemovedReplacement
POST /api/subscriptions/:id/change-planNone — there are no tiers to move between; products are bought and dropped individually
POST /api/payments/create-intentPOST /api/v1/checkout/intent
GET/POST /api/payments/bank-transfer/*None — collection runs on the PayTR rail
POST /api/v1/marketplace/addons/purchasePOST /api/v1/checkout/intent (comps live only on the superadmin surface)
GET /api/subscriptions/usage/snapshotGET /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 envelopeNone — with a free core the global subscription-status guard is gone; gates are per-route and return ENTITLEMENT_REQUIRED
  • Entitlement Matrix — the free core, product grants, fold rules, and the ENTITLEMENT_REQUIRED envelope.
  • 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.