Billing
The billing resource exposes the merchant's subscription state, current plan, recent invoices, and a checkout/cancel pair for upgrading and downgrading. Under the hood Ripllo bills via Plugipay's Pattern 2 partner billing — the merchant pays Plugipay, Plugipay forwards a share to Ripllo's connected account, and Ripllo reflects state through this API. From the merchant's perspective it's one bill.
This page documents the merchant-facing surface. The Plugipay-side webhook handlers and reconciliation logic are internal — you don't need to know about them to read or change a plan.
Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/v1/billing/plans |
none | Public plan catalog |
GET |
/api/v1/billing/plan |
required | Current plan |
GET |
/api/v1/billing/subscription |
required | Current subscription |
GET |
/api/v1/billing/usage |
required | Usage against plan limits |
GET |
/api/v1/billing/invoices |
required | Invoice history |
POST |
/api/v1/billing/checkout |
required | Start a checkout to change plan |
POST |
/api/v1/billing/cancel |
required | Cancel the subscription |
Public plan catalog
GET /api/v1/billing/plans
No auth. Returns the public catalog of plans, suitable for rendering the landing-page pricing section and the dashboard's plan picker. Four plans ship — free, starter, growth, scale — each with {id, name, price, currency, features}. price is monthly IDR; there is no limits object on this endpoint (read limits from GET /billing/plan).
{
"data": [
{
"id": "free",
"name": "Free",
"price": 0,
"currency": "IDR",
"features": [
"500 marketing contacts",
"1 discount code",
"1 referral program",
"Storefront blog (1 post)",
"Email support"
]
},
{
"id": "starter",
"name": "Starter",
"price": 299000,
"currency": "IDR",
"features": [
"10,000 contacts",
"Unlimited discount codes",
"1 referral program",
"Abandoned-cart recovery",
"Pixels + CAPI",
"25 blog posts"
]
},
{
"id": "growth",
"name": "Growth",
"price": 799000,
"currency": "IDR",
"features": [
"50,000 contacts",
"5 referral programs",
"Product feeds (Google Merchant Center)",
"Unlimited blog posts",
"Priority support"
]
},
{
"id": "scale",
"name": "Scale",
"price": 2499000,
"currency": "IDR",
"features": [
"Unlimited contacts",
"Unlimited referral programs",
"Custom segments",
"SLA"
]
}
],
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Every workspace without a Subscription row sits on free. USD pricing exists for international merchants but is not part of this catalog — the equivalent monthly amounts are $19 (Starter), $49 (Growth), $159 (Scale), selected at checkout via currency.
Current plan
GET /api/v1/billing/plan
Returns the workspace's stored plan flattened together with its limits. The limits are what dashboard gauges should render.
{
"data": {
"plan": "growth",
"planName": "Growth",
"isForjioInternal": false,
"contactsLimit": 50000,
"rateLimit": 2000,
"pixelsEnabled": true,
"feedsEnabled": true,
"blogPostsLimit": -1,
"discountCodesLimit": -1,
"referralProgramsLimit": 5,
"billingCycleEnd": "2026-06-01T00:00:00.000Z"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
-1 means unlimited. A Forjio-internal workspace reports plan: "forjio_internal" with every limit lifted.
Two caveats worth knowing:
- This endpoint reports the plan as stored. Limit enforcement elsewhere in the API goes through
getEffectivePlan, which additionally falls back toFREEwhencurrentPeriodEndorcancelAthas passed (and rewrites the row toFREE/CANCELEDas it does so). So immediately after an expiry the two can disagree until the next gated write. - There is no trial concept and no
past_duedowngrade tier. Expiry and cancellation both land onfree, neverstarter.
Current subscription
GET /api/v1/billing/subscription
Returns a projection of the subscription record — plan, status and current-period dates. The Plugipay identifiers are stored but deliberately not exposed here.
{
"data": {
"plan": "growth",
"planName": "Growth",
"isForjioInternal": false,
"status": "active",
"currentPeriodStart": "2026-05-01T00:00:00.000Z",
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"cancelAt": null
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
A workspace with no subscription row still gets a well-formed response: plan: "free", status: "active", all dates null.
status values (lowercased from the stored enum): active, canceling, canceled, past_due, incomplete. cancelAt is a nullable date, not a boolean flag — see Cancel subscription for how it behaves.
Usage against plan limits
GET /api/v1/billing/usage
Returns this calendar month's consumption counters as a flat object. Useful for dashboard "you're using X of Y" widgets and pre-flight checks before bulk operations.
{
"data": {
"plan": "growth",
"contacts": 1284,
"contactsLimit": 50000,
"discountsRedeemed": 431,
"remindersSent": 96,
"referralAttributions": 27,
"blogPostsPublished": 12,
"resetAt": "2026-06-01T00:00:00.000Z"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
The counters come from the MonthlyUsage row for the current year/month, so they reset at resetAt (the first instant of next month). contactsLimit is -1 when unlimited. contacts is the tracked-contacts counter for the month, not a live COUNT(*) — for "right-now" checks, the per-resource limit enforcement at create time is authoritative.
Invoice history
GET /api/v1/billing/invoices
Cursor-paginated. Returns invoices Ripllo has emitted for this workspace.
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
20 |
Upper-bounded at 50. There is no lower clamp — don't send a negative value. |
cursor |
— | Opaque cursor from the previous response (data.cursor). |
Response
The page object is itself under the envelope's data, so the rows live at body.data.data[] and the next cursor at body.data.cursor.
{
"data": {
"data": [
{
"id": "clx8f2k9r0000...",
"plan": "growth",
"amount": 799000,
"currency": "IDR",
"status": "paid",
"paidAt": "2026-05-12T00:01:23.000Z",
"receiptUrl": "https://pay.plugipay.com/r/…",
"createdAt": "2026-05-12T00:00:00.000Z"
}
],
"cursor": null,
"hasMore": false
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Rows are newest-first by createdAt. amount is in the invoice's own currency, which is IDR for Indonesian merchants and USD (cents) for international ones. receiptUrl is a Plugipay-hosted receipt link and may be null until the invoice settles; the invoice id is a cuid, and the upstream plugipayInvoiceId is stored but not returned.
Start checkout
POST /api/v1/billing/checkout
Begins a plan-change flow. Returns a Plugipay checkout session URL the merchant follows to complete the payment. Once Plugipay confirms, Ripllo's webhook handler flips the subscription to the new plan.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
plan |
enum | yes | One of STARTER, GROWTH, SCALE (uppercase). |
email |
string | no | Where Plugipay should send the receipt. Defaults to the email in the auth token; if neither is present the call is a 400. |
name |
string | no | Display name on the receipt. |
currency |
enum | no | "IDR" or "USD". See below — this decides what the merchant is actually charged. |
Currency selection. If you send currency, it wins. If you omit it, Ripllo geo-routes off the CF-IPCountry request header: ID, XX and T1 (and a missing header) bill in IDR via QRIS / VA / e-wallet / card; every other country bills in USD via PayPal. Concretely, Growth is IDR 799,000 or USD $49 depending on which branch you land in, so pass currency explicitly whenever you are not behind Cloudflare.
Response
{
"data": {
"subscriptionId": "sub_plugipay_01HX...",
"invoiceId": "inv_plugipay_01HX...",
"checkoutSessionId": "cs_plugipay_01HX...",
"checkoutUrl": "https://pay.plugipay.com/c/cs_plugipay_01HX..."
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
subscriptionId and invoiceId are the upstream Plugipay ids for the subscription that was just created and its first auto-issued invoice. The plan is not echoed back — you already know it from the request.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Unknown plan, email required (and not derivable from the auth token). |
403 |
NO_ACCOUNT |
The token carries no accountId. |
503 |
PLAN_NOT_CONFIGURED |
The target plan exists in the catalog but no underlying Plugipay product is wired up yet. This shouldn't happen in production; contact support if it does. |
500 |
CHECKOUT_FAILED |
Any other Plugipay-side failure — no active price in the requested currency, or the first invoice not being auto-issued yet. Reachable on ordinary upstream hiccups, so handle it rather than treating it as impossible. |
const { checkoutUrl } = await ripllo.billing.checkout({ plan: 'GROWTH' });
window.location.href = checkoutUrl;
Cancel subscription
POST /api/v1/billing/cancel
Cancels at period end upstream in Plugipay. Locally the row flips to status: "canceling" immediately and keeps its paid plan until currentPeriodEnd passes, at which point the effective plan falls back to free (not starter) and the status becomes canceled.
Response
The updated subscription object (same shape as Current subscription). The signal that the cancel landed is status === "canceling". Note that cancelAt is explicitly set to null by the cancel, so it is not a period-end marker — read currentPeriodEnd for the date access ends. There is no cancelAtPeriodEnd key in the response; polling for it will wait forever.
const sub = await ripllo.billing.cancel();
sub.status; // "canceling"
Errors
| Status | error.code |
When |
|---|---|---|
403 |
NO_ACCOUNT |
The token carries no accountId. |
500 |
CANCEL_FAILED |
Plugipay rejected the cancel. The local row is left untouched, so it is safe to retry. |
Cancelling a workspace that has no Plugipay subscription id is a no-op and returns the current view unchanged.
To uncancel before period end, start a fresh checkout for the same plan — the webhook handler re-activates the subscription.
Events
The billing resource emits events that bridge Ripllo's local state with the upstream Plugipay billing model. These are useful for downstream automations (e.g., notify ops when a workspace hits past_due).
| Event type | Fires on | Status |
|---|---|---|
ripllo.billing.subscription_activated.v1 |
First successful charge after a checkout. | Reserved — not currently emitted. |
ripllo.billing.subscription_updated.v1 |
Plan change. | Reserved. |
ripllo.billing.subscription_past_due.v1 |
Payment failed, subscription enters past_due. |
Reserved. |
ripllo.billing.subscription_canceled.v1 |
Subscription transitions to canceled at period end. |
Reserved. |
ripllo.billing.invoice_paid.v1 |
A new invoice settles. | Reserved. |
For now, poll GET /billing/subscription and GET /billing/invoices for state changes.
Next
- API keys — mint a programmatic key to query billing from your own dashboard.
- Portal → Marketing — dashboard walkthrough of plan management.