/api/v1/plans

A plan is a product you sell (for example “Pro”). A plan holds many prices that are on sale at the same time — for example Pro monthly, Pro half year and Pro yearly — each with its own amount, billing interval and optional trial. Subscriptions are always started on a price (03.01).

Endpoint Auth
GET /api/v1/plans HMAC or Firebase ID Token
GET /api/v1/plans/{id} HMAC or Firebase ID Token
POST /api/v1/plans HMAC only
PATCH /api/v1/plans/{id} HMAC only
POST /api/v1/plans/{id}/prices HMAC only
PATCH /api/v1/plans/{id}/prices/{price_id} HMAC only

Reads are open to any authenticated app of yours (so a client can render a pricing table). Writes are for your backend only: a Firebase ID Token app gets 403 forbidden.

Ids: plan vs price

Id Looks like Where you use it
Plan id plan_… /plans/{id} URLs
Plan code pro Your own stable label, unique per app; this is the plan_ref you see on subscriptions, transactions and webhooks
Price id price_… price_id in checkout and PUT .../subscriptions/{external_ref}/plan

POST /api/v1/plans — create a plan with its prices

Field Type Required Notes
code string Yes [a-z0-9_-], 1-64 characters, unique within your app
name string Yes Max 191 characters
description string No Max 5000 characters
prices object[] Yes 1 to 20 prices, see below

Each price:

Field Type Required Notes
name string No Label, e.g. Pro monthly (max 191 characters)
amount integer Yes Smallest currency unit, > 0
currency string Yes 3 letters, stored upper-case
interval_unit string Yes day, week, month, year or lifetime (one payment, never expires — see Lifetime prices)
interval_count integer Yes, except lifetime >= 1 — the period length is interval_count x interval_unit. Omit it (or send 1) for lifetime
trial object No { "amount", "interval_unit", "interval_count" }; amount >= 0 (0 = free trial), same interval rules (day/week/month/year only). Not allowed on a lifetime price
curl -X POST https://payments.example.com/api/v1/plans \
  -H "X-App-Key: pgk_xxx" -H "X-Timestamp: 1700000000" -H "X-Signature: <HMAC>" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "pro",
    "name": "Pro",
    "description": "All features",
    "prices": [
      { "name": "Pro monthly",   "amount": 99000,  "currency": "VND", "interval_unit": "month", "interval_count": 1,
        "trial": { "amount": 0, "interval_unit": "day", "interval_count": 14 } },
      { "name": "Pro half year", "amount": 499000, "currency": "VND", "interval_unit": "month", "interval_count": 6 },
      { "name": "Pro yearly",    "amount": 899000, "currency": "VND", "interval_unit": "year",  "interval_count": 1 }
    ]
  }'

Response 201 Created:

{
  "success": true,
  "data": {
    "id": "plan_3f9c1a7e5b2d4c8a91e0f6b2",
    "code": "pro",
    "name": "Pro",
    "description": "All features",
    "status": "active",
    "prices": [
      {
        "id": "price_8a1d6c3e9f024b7d5a2c1e90",
        "name": "Pro monthly",
        "amount": 99000,
        "currency": "VND",
        "interval_unit": "month",
        "interval_count": 1,
        "trial": { "amount": 0, "interval_unit": "day", "interval_count": 14 },
        "status": "active",
        "created_at": "2026-10-03 08:00:00"
      },
      { "id": "price_…", "name": "Pro half year", "amount": 499000, "currency": "VND", "interval_unit": "month", "interval_count": 6, "trial": null, "status": "active", "created_at": "2026-10-03 08:00:00" },
      { "id": "price_…", "name": "Pro yearly", "amount": 899000, "currency": "VND", "interval_unit": "year", "interval_count": 1, "trial": null, "status": "active", "created_at": "2026-10-03 08:00:00" }
    ],
    "created_at": "2026-10-03 08:00:00",
    "updated_at": "2026-10-03 08:00:00"
  }
}

A code already used in your app returns 409 plan_code_taken.

GET /api/v1/plans and GET /api/v1/plans/{id}

