Buy-ups Admin REST API
Admin REST endpoints for managing buy-up definitions. All endpoints require the manage_options capability and a valid WP REST nonce (X-WP-Nonce header). Creating or editing a buy-up additionally requires edit_post on the show it belongs to.
Base path: /wp-json/benecaster/v1/buyups
These routes are not show-scoped in the path. A buy-up belongs to exactly one show, but the show is carried in the request body on create and read from the stored row afterwards — it is not a path segment. The subscriber-facing routes work differently; see Buy-ups Subscriber REST API.
Buy-ups have no slug. They are addressed by integer ID throughout. token_type is a separate thing — the access token a grant issues — and is not an identifier for the buy-up.
List Buy-ups
GET /buyups
Returns buy-up definitions, active and inactive.
| Query arg | Effect |
|---|---|
show_id |
Optional. Restricts the list to that show’s buy-ups |
Omitting show_id lists the whole install, and that is deliberate. It is the only way to see buy-ups that have no show at all — a per-show query hides them from every show. If a buy-up has gone missing from the admin, list unfiltered to find it.
Response — 200 OK:
{
"items": [
{
"id": 42,
"show_id": 17,
"name": "Transcript Archive",
"description": "Full text transcripts for every episode.",
"price": 5.00,
"prices": [
{
"id": 7,
"cadence_label": "Monthly",
"interval_unit": "month",
"interval_count": 1,
"amount_cents": 500,
"currency": "USD",
"is_default": true,
"sort_order": 0,
"stripe_price_id_test": "price_1abc...",
"stripe_price_id_live": null
},
{
"id": 8,
"cadence_label": "Annual",
"interval_unit": "year",
"interval_count": 1,
"amount_cents": 0,
"currency": "USD",
"is_default": false,
"sort_order": 1,
"stripe_price_id_test": "price_1def...",
"stripe_price_id_live": null
}
],
"available_to_tiers": ["gold", "silver"],
"token_type": "transcript-archive",
"subscriber_cap": 100,
"available_from": null,
"available_until": "2026-10-01 04:00:00",
"active": true,
"tax_code": "txcd_10503002",
"stripe_price_id_test": "price_1abc...",
"stripe_price_id_live": null,
"stripe_product_id_test": "prod_1abc...",
"stripe_product_id_live": null,
"created_at": "2026-08-01 10:14:22",
"updated_at": "2026-08-27 09:02:41"
}
]
}
prices is the buy-up’s pricing: one row per billing period it is sold on. amount_cents is in minor units, and 0 is a real price: the buy-up is included free with that period and granted rather than sold. A billing period with no row simply isn’t offered.
price and the two top-level stripe_price_id_* keys are a read-only view of the default row, kept so older callers keep working. price is in major units. It can’t tell a genuine free row from a buy-up with no price rows at all, because both read as 0, so use prices.
available_from / available_until are the sale window, stored and returned in UTC, with null meaning that end is open. The single-buy-up response also carries available_from_local / available_until_local, the same values in the site’s timezone, which is what the admin screen shows. created_at and updated_at, on the buy-up and on each price row, are UTC Y-m-d H:i:s strings. updated_at moves on every save, even one that changes nothing. subscriber_cap is null when uncapped, description is null when unset, tax_code is null when no tax category has been chosen, and show_id is 0 on an unassigned row.
Create a Buy-up
POST /buyups
Request body:
| Field | Required | Notes |
|---|---|---|
show_id |
Yes | The show this buy-up belongs to. Immutable afterwards |
name |
Yes | |
token_type |
Yes | The access token a grant issues. Must not collide with an existing tier slug on the show |
prices |
Array of price rows: { cadence_label, interval_unit, interval_count, amount_cents, currency, is_default?, sort_order? }, the same shape the tier endpoints use. interval_unit is day, week, month or year; currency is a 3-letter code |
|
price |
Legacy. A decimal amount, meaning one monthly row. On create the row is in USD; on update the amount is read in the currency of the buy-up’s default row. It must be above 0 and at least Stripe’s minimum in that currency. Send prices or price, not both; when prices is sent, price is ignored |
|
available_from / available_until |
Optional sale window, in the site’s local time — any date and time strtotime() can read. The server converts to UTC; don’t pre-convert. An empty string or omission leaves that end open |
|
description |
||
available_to_tiers |
Array of tier slugs. An empty or omitted list means every tier on the show | |
subscriber_cap |
Omit for uncapped | |
active |
Defaults to true |
|
tax_code |
See below | The buy-up’s Stripe Tax category: a Stripe product tax code, txcd_ followed by eight digits (for example txcd_10000000). Any well-formed code is accepted. It is set on the buy-up’s Stripe Product |
tax_code is required while Stripe Tax is ready in the show’s Stripe account, whether or not Benecaster’s Calculate tax automatically switch is on. Benecaster never picks a category for you: ask the podcaster. To find out whether a show needs one, call GET /membership/stripe/tax-status?show_id={id} (same manage_options permission), which answers { "show_id": 17, "state": "ready", "category_required": true }. See Create a tier or buy-up on a show that collects tax.
Response: 201 Created with the full buy-up object. On success the plugin provisions the Stripe Product, and one Price per price row (including a $0 Price for a free row). Saving a free row also grants the buy-up to every existing subscriber already on that billing period.
Errors:
| Code | Meaning |
|---|---|
400 invalid_param |
show_id missing or not a show; name or token_type empty; a malformed price row; an amount above 0 but below Stripe’s minimum charge in that row’s currency, with the message naming the minimum in that currency (USD 0.50, JPY 50, KWD 0.500; a currency Stripe lists no minimum for takes 0.50 of the currency); two rows on the same billing period and currency; an unreadable window date; or a closing date at or before the opening one |
400 invalid_tier |
available_to_tiers names a slug that does not exist on this show |
403 rest_forbidden |
Caller cannot edit_post on that show |
400 benecaster_tax_code_invalid |
tax_code is not a Stripe tax code (txcd_ followed by eight digits) |
400 benecaster_tax_code_required |
Stripe Tax is ready in the show’s Stripe account and no tax_code was sent. Nothing is written |
409 slug_conflict |
token_type matches an existing tier slug on the show |
500 create_failed |
Row could not be written |
An unknown tier slug is rejected, not dropped. This matters because an empty available_to_tiers means every tier on this show — so silently discarding a bad slug would leave the list empty and widen access to everyone rather than narrowing it to the tiers you meant. The error names the offending slugs.
Tier validation runs against that show’s own tiers, native and bridge-mapped. Tier slugs are unique per show rather than across the install, so a slug that works on one show may be rejected on another.
Get a Buy-up
GET /buyups/{id}
Response: 200 OK with the buy-up object, in the same shape as the list entries above, plus available_from_local / available_until_local. Each free row in prices also carries free_holder_count, the number of subscribers who currently hold the buy-up on that row at no charge.
Update a Buy-up
PUT /buyups/{id}
Partial updates are supported — send only the fields you want to change.
show_id is immutable and is read from the stored row, never from the request. There is deliberately no way to move a buy-up between shows: its Stripe Product and Price, and every existing grant, carry the original show. To offer the same buy-up on another show, create a second buy-up there.
prices replaces the whole set, and omitting it is different from sending an empty list.
- Omit
pricesand the price rows are left alone. - Send
pricesand it becomes the complete set. A row with anidis updated, a row without one is inserted, and any existing row missing from the list is deleted. - Send
"prices": []and every price row is deleted.
The two last cases look alike in a request body, and only the first is harmless. A deleted row’s holders keep the Stripe Price they’re billed on, and nobody is asked about them.
Changing a row’s amount mints a new Stripe Price for that row. What happens to the people paying on that row’s billing period is controlled by an optional price_changes list, with one entry per re-priced row:
| Field | Notes |
|---|---|
price_changes[].price_id |
The price row being re-priced |
price_changes[].action |
keep (default) or move |
price_changes[].notice_days |
Days of notice before holders move to the new price. 0 is valid — the change applies at each holder’s very next renewal |
price_changes[].reminder_days |
Optional. Days before the change takes effect to send a reminder email. Must be fewer than notice_days |
A change reaches only that row’s holders; everyone on another billing period is left alone. Entries use the same shape as a tier’s price_changes. A singular price_change object in the same shape also works, and applies to the buy-up’s default price row only. With neither, every holder is kept on what they pay. An invalid choice (an unrecognised action, or reminder_days not less than notice_days) returns 400 and writes nothing — the whole save is refused, not just the price change.
tax_code works as on create. Send "" to clear it. Omitting it keeps the stored code, and the requirement is checked against what the buy-up will carry after the save: on a show where Stripe Tax is ready, any update to a buy-up that has no category, even a name change, answers 400 benecaster_tax_code_required unless the same request sends a tax_code. Clearing the code there is refused the same way. Nothing is written in either case.
available_from / available_until work as on create. Sending an empty string clears that end. An end you don’t send keeps its stored value, and is still checked against the one you do send.
Response: 200 OK with the updated buy-up object.
A row whose old amount was 0 can only be kept. action: move on it returns 400 price_change_free: its holders were given the buy-up and never agreed to a price, so they keep it free and the new price applies to new subscribers only.
Errors: as for create (including both tax_code errors), plus 404 for an unknown ID, 400 invalid_param for an invalid price_changes entry or price_change, and 400 price_change_free for a move off a free row. 403 rest_forbidden is resolved against the stored row’s show, not the caller’s request.
Retry a Failed Price Change
POST /buyups/{id}/migrate
Re-queues the holders currently in a failed state for the buy-up’s latest price change, on any of its price rows, and applies them immediately rather than waiting for the next cron pass.
Response: 200 OK:
{
"requested": 12,
"retried": 9
}
requested is the number of failed holders found; retried is the number actually re-queued. A buy-up with no pending or recent price change, or none in a failed state, returns {"requested": 0, "retried": 0}.
Delete a Buy-up
DELETE /buyups/{id}
Archives the definition. Active subscriber grants are not revoked — they continue until the Stripe subscription item ends naturally. The buy-up disappears from the subscriber account page immediately.
Response: 200 OK with { "deleted": true }.
Price Changes
These endpoints cover price changes for both buy-ups and membership tiers — the underlying record and lifecycle are shared, and a buy-up’s price change is a target_type of buyup_price (targeting one of its price rows) on the same table where a tier’s is tier_price.
List Price Changes
GET /price-changes
| Query arg | Effect |
|---|---|
show_id |
Restricts the list to that show |
tier_id |
Restricts to changes on one tier’s prices |
target_type |
tier_price or buyup_price |
target_id |
Combined with target_type, restricts to one tier price row or buy-up price row |
Lists changes newest first. Superseded changes (replaced by a later change on the same price before they applied) are hidden. Each entry carries counts (a breakdown by subscriber status) and cancellable (whether POST /price-changes/{id}/cancel would currently succeed).
Cancel a Price Change
POST /price-changes/{id}/cancel
Cancels a pending change and emails everyone who was told about it that it isn’t happening. Only works before the notice period ends.
Errors: 409 price_change_not_cancellable once the notice period has ended — at that point the apply step may already have started moving holders.
Retry a Price Change
POST /price-changes/{id}/retry
Re-queues this change’s failed holders and applies them immediately. This is the general-purpose form of POST /buyups/{id}/migrate above, which is scoped to one buy-up’s latest change; use this endpoint directly when you have the change ID (for tier price changes, or an older buy-up change that isn’t the latest).
See Also
Need this built rather than just documented? See our services →