Subscriptions
This page explains how to sell recurring plans for memberships, hosted services, or software maintenance. The store tracks each subscription's billing schedule, produces a receipt for every period, and shows its status to both you and the customer.
Subscriptions are an add-on, not part of KahunaCart itself. Install the kahunacart-subscriptions plugin alongside the base plugin and you can offer recurring plans from your catalog. Leave it out and nothing on this page applies.
Your store is the system of record. A subscription is a row in your own database naming a plan, a customer, a status and the period being paid for. The payment provider moves money and reports back as it does for a one-off order. Every billing period is a real order, with an order number, a receipt, an invoice, tax, a payment record and a place in your reports, so refunds, emails, licence issuance and reporting all work on a renewal.
Before you begin
- A working store you can already take an order on. See Getting started.
- The
kahunacart-subscriptionsplugin installed and enabled, andkahunacart.subscriptions.viewor.manageon whoever is going to run it. - The scheduler running. Invoice mode relies on
bin/plugin kahunacart workto process its billing schedule. Without it no reminder goes out and nothing renews. See CLI. system.custom_base_urlset to the store's public address. Emails queued by the scheduler have no browser request to read it from, and every pay link would come out relative.- A product with the variant you want to offer as a recurring plan. See Products and catalog.
- KahunaCart 1.2.11 or later. Subscriptions 1.1.0 needs it to charge renewals at the price agreed at sign-up.
Plans are variants
A plan is a product variant plus a billing interval. There is no second catalog: the title, description, images, tax class and translations all live on the variant, and so does the price unless the plan names its own. The plan adds the calendar.
One variant can carry several plans. Each plan is a billing option, such as Monthly, Quarterly or Yearly. The buy button offers them as a choice, and they all sell from the variant's one stock count. See Billing options.
Create a plan
- In the admin sidebar, click KahunaCart, then Subscriptions → Plans.
- Click New plan.
- Pick the variant this plan sells. A variant that already carries a plan says so in the list, and picking it adds another billing option rather than replacing the first.
- Leave Price blank to sell at the variant's price, or type this plan's own. Give it a Label on the button if the variant will carry more than one plan.
- Choose what happens When a subscription ends. Its place stays used is the usual answer. See When a subscription ends.
- Set the interval: a count and a unit.
1and Months is monthly;3and Months is quarterly;1and Years is annual. - Set a free trial in days, or leave it at
0. - Set the billing mode. Automatic where possible is the right answer unless you have a reason.
- Leave On sale ticked and click Create the plan.
The plan is live immediately. The product's buy button now sells a subscription.
The plan fields
| Field | What it does |
|---|---|
| Variant | What is sold. Not editable afterwards, because every running subscription froze its amount when it was sold. |
| Price | What this billing option costs. Blank sells at the variant's price. A change applies to new subscribers only. |
| Label on the button | The words on this option's choice, such as "Quarterly, save $10 a month". Blank names it by how often it renews, as in "Renews every 3 months". It also appears on the receipt, in the admin and at the payment platform. |
| When a subscription ends | Its place stays used or Its place goes back to stock. See When a subscription ends. |
| Interval | How long one period lasts: a count and a unit of days, weeks, months or years. The count is capped at 52. |
| Free trial | Days before the first invoice is due. 0 means the customer pays immediately. |
| Billing mode | Automatic where possible, Automatic, or Invoice. See The two billing modes. |
| Renewal variant | The variant a renewal order carries when it should not be the plan's own. Blank means the same variant. |
| Grace period | Invoice mode only. Days an unpaid invoice keeps the subscription alive. Blank uses the store default. |
| Customer group while subscribed | A customer group subscribers on this plan are in for as long as their subscription is live. None grants no group. See Access for subscribers. |
| On sale | Untick to stop selling the plan. Running subscriptions keep renewing; nobody new can subscribe. |
Months and years use calendar arithmetic with end-of-month clamping: a monthly period starting on 31 January ends on 28 February (29 in a leap year), then on 31 March. Days and weeks are exact multiples of 86,400 seconds. Every date is UTC.
The renewal variant is how an introductory period renews at the full price. Sell "Pro — first year $99" as the plan's variant and name "Pro — annual $149" as the renewal variant. The first order charges 99 and every renewal charges 149.
A plan with live subscriptions cannot be deleted. Untick On sale to stop selling it instead.
Billing options: several plans on one variant
Put several plans on one variant and the customer picks how often they pay. Every option sells from the same stock count, so a limited number of places stays one number however people choose to pay for them.
Say you run a surf club with twenty places a season. You want to sell membership monthly at $100, quarterly at $270 and yearly at $960, and you want one count of places left, not three.
- In the product editor, create a product called Surf Club Membership with one variant priced at $100.00. Tick Track stock and set the Stock quantity to
20. - Open KahunaCart → Subscriptions → Plans and click New plan.
- Pick the membership's variant. Leave Price blank, set Label on the button to
Monthly, and set the interval to1Months. If a member who leaves should free their place for somebody else, set When a subscription ends to Its place goes back to stock. Click Create the plan. - On that plan, click Add another billing option. The new plan opens with the same variant already picked. Set Price to
270.00, the label toQuarterly, save $10 a monthand the interval to3Months, pick the same When a subscription ends setting, then create it. - Click Add another billing option again. Set Price to
960.00, the label toYearly, save $20 a monthand the interval to1Years, pick the same setting again, then create it. - Back on the Plans tab, the three plans are listed together, in the order the buy button offers them. Use Move up and Move down in a row's ⋯ menu to change the order. The button opens on the first option that is on sale.
The product page now shows How often you pay with the three choices. Picking one changes the price and the renewal terms shown under the button, and the cart charges that option's price. Every subscription takes one place from the same 20, whichever option it was bought on, so the twenty-first customer is told the membership is sold out. A renewal never takes another place.
You can also add an option from the row's ⋯ menu on the Plans tab, which does the same as the button on the plan.
A few details worth knowing:
- A blank price follows the variant. A plan at the variant's price picks up a sale on the variant and any trade price list, exactly as the variant does. A plan with its own price is charged that price; a sale or a coupon still takes its share off it, but a trade price list does not, because the price is no longer the variant's.
- A variant with one plan shows no choice. The buy button looks exactly as it did before, and the label only appears on the receipt and in the admin.
- The label goes to the payment platform. Three options on one variant show up in Stripe's or Polar's dashboard as three agreements you can tell apart.
- Each option has its own settings. The trial, billing mode, renewal variant, grace period and On sale are per plan, so you can offer a trial on Monthly only, or take Quarterly off sale while the others keep selling.
If you want each option to have its own stock count, give the product one variant per interval with a plan on each, as you would have before billing options existed. That still works.
When a subscription ends
Each subscription takes one unit of the variant's stock when it is sold. This setting, per plan, decides what happens to that unit when the subscription ends.
| Setting | What happens |
|---|---|
| Its place stays used | The unit stays sold. Twenty places means twenty subscriptions, ever. This is the default, and it is how every plan behaved before the setting existed. |
| Its place goes back to stock | The unit goes back on the variant's stock once the subscription has ended. Twenty places means twenty subscribers at a time. |
Pick "Its place stays used" when the stock count is how many you will ever sell, such as a founders' membership limited to 100 people, or when you don't track stock on the variant at all.
Pick "Its place goes back to stock" when the stock count is capacity: places in a club, seats in a class, spots on a coaching roster. When somebody leaves, their place is free for the next person.
A place goes back only when the subscription has actually ended: cancelled straight away, cancelled at the end of its period once that period has run out, or expired because an invoice went unpaid. A customer who cancels at the end of their period keeps their place until the time they paid for runs out.
A place goes back once, however many times the ending is reported. It does not go back when the order that started the subscription was refunded or cancelled, because reversing that order already put the unit back, and it does not go back for a variant that doesn't track stock, because there is no count to add it to. When a place does go back, the subscription's history shows Place returned to stock.
Plans on one variant share its stock, but each plan's setting applies to its own subscribers, so you could keep Monthly places used and give Yearly places back.
This is a different setting from the one with the same name on a licensing rule, which decides what happens to a license key.
Connect a plan to a payment platform
Some platforms want a price or product created on their side before a subscription can start. That reference lives on the plan, as a small set of ids for each provider.
- Open the plan and scroll to Provider references.
- Find the block for your provider. One appears for every payment provider registered on your store.
- Add the ids the driver expects. Stripe wants
price, and Polar wantsproduct. Each driver's own documentation names what it reads. - Save that provider's block.
Blocks are saved one provider at a time and merged, so a store running two providers can't wipe one provider's ids by saving the other's.
Leaving them blank is usually right. A driver that can create its own price does so on the first checkout and writes the id back here, so the second checkout reuses it. Fill them in only when you want a plan attached to a price you made by hand at the platform.
What a plan does not control
- The name, description, images, tax class and translations. Those belong to the variant, and so does the price unless the plan names its own.
- The stock count. That's the variant's too, shared by every plan on it.
- Whether the product is published. That's the product's.
- Who may buy it. Subscriptions are sold to anybody who can check out, guests included.
- The reminder schedule and the store-wide grace default. Those live under Subscriptions → Settings. See Invoice mode.
A subscription checks out on its own
A subscription cart contains only one plan, with a quantity of one. Adding a second subscription or an ordinary product alongside it is refused at add-to-cart with a message beside the button. This applies in both billing modes, regardless of the payment provider. The cart also rejects quantity increases above one.
The recurring price is frozen at checkout
A subscription's amount is fixed when it is created, and every renewal is charged that amount. A later price change never reprices anybody already paying. To change what new customers pay, edit the plan's own price, or the variant's price for a plan that uses it. Each renewal order shows Price agreed at sign-up under its line, so the customer can see why it may differ from today's price. A sale running on the day of a renewal is not taken off it again. Shipping and tax do not carry over; they are recomputed for each period against wherever the customer is by then.
A plan with a renewal variant is the one exception: its renewals are charged whatever the renewal variant costs on the day, because naming that variant is how you ask for that price.
Taking a plan off sale stops new sign-ups only. Renewals of the people already on it carry on as normal.
Important
The amount frozen is what the customer actually paid for the plan line, not the plan's or the variant's list price. A sale running when they subscribed, or a coupon they entered at checkout, renews at that price for the life of the agreement. In provider mode the agreement is opened at the platform at that price too. Use the renewal variant field when you want a cheaper first period and the full price after it.
The two billing modes
Provider mode. The payment platform runs the calendar. It holds the customer's card, charges it on schedule, and tells your store what happened through the provider's webhook. KahunaCart mirrors the state and mints the renewal order. Choose it when your provider supports recurring billing.
Invoice mode. Your store runs the calendar. It builds an order for each period, emails the customer a pay link, sends reminders, allows a grace period, and lapses the subscription if nothing arrives. Choose it when your provider has no recurring billing, you would rather not store cards, or your customers pay by bank transfer.
A declined card in provider mode. The platform is the one retrying the card, but your store is the one that knows the customer. So when a renewal is declined and the subscription moves to past due, the store sends the same Payment failed email that invoice mode sends. It has no pay link, because there is nothing to pay here, and it points at the customer's subscription page, where Update payment method opens the billing portal. emails.past_due switches the message off in both modes. emails.provider_past_due switches off only the provider-mode half, which is what you want if your platform already emails customers about a declined renewal. Stripe has that under Settings, Billing, Customer emails, and it's off by default on a new account.
A plan set to Automatic where possible resolves to provider mode when the provider the customer picked can run a calendar, and to invoice mode when it cannot. A plan set to Automatic removes providers that cannot from the checkout. Whichever mode it resolves to is frozen onto the subscription when it is created, so changing a plan's billing mode affects new subscribers only.
Which providers run which mode
| Provider | Provider mode | Invoice mode |
|---|---|---|
| Stripe, including Managed Payments | yes | yes |
| Polar | yes | yes |
Test provider (kahunacart-dummy) |
yes | yes |
| PayPal | — | yes |
| Offline (bank transfer, cheque) | — | yes |
| Any other provider | only if its driver declares the Subscriptions capability |
yes |
Invoice mode needs nothing from a driver. It uses the ordinary payment flow, once per period, on a link the store emailed. Stripe Managed Payments sells subscriptions too, and Stripe stays merchant of record on every period. See Payment providers and Tax → Merchant-of-record providers.
Invoice mode
The reminder ladder
Reminders are whole days before the period ends. The default schedule is 14, 3, 0: two weeks out, three days out, and the day the period ends. The renewal order is created once, when the first reminder is due, and each later reminder links to that same order. A customer who pays on the first email and one who pays on the last both pay one invoice.
Edit Reminder Schedule in the plugin's settings to change it. The list is sorted, de-duplicated and capped at Maximum Reminders. A schedule of [0] sends one email on the day the period ends, and an empty schedule is treated as [0].
Grace, past due and expired
Past due means the money has not arrived and the clock is running. The subscription is marked past_due the moment the period ends unpaid. past_due counts as live: access continues, licences keep validating, and downloads keep working.
Paying the outstanding invoice, through the link or by you marking the order paid, puts it straight back to active with the period advanced. No period is lost, because the new period is measured from the end of the old one.
Expired means grace ran out. The subscription is over, the unpaid renewal order is cancelled, and the customer is told. An expired subscription cannot be resumed; the customer subscribes again and a fresh period starts that day. The store-wide grace default is seven days, a plan can set its own, and the pay link expires when grace does.
The four emails
| When | Setting | |
|---|---|---|
| Renewal reminder | Each scheduled reminder before the period ends | emails.renewal_reminder |
| Invoice due | The period has ended and the invoice is unpaid | emails.renewal_due |
| Payment failed | A charge failed, or the invoice went past its due date | emails.past_due |
| Subscription ended | Grace ran out and access ended | emails.expired |
All four go through KahunaCart's own translatable email pipeline and carry the pay link. Switching one off stops the message, not the calendar. The subscription still goes past due and still expires on schedule. See Emails.
Note
With the default schedule, the past-due email is not sent separately. The 0 reminder's invoice-due message is sent when the subscription is marked past due, with the same amount, pay link and cut-off date. Remove the 0 reminder to get a separate past-due message instead. The same goes for a grace period of zero days, where the lapse notice follows straight away and a past-due warning about a deadline that has already passed helps nobody.
Invoice mode settings
| Setting | Default | What it does |
|---|---|---|
invoice.reminder_days |
[14, 3, 0] |
Days before the period ends that a pay link goes out. |
invoice.grace_days |
7 |
How long an unpaid invoice keeps a subscription alive past the period end. |
invoice.max_reminders |
4 |
A ceiling on the schedule above. |
Everything here is whole days in UTC, so a daylight-saving change neither gains nor loses an hour.
Important
The billing sweep queues itself from bin/plugin kahunacart work, at most once every ten minutes. If your scheduler is not running, nothing about invoice mode works. See Troubleshooting → Emails never arrive, jobs pile up.
Each sweep attempts at most 200 invoice subscriptions, in billing-date order, and the next sweep carries on after the last one it tried, failed attempts included. So one subscription nobody can invoice can't hold up the ones behind it, and a store with more than 200 due at once works through them over several sweeps.
Renew early, or mark an invoice paid by hand
A customer can pay for the next period before it is due, from their own subscription page. Renew now builds the invoice immediately and takes them to the payment page. The new period is measured from the end of the current one, so paying two weeks early buys a whole period. Pressing the button twice does not raise two invoices. The button appears only for a live invoice-mode subscription. A provider-mode customer has nothing to renew early, because the platform charges their card on its own schedule.
When money arrives by bank transfer, find the renewal order in Orders and click Mark paid. That is an ordinary order operation: the customer gets their receipt, the ledger records the payment, any licence is extended, and the subscription advances to the next period.
Resend the pay link queues the same message the calendar sends for an invoice that is already outstanding. It never builds a new one, so a support call cannot bring a billing period forward. See Orders and the admin.
Trials
A plan with Free trial set starts its subscription in trialing, and the first paid period begins where the trial ends. A 14-day trial of a monthly plan buys 14 free days and then a whole month.
In provider mode the trial costs nothing. The origin order is completed and paid with a total of 0, carrying a discount adjustment labelled "Free trial (14 days)". The label is a language key, so it translates. The first real money arrives as the renewal at the end of the trial.
In invoice mode the origin order is charged at the plan's price, because no platform holds a card to charge later. The trial days then run on top of the period that order paid for, and the first pay link climbs the reminder ladder against the end of the trial.
The cart and the checkout page say what is about to happen either way, with a sentence beside the plan line: "Free for the first 14 days, then $49.00 every month." A theme with its own cart or checkout template gets that sentence by calling kahunacart_cart_line_notes(order, items). A trial never reprices the subscription. The plan line keeps its price, and the frozen amount is what renews.
How a period becomes an order
- The calendar says a period is due: the provider's webhook in provider mode, the billing tick in invoice mode.
- The store builds a renewal order through the same calculator as any checkout. The line is priced at the amount agreed at sign-up, or at the renewal variant's price when the plan names one, and tax and shipping are worked out as they would be for a normal purchase, in the usual minor units.
- Any add-on that cares is asked what to attach to the line before it is built.
- The order is marked paid (provider mode) or left unpaid with a pay link (invoice mode).
- The subscription's period advances, an event is written to the timeline, and the customer gets the receipt your store already sends for every order.
Step 2 is the ordinary order builder, so everything downstream works on a renewal without knowing it is one.
The lifecycle
| Status | What it means |
|---|---|
incomplete |
The first payment has not arrived. Nothing is billed and nothing is granted. |
trialing |
A free trial is running. The first invoice is due when it ends. |
active |
Paid and running. |
past_due |
A payment did not go through. Access continues through the grace period. |
paused |
Billing is stopped. Nothing renews until it is resumed. |
cancelled |
Cancelled. It will not renew again. |
expired |
Ended without being renewed. |
trialing, active and past_due are the live statuses. They're the ones that keep access, including a plan's customer group. See Access for subscribers.
A cancellation scheduled by the customer leaves the subscription running until the end of the period already paid for, and only then moves it to cancelled.
A pause is usually scheduled too. Polar and Stripe both stop billing at the end of the period the customer has already paid for, not the moment the button is pressed. Where the platform says so, the subscription stays active and keeps billing, a Pausing flag appears beside its status, and the customer's page says "Billing pauses on 3 October". It moves to paused when the platform reports that billing has actually stopped. A platform that pauses straight away is followed straight away. Until then the customer, or you, can call the pause off. Any move not on the list is refused with a readable error, from the admin and from the API alike. A cancelled subscription cannot be paused, and an expired one cannot be renewed.
Every change writes a timestamped event naming who made it: the customer, the store, the payment provider or the store's own calendar. That timeline answers "why was I charged twice in March".
What the customer sees
The account tab. {base}/account/subscriptions lists everything a signed-in customer is paying for, with the plan, a status chip, the current period, the next date and the amount. It sits beside Orders and Downloads in the account navigation. Subscriptions are matched by customer record and by email address, so somebody who bought as a guest and created an account afterwards still sees what they bought. See Customer accounts.
The subscription's own page. {base}/subscription/{hash} carries the whole record: the current period, the next date, every order the subscription has produced with links to each receipt and invoice, and the full history. It is addressed by a 32-character random hash rather than a login, so a guest subscriber reaches it from the link in their email. The page is served private, no-store, and its buttons appear only when they would work:
| Button | When it appears |
|---|---|
| Cancel this subscription | While it is live. Two-step: a confirmation box has to be ticked. The period already paid for is kept. |
| Keep my subscription | Only while a cancellation is scheduled. Undoes it, with nothing lapsed in between. |
| Do not pause after all | Only while a pause is scheduled. Calls it off, and billing carries on as before. |
| Update payment method | For a provider-run subscription where the platform has named a customer. The portal session is created when the button is pressed, so a platform having a bad afternoon shows as a message and not as a button that quietly went missing. |
| Renew now | Invoice mode only, on a live subscription. |
| Pay now | On an unpaid renewal order that can still be paid. |
The status chip reads Ending while a cancellation is scheduled and Pausing while a pause is.
In provider mode a cancellation is mirrored to the payment platform as well. If the platform confirmed it, the customer gets a plain confirmation. If it refused or could not be reached, the page says the store has recorded the change and could not confirm it, and asks the customer to get in touch if they are charged again.
Below the buttons, a table lists every order the subscription has produced, oldest first, with the period it covers, its total and its status. A paid one links to its invoice. An unpaid one shows what your store's record says, such as awaiting payment, failed or canceled, and offers Pay now only when the order can still be paid. The button, the link in the email and the page behind them can never disagree, because they all ask the same question. The history under it lists every change, newest first, and names who made it.
Every order that started or renewed a subscription carries a panel on its receipt and in its order email: which subscription it belongs to, which dates it covers, and a link to the subscription's page. All of it is Twig partials you can copy into your theme. The stylesheet reads the --kc-* custom properties the base plugin publishes. See Theming.
Refunds
A renewal is an ordinary order, so it refunds like one. What changes is the calendar.
- Refunding a renewal rolls the period back. The subscription returns to the period before the refunded one, and a
period_rolled_backentry is written to the timeline. - Refunding the order that started a subscription cancels it. There is no earlier period to return to.
- Refunding an older period changes no dates. A goodwill refund of the third period of six leaves a customer who has paid through December in December. The timeline records that the money went back and the period did not move.
Orders and the admin → What a full refund reverses covers what a refund does to the rest of the order.
The admin
Subscriptions appear inside KahunaCart's own admin under Add-ons in the side navigation, beside Orders, Products and Licenses, with three tabs: Subscriptions, Plans and Settings.
Finding a subscription
Filter by status with the chips along the top, each with its own count, then narrow by plan, billing mode or payment provider. Search by email address or by the subscription's own link. A customer writes in from the address they bought with, or forwards the email that carries their link.
Press a column heading to sort the whole list by it, and press it again to turn the order round. The sort covers every subscription that matches the filters, not only the page on screen. Until you press a heading the list runs newest first. Sorting needs KahunaCart 1.3.8 and Subscriptions 1.1.2.
Opening a row shows the current period, the next date, the billing mode and provider, the failed-attempt counter, every order the subscription has produced and the full history. Customer's page opens the customer's own view of the same subscription in a new tab, which helps when somebody is describing a button and you want to see what they see.
The Plans tab
The list shows what each plan sells, how often it bills, its own price when it has one, its mode, its trial, how many live subscriptions are running on it and whether it's on sale. A variant with several plans lists them together, in the order its buy button offers them, and each row says which option it is and which one the button opens on.
The live count is what makes Delete honest. You're about to remove the row that names somebody's billing interval, and "3 live" beside the button says more than a confirmation box would.
Everywhere the admin names a variant, in the Variant picker, the Renewal variant picker and this table, it uses the same label the storefront does: the product's title, then the option combination, then the SKU, as in Sandbar Boardshort — 28 / Slate · SAND-28-SLATE. Rename a value from "Navy" to "Midnight" and the plan editor follows. That matters most on a store with options, where three sizes of one boardshort would otherwise be offered as the same name three times.
The Settings tab
The add-on's own settings form, drawn inside this screen, with one Save button in the page header. Plugins → KahunaCart Subscriptions in the Grav admin lands here too, so there's one place to change a setting. See the configuration reference.
Permissions
| Permission | What it allows |
|---|---|
kahunacart.subscriptions.view |
Reading. The list, the filters, the detail, the history, the figures, the plans. |
kahunacart.subscriptions.manage |
Everything that changes something: cancel, pause, resume, extend, note, resend a pay link, and every write on the Plans tab. |
A reader without .manage gets the whole screen with the buttons absent and a line saying why. Mark paid also needs the base plugin's kahunacart.orders.manage, because marking a renewal paid is an order operation.
The dashboard row
Four figures sit across the top of the Subscriptions tab: Monthly recurring, with the yearly equivalent underneath; Active, with the trial count underneath; Past due; and Paused.
Monthly recurring revenue is each live subscription's amount divided by the months in one billing period. A $120 yearly plan contributes $10, a $30 quarterly plan $10, and a $10 weekly plan $43.33 (52 weeks and 12 months to the year, 365 days for a daily plan). active and past_due both count; trials are reported separately. Currencies are never added together, so a store selling in USD and EUR gets two figures, with the store's own currency first.
The actions
Every action goes through the same state machine the webhooks and the billing calendar use, and every one writes an entry the customer can see on their own page.
| Action | What it does |
|---|---|
| Cancel at period end | Stops it renewing and leaves the period already paid for intact. Almost always the right button. |
| Cancel now | Ends it immediately. Access stops and the paid period is lost. For a chargeback or a fraudulent signup. |
| Undo the cancellation | Clears a scheduled cancellation. |
| Pause billing / Call off the pause / Resume billing | Stops and restarts the calendar without ending the subscription. See below. |
| Extend the period | Pushes the current period out by a number of days, with a required note. Negative pulls it in. Capped at ten years. |
| Resend the pay link | Invoice mode only. Queues the outstanding invoice's email again. |
| Mark paid | On any unpaid order in the subscription's order list. |
Cancelling, un-cancelling, pausing and resuming a provider-mode subscription change the store's record and tell the payment platform. A green toast means both happened, or there was no platform to tell. An amber toast means the platform did not confirm it and will charge the customer again next month, so finish the job at the provider first. Pausing is the one action that does not go ahead locally when the platform refuses it, because a pause the platform didn't accept would stop access while the card went on being charged.
A pause is usually scheduled rather than immediate. The row stays live and keeps billing, a Pausing flag appears beside the status, the Billing panel says "Pauses on 3 October", and the toast says when it takes effect instead of claiming it already has. While a pause is only scheduled the button becomes Call off the pause. A subscription that is actually paused gets Resume billing instead. A pause the store's own rules refuse, such as one on a cancelled subscription, is refused before the platform is asked anything.
A customer with subscriptions also gets a Subscriptions panel on their admin record, linking straight into the detail here.
The admin API
Everything the screen does goes through documented endpoints under the Grav API plugin's base (/api/v1 in a stock install), authenticated like every other KahunaCart admin endpoint.
| Method and path | Permission |
|---|---|
GET /kahunacart/subscriptions, …/metrics, …/{id} |
view |
POST /kahunacart/subscriptions/{id}/ + cancel, uncancel, pause, resume, extend, note, resend-pay-link |
manage |
GET /kahunacart/subscription-plans, …/variants, …/{id} |
view |
POST /kahunacart/subscription-plans, PATCH and DELETE …/{id} |
manage |
PUT /kahunacart/subscription-plans/{id}/provider-refs/{provider} |
manage |
GET /kahunacart/subscriptions also takes sort (customer, plan, status, billing, amount or next) and dir (asc or desc). A subscription with nothing in that column sorts last whichever way it runs, and any other sort is ignored. Times are UTC epoch seconds as integers; an ISO-8601 string is accepted on the way in. A refusal from the state machine is a 422 carrying the reason as a sentence and a machine code in the X-KahunaCart-Error header.
Messages to you
Under Notifications, Store owner, the add-on adds rows that go to whatever channels you have ticked, such as Slack, Discord or your phone, or to the ones you chose under New messages go to. Each names the plan and the customer's name, never their email address, and links to the subscription.
| Row | Priority | When |
|---|---|---|
| A new subscriber | Low, switched off until you turn it on | A subscription goes live, paid or on a free trial. A checkout that was never paid is not a subscriber. |
| A subscription is cancelled | Normal | A cancellation is set to take effect at the end of the paid period, and again when it ends. Cancelling right away sends only the second. |
| A subscription is past due | High | A renewal was not paid. Once for each missed renewal. |
| A subscription expired | Normal | A past-due subscription ran out of time. |
| A renewal could not be priced | High | Only on a store that sells in more than one currency. See A renewal is waiting on a price. |
A renewal payment that fails is already sent by KahunaCart as Payment failed, so there is no second row for it here. The alert for renewals waiting on a price is sent to your channels too, once when it shows and once more when it clears, under its own row. That needs KahunaCart 1.5.3 and Subscriptions 1.1.9.
Access for subscribers
A plan can put its subscribers in a customer group for as long as their subscription is live. That group can then open pages, price lists and products the rest of the store can't, which is how you sell a members area, a private downloads page or a subscriber discount. It needs KahunaCart 1.3.5 and Subscriptions 1.1.1 or later.
Pick the group under Customer group while subscribed, in the Access part of the plan editor. None grants no group, which is what every plan did before the setting existed. The Plans tab shows a plan's group as a small tag on its row.
The list offers the store's customer groups. With no groups yet, the field is disabled and links to Customer groups so you can create one. Reading the groups needs kahunacart.settings, so an account without it sees the field disabled with a note saying why.
The group counts everywhere a customer group counts: price lists, coupon and sale conditions, member-only products, the badges on the customer's account page, and the site.customer_group.<code> permission a page can ask for. See Customer accounts → Pages only a group can see.
Which subscriptions keep access
| Status | In the group? |
|---|---|
trialing |
Yes. |
active |
Yes. |
past_due |
Yes. The grace period is there so a declined card doesn't lock somebody out the same afternoon. |
paused |
No. Billing has stopped, and access stops with it. |
cancelled |
No. |
expired |
No. |
incomplete |
No. The first payment hasn't arrived. |
A customer who cancels at the end of their period stays active until that period ends, so they keep access for the time they paid for.
Nothing is written to the group's member list. The store works it out from the subscription every time the customer loads a page. When a subscription stops being live, the customer is out of the group on their next click, even in the middle of a session. A late webhook or a status changed by hand can't leave anybody in a group they've stopped paying for, because there's no stored membership to forget to remove. People you put in the group yourself, on their customer record, stay in it as they always did.
Some details to keep in mind:
- The customer has to be signed in to a store account. A subscription bought as a guest has no account behind it, so it grants nothing.
- Several plans can name the same group. A customer with two live subscriptions that both grant
membersis inmembers, once. - Deleting the group means the plan grants nothing. The plan editor shows the old code as no longer a group, so you can pick another.
- It gives no admin rights of any kind. The permission only ever reaches
site.pages. - Grav's own accounts never get the group. Your staff logins aren't store customers. Super users can open a restricted Downloads Pro page anyway, so they can preview it.
On the customer's record in the admin, the group shows on the Customer groups panel with the reason, such as "In Members through Subscription to Surf Club Membership". See Customers → Groups an add-on puts a customer in.
Task: a downloads page only subscribers can open
This uses Downloads Pro, but the same steps work for any Grav page with an access: header. Downloads Pro checks the page's access: header when the page is shown and again on every download link it hands out, so a customer outside the group can open neither the page nor a copied link to one of its files. Nothing needs changing in Downloads Pro itself.
-
Create a customer group with the code
members. See Customers → Create a customer group. -
Open each plan that should unlock the downloads, pick Members under Customer group while subscribed, and save. Do it for every billing option, Monthly and Yearly alike, or subscribers on the one you missed are locked out.
-
Ask for the group in the downloads page's header:
YAML--- title: Member downloads access: site.customer_group.members: true downloads: enabled: true include_all: true ---In Admin Next you can pick the permission from the page's Security → Access picker instead, where every group is listed under Customer groups.
-
Block direct access to the files on the web server, as described below.
-
Check it from a private window: signed out, then as a customer with no subscription, then as a subscriber. Only the subscriber should see the page and fetch a file. Then
curla file's/user/pages/...path while signed out and expect a 403.
include_all: true (Include all media in the page editor) lists every file in the page's folder. Without it, Downloads Pro lists only the files the page names under File Overrides, so a page that names none shows an empty list to the very subscribers you just let in.
A visitor who isn't signed in is sent to the login page, and a signed-in customer without a live subscription gets the access-denied page. When a subscription ends, is paused or runs out of grace, the page and its download links close on the customer's next page load.
The same group can do more at the same time. Give it a price list and subscribers get member prices, or restrict a product to it and only subscribers can buy it. See Customers → Member-only products.
Block direct access to the files
Downloads Pro protects the page and its download links, but the files still sit inside user/pages, and a web server will happily serve them straight off disk at /user/pages/<route>/<file> to anybody who guesses the path, skipping Grav and every access check. Grav's stock .htaccess only blocks a handful of extensions under user/ (.md, .yaml, .php, .twig and friends), so documents, images, spreadsheets and archives are all reachable.
Deny web access to the folder holding the files. Grav reads them from disk in PHP, so Downloads Pro keeps working.
Apache, as a .htaccess in that folder:
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
nginx, in the server block:
location ~ ^/user/pages/.*/documents/ {
deny all;
return 403;
}
Caddy:
@private path_regexp ^/user/pages/.*/documents/
respond @private 403
Change the path to wherever your files live, and check it with curl from a signed-out session before you call it done.
Articles with a teaser
The same group can also fill the subscribe panel on a paywalled article. When a page is marked paywall: members, everybody who isn't a member gets the opening of the article and a panel in place of the rest. The panel lists the plans that let them in: one button for every plan whose Customer group while subscribed is members, with its price, on a product that's published and can be bought right now. An unlisted product counts, so a membership you sell only from your articles can stay out of the shop's lists. A product with Monthly and Yearly billing options that both grant the group gets a button for each. They come in the order your shop lists products in, then the order the product page offers the plans. There's nothing to switch on: give the plan the group and it shows up. Take the plan off sale, unpublish the product or sell it out, and it leaves the panel.
The button opens the product page. If the plan sits on a variant other than the one the page opens on, the link picks that variant. A product page can't open on a chosen billing option of one variant, so plans that share a variant share a link, and the customer picks under How often you pay. It needs KahunaCart 1.3.8 and Subscriptions 1.1.2 or later. The setup, the teaser and the panel's other settings are in Customer accounts → Articles with a teaser.
Subscriptions and license keys
If you also run the Licenses add-on, a subscription turns its manual renewals into automatic ones. Write the licensed product, the renewal product and the renewal rule as Renewals describes, then create a plan on the licensed product's variant and set the plan's renewal variant to the renewal product's variant. That second step is the whole integration.
- The first order mints the key, as any licensed purchase does, and the key is stamped with the subscription when it activates.
- Every period after that extends the key instead of minting another, because the renewal variant carries a
renewalrule. A subscription holding several keys gets one line per key. - Refunds roll both back. Refunding a period restores the key's dates to what they were before it, and the subscription's period rolls back with it.
- The account tab says how it renews. A key paid for by a subscription reads Renews automatically in provider mode, or names the date the renewal is due in invoice mode, with a link to the subscription's page. The manual Renew link is hidden while the subscription is live and comes back when it ends.
Each licensing rule also carries a When a subscription ends setting: nothing (the default) leaves the key alone, so the customer keeps the term they paid for; suspend stops it working while keeping every seat; revoke kills it and frees the seats. It applies when the subscription actually ends, not when the customer clicks cancel.
When something looks wrong
- A subscription is stuck in
incomplete. The first payment never arrived. Open the subscription and look at its first order. If that order is unpaid, the customer left the payment page, and the pay link on the order is the fix. If the order is paid, the platform's activation never reached you: in provider mode a webhook activates the subscription, not the checkout coming back. Look in the Webhook log in the KahunaCart admin. A delivery row endingsubscription:activated:unmatchedmeans the driver couldn't match it to a row. That's a driver fix. There's no button that forces a subscription active, on purpose, because a subscription nobody paid for is worse than one that has to be bought again. - No reminder emails go out. Check that the scheduler is running, that the four emails are switched on under Settings, and that the subscription is in invoice mode, because a provider-mode subscription never gets a reminder. Then check
system.custom_base_url, since without it the pay link comes out relative. Cron also needsPATHset in the crontab, becausebin/pluginstarts withenv php. With the default reminders the past-due email never sends. See the note under The four emails. - It says "Pausing" but the customer is still being billed. That's correct. The pause takes effect at the end of the period they already paid for. If the date has gone by and the status hasn't moved, the platform's
subscription.pauseddelivery never arrived, so look in the Webhook log the same way. - The customer says Cancel did nothing. Cancelling is two steps and the confirmation box has to be ticked. Check the timeline for a
cancel_scheduledentry, which means it worked and their page should say Ending. An error about the session means the page was left open overnight, and reloading fixes it. A generic failure means the provider refused, and the real error is in the Grav log. You can always cancel it for them from the admin. - There's no Update payment method button. An invoice-mode subscription has no stored card to update, so that's correct. For a provider-run one, the driver never named a customer at the platform. Check
provider_customer_refon the subscription in the admin. Empty means a driver fix. - The provider is billing a subscription the store says is cancelled. The store won't reactivate a cancelled subscription from a webhook, but it doesn't stay quiet about it. A note on the timeline says what the provider reported, and a renewal adds the amount: "The payment provider billed $6.00 for this subscription after it had already ended here." No order is created. Open the agreement at the provider and end it there, because only the platform can stop taking the money.
- The admin buttons are missing but the list is there. The account has
kahunacart.subscriptions.viewand not.manage. Mark paid also needskahunacart.orders.manage. - MRR doesn't match your accountant's number. Trials are left out, past due is counted, weeks are 52 to the year and days 365, and currencies are never added together.
Anything the add-on swallows instead of showing a customer goes to the Grav log, starting kahunacart-subscriptions:. See also Troubleshooting.
Selling in more than one currency
A store that sells in more than one currency sells each plan in the currencies it has an amount for (see Price in other currencies in Plans). A subscription is in the currency of the order that started it. Its amount is frozen in that currency, its renewal orders are built as orders in it, and its page, its emails and the admin print its money in it, whatever the visitor is browsing in and whatever the store's base currency is.
A renewal that cannot be priced in the subscription's currency is not repriced in another one. In provider mode a platform that reports a charge in a different currency from the subscription's has that amount left out of the record, with one note on the timeline, and the renewal order stays in the subscription's own currency. A store that sells in one currency works exactly as it did before.
Price in other currencies
These boxes appear when the store sells in more than one currency (Configuration → General → Selling in More Than One Currency). Under the plan's price there is a box for each of the other currencies, and each takes that currency's own decimal places: euros take two, yen take none.
A plan with a price of its own is sold in another currency only if it has an amount in it. Leave a currency blank and the plan cannot be bought there. The buy button draws the option greyed out and says "Not available in EUR", the form opens on the first option that can be bought, and a customer who adds it to the cart some other way is told "Sandbar Club, Yearly is not available in EUR." The store never charges the dollar number in euros and never sells the plan for nothing. A box holding 0 is a price: it makes the plan free in that currency.
The variant also needs a price in that currency, even when the plan has its own, because KahunaCart checks the variant before it asks the plan. A plan's amount in euros with no euro price on its variant cannot be bought in euros.
A plan with no price of its own sells at the variant's price, so there is nothing to type beside it and the boxes are not offered. In another currency it sells at the variant's price in that currency, which you set on the variant. If the variant has none there, the product page already says it is not available in that currency.
An amount can only be given to a plan that has a price of its own. Clearing the plan's price clears its other prices too, because there would be nothing left for them to sit beside, and deleting the plan deletes them. A store that sells in one currency never sees the boxes.
A subscriber keeps their currency and their amount
A subscription is in the currency of the order that started it, and it renews in that currency at the amount it was sold at. Changing a plan's euro amount, or clearing it, does not touch anybody already paying in euros. The same goes for the plan's own price in the store's base currency.
To stop selling a plan in one currency, clear that currency's box. New customers can no longer buy it there and the people already subscribed carry on. Their renewals stop only when the store itself can no longer price them: the currency is taken out of the store's list, or the variant (or the renewal variant) loses its price in it. A renewal in that position is not built in another currency. It fails the way a failed payment does, and the store owner is told. See A renewal is waiting on a price.
A renewal is waiting on a price
A subscription is in the currency of the order that started it, and its renewal is built in that currency. If the store cannot price that renewal there, nothing is built in another currency. The subscription gets a failed payment on its timeline, "Waiting on a price in EUR" beside its status, and the store owner gets a notification when it first happens (once a day for each plan and currency, however many subscriptions it catches) and an alert on the admin that stays up until the last of them is fixed. The subscription's status does not change, because the customer has not failed to pay anything, and this add-on sends the customer no email about it.
The usual causes are a currency taken out of Selling in More Than One Currency while people were still subscribed in it, and a variant or renewal variant whose price in that currency was cleared. Put it back. In invoice mode the next sweep builds the invoice by itself. In provider mode the platform's next retry of the charge builds the order, and until then the platform may already have taken the money, which is why the notification is high priority. The mark clears when a renewal goes through.
In invoice mode the invoice is an order in the subscription's own currency. If the store cannot price it there (the currency is no longer sold, or the variant has no price in it), the sweep builds no invoice, sends no reminder for it and does not fall back to another currency. It records a failed payment on the subscription, tells the store owner, and tries again on its next run, so the invoice is built by itself once the price is back. Renew now says the subscription cannot be renewed on demand right now, and the owner gets the same notice.
In the admin
On a store that sells in more than one currency the plan editor has a box for each of the other currencies beside the price, and the table lists them under it. They are saved with the plan in one go. An amount is refused for a currency the store does not sell in, with more decimals than the currency has, or on a plan that has no price of its own. The API carries them as currency_amounts, like every other price in the admin ({"EUR": {"price": "25.50"}}, an empty string clearing one, a key left out leaving it alone), and a store that sells in one currency neither sends nor accepts the key.
Every amount in the section, on the list, in a subscription's detail and in its orders, is printed in that subscription's own currency with that currency's decimals. A subscription whose renewal could not be priced says "Waiting on a price in EUR" next to its status, and while any are waiting the store's alert strip says how many and takes you to the plans. The row also appears on Notifications under Store owner (a push or chat message names the plan, the currency and the subscription's number, never the customer).
What the customer sees
A subscription that started in euros shows euros on the account tab, on its own page, in the payments table and in every email, even when the customer is now browsing the shop in dollars. The currency is the one on the orders the subscription produces, so the page and the receipts always agree.
What is not in this release
- Plan changes and proration. A customer who wants a different plan cancels this one and subscribes to the other. Nothing lapses, because the cancellation takes effect at the end of the paid period. The account page says so.
- Charging a stored card on the store's own calendar. The interface is declared in the base plugin and nothing implements it yet. The choice today is the provider's calendar or an emailed invoice.
- Usage-based and metered billing. Every period bills the plan's amount.
- Creating a price at the provider from the admin. A driver that can create its own price does so on the first checkout and writes the id back to the plan's Provider references. Fill those in by hand only to attach a plan to a price you created at the platform yourself.
Configuration reference
Edit these at KahunaCart → Add-ons → Subscriptions → Settings, or in the Grav plugin list at Plugins → KahunaCart Subscriptions. Both screens write the same file, user/config/plugins/kahunacart-subscriptions.yaml, and every one of them is store-wide.
Nothing here is per-plan. The price, label, what happens when a subscription ends, the interval, trial, billing mode, renewal variant, a plan's own grace period and the customer group it grants are set on the plan; see The plan fields.
Invoice billing
Under the Invoice Mode tab.
| Config key | Admin field | Default | What it does |
|---|---|---|---|
enabled |
Plugin Status | true |
Turns the add-on off without uninstalling it. With it off no plan sells, no calendar runs and none of the screens on this page appear. |
invoice.reminder_days |
Reminder Schedule | [14, 3, 0] |
Whole days before the period ends that a pay link goes out. 0 is the day the period ends. The list is sorted, de-duplicated and capped at Maximum Reminders, and an empty list is treated as [0]. |
invoice.grace_days |
Grace Period (days) | 7 |
How long an unpaid invoice keeps a subscription alive past the end of its period. Nothing lapses during grace. A plan can set its own; this is the default. 0–365. |
invoice.max_reminders |
Maximum Reminders | 4 |
A ceiling on the schedule above, so a long reminder list edited by hand cannot turn into a mail storm. 1–12. |
Emails
Under the Emails tab. Switching one off stops the message, not the calendar.
| Config key | Admin field | Default | What it does |
|---|---|---|---|
emails.renewal_reminder |
Renewal Reminder | true |
Sent before the period ends, on the schedule above, with the pay link. Invoice mode only. |
emails.renewal_due |
Invoice Due | true |
Sent when the period has ended and the invoice has not been paid. Invoice mode only. |
emails.past_due |
Payment Failed | true |
Sent when a charge fails or an invoice goes unpaid past its due date, in either mode. |
emails.provider_past_due |
Send It For Provider Billing Too | true |
Also sends the Payment Failed message when the payment provider is billing the card itself. Both this and emails.past_due must be on. Turn it off if the provider already emails customers about a declined renewal (Stripe: Settings, Billing, Customer emails), so nobody hears about it twice. |
emails.expired |
Subscription Ended | true |
Sent when the grace period runs out and access ends. |
emails.cancelled |
Cancellation Confirmed | true |
Sent when a cancellation takes effect — either immediately, or at the end of the period the customer had already paid for. |
emails.renewed |
Renewal Confirmation | false |
A separate note that a period renewed. Off by default, because the renewal order already sends the customer a receipt of its own. |
Plan editor defaults
Under the Admin tab. Nothing outside the admin reads these, and changing one never affects a plan that already exists.
| Config key | Admin field | Default | What it does |
|---|---|---|---|
admin.default_interval_unit |
Default Interval | month |
The unit a new plan opens with: day, week, month or year. |
admin.default_interval_count |
Default Interval Count | 1 |
How many of the unit above make one billing period. 3 months is a quarterly plan. 1–52. |
admin.default_trial_days |
Default Trial (days) | 0 |
How long a new plan's free trial runs. 0 means no trial, which is the usual case. 0–365. |
admin.page_size |
Rows per Page | 50 |
How many subscriptions the admin list loads at a time. 10–200. |