GET /api/v1/plans returns { "plans": [ … ] } (newest first, each shaped like the object above). Each plan lists only the prices that are on sale (status: "active") — archived prices are left out, so a client can render a pricing table straight from the response and any price_id in it can be bought (unless the plan itself is archived). GET /api/v1/plans/{id} behaves the same. The write endpoints (POST/PATCH) answer with the whole plan, archived prices included — keep the id of a price you archive if you may want to restore it. Add ?status=active or ?status=archived to filter; any other value returns 400 invalid_request. GET /api/v1/plans/{id} returns one plan; an unknown id, or one from another app, returns 404 not_found.

PATCH /api/v1/plans/{id}

Send any of name, description (null clears it) or status (active / archived). An archived plan can’t be bought: checkouts for any of its prices return 400 price_not_found.

Adding a price: POST /api/v1/plans/{id}/prices

Body is a single price object (same fields as above). The plan’s other prices stay on sale. Response 201 with the whole plan.

curl -X POST https://payments.example.com/api/v1/plans/plan_3f9c1a7e5b2d4c8a91e0f6b2/prices \
  -H "X-App-Key: pgk_xxx" -H "X-Timestamp: 1700000000" -H "X-Signature: <HMAC>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Pro 2 years", "amount": 1599000, "currency": "VND", "interval_unit": "year", "interval_count": 2 }'

Archiving a price: PATCH /api/v1/plans/{id}/prices/{price_id}

Only name and status (active / archived) can be changed; response 200 with the whole plan.

curl -X PATCH https://payments.example.com/api/v1/plans/plan_3f9c.../prices/price_8a1d... \
  -H "X-App-Key: pgk_xxx" -H "X-Timestamp: 1700000000" -H "X-Signature: <HMAC>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "archived" }'

An archived price can no longer be chosen in a checkout or in PUT .../subscriptions/{external_ref}/plan (400 price_not_found). Setting it back to active puts it on sale again. Subscriptions already running on an archived price keep renewing at that price.

Changing an amount

Prices are immutable — a price’s amount, currency and interval can never be edited, so running subscriptions keep the price they were sold at. To change a price:

  1. POST /api/v1/plans/{id}/prices with the new amount.
  2. PATCH /api/v1/plans/{id}/prices/{old_price_id} with { "status": "archived" }.
  3. Optionally move existing subscribers with PUT /api/v1/subscriptions/{external_ref}/plan (effective from their next renewal).

Lifetime prices

A price with "interval_unit": "lifetime" is a one-time payment that grants access forever:

{ "name": "Premium lifetime", "amount": 1990000, "currency": "VND", "interval_unit": "lifetime" }
  • The price reads back with interval_unit: "lifetime" and interval_count: 1.
  • Checking out on it activates the subscription with current_period_end: null and status: "active" — it never becomes expired, and no renewal reminders are sent.
  • It cannot carry a trial (400 invalid_request).
  • A user who already owns a lifetime subscription cannot check out again: 409 already_lifetime.
  • To move a running subscriber to it, use PUT /api/v1/subscriptions/{external_ref}/plan — at their next renewal they pay the lifetime price and the subscription becomes permanent.

Trials

A price can carry a trial: for trial.interval_count x trial.interval_unit the customer pays trial.amount (0 = free; a free trial activates the subscription immediately with no payment method — see 03.01). After the trial, the customer renews at the regular price.

  • A trial applies once per end user and plan: if the end user already had a trial on any price of the plan, later checkouts on any of its prices skip it.
  • Pass skip_trial: true at checkout to charge the regular price immediately.
  • When a trial ends it behaves like an expiry: nothing is charged automatically; the customer is reminded to pay (03.08).

Errors

Code HTTP When
invalid_request 400 Validation failed — message names the field, e.g. prices[1]: amount is required and must be an integer greater than 0.
forbidden 403 Write call from a Firebase ID Token app
not_found 404 Plan or price doesn’t exist, isn’t yours, or the price isn’t under that plan
plan_code_taken 409 code already exists in your app

See Errors.


This site uses Just the Docs, a documentation theme for Jekyll.