Skip to main content

Buy-ups Subscriber REST API

Subscriber-facing REST endpoints for purchasing and cancelling buy-ups. Their messages call buy-ups “extras”, because that is the name subscribers see on the account page; they are quoted verbatim below. Match on the error code, never on the message. Messages are written for subscribers and their wording changes; codes and statuses do not. Cookie auth and subscriber identity — the caller must be the subscriber logged in to WordPress.

Base path: /wp-json/benecaster/v1/shows/{show_id}/buyups

Buy-ups are addressed by integer ID, not by slug. There is no slug column on a buy-up. IDs come from the list endpoint below.

Every route is show-scoped. A buy-up belongs to exactly one show, and each route resolves {buyup_id} against {show_id} first. A buy-up that exists on a different show returns 404, not 403 — the route will not confirm that an ID exists elsewhere.

Self-service requires a Benecaster-managed subscription. Subscribers whose billing runs through an external membership plugin get 412 native_subscription_required; there is no Stripe subscription for Benecaster to add an item to.


List Available Buy-ups

GET /shows/{show_id}/buyups

Returns the active buy-ups available to the subscriber’s current tier on this show, each with its grant state and its price for that subscriber.

This response is per subscriber, not per buy-up. A buy-up has one price per billing period, and the price fields describe the one matching the caller’s own period. Two subscribers on different periods legitimately get different figures for the same buy-up ID, so don’t cache the response by buy-up ID or share it between users. If you do, someone will be quoted a price they can’t buy at.

Response — 200 OK:

{
  "items": [
    {
      "id": 12,
      "name": "Transcript Archive",
      "description": "Full text transcripts for every episode.",
      "price": 49.00,
      "amount_cents": 4900,
      "is_free": false,
      "currency": "USD",
      "interval_unit": "year",
      "interval_count": 1,
      "token_type": "bonus",
      "subscriber_cap": null,
      "is_active_for_user": false,
      "is_sold_out": false,
      "cancel_at_period_end": false,
      "current_period_end": null,
      "is_billing_incompatible": false,
      "billing_incompatible_message": null
    }
  ],
  "has_native_subscription": true,
  "tier_slug": "premium"
}
Field Meaning
id Integer buy-up ID — what you pass as {buyup_id} below
price Decimal currency amount, not minor units, for the caller’s billing period. Nullable: null means the buy-up has no price for this subscriber’s period (or they have no subscription), so there is nothing they can buy it at. Never read null as 0, because 0 is a real price: a buy-up included free with that period
amount_cents The same figure in minor units, or null. Send this as confirm_amount_cents
is_free true when the buy-up is included free with the caller’s billing period (amount_cents is 0). Show it as included with their plan, not as a price of zero. false when price is null
currency Uppercase ISO-4217 code for that price
interval_unit / interval_count The billing period that price is for, e.g. year × 1, or month × 3 for quarterly. null when price is
subscriber_cap Integer cap, or null when uncapped
is_active_for_user true when the subscriber holds a grant, including one already scheduled to cancel
is_sold_out true only when the cap is reached and this subscriber has no grant
cancel_at_period_end true when the subscriber has scheduled cancellation
current_period_end End of the paid period; null when there is no grant
is_billing_incompatible true when the subscriber can’t buy this buy-up right now, for any reason in the list under the purchase errors below: a pending billing-period switch, the sale window, no price for their billing period, a different currency, or a price still being set up in Stripe. Most of these are temporary or deliberate. A buy-up sold annually only, seen by a monthly subscriber, is an upsell, not a fault. false for a subscriber with no native subscription at all — they can’t buy anything here regardless, and the template already says why
billing_incompatible_message Translated, subscriber-facing reason, set only when is_billing_incompatible is true; null otherwise

A subscriber with no tier on this show gets 200 with an empty items array, not an error — read tier_slug and has_native_subscription to tell “nothing available to your tier” apart from “you are not subscribed here.”


Purchase a Buy-up

POST /shows/{show_id}/buyups/{buyup_id}/subscribe

Adds the buy-up as a Stripe subscription item on the subscriber’s existing subscription for this show.

