API
Subscriptions
Recurring bills, collected by QRIS every cycle.
Available to every account. Test mode works for everyone from the first day; a live subscription needs a verified account, as described below. The full reference is in /v1/openapi.json.
Every cycle raises an invoice, and the customer authorises a QRIS payment for it. There is no card on file and nothing is charged automatically — a renewal happens because somebody scanned a code.
The shape of it
A customer is a person you bill. A plan is a price and a cycle. A subscription joins the two. Each cycle it raises an invoice, and each invoice gets a payment request you can point the customer at.
1. Set up a plan and a customer
curl __BASE__/v1/subscription_plans \
-H "Authorization: Bearer kp_test_..." \
-H "Content-Type: application/json" \
-d '{"code":"basic","name":"Bulanan","amount":150000,"interval_unit":"month"}'
curl __BASE__/v1/subscription_customers \
-H "Authorization: Bearer kp_test_..." \
-H "Content-Type: application/json" \
-d '{"name":"Budi","phone":"+628123456789","external_id":"crm-42"}'Send external_id if you have your own identifier. Posting the same one again updates that customer instead of creating a second — which is what makes re-running an import safe.
To edit a customer, PATCH /v1/subscription_customers/{id}. Use it rather than posting again whenever the external_id itself is what is being corrected: the create endpoint matches on that field, so changing it there creates a second customer and leaves the subscriptions on the first.
amount is required even when it is 0. A free plan is a real plan, so an absent field cannot be read as a free one.
Changing or retiring a plan
To change a plan's price or cycle, post it again with the same code. That writes the plan's next version and archives the one it replaces, which is why code is required: it is what ties the versions together. Existing subscribers are not repriced. Each stays on the version it was opened with; to move one onto the new version, use POST /v1/subscriptions/{id}/change below.
To stop offering a plan without replacing it, POST /v1/subscription_plans/{id}/archive. It leaves GET /v1/subscription_plans, so it can no longer be picked for a new subscription or an import, and every subscription already on it keeps billing exactly as before. Archiving withdraws a product; it does not cancel the people who bought it. An id that is already archived, or is not yours, answers 404.
2. Start the subscription
curl __BASE__/v1/subscriptions \
-H "Authorization: Bearer kp_test_..." \
-H "Content-Type: application/json" \
-d '{"customer_id":"cust_...","plan_id":"plan_..."}'The opening cycle is billed immediately, so the first invoice already exists when this returns. If the plan has a trial, nothing is raised until the trial ends — a trial that never converts never appears in your invoices at all.
3. Collect
Read the invoice, take its payment_request_id, and fetch that from GET /v1/transactions/{id} for the QRIS payload. The invoice outlives every attempt: a QRIS code expires in an hour, the invoice is due for days, and a fresh attempt replaces the dead one.
4. Grant access on paid_through, not on status
status is where the subscription sits in its lifecycle. paid_through is whether the customer may use your service right now. They answer different questions.
A past_due subscriber inside the period they already paid for still has access — that is what the grace period is for. Gate on status alone and you cut off paying customers the moment an invoice slips.
const ok = sub.paid_through && new Date() < new Date(sub.paid_through);5. Missed payments
An unpaid invoice past its grace moves the subscription to past_due and emits invoice.overdue. Access continues until paid_through lapses on its own. Paying late settles the invoice and extends access from there — nothing is lost.
6. Changes and cancellation
POST /v1/subscriptions/{id}/change takes effect at the next renewal, and only then. There is no immediate option: proration is not available in this release, and applying a paid upgrade before it is paid for would grant service nobody authorised. The scheduled values show as pending_plan_id and pending_quantity until the renewal applies them.
Cancelling with at_period_end: true honours the service already paid for. The default stops access now. Neither refunds anything.
Cancellation is final. A payment arriving afterwards settles its invoice — the debt was real — and grants no access and restarts no billing. A second cancel is 409 already_canceled, not an error in your request.
7. Import the subscribers you already have
Subscribers who already pay you somewhere else come over in one file, on the dates they already pay, instead of being created one by one. In the dashboard, open Subscriptions → Import CSV: download the sample CSV and fill it in, or select the rows in Excel or Google Sheets and paste them straight in. Then Check the file. The check creates nothing and messages nobody; it shows which rows are ready, which are skipped and which are refused, and why.
A refused row is fixed on the import page itself: name, contact, plan and date. A plan the file names that does not exist yet can be mapped to one of yours or created from there. When too many rows are refused to edit by hand, download the rejected rows (the reason is in a column), fix them in your spreadsheet and upload again. The rejected rows keep their whatsapp and no_email values, so a fixed file comes back with the same choices.
The preview has a WhatsApp and a No email tick box on every row, plus one switch above each column for the whole file. A row without a phone number cannot be ticked for WhatsApp, and a row without an email address has no email to stop; both boxes are disabled there with the reason. The switches only change rows that can carry the flag, and read as mixed when the file is. Before the import button, the page shows the WhatsApp credits the first cycle can use, counted only over rows that are opted in and have a phone number.
| Column | What goes in it |
|---|---|
name | Required. |
email, phone | At least one of the two, so the subscriber can be reminded. An email must be a valid address and a phone number 10 to 15 digits. A row with either written wrongly is refused, not imported without it. |
plan | Required. The name, code or id of a live plan you already have. A plan with a trial is refused: an imported subscriber is not a new one. |
next_bill_date | Required. Their next bill, not their last: 2026-10-01, 01/10/2026 or 01-10-2026, read day first. A date already past is refused. |
quantity | Optional, whole number, 1 when empty. |
external_id | Optional, and what makes a re-run safe. |
whatsapp | ya to send reminders by WhatsApp. A phone number alone is not consent. |
no_email | ya to send this subscriber no billing email: no reminders and no receipts. Empty means email is sent when there is an address. An email address alone says nothing either way. |
Both flag columns read ya, yes, true, 1 or y as yes, and anything else as no. The meaning is the column's: whatsapp=ya asks for WhatsApp, no_email=ya asks for no email. Columns may come in any order and columns not listed here are ignored. One file holds at most 500 rows and 1 MB. For example:
name,email,phone,plan,next_bill_date,quantity,external_id,whatsapp,no_email
Budi Santoso,budi@contoh.id,081234567890,Paket Bulanan,2026-11-01,1,PLG-001,ya,
Siti Rahayu,siti@contoh.id,,Paket Bulanan,2026-11-05,1,PLG-002,,ya
Warung Makan Sederhana,,081234567892,Paket Tahunan,2026-12-12,2,PLG-003,ya,Budi is reminded by WhatsApp and email. Siti has no phone and asked for no email, so her invoices are issued but nobody is messaged. The warung is reminded by WhatsApp only. The same two steps over the API, with the file as the request body:
curl __BASE__/v1/subscription_imports/sample -H "Authorization: Bearer kp_test_..." -o langganan.csv
curl "__BASE__/v1/subscription_imports?dry_run=true" -H "Authorization: Bearer kp_test_..." -H "Content-Type: text/csv" --data-binary @langganan.csv
curl __BASE__/v1/subscription_imports -H "Authorization: Bearer kp_test_..." -H "Content-Type: text/csv" --data-binary @langganan.csvBoth answer 200 with a report: ready, created, skipped and failed counts, and one entry per row with its line in your file, its status, a message and a reason naming the cell to fix. A problem with one row never fails the request; 400 is kept for a file that cannot be read, a header row missing name, plan or next_bill_date, or more than 500 rows.
Re-running is safe. A row whose external_id already has a subscription is reported skipped rather than opened twice, and a subscriber already on file is reused by external_id, email or phone, never duplicated. So a commit that stopped halfway is finished by sending the same file again. Without an external_id a re-run cannot tell a repeat from a second subscription, which is why the sample fills it in.
An imported subscription is an ordinary one: its first invoice is due on next_bill_date, and the reminders hang off that date, so a row imported a month early messages nobody today. Live imports need a verified account, row by row; a sandbox key works throughout. The import endpoints are not in /v1/openapi.json yet; this section is their contract.
Webhooks
invoice.issued, invoice.paid, invoice.overdue, invoice.voided, invoice.uncollectible, and the subscription.* lifecycle events. Each body carries the same object this API returns for it, so what you store from a webhook and what you fetch from a GET are the same thing.
An invoice nobody pays is given up on 14 days after its due date: it becomes uncollectible and the subscription cancels with it, so a subscriber who stopped paying stops being a subscriber rather than sitting past due forever.
Delivery is at-least-once. Deduplicate on Kasera-Event-Id and treat events as possibly out of order — an event is a nudge to read the object, not a substitute for reading it.
Test mode
A kp_test_ key creates a sandbox subscription. It raises sandbox payment requests and sends no reminders to anybody, so a real phone number never hears from a subscription you were only trying out.
Simulating a renewal
Waiting a month to see the second invoice is not a test. Move a sandbox subscription's clock instead:
curl __BASE__/v1/subscriptions/sub_.../advance \
-H "Authorization: Bearer kp_test_..." \
-H "Content-Type: application/json" \
-d '{"to":"2026-05-01T00:00:00+07:00"}'This bills nothing by itself. It moves the subscription's own clock, and the renewal sweep then does on its next pass exactly what it does in production — issues the invoice for each cycle passed, creates the QRIS request, and extends access when it is paid. Give it a minute and read the subscription back.
Advances accumulate, so you can walk a year of renewals one call at a time, and test_clock on the subscription tells you where its clock stands. It only moves forward — rewinding would put a subscription behind cycles it has already billed.
Sandbox only. A live subscription is refused 422, and the database refuses it too.
Live subscriptions are for verified accounts only. Until your verification is through, POST /v1/subscriptions with a live key answers 403 verification_required and writes nothing; a sandbox key works throughout, so you can build the integration first.
What is not here yet
Cycles longer than a year are refused. Proration, coupon codes and partial payments are not in this release, and automatic debit does not exist — every cycle is authorised by the customer.
Prefer to keep the schedule in your own system? You can also bill a subscription by issuing one ordinary payment per period. That path is written up in billing a monthly subscription without auto-debit, and the customer authorises each cycle there too.