Currency

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 to FREE when currentPeriodEnd or cancelAt has passed (and rewrites the row to FREE/CANCELED as 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_due downgrade tier. Expiry and cancellation both land on free, never starter.

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.
CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.