Custom Cadence
A subscription can bill every N hours, days, weeks, or months — every 6 hours, every 10 days, every 3 weeks, every 2 months — not only on the five named intervals. This page covers what you can send, what Throttle stores, how it labels a cadence, and how it computes each renewal date.
Named and custom cadences
Every subscription stores its cadence as a unit and a count: intervalUnit (hour, day, week, or month) and intervalCount
(how many). The interval field names the cadence when it is
one of the five named intervals, and is "custom"
for everything else. The named intervals are shorthand:
| interval | intervalUnit | intervalCount | Means |
|---|---|---|---|
weekly | week | 1 | Every 7 days |
biweekly | week | 2 | Every 14 days (26 charges a year, not twice a month) |
monthly | month | 1 | Every calendar month |
quarterly | month | 3 | Every 3 calendar months |
yearly | month | 12 | Every 12 calendar months |
custom | any | any | Any other combination within the bounds below |
You send one of three shapes, and every response carries all three fields:
// Any of these three shapes, anywhere a cadence is accepted:
{ "interval": "monthly" } // named
{ "intervalUnit": "day", "intervalCount": 10 } // any cadence
{ "interval": "custom", "intervalUnit": "day", "intervalCount": 10 }
// Every subscription response carries all three fields:
{ "interval": "custom", "intervalUnit": "day", "intervalCount": 10 }
{ "interval": "monthly", "intervalUnit": "month", "intervalCount": 1 }
The server reduces what you send to its largest exact unit, then names it when a named
interval matches. Hours become days when the count is a multiple of 24, and days become weeks
when it is a multiple of 7. Weeks never become months and months never become anything else,
because a month is not a fixed length. So every 24 hours is stored as custom / day / 1, every 14 days as biweekly, and every 4 weeks as custom / week / 4 (28 days — never monthly).
Each cadence has exactly one stored form, so filtering by interval=biweekly also finds a subscription you created as
"every 14 days".
A cadence is accepted in five places, with the same rules in each:
-
POST /api/v1/subscriptions— required: send a namedintervalor a unit and count. -
recurringonPOST /api/v1/checkout/sessions— checked when the session is created, so a bad cadence fails for you, not for a buyer at the end of checkout. -
recurringon a cart line (POST /api/v1/carts/{id}/items) or a quote line — checked when the line is added. Each such line becomes its own subscription; see Mixed Carts. -
POST /api/v1/subscriptions/{id}/change-plan— required, as on create. -
PATCH /api/v1/subscriptions/{id}— optional; a term-only edit (see Changing the cadence).
Validation
The shortest cadence is 1 hour and the longest is one year, measured in the unit you sent:
at most 8,760 hours, 365 days, 52 weeks, or 12 months. The bound is checked before the
reduction, so 8,784 hours (366 days) is refused even though it would reduce to days. Every
message below is the exact error.message.
| You send | Result |
|---|---|
interval: "monthly" | 201 — monthly / month / 1 |
intervalUnit: "day", intervalCount: 10 | 201 — custom / day / 10 |
interval: "custom", intervalUnit: "day", intervalCount: 10 | 201 — custom / day / 10 |
interval: "custom", intervalUnit: "week", intervalCount: 2 | 201 — biweekly / week / 2. "custom" never conflicts; it is stored under its name. |
interval: "weekly", intervalUnit: "day", intervalCount: 7 | 201 — weekly / week / 1. Both halves agree after reduction. |
intervalUnit: "hour", intervalCount: 1 | 201 — custom / hour / 1, the shortest cadence |
intervalUnit: "hour", intervalCount: 8760 | 201 — custom / day / 365, the longest |
intervalUnit: "hour", intervalCount: 8761 | 400 invalid_interval — "A cadence can be at most one year: 8760 hours" |
intervalUnit: "day", intervalCount: 366 | 400 invalid_interval — "A cadence can be at most one year: 365 days" |
intervalUnit: "week", intervalCount: 53 | 400 invalid_interval — "A cadence can be at most one year: 52 weeks" |
intervalUnit: "month", intervalCount: 13 | 400 invalid_interval — "A cadence can be at most one year: 12 months" |
intervalCount: 0, -1, or 1.5 | 400 invalid_interval — "intervalCount must be a whole number of at least 1" |
intervalUnit: "minute" or "year" | 400 invalid_interval — "intervalUnit must be one of hour, day, week, month" |
interval: "daily" or "fortnightly" | 400 invalid_interval — "Unknown interval "daily"". There is no named daily interval; send day × 1. |
intervalUnit without intervalCount, or the reverse | 400 invalid_interval — "intervalUnit and intervalCount must be sent together" |
interval: "custom" alone | 400 invalid_interval — "A custom interval needs intervalUnit and intervalCount" |
| No cadence field at all (create, change-plan) | 400 invalid_interval — "Send an interval, or intervalUnit and intervalCount" |
interval: "monthly", intervalUnit: "week", intervalCount: 4 | 400 interval_conflict — "interval "monthly" does not match every 4 weeks" |
intervalCount: "3" (a string) | 400 validation_error — the body schema requires a JSON number |
Errors use the standard envelope:
// 400 — POST /api/v1/subscriptions with { "intervalUnit": "day", "intervalCount": 366, ... }
{
"error": {
"code": "invalid_interval",
"message": "A cadence can be at most one year: 365 days"
},
"meta": { "requestId": "req_01J9..." }
} Labels
@usethrottle/subscriptions exports two helpers so every
surface words a cadence the same way. cadenceLabel is a
standalone label for an Interval column; cadenceNoun is the
period that reads correctly after a slash, as in $49.00 / 10 days. The table shows what the server stores for
each input and what the helpers return for that stored row.
| You send | Stored interval / unit / count | cadenceLabel | cadenceNoun |
|---|---|---|---|
| day × 14 | biweekly / week / 2 | Biweekly | 2 weeks |
| hour × 24 | custom / day / 1 | Every day | day |
| hour × 48 | custom / day / 2 | Every 2 days | 2 days |
| day × 7 or hour × 168 | weekly / week / 1 | Weekly | week |
| day × 21 | custom / week / 3 | Every 3 weeks | 3 weeks |
| week × 4 | custom / week / 4 | Every 4 weeks | 4 weeks |
| month × 3 | quarterly / month / 3 | Quarterly | quarter |
| month × 12 | yearly / month / 12 | Yearly | year |
| month × 2 | custom / month / 2 | Every 2 months | 2 months |
| hour × 25 | custom / hour / 25 | Every 25 hours | 25 hours |
| hour × 6 | custom / hour / 6 | Every 6 hours | 6 hours |
| hour × 1 | custom / hour / 1 | Every hour | hour |
| day × 10 | custom / day / 10 | Every 10 days | 10 days |
| hour × 8760 | custom / day / 365 | Every 365 days | 365 days |
cadenceLabel({ interval: 'custom', intervalUnit: 'day', intervalCount: 14 })
returns "Every 14 days", while the server stores and returns that cadence as biweekly. A value the helpers do not recognise is returned
unchanged rather than dropped.
How renewal dates are computed
Renewal dates sit on a grid measured from billingAnchorAt, which every subscription response carries.
Each period end is computed from the anchor, never chained from the previous period, so a
rounding or clamping step can never compound.
- Hour, day, and week cadences advance by exact UTC durations: 3,600 s, 86,400 s, or 604,800 s times the count. Every 10 days is always exactly 240 hours.
- Month cadences land on the anchor's day of month and UTC time of day,
in the month
anchor month + k × count, with the day clamped to that month's last day. See Month-end clamping. - The first period. A subscription created by checkout starts when the buyer
pays and its first period is exactly one cadence long. On
POST /api/v1/subscriptionsyou passcurrentPeriodStartandcurrentPeriodEnd;billingAnchorAtis set tocurrentPeriodStart(or totrialEndwhen there is a trial).currentPeriodEndmust be aftercurrentPeriodStartand in the future, or the create is400 validation_error.
The renewal job picks up a subscription once its currentPeriodEnd has passed and decides which period the
charge opens:
flowchart TD
tick["Renewal job runs every 5 minutes"] --> due{"Is currentPeriodEnd in the past?"}
due -- "no" --> idle["Wait for the next run"]
due -- "yes" --> restart{"Does billing restart?"}
restart -- "yes" --> ra["Anchor moves to the restart point"]
ra --> late{"Has a whole period already passed since then?"}
late -- "yes" --> anow["Anchor moves to now"]
late -- "no" --> open1["Open one period from the anchor"]
anow --> open1
restart -- "no" --> grid{"Is the stored period end on the anchor grid?"}
grid -- "yes" --> contain["Open the grid period that contains now"]
grid -- "no" --> reanchor["Anchor moves to the stored period end"]
reanchor --> open2["Open one period from there, or the one containing now if behind"]
contain --> charge["Charge once and write the new period"]
open1 --> charge
open2 --> charge Billing restarts when a trial converts, when a failed payment is recovered, and when a scheduled change of cadence applies. Everything else is an ordinary renewal.
The anchor stays where it is except in these cases:
| Event | billingAnchorAt |
|---|---|
| Subscription created | The start of the first period: checkout completion, or the currentPeriodStart you sent |
| Created with a trial | trialEnd — the first paid period starts there |
| Trial converts to paid | Stays at trialEnd; moves to the charge time if the conversion runs more than one full period late |
| A failed payment recovers (retry or buyer card update) | The moment of the successful retry, or its parked retry date if that was earlier |
| Resume from pause | Shifted forward by exactly the time spent paused |
Immediate plan change (effective: "now") | The change time |
| Scheduled plan change that changes the cadence | The renewal boundary where it applies |
| Scheduled plan change with the same cadence | Unchanged |
Waive of a past_due period | Unchanged — the waive opens the grid period that contains now. Only when that period would be the one already paid for does it re-anchor, at the earlier of the parked retry date and now. |
| Ordinary renewal, stored period end on the grid | Unchanged |
| Ordinary renewal, stored period end off the grid | Moves to the stored period end — that date is what the buyer was promised |
The last row matters when you create subscriptions yourself. If the currentPeriodEnd you send is not one cadence after currentPeriodStart, the first renewal happens on the date you
sent and the grid is re-anchored there, one cadence at a time.
Month-end clamping
A month cadence keeps the anchor's day of month. When a month is too short, the date
clamps to that month's last day, and the next period returns to the anchor day. A plan
anchored on August 31 renews on September 30, then October 31 — it does not stay on the 30th.
Every month-based cadence works this way, named or custom: yearly is stored as 12 months, so a February 29 anniversary
bills on February 28 in common years and on February 29 in leap years.
gantt title Monthly plan anchored on Aug 31 at 15.00 UTC dateFormat YYYY-MM-DD todayMarker off axisFormat %b %d section Billing periods Period 1 clamped to Sep 30 :p1, 2026-08-31, 2026-09-30 Period 2 back on the 31st :p2, 2026-09-30, 2026-10-31 Period 3 clamped to Nov 30 :p3, 2026-10-31, 2026-11-30 Period 4 back on the 31st :p4, 2026-11-30, 2026-12-31
| Anchor (UTC) | Cadence | Next period ends |
|---|---|---|
2026-08-31T15:00:00.000Z | monthly | Sep 30, Oct 31, Nov 30, Dec 31 — each at 15:00 UTC |
2027-01-31T15:00:00.000Z | monthly | Feb 28, Mar 31, Apr 30 |
2028-02-29T12:00:00.000Z | yearly | 2029-02-28, 2030-02-28, 2031-02-28, 2032-02-29 |
2026-10-01T09:00:00.000Z | every 10 days | Oct 11, Oct 21, Oct 31 — each exactly 240 h apart |
All boundaries are computed in UTC, so daylight-saving changes never move a renewal. A plan anchored at 15:00 UTC renews at 15:00 UTC all year, which is a different local wall-clock time in summer and winter.
// Wrong for month cadences: setMonth overflows.
const end = new Date('2027-01-31T09:00:00.000Z');
end.setUTCMonth(end.getUTCMonth() + 1); // → 2027-03-03, not Feb 28
// If you send that as currentPeriodEnd, Throttle treats Mar 3 as the date you
// promised the buyer and re-anchors there: the plan bills on the 3rd from then on.
// Clamp instead (or let checkout create the subscription for you):
function addMonthsClamped(date: Date, months: number): Date {
const y = date.getUTCFullYear();
const m = date.getUTCMonth() + months;
const lastDay = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
return new Date(Date.UTC(y, m, Math.min(date.getUTCDate(), lastDay),
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()));
}
addMonthsClamped(new Date('2027-01-31T09:00:00.000Z'), 1); // → 2027-02-28T09:00:00.000Z Hour and day cadences
- Renewal job granularity. The renewal job runs every 5 minutes (UTC) and handles up to 100 due subscriptions per run, most overdue first. An hourly renewal is charged within a few minutes of its boundary, but the period it records stays on the grid: a charge at 15:04 still opens the period 15:00–16:00, so lateness never accumulates.
- Volume. Every successful charge produces an order, a
subscription.renewedand apayment.capturedwebhook, and a buyer receipt email. An hourly plan does that 24 times a day. - Renewal reminder. The reminder email sent 3 days before a renewal goes out only for cadences of 7 days or longer. Every month cadence qualifies; an hourly, daily, or other sub-week cadence never gets one.
- Dunning scales with the cadence. Retry n (1, 2, 3) waits
min(n × cadence, [1 day, 3 days, 7 days][n − 1]), where a month counts as 28 days. Weekly and longer cadences keep the 1 / 3 / 7-day ladder.
| Cadence | Retry 1 | Retry 2 | Retry 3 | Renewal reminder |
|---|---|---|---|---|
| every hour | +1 h | +2 h | +3 h | No |
| every 6 hours | +6 h | +12 h | +18 h | No |
| every 12 hours | +12 h | +1 d | +1.5 d | No |
| every day | +1 d | +2 d | +3 d | No |
| every 2 days | +1 d | +3 d | +6 d | No |
| every 10 days, weekly, biweekly | +1 d | +3 d | +7 d | Yes |
| monthly and longer | +1 d | +3 d | +7 d | Yes |
The hosted checkout page shows the time as well as the date for a sub-daily cadence, in UTC and labelled as such — for example "Free trial, then 49.00 USD/6 hours starting October 3, 2026, 2:30 PM UTC. Cancel anytime." Cadences shorter than 1 hour are not supported.
When the renewal job falls behind
An outage, a stalled retry, or a backlog can leave a subscription more than one period
overdue. Throttle charges it once, for the grid period that contains the
moment of the charge. The periods it missed are skipped: never billed back to back, never
billed later, and never counted toward an installment plan's totalPayments.
gantt title Every 10 days, renewal job down from Oct 11 to Oct 25 dateFormat YYYY-MM-DD todayMarker off axisFormat %b %d section Grid periods Paid at signup :done, g1, 2026-10-01, 2026-10-11 Skipped and never charged :crit, g2, 2026-10-11, 2026-10-21 Charged once on Oct 25 :active, g3, 2026-10-21, 2026-10-31 Next renewal on the grid :g4, 2026-10-31, 2026-11-10
The anchor does not move in this case, so the plan stays on its original grid. A restart that is overdue is the exception: when a trial conversion or a payment recovery runs more than one full period late, the grid restarts at the charge time instead.
Pause and resume
POST /api/v1/subscriptions/{id}/pause works on an active subscription only; the renewal job skips it while
paused. POST /api/v1/subscriptions/{id}/resume
shifts both currentPeriodEnd and billingAnchorAt forward by exactly the time spent paused. A
subscription paused for 10 days renews 10 days later than it would have, and so does every
renewal after it.
For a month cadence the shift moves the anchor's day of month too. A monthly plan anchored on August 31 and paused for 10 days is anchored on September 10 after it resumes, and bills on the 10th from then on.
Trials
A trial works with any cadence. On a checkout session or a cart line send recurring.trialDays (whole days, 0–365); on POST /api/v1/subscriptions send trialEnd. The subscription is trialing and anchored at trialEnd. At the first renewal-job run after trialEnd it becomes active,
emits subscription.activated, and is charged for the period [trialEnd, trialEnd + cadence). A 14-day trial on an
every-6-hours plan is charged for the first time at the end of day 14, then every 6 hours.
An installment plan cannot have a trial: see Installment Plans.
Changing the cadence
POST /api/v1/subscriptions/{id}/change-plan takes
the same cadence fields plus planReference, amount, and effective.
| effective | Charge | What happens to the grid |
|---|---|---|
now | The new amount minus the credit for unused paid time, charged immediately | Restarts at the change time: the new period is [now, now + new cadence) and billingAnchorAt is now. A trialing subscription is charged nothing and keeps its trial and anchor. |
period_end | Nothing today | The new cadence is held in pendingInterval, pendingIntervalUnit, and pendingIntervalCount. At the next boundary it applies; a change of unit or count restarts the grid at that boundary, while a price-only or plan-only change keeps the anchor. |
# Move to every 2 weeks at the end of the current period (no charge today).
curl -X POST https://api.usethrottle.dev/api/v1/subscriptions/b3c1f0a2-6d4e-4c1b-9f7a-8e2d5c4b3a10/change-plan \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"planReference": "api_credits_2w",
"planName": "API credits, fortnightly",
"intervalUnit": "day",
"intervalCount": 14,
"amount": 9900,
"effective": "period_end"
}'
# → data.pendingInterval "biweekly", pendingIntervalUnit "week", pendingIntervalCount 2,
# pendingAmount 9900. subscription.plan_change_scheduled fires. -
A pending change on a
past_duesubscription applies on the tick that recovers the payment, and the grid restarts at that recovery. -
An immediate change to terms identical to the current ones (same plan reference, cadence,
and amount) is
400 invalid_subscription_state("Nothing to change"). A scheduled one is refused the same way. -
An immediate change on a
pausedsubscription is422 invalid_state; schedule it withperiod_endor resume first. -
PATCH /api/v1/subscriptions/{id}with cadence fields is a term-only edit: it rewrites the stored cadence without charging, crediting, or movingcurrentPeriodEnd. The next renewal still happens at the stored period end. -
An installment plan's cadence cannot change:
409 installment_plan_lockedon both routes.
Proration and credits work the same for every cadence; see Managing Subscriptions .
End to end
Checkout: every 10 days
# 1. An empty cart. Here the plan rides on the session (one plan per session).
curl -X POST https://api.usethrottle.dev/api/v1/carts \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{ "applicationId": "9b1f6c1e-3a52-4c0e-9d7a-2f1e8c4b7a10", "currency": "USD" }'
# → { "data": { "id": "c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21", ... }, "meta": { ... } }
# 2. A checkout session that bills 49.00 USD every 10 days.
curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"applicationId": "9b1f6c1e-3a52-4c0e-9d7a-2f1e8c4b7a10",
"cartId": "c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21",
"returnUrl": "https://shop.example.com/thanks",
"cancelUrl": "https://shop.example.com/pricing",
"customer": { "externalCustomerId": "user_42", "email": "[email protected]" },
"recurring": {
"plan": "coffee_10d",
"planName": "Coffee, every 10 days",
"intervalUnit": "day",
"intervalCount": 10,
"amount": 4900
}
}' // 201 — the session route returns these fields at the top level (no data envelope)
{
"sessionId": "0f3b7c2d-9e1a-4b6c-8d5f-2a7e9c1b4d60",
"checkoutUrl": "https://checkout.usethrottle.dev/s/0f3b7c2d-9e1a-4b6c-8d5f-2a7e9c1b4d60",
"expiresAt": "2026-10-02T09:00:00.000Z"
}
// The hosted page tells the buyer: "Then 49.00 USD/10 days. Cancel anytime."
// When the buyer pays, Throttle creates the subscription: interval "custom",
// intervalUnit "day", intervalCount 10, and a first period of exactly 10 × 24 h.
The session recurring block sells one plan. To sell several
plans, or goods and a plan, in one checkout, put the cadence on each subscription line
instead (up to ten per cart; the line's unitPrice is the
price per period). See Mixed Carts.
# The same plan as a cart line: sell it next to goods or other plans in one checkout.
curl -X POST https://api.usethrottle.dev/api/v1/carts/c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21/items \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"name": "Coffee, every 10 days",
"unitPrice": 4900,
"recurring": { "plan": "coffee_10d", "intervalUnit": "day", "intervalCount": 10 }
}'
# Then create the session with no "recurring" block: the line carries it. Direct: every 6 hours
# Bill 5.00 USD every 6 hours. POST /subscriptions does not charge anything:
# it records the period you pass as already paid, and the renewal job bills
# every period after it. The customer needs a default vaulted card.
curl -X POST https://api.usethrottle.dev/api/v1/subscriptions \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"customerId": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"planReference": "api_credits_6h",
"planName": "API credits",
"intervalUnit": "hour",
"intervalCount": 6,
"amount": 500,
"currency": "USD",
"currentPeriodStart": "2026-10-01T09:00:00.000Z",
"currentPeriodEnd": "2026-10-01T15:00:00.000Z"
}' // 201 (trimmed — the response carries every subscription field)
{
"data": {
"id": "b3c1f0a2-6d4e-4c1b-9f7a-8e2d5c4b3a10",
"customerId": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"status": "active",
"planReference": "api_credits_6h",
"planName": "API credits",
"interval": "custom",
"intervalUnit": "hour",
"intervalCount": 6,
"billingAnchorAt": "2026-10-01T09:00:00.000Z",
"totalPayments": null,
"paymentsMade": 0,
"completedAt": null,
"amount": 500,
"quantity": 1,
"currency": "USD",
"currentPeriodStart": "2026-10-01T09:00:00.000Z",
"currentPeriodEnd": "2026-10-01T15:00:00.000Z",
"cancelAtPeriodEnd": false,
"failureCount": 0,
"pendingInterval": null,
"pendingIntervalUnit": null,
"pendingIntervalCount": null,
"renewalIssue": null,
"metadata": {}
},
"meta": { "requestId": "req_01J9..." }
} Reading custom cadences
GET /api/v1/subscriptions accepts interval with any of the six stored values: interval=custom matches every non-named cadence, and any
other value (daily, say) is 400 validation_error. There is no filter by unit or count;
filter the page yourself. The charge for each period is amount × quantity, plus any reported usage and tax.
# Every subscription on a non-named cadence
curl -s "https://api.usethrottle.dev/api/v1/subscriptions?interval=custom&status=active&limit=20" \
-H "x-api-key: sk_test_…" // 200 (trimmed)
{
"data": [
{
"id": "b3c1f0a2-6d4e-4c1b-9f7a-8e2d5c4b3a10",
"status": "active",
"interval": "custom",
"intervalUnit": "hour",
"intervalCount": 6,
"amount": 500,
"quantity": 1,
"currency": "USD",
"currentPeriodStart": "2026-10-01T15:00:00.000Z",
"currentPeriodEnd": "2026-10-01T21:00:00.000Z",
"renewalIssue": null,
"customer": { "id": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90", "email": "[email protected]" }
}
],
"meta": { "requestId": "req_01J9...", "pagination": { "cursor": null, "hasMore": false } }
} TypeScript SDK
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!, // sk_test_… in a test environment
});
// Hosted or embedded checkout: Throttle creates the subscription when the buyer pays.
const session = await subscriptions.createCheckoutSession({
applicationId: process.env.THROTTLE_APPLICATION_ID!,
cartId,
returnUrl: 'https://shop.example.com/thanks',
cancelUrl: 'https://shop.example.com/pricing',
customer: { externalCustomerId: user.id, email: user.email },
recurring: {
plan: 'coffee_10d',
planName: 'Coffee, every 10 days',
intervalUnit: 'day',
intervalCount: 10,
amount: 4900,
},
});
// session.sessionId, session.checkoutUrl
// Direct: you already collected the first period and the card is vaulted.
const HOUR_MS = 3_600_000;
const start = new Date();
const sub = await subscriptions.create({
externalCustomerId: user.id, // resolved to customerId for you
planReference: 'api_credits_6h',
planName: 'API credits',
intervalUnit: 'hour',
intervalCount: 6,
amount: 500,
currentPeriodStart: start,
currentPeriodEnd: new Date(start.getTime() + 6 * HOUR_MS),
});
// sub.interval === 'custom', sub.intervalUnit === 'hour', sub.intervalCount === 6
// Read back
const custom = await subscriptions.list({ interval: 'custom', status: 'active' });
const one = await subscriptions.get(sub.id);
// Change the cadence at the next boundary
await subscriptions.changePlan({
id: sub.id,
planReference: 'api_credits_2w',
intervalUnit: 'day',
intervalCount: 14, // stored as biweekly
amount: 9900,
effective: 'period_end',
}); React
import {
cadenceLabel,
cadenceNoun,
useSubscriptions,
SubscriptionStatusBadge,
} from '@usethrottle/subscriptions';
const usd = (cents: number) =>
(cents / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
export function CustomCadencePlans({ userId }: { userId: string }) {
const { data, isLoading, error } = useSubscriptions({
externalCustomerId: userId,
interval: 'custom',
});
if (isLoading) return <p>Loading…</p>;
if (error || !data) return <p>Could not load your plans.</p>;
return (
<ul>
{data.data.map((sub) => (
<li key={sub.id}>
{/* "Every 10 days", "Every 6 hours", "Biweekly" */}
<strong>{cadenceLabel(sub)}</strong>{' '}
{/* "$49.00 / 10 days" */}
{usd(sub.amount)} / {cadenceNoun(sub)}{' '}
<SubscriptionStatusBadge status={sub.status} />
{/* Sub-daily cadences: show the time, not just the date */}
<div>Next charge {new Date(sub.currentPeriodEnd).toLocaleString()}</div>
</li>
))}
</ul>
);
}
The hooks and helpers ship in @usethrottle/subscriptions
3.x. Older releases still work against the API but show raw values such as "/ custom";
upgrade to get the labels. See React Package for the
provider and proxy setup.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_interval | Unknown interval or unit, a count that is not a whole number of at least 1, a cadence over one year, a unit without a count (or the reverse), "custom" alone, or no cadence at all |
| 400 | interval_conflict | A named interval sent with a unit and count that reduce to a different cadence |
| 400 | validation_error | A field of the wrong JSON type (intervalCount as a string), an unknown body field, a currentPeriodEnd that is not after currentPeriodStart or not in the future, or an unknown interval value on the list filter |
| 400 | invalid_subscription_state | change-plan to the exact current terms; pause on anything but active; resume on a cancelled or completed subscription |
| 409 | installment_plan_locked | A cadence change on an installment plan, through change-plan or PATCH |
| 409 | invalid_state | Scheduling a change on a subscription set to cancel at period end |
| 422 | invalid_state | change-plan on a cancelled or completed subscription, or an immediate change on a paused one |
| 402 | payment_failed | The stored card declined an immediate change; nothing was changed |
| 409 | renewal_needs_attention | An immediate change on a subscription with an unrecorded charge (orphan) |
Edge cases
Is "every 4 weeks" the same as monthly?
No. Every 4 weeks is exactly 28 days and bills 13 times a year; monthly follows the calendar and bills 12 times. Throttle never converts one into the other.
How is a custom cadence valued for MRR?
A month cadence counts as amount × quantity ÷ intervalCount
per month. A fixed-duration cadence counts as amount × quantity × 52/12 × (7 days ÷ period length), which keeps weekly
at exactly 52/12 and biweekly at 26/12. Installment plans are excluded from MRR.
What time of day does a plan renew?
The anchor's UTC time of day: the moment checkout completed, or the currentPeriodStart you sent. Hour cadences step from that
instant too.
My Jan 31 subscription bills on the 3rd. Why?
The currentPeriodEnd you sent was probably computed with setMonth, which turns January 31 plus one month into March
3. Throttle keeps the date you sent and re-anchors there. Compute a clamped month end, as in Month-end clamping, or let checkout create the subscription.
Does a slow renewal-job run shift an hourly plan?
No. The charge time is recorded in lastPaymentAt, but the
period always comes from the grid. Only a restart (trial conversion, payment recovery, a
scheduled cadence change, a resume) moves the grid.
Can a buyer choose the cadence at checkout?
The cadence is part of a recurring block — on the session,
or on each subscription line of the cart — which your code sets. Offer the choice in your own
UI and send the cadence the buyer picked.
Next
- Installment Plans — a fixed number of payments that ends itself.
- Lifecycle and States — the state machine, dunning, and credits.
- Subscription Webhooks — every event a renewal emits.