Adding a free buy-up (amount_cents 0) is a grant, not a purchase: it clears any decline the subscriber recorded earlier and fires benecaster_buyup_auto_granted, not benecaster_buyup_purchased.

Request body:

Field Type Required Meaning
confirm_amount_cents int Yes The price in minor units that the subscriber is agreeing to pay

Response — 201 Created:

{
  "grant_id": 481,
  "stripe_subscription_item_id": "si_1abc...",
  "buyup_id": 12
}

Checks run in order, before Stripe is ever called

The purchase route checks eligibility in a fixed sequence, each one short-circuiting the rest: tier eligibility (buyup_unavailable) first, then whether the subscriber can buy it now (buyup_billing_incompatible, which covers a pending switch, the sale window, their billing period, their currency and Stripe provisioning, in that order), then the confirm_amount_cents consent check below. Stripe is not contacted until all three pass. A subscriber on the wrong tier, the wrong billing period or the wrong currency never reaches Stripe at all, and gets a translated reason instead of Stripe’s own error.

The purchase uses the price row matching the subscriber’s own billing period, for both the amount they consent to and the Stripe Price the item is created against, never the buy-up’s default row.

The purchase is never a blind POST. The caller states the price the subscriber actually saw, and the server refuses if that is no longer the live price — so a price changed between render and click is rejected rather than silently charged at the new amount.

Send cents, not dollars. The list endpoint returns price as a decimal (49.00); this field wants 4900, which the list also returns as amount_cents. The value is compared with the amount_cents of the price row matching the subscriber’s billing period.

Dollars sent where cents are expected come back as 409 amount_mismatch, not as a validation error. "5.00" is a whole number — just the wrong one — so it passes the format check and fails the comparison. If you are seeing amount_mismatch on a price nobody changed, this is almost always why.

The amount_mismatch error carries the live values so you can re-render rather than guess:

{
  "code": "amount_mismatch",
  "message": "The price of this extra has changed. Please review the new price and try again.",
  "data": { "status": 409, "amount_cents": 5400, "currency": "USD" }
}

Both values come from the price row for the subscriber’s own billing period.

currency is that price row’s own currency, set per row in the buy-up editor, and is always uppercased here (GBP).

Idempotency-Key — optional, and worth sending

Send an Idempotency-Key header and the response is cached for 24 hours per (user, show, buy-up, key) and the key forwarded to Stripe. A retry of the same action returns the original 201 and the same grant_id instead of 409 already_subscribed.

The replay happens before any eligibility check. A retry gets the answer the first call earned, not a fresh verdict — so a cap that filled in between cannot turn a completed purchase into a failure.

Mint the key per user action, reuse it only for retries of that action, and retire it once the server has answered with an error. Omitting the header is supported and behaves as it always did.

For uncapped buy-ups this is the only concurrency protection. The advisory lock described below runs for capped buy-ups only.

Errors:

Code Meaning
400 confirm_amount_required Field missing or empty
400 confirm_amount_invalid Not a whole number — 4.5 and abc alike
403 buyup_unavailable Not offered to the subscriber’s current tier
403 buyup_billing_incompatible The subscriber can’t buy this buy-up right now — one of the reasons listed below. Message matches billing_incompatible_message above
404 No such buy-up on this show, or the buy-up is inactive, or the show is not published (draft, scheduled, private, trashed): rest_not_found, Buy-up not found., before anything reaches Stripe
409 amount_mismatch Confirmed amount is not the live price
409 already_subscribed Subscriber already holds an active grant
409 concurrent_purchase Cap lock wait timed out
409 sold_out Cap reached
412 native_subscription_required No Benecaster-managed subscription on this show
500 grant_persist_failed Stripe accepted the charge but the local grant could not be written
502 / 500 stripe_error Stripe rejected or failed the request
409 benecaster_billing_unavailable This install is a copy of another site, so billing is refused. See below
503 stripe_not_configured The matching price has no Stripe Price in the active Stripe mode. Normally caught earlier as the not ready to purchase yet reason

billing_incompatible_message / 403 buyup_billing_incompatible’s message, verbatim, in the order they’re checked:

  • “You have a billing change scheduled. Extras can be added once it takes effect, or if you cancel it.” — a billing-period switch is pending
  • “This extra is not on sale yet. Check back soon.” — before the buy-up’s On sale from time
  • “This extra is no longer available.” — after its On sale until time. It’s checked before the billing period, so a subscriber whose period was never offered it still gets this rather than a prompt to change period
  • “This extra is not offered on your billing period. Change your billing period to add it.” — no price for the subscriber’s billing period
  • “This extra is billed in a different currency from your subscription, so it cannot be added to your plan.” — a price exists for their period, but not in their currency
  • “This extra is not ready to purchase yet. Please try again in a few minutes.” — the matching price hasn’t been set up in Stripe yet

grant_persist_failed means the subscriber has been charged. Stripe holds a subscription item with no matching local grant. It is not retryable by the subscriber and needs manual reconciliation.

Concurrent purchase (concurrent_purchase): capped buy-ups serialise the count-check-then-insert path behind a per-buy-up advisory lock so two simultaneous purchases cannot both pass the cap check. If the lock wait times out after 5 seconds the endpoint returns 409 with “This buy-up is currently being purchased by another subscriber. Please try again in a moment.” This is retryable and usually succeeds immediately. Uncapped buy-ups take no lock and never return it.

On success the plugin fires benecaster_buyup_purchased with the user, buy-up, show and Stripe subscription item IDs.


Cancel a Buy-up

POST /shows/{show_id}/buyups/{buyup_id}/cancel

Schedules the buy-up to end at the end of the current paid period. It is not an immediate removal and there is no refund — the subscriber keeps access until the period they have paid for runs out.

Response — 200 OK:

{
  "grant_id": 481,
  "cancel_at_period_end": true,
  "current_period_end": 1772409600
}

Calling it twice is safe. A grant already scheduled to cancel returns the same 200 payload without touching Stripe again.

Errors:

Code Meaning
404 No such buy-up on this show, or no active grant for this subscriber
409 benecaster_billing_unavailable This install is a copy of another site, so the cancellation is refused. See below
502 / 500 stripe_error Stripe rejected or failed the cancellation

Both “wrong show” and “you do not hold this” return 404 — the endpoint does not distinguish them, deliberately.

Cancelling a free buy-up records a decline. When the buy-up is priced at 0 on the subscriber’s billing period, the cancellation is also recorded as declining it, so it isn’t given back to them automatically. Cancelling a paid buy-up records nothing.


benecaster_billing_unavailable — the site is a clone

Both routes on this page refuse when Benecaster detects that the install is a copy of another site: it recorded one address when it first ran and is now running at a different one. A staging copy is a database clone, so it carries production’s real Stripe IDs — and the refusal therefore ignores key mode entirely. Test keys on a clone are not a safe state.

Note the status change, because it reverses what a client should do. Both routes previously reported this as stripe_error (500), which reads to a client as retry me. Nothing about retrying changes the answer on a copy, so it is now 409. Any retry logic keyed on 5xx will spin here forever against the old behaviour.

No local grant row is written on a refusal, and that matters more than it looks. A grant written on a clone would travel back to production with the next database refresh — the subscriber would hold access nobody charged them for, on the real site.

The message is deliberately generic and identical on both routes; the operator’s explanation, naming both site addresses, goes to the debug log once per operation per hour. A genuine production site that trips the check is re-enabled with define( 'BENECASTER_IS_PRODUCTION', true ); in wp-config.php — see Using a Staging Site.

So how do you test a buy-up purchase before you ship? Not on a copy of production — that is the case this refusal exists for, and it will refuse every time. Build the test site as a fresh install instead: a new database at the staging domain, with test Stripe keys. An install records its canonical address the first time it runs, so one that has only ever lived at the staging domain arms itself there, passes this check, and takes test-mode purchases normally.

Do not reach for BENECASTER_IS_PRODUCTION to get a clone working. It is the escape hatch for a legitimate domain change, and setting it removes every other staging rail on that install at the same time.

See Also

Need this built rather than just documented? See our services →