---
name: paykit
description: Use when integrating an app with PayKit (api.paykit.co.za), designing or editing a PayKit catalog of plans, plan versions, features, entitlements or add-ons, gating features on a customer's entitlements, selling add-ons, or handling PayKit webhooks, checkout, renewals or dunning.
metadata:
  version: "37a2f5df10f4"
---

# PayKit

PayKit is a white-label subscription billing platform. It sits between a merchant's app and
their payment gateway (Paystack or PayFast) and owns *how* customers are billed: hosted
checkout, card tokenization, renewals, retries, grace periods, invoices and refunds. The
merchant's app only says *what* to bill (a catalog entry) and asks *what the customer may
do* (entitlements). Money is settled directly between the gateway and the merchant; PayKit
never holds funds.

- API: `https://api.paykit.co.za/v1`, JSON, resource-oriented.
- Full reference (endpoints, payloads, every error code): `https://paykit.co.za/docs`
- Webhooks guide: `https://paykit.co.za/docs/webhooks`. Entitlement tokens: `https://paykit.co.za/docs/entitlement-tokens`
- This skill: `https://paykit.co.za/skills/paykit/SKILL.md`

Read the reference for exact request and response shapes. This file is the mental model
you need before you read it.

## Keeping this skill current

This copy is version `37a2f5df10f4`. Check for a newer one when any of these is true:

- the API returns a field, error code or webhook event this file does not describe;
- the user asks about a PayKit capability this file does not cover;
- the file you loaded this skill from was last modified more than 30 days ago.

To check, compare the frontmatter `version:` line of the live copy with this one:

```bash
curl -fsSL https://paykit.co.za/skills/paykit/SKILL.md | grep -m1 '^  version: '
```

No output means the live copy predates versioning or the fetch failed: treat it as
different only if the fetch itself succeeded.

If it differs, download the live copy over the file you loaded this skill from (the same
`curl` with `-o <that path>`), re-read it, and continue from the new version. If it matches,
`touch` that file so the 30-day check does not fire again in the next session. If you cannot
reach the URL, tell the user this skill may be out of date and trust the live API reference
over this file wherever they disagree.

## The model in one picture

```
Client (the merchant's business; owns members, API keys, webhook endpoints)
 └─ Project (one product; environment "test" or "live")
     ├─ Catalog
     │   ├─ Features        what can be granted: boolean | limit | metered
     │   ├─ Plans           stable identity (code "pro") + display copy
     │   │   └─ Plan versions   IMMUTABLE price + cadence + trial + policy + entitlements
     │     │                  + meteredPrices (usage billed beyond an allowance)
     │   └─ Add-ons         stable identity (code "extra-game") + display copy
     │       └─ Add-on versions IMMUTABLE price + billing mode + lifetime + entitlements
     └─ Customers       the merchant's own users, keyed by `externalRef`
         ├─ Subscriptions   bound to ONE plan version, own state machine
         ├─ Usage days      one reported total per metered feature and day
         └─ Add-on grants   bound to the add-on version they were sold against
```

**Entitlements are what the app checks, never subscription state.** A plan version grants
features; an add-on grant adds to them. The app asks "can this customer use `sso`, how many
`seats`?" and PayKit answers from everything the customer holds. Repricing or re-tiering is
then a catalog change, not a code change.

## Features

A feature is a project-wide capability with a `code` and a `kind`. **The kind can never
change after creation** (an import that disagrees fails that row).

| kind | An entitlement to it sets | Meaning |
| --- | --- | --- |
| `boolean` | `boolValue` only | on or off (`sso`, `priority-support`) |
| `limit` | `limitValue` only (positive integer) | a cap (`seats: 10`, `projects: 3`) |
| `metered` | none: not a plan entitlement (`422 metered_feature_needs_metered_price`) | usage billed beyond an allowance. Priced by `meteredPrices` on a plan version, not by an entitlement. Report usage per customer, feature and day with `PUT .../customers/{externalRef}/usage/{featureCode}/days/{day}` (this SETS the day's total, at most 9007199254740991 a day). PayKit rates each period after its settle delay and charges the overage to the default card. Add-ons cannot grant it. |

Codes everywhere (features, plans, add-ons) are 1 to 40 chars of lowercase letters, digits,
`-` and `_`, starting and ending alphanumeric.

## Plans and plan versions

A **plan** is identity and copy only: `code`, `name`, `displayName`, `tagline`,
`description`. Everything that can change lives on a **plan version**, and a version is
immutable once created:

| Field | Notes |
| --- | --- |
| `amountMinor` | integer minor units (`49900` + `ZAR` = R499.00). Never a float. |
| `currency` | upper-case ISO 4217 |
| `billingInterval` + `intervalCount` | `day` / `week` / `month` / `quarter` / `year`, times N |
| `trialDays` | `> 0` makes new subscriptions start `trialing` |
| `pricingType` | `fixed` (default) or `custom` (amount is a suggestion; each subscription may carry a negotiated `customAmountMinor`) |
| `annualDiscountPercent` | only on a `fixed`, `month`ly version. Subscribers then choose `cadence: "annual"` and pay 12 months less the discount. |
| `billingRules` | optional dunning/policy overrides (retry schedule, grace, then suspend or cancel) |
| entitlements | per feature: `boolValue` or `limitValue`, matching the feature's kind |
| `meteredPrices` | optional, up to 10, one per `metered` feature: `featureCode`, `includedQuantity`, `unitSize`, `rounding` (`up`/`down`), `unitAmountMinor`, `maxChargeMinor` (or null), `usagePeriod` (`calendar_month`/`billing_period`), `timeZone`, `settleDays` (0 to 28), `unitLabel`. Omitted on a new version = carried forward from the previous one; `[]` removes them. Changing one appends a version. |

Rules that drive every design decision:

- **To change a price, add a version.** New subscribers get the newest version
  (`versions[0]` in the catalog). **Existing subscribers keep the version they subscribed
  to, forever,** until moved explicitly with `change-plan` (credit-based proration).
- A **price of 0 is a real plan.** Subscribing to it is `active` immediately: no checkout,
  no payment webhook, and `checkout` returns `409 invalid_state`. Model the Free tier as a
  plan and give every customer a subscription, paying or not.
- Policy resolves in three layers, platform defaults, then client defaults, then the plan
  version's `billingRules`, and is **snapshotted onto the subscription at creation**.
- Plans are archived, never deleted. A customer may hold several subscriptions at once;
  their entitlements merge (below).

## Add-ons

Purchasables on top of a subscription: a game unlock, a seat pack, a block of credits. **An
add-on has no type.** Its current version says three independent things:

1. **What it grants:** `entitlements` (booleans switched on, limits raised). An empty list
   is a valid product: the add-on itself is the thing owned.
2. **How long:** `durationDays` (fixed window from purchase) OR `boundToPeriod: true`
   (ends and renews with the subscription's period) OR neither (permanent). Optionally
   `packQuantity` (a spendable balance of `packQuantity * quantity` units) and
   `expiresWithPeriod` (unspent units die at period end).
3. **How it is paid:** `billingMode`, `one_time` or `recurring`.

Hard rules (the API and the database both refuse violations):

- `durationDays` and `boundToPeriod` are mutually exclusive.
- `recurring` requires `boundToPeriod: true`; `one_time` requires `boundToPeriod: false`.
- `boundToPeriod` or `expiresWithPeriod` needs the customer to have an active subscription
  at purchase (`409 subscription_required`).
- An add-on keeps one currency for life, and a recurring one must match the
  subscription's currency.
- Only `fixed`-priced, non-zero versions are purchasable. Free product is a dashboard-only
  "comp", never a zero-price purchase.

Recipes:

| Product | Version |
| --- | --- |
| A game / one-off unlock | `entitlements: []`, permanent, `one_time` |
| +5 seats every month | `[{featureCode:"seats", limitValue:5}]`, `boundToPeriod: true`, `recurring` |
| 100 support credits | `entitlements: []`, `packQuantity: 100`, `one_time` |
| 7-day feature pass | `[{featureCode:"export", boolValue:true}]`, `durationDays: 7`, `one_time` |

**A grant reads the version it was sold against, forever.** Publishing a new add-on
version never changes what was already sold, with one exception: a renewing
(`boundToPeriod`) grant is re-charged and re-granted against the add-on's **current**
version at each renewal, so new prices and new entitlement lists reach existing holders at
their next renewal.

## How entitlements resolve

`GET .../customers/{externalRef}/entitlements` returns `entitlements`, `subscriptions`,
`addons` and `balances`. Per feature:

- **Across subscriptions:** booleans OR, limits take the **MAX**.
- **Add-on grants on top:** limits **SUM** and scale with quantity (two `+5 seats` packs on
  a 5-seat plan = 15); booleans are true-wins. Plans of 5 and 8 seats plus two `+3` packs
  = max(5, 8) + 2 x 3 = 14.
- A grant with no entitlements and no balance appears in `addons` by code (the game case).
- A grant with a balance appears in `balances[addOnCode]`, summed across grants. Spend it
  with `POST .../addon-grants/{grantId}/consume { "units": n }`.

A `metered` feature appears as an entry with `includedQuantity`, `usedQuantity`,
`periodStart`, `periodEnd` and `unitLabel` (`limitValue` repeats `includedQuantity`).
Going over the allowance does not remove access: the overage is billed when the period is
rated. In a token, `usedQuantity` is the value at issue.

For hot paths, mint a signed token (`POST .../entitlement-tokens`) and verify it locally
against `https://api.paykit.co.za/.well-known/jwks.json`. If the token says
`addonsTruncated: true`, fall back to the direct read.

## The integration loop

Everything per customer runs on the merchant's **server** with a `management` API key
(`Authorization: Bearer mbx_...`). The key never reaches a browser. The key, the
`clientId` and the `projectId` all come from the dashboard: Developers, then API keys.

1. `POST /v1/clients/{clientId}/projects/{projectId}/customers` `{ externalRef, email }`
   (idempotent by `externalRef`).
2. `POST /v1/clients/{clientId}/subscriptions` `{ customerId, planVersionId }` (optional
   `cadence`, `couponCode`, `customAmountMinor`). Starts `incomplete`, `trialing` or
   (free) `active`.
3. `POST .../subscriptions/{id}/checkout` `{ callbackUrl }` and redirect to `checkoutUrl`.
   The callback is a UX return, **not** proof of payment.
4. Grant access on the **webhook** `subscription.activated`, after verifying its signature.
5. Gate features with the entitlements read or token.

Moving a subscriber to another plan version: `POST .../subscriptions/{id}/change-plan`
`{ planVersionId }` (credit-based proration). Cancel: `POST .../subscriptions/{id}/cancel`,
optionally `{ atPeriodEnd: true }`.

Pricing pages read `GET /v1/projects/{projectId}/catalog` with **no auth** (live projects
only, CORS open, cached 5 min). Add-on purchase: the browser sends the add-on `code` to
the merchant's server, which calls `POST .../customers/{externalRef}/addon-purchases`
`{ addOnCode, quantity }`. Outcomes: `201` granted, `202` 3DS challenge (redirect to
`requiresActionUrl`; success shows up as the grant appearing or `addon.purchased`, and
failure produces **no** webhook), or an error such as `402 payment_method_required`.

## Non-negotiable conventions

- **Never send an amount** to subscribe or purchase. Prices come only from the catalog;
  an amount in a purchase body is ignored. (Card-testing defense.)
- **Every mutating call needs `Idempotency-Key: <uuid>`.** Reuse it on retry; a new
  operation gets a new key.
- **Money and counts are Int64 carried as JSON strings** in responses (`"49900"`, `"15"`).
  Parse as integers or BigInt, never floats. Requests accept either an integer or a
  digit string for amounts and limits. Timestamps are UTC ISO-8601. IDs come back dash-less.
- **Webhooks are at-least-once.** The endpoint's signing secret is shown once when it is
  registered. Verify `HMAC-SHA256("<x-mbx-timestamp>.<raw body>")`
  against the `v1=` hex in `x-mbx-signature` (constant-time compare, reject timestamps
  older than about 5 minutes) on the raw body before parsing, and dedupe on
  `x-mbx-delivery`. Events are `noun.verb_past`: `subscription.activated`,
  `payment.failed`, `subscription.suspended`, `addon.purchased`, and the rest in the
  webhooks guide.
- **Test vs live is per project.** A project starts in `test` (gateway test keys, public
  catalog returns 404) and is taken live from the dashboard.

## Who can do what

| Action | Where |
| --- | --- |
| Create/edit features, plans, plan versions, coupons | Dashboard only (or a catalog file imported in the dashboard). API keys do not hold `catalog.write`. |
| Create/edit add-ons and add-on versions | Dashboard, or the API with a `management` key (`addons.manage`) |
| Customers, subscriptions, checkout, purchases, refunds, cards, invoices, reporting and reading usage (`usage.write`, `usage.read`) | `management` key |
| Read entitlements, subscriptions, add-on grants | `entitlements_read` key (or `management`) |
| Comp a free add-on grant, manage members, keys, payouts, go live | Dashboard only |

## Designing a catalog for a merchant

Because plans are dashboard-managed, the most useful thing to produce is a **catalog
file**: the merchant opens Catalog in the dashboard, chooses Import, previews the dry-run
report, then applies it. Rows match by `code`, so re-importing a corrected file converges.
Only `versions[0]` of each plan is applied, and a new version is appended only when a
priced field or the entitlement set differs.

```json
{
  "paykit": { "kind": "catalog", "version": 2 },
  "catalog": {
    "features": [
      { "code": "seats", "name": "Team seats", "kind": "limit" },
      { "code": "sso", "name": "Single sign-on", "kind": "boolean" }
    ],
    "plans": [
      { "code": "free", "name": "Free", "versions": [
        { "amountMinor": "0", "currency": "ZAR", "billingInterval": "month",
          "entitlements": [ { "featureCode": "seats", "limitValue": "1" } ] } ] },
      { "code": "pro", "name": "Pro", "tagline": "For growing teams", "versions": [
        { "amountMinor": "49900", "currency": "ZAR", "billingInterval": "month",
          "trialDays": 14, "annualDiscountBps": 1500,
          "billingRules": {
            "retrySchedule": [ { "offsetDays": 1 }, { "offsetDays": 3 }, { "offsetDays": 7 } ],
            "grace": { "days": 7, "thenState": "suspend" } },
          "entitlements": [ { "featureCode": "seats", "limitValue": "10" },
                            { "featureCode": "sso", "boolValue": true } ] } ] }
    ],
    "addons": [
      { "code": "seat-pack", "name": "5 extra seats", "currentVersion": {
          "amountMinor": "9900", "currency": "ZAR", "billingMode": "recurring",
          "boundToPeriod": true,
          "entitlements": [ { "featureCode": "seats", "limitValue": "5" } ] } }
    ]
  }
}
```

In the file the annual discount is `annualDiscountBps` (basis points, 1500 = 15%); the
plan-version API takes `annualDiscountPercent` instead. Omitting `billingRules` from an
imported version that had them puts new subscribers back on the defaults.

## Common mistakes

| Mistake | Instead |
| --- | --- |
| Granting access on the checkout callback | Wait for `subscription.activated` |
| Editing a plan's price to reprice existing customers | New version, then `change-plan` each subscription you mean to move |
| Checking `subscription.state === "active"` to gate a feature | Read entitlements (covers trials, dunning, add-ons, free plans) |
| A "free" add-on priced at 0 (purchase fails `400 zero_price_unsupported`) | A comp in the dashboard. (A free *plan* priced at 0 is fine.) |
| A `recurring` add-on without `boundToPeriod` | They must agree |
| Parsing `amountMinor` with `parseFloat` | Integer / BigInt parse of the string |
| Waiting for a failure webhook after a `202` add-on purchase | Treat the grant's continued absence past your own timeout as failure |
| Adding to a day's usage instead of sending the day's total | `PUT` the day's total; a re-sent day replaces the old value |
| Granting a metered feature as a plan entitlement (refused with `422 metered_feature_needs_metered_price`) | Use `meteredPrices` on the plan version |
| Expecting usage on `trialing`, `suspended`, `paused` or `pending_action` days to be billed | Only days the subscription was `active`, `past_due` or `grace` at the day's first instant count |
| Reporting a day after its period was rated (`409 usage_period_closed`) | Send corrections inside the settle delay; after that, reconcile by hand |
| Expecting reinstate to collect unpaid usage | Retry the `uncollectible` period with `POST .../usage-periods/{id}/retry` first. A paid retry returns a subscription the usage escalated from `active` to `active` (`subscription.recovered`) once no escalated usage is unpaid; a `suspended` one still needs reinstating |
| Using `metered` for a hard cap or a prepaid balance | Use a `limit` feature, or an add-on `packQuantity` balance |
