/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:
POST /api/v1/plans/{id}/priceswith the new amount.PATCH /api/v1/plans/{id}/prices/{old_price_id}with{ "status": "archived" }.- 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"andinterval_count: 1. - Checking out on it activates the subscription with
current_period_end: nullandstatus: "active"— it never becomesexpired, 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: trueat 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.