Subscriptions

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:

intervalintervalUnitintervalCountMeans
weeklyweek1Every 7 days
biweeklyweek2Every 14 days (26 charges a year, not twice a month)
monthlymonth1Every calendar month
quarterlymonth3Every 3 calendar months
yearlymonth12Every 12 calendar months
customanyanyAny other combination within the bounds below

You send one of three shapes, and every response carries all three fields:

json
// 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 named interval or a unit and count.
  • recurring on POST /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.
  • recurring on 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 sendResult
interval: "monthly"201 — monthly / month / 1
intervalUnit: "day", intervalCount: 10201 — custom / day / 10
interval: "custom", intervalUnit: "day", intervalCount: 10201 — custom / day / 10
interval: "custom", intervalUnit: "week", intervalCount: 2201 — biweekly / week / 2. "custom" never conflicts; it is stored under its name.
interval: "weekly", intervalUnit: "day", intervalCount: 7201 — weekly / week / 1. Both halves agree after reduction.
intervalUnit: "hour", intervalCount: 1201 — custom / hour / 1, the shortest cadence
intervalUnit: "hour", intervalCount: 8760201 — custom / day / 365, the longest
intervalUnit: "hour", intervalCount: 8761400 invalid_interval — "A cadence can be at most one year: 8760 hours"
intervalUnit: "day", intervalCount: 366400 invalid_interval — "A cadence can be at most one year: 365 days"
intervalUnit: "week", intervalCount: 53400 invalid_interval — "A cadence can be at most one year: 52 weeks"
intervalUnit: "month", intervalCount: 13400 invalid_interval — "A cadence can be at most one year: 12 months"
intervalCount: 0, -1, or 1.5400 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 reverse400 invalid_interval — "intervalUnit and intervalCount must be sent together"
interval: "custom" alone400 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: 4400 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:

json
// 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 sendStored interval / unit / countcadenceLabelcadenceNoun
day × 14biweekly / week / 2Biweekly2 weeks
hour × 24custom / day / 1Every dayday
hour × 48custom / day / 2Every 2 days2 days
day × 7 or hour × 168weekly / week / 1Weeklyweek
day × 21custom / week / 3Every 3 weeks3 weeks
week × 4custom / week / 4Every 4 weeks4 weeks
month × 3quarterly / month / 3Quarterlyquarter
month × 12yearly / month / 12Yearlyyear
month × 2custom / month / 2Every 2 months2 months
hour × 25custom / hour / 25Every 25 hours25 hours
hour × 6custom / hour / 6Every 6 hours6 hours
hour × 1custom / hour / 1Every hourhour
day × 10custom / day / 10Every 10 days10 days
hour × 8760custom / day / 365Every 365 days365 days
Pass the helpers a stored row, not your request body
The helpers read the fields as given; they do not reduce them. Called on a Throttle response they always match the table above. Called on raw input, 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/subscriptions you pass currentPeriodStart and currentPeriodEnd; billingAnchorAt is set to currentPeriodStart (or to trialEnd when there is a trial). currentPeriodEnd must be after currentPeriodStart and in the future, or the create is 400 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
How the renewal job picks the period a charge opens, and when the anchor moves.

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:

EventbillingAnchorAt
Subscription createdThe start of the first period: checkout completion, or the currentPeriodStart you sent
Created with a trialtrialEnd — the first paid period starts there
Trial converts to paidStays 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 pauseShifted forward by exactly the time spent paused
Immediate plan change (effective: "now")The change time
Scheduled plan change that changes the cadenceThe renewal boundary where it applies
Scheduled plan change with the same cadenceUnchanged
Waive of a past_due periodUnchanged — 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 gridUnchanged
Ordinary renewal, stored period end off the gridMoves 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
A monthly plan anchored on Aug 31 clamps to the 30th in short months and returns to the 31st.
Anchor (UTC)CadenceNext period ends
2026-08-31T15:00:00.000ZmonthlySep 30, Oct 31, Nov 30, Dec 31 — each at 15:00 UTC
2027-01-31T15:00:00.000ZmonthlyFeb 28, Mar 31, Apr 30
2028-02-29T12:00:00.000Zyearly2029-02-28, 2030-02-28, 2031-02-28, 2032-02-29
2026-10-01T09:00:00.000Zevery 10 daysOct 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.

If you compute currentPeriodEnd yourself
// 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.renewed and a payment.captured webhook, 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.
CadenceRetry 1Retry 2Retry 3Renewal reminder
every hour+1 h+2 h+3 hNo
every 6 hours+6 h+12 h+18 hNo
every 12 hours+12 h+1 d+1.5 dNo
every day+1 d+2 d+3 dNo
every 2 days+1 d+3 d+6 dNo
every 10 days, weekly, biweekly+1 d+3 d+7 dYes
monthly and longer+1 d+3 d+7 dYes

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
A 10-day plan anchored on Oct 1: the Oct 11 to Oct 21 period is skipped and the plan is charged once, on Oct 25, for Oct 21 to Oct 31.

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.

effectiveChargeWhat happens to the grid
nowThe new amount minus the credit for unused paid time, charged immediatelyRestarts 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_endNothing todayThe 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.
curl
# 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_due subscription 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 paused subscription is 422 invalid_state; schedule it with period_end or resume first.
  • PATCH /api/v1/subscriptions/{id} with cadence fields is a term-only edit: it rewrites the stored cadence without charging, crediting, or moving currentPeriodEnd. The next renewal still happens at the stored period end.
  • An installment plan's cadence cannot change: 409 installment_plan_locked on both routes.

Proration and credits work the same for every cadence; see Managing Subscriptions .

End to end

Checkout: every 10 days

curl
# 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
    }
  }'
response
// 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.

curl
# 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

curl
# 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"
  }'
response
// 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.

curl
# 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_…"
response
// 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

TypeScript
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

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

StatusCodeWhen
400invalid_intervalUnknown 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
400interval_conflictA named interval sent with a unit and count that reduce to a different cadence
400validation_errorA 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
400invalid_subscription_statechange-plan to the exact current terms; pause on anything but active; resume on a cancelled or completed subscription
409installment_plan_lockedA cadence change on an installment plan, through change-plan or PATCH
409invalid_stateScheduling a change on a subscription set to cancel at period end
422invalid_statechange-plan on a cancelled or completed subscription, or an immediate change on a paused one
402payment_failedThe stored card declined an immediate change; nothing was changed
409renewal_needs_attentionAn 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