Membership Subscribe REST API
The endpoint behind the subscribe button on [benecaster_subscribe]. It signs a visitor up to one tier at one billing cadence, creating their WordPress account along the way when they are not already logged in.
POST /benecaster/v1/shows/{id}/subscribe
Subscribes a visitor to a tier. The free path skips payment entirely; the paid path creates the Stripe subscription from a PaymentMethod the browser has already confirmed.
Auth: Public. There is no nonce and no login requirement — the endpoint identifies a logged-in caller from their session, and treats anyone else as a new signup who must supply email and password. See Why a public endpoint can accept a password below; the ordering that makes this safe is a contract, not an implementation detail.
Rate limit: 10 requests per 5 minutes per IP address, separate from the general limit on Benecaster’s routes. Past that the endpoint answers 429 benecaster_rate_limited with a Retry-After header. A real signup is one request, or two or three when a card is declined and the visitor tries another, so the limit leaves room for several people signing up from one office or mobile network. It raises the cost of card testing, where someone runs stolen card numbers through a public payment form, but it does not detect it: Stripe Radar, on your own Stripe account, is what recognises that. /subscribe/confirm stays on the general limit.
Path parameter:
| Param | Type | Description |
|---|---|---|
{id} |
int | Show post ID |
Body parameters:
| Param | Type | Required | Description |
|---|---|---|---|
tier_slug |
string | yes | Slug of the tier being subscribed to |
price_id |
int | preferred | Identifies which of the tier’s billing cadences was chosen. Optional on free tiers |
billing_interval |
string | legacy | month or year. Accepted as a fallback when price_id is absent |
email |
string | logged-out only | Address for the new account. Ignored for a logged-in caller |
password |
string | logged-out only | The password the visitor chose. Required on the logged-out branch — there is no fallback to a generated one — and ignored entirely for a logged-in caller |
payment_method_id |
string | paid tiers | A confirmed Stripe PaymentMethod. Omit on free tiers, where it is ignored |
address |
object | paid tiers on a show that collects tax | The subscriber’s billing address: { line1, line2, city, state, postal_code, country }, with country as an ISO 3166-1 alpha-2 code (US, GB). It is the value of Stripe’s Address Element. Ignored on a show that does not collect tax and on a free tier |
Password rules
At least 12 characters — WordPress’s own suggested minimum, and the figure the signup form displays — and at most 4096 bytes. Length is counted in characters, not bytes, so a short passphrase in a non-Latin script is not wrongly rejected.
Length is the only rule. There is no required digit, symbol, or mixed case, because WordPress core enforces none of its own and a subscriber facing two different password rules on the same site has no way to make sense of it.
The 4096-byte ceiling is not a statement about password strength. Password hashing is deliberately slow, so an unbounded value on a public endpoint is a cheap way to spend the site’s CPU.
Why a public endpoint can accept a password
On the logged-out branch the endpoint checks whether the email already belongs to a WordPress account before it reads the password at all, and answers benecaster_login_required if it does. The payment method and price are checked next, before any account is created. An account the request created is deleted again if the signup then fails without charging anything, so a retry is not answered benecaster_login_required.
That ordering is the whole safety property. Anyone can call this endpoint, so every submitted password is untrusted. If a password were ever applied to an address that already had an account, the endpoint would be account takeover by email address alone. Do not build a client that expects the password to be honoured for an existing address, and if you are reimplementing this flow, keep the collision check first.
Sales tax and the billing address
A show collects sales tax when the podcaster has turned on Calculate tax automatically, Stripe Tax is ready in the show’s Stripe account, and every active paid tier and buy-up on the show has a tax category. On such a show a paid signup must carry address:
- No usable address (missing, or no two-letter
country) answers400 benecaster_tax_address_requiredbefore anything else happens: no WordPress account, no Stripe customer, no card attached. The message asks the visitor to reload the page, because the usual cause is a form rendered before the show started collecting tax, which had no address fields. - An address Stripe cannot place answers
422 benecaster_tax_location_invalid. For a logged-out visitor this is checked after their WordPress account is created, so a retry should be made logged in.
The address is set on the subscriber’s Stripe customer, and the subscription is created with Stripe’s automatic tax turned on. Benecaster does not store or log it. Show the visitor the taxed total first, from /subscribe/tax-preview, described below.
Choosing the cadence
A tier carries N billing cadences rather than a fixed monthly/annual pair, so the request has to say which one — a bare interval name cannot identify a price on a tier that sells quarterly, six-monthly, and annual.
price_id names one cadence in the tier’s price collection, and it is what the shortcode sends: the data-prices JSON on each tier card carries one entry per cadence, each with its identifier, and the selected radio button supplies it.
billing_interval also works, as a convenience for simple clients — custom checkout buttons, third-party integrations. The server resolves month to the tier’s month × 1 price and year to its year × 1 price.
It cannot address anything else. When the tier has no month × 1 or year × 1 price to match, the server falls back to the tier’s default price, so on a tier that sells only quarterly the visitor is billed at its default cadence, not what they asked for. Send price_id in anything you write.
When both are supplied, price_id wins and billing_interval is ignored.
Response — subscribed (201):
{
"subscription_id": 101,
"redirect_url": "https://yoursite.com/podcast-account/",
"status": "active"
}
redirect_url is your site’s account page, from benecaster_account_page_url(), with /podcast-account/ only as the fallback.
On a paid tier, a 201 means Stripe has reported the first payment as paid. Nothing is written on this site — no membership, no feed token, no welcome email — until it has.
Response — the card needs 3D Secure (202):
{
"requires_action": true,
"client_secret": "pi_…_secret_…",
"stripe_subscription_id": "sub_…",
"status": "incomplete"
}
The subscriber’s bank wants to verify the payment. Nothing has been written. Pass client_secret to Stripe.js stripe.handleNextAction() to show the bank’s check, then call /subscribe/confirm (below) with stripe_subscription_id. If the browser closes after the check and never calls it, the signup still completes from Stripe’s webhook. A subscription nobody verifies is expired by Stripe after about 23 hours, uncharged; for a guest, that also deletes the account the signup created. Call /subscribe/confirm after a failed check too, not only a successful one, so a guest’s signup is cancelled straight away.
Response — payment still processing (202):
{
"pending": true,
"stripe_subscription_id": "sub_…",
"status": "incomplete"
}
A payment method that settles later. The signup completes from the webhook once Stripe marks it paid, and the welcome email is how the subscriber hears. pending is not a failure — tell the visitor their membership is being set up.
A client that treats any 2xx as success must now check the body. Both 202 answers mean not subscribed yet: there is no subscription_id and no feed.
Errors:
| Code | HTTP | Cause |
|---|---|---|
rest_invalid_param |
400 | No tier_slug; a logged-out request with no valid email; or a paid tier with no payment_method_id |
benecaster_rate_limited |
429 | More than 10 requests from this IP address in 5 minutes. Wait for the Retry-After header’s number of seconds |
benecaster_tier_unavailable |
404 | No tier with that slug on this show, or the tier is inactive, or {id} is not a published show (draft, scheduled, private, trashed, or not a show). Refused before any account, Stripe Customer or charge is created. /subscribe/confirm and /subscribe/tax-preview do not make this check |
benecaster_tax_address_required |
400 | The show collects sales tax and the request has no usable address. Refused before any account is created |
benecaster_tax_location_invalid |
422 | The show collects sales tax and Stripe cannot work out where the address is |
benecaster_price_mismatch |
404 | The price_id does not belong to this tier |
already_subscribed |
409 | The user already has an active subscription to this show. A member on a free tier taking out a paid tier is the one exception |
benecaster_login_required |
409 | The email supplied already has an account. Resolves before the password is read |
benecaster_password_required |
400 | Logged-out request with no password |
benecaster_password_too_weak |
400 | Under 12 characters |
benecaster_password_invalid |
400 | Over the byte ceiling, or otherwise unusable |
stripe_not_configured |
503 | No Stripe keys saved for the active mode; the tier has no prices; or the chosen cadence has no Stripe Price for the active mode yet (see below) |
benecaster_stripe_error |
502 | Stripe refused the request for another reason. The message is Stripe’s |
benecaster_billing_unavailable |
409 | This install is a copy of another site, so billing is refused. See below |
benecaster_payment_declined |
402 | The first payment was declined. The incomplete Stripe subscription is cancelled at once, nothing is charged and nothing is written. The message is the card issuer’s reason when Stripe supplies one |
subscription_persist_failed |
500 | The membership could not be saved on this site. On a paid sign-up the Stripe subscription has already been created, and Benecaster cancels it before answering; its first payment is not refunded, and the account is kept. No token or feed URL is issued and no welcome email is sent. Also returned for a free sign-up, where there is nothing at Stripe to cancel |
No set-password email is sent from this endpoint
It used to send one, because the account it created had a generated password nobody had seen. That is no longer true: the visitor chooses their own password here, so they can log in the moment the request succeeds.
The guest_password_set email still exists, but only for accounts created for somebody — bulk enrollment, a subscriber added by hand, the donation-completion flow. A client that waits for that email after calling this endpoint will wait forever.
benecaster_billing_unavailable means the site is a clone, not that Stripe is broken
Benecaster records the site address it first ran at, and refuses to move money once it is running somewhere else. That is what a staging copy is: a database clone carrying production’s real Stripe subscription IDs. The refusal happens whatever mode the keys are in — pasting test keys onto a clone does not make it safe, because the subscription IDs it inherited are still the live ones.
The response message deliberately explains almost nothing. It reads “Billing changes are temporarily unavailable on this site. Please try again later or contact the podcast owner.” — the same sentence every time. This route is public, so anyone can reach it, and the detail that would explain the refusal (both site addresses and the wp-config.php constant that disarms the guard) belongs nowhere near an anonymous caller. The operator’s copy of that detail is in the debug log, written once per operation per hour.
Until recently this path answered stripe_not_configured (503) — and put the explanation in the message. If you have support notes, monitoring rules, or client-side handling that treat a 503 here as “the podcaster’s Stripe keys are wrong”, they are now wrong for the clone case. The status is 409 precisely because retrying cannot change the answer: a copy is a settled state, and a 5xx invites a retry loop.
If the site tripping this really is production — a genuine domain change, for instance — the operator adds define( 'BENECASTER_IS_PRODUCTION', true ); to wp-config.php. See Using a Staging Site.
“Not yet available for purchase” is a transient state, not a bug
Stripe Prices are immutable, so changing an amount cannot edit the existing Price — Benecaster nulls the stored Stripe IDs and mints a replacement. Between those two moments the cadence exists, is visible in the admin, and cannot be checked out: the request answers 503 stripe_not_configured with the message “This tier is not yet available for purchase. Please try again shortly.”
The window is short. A client hitting this should retry rather than surface a hard failure, and an operator seeing it persist should check that the Stripe keys for the active mode are valid — a failing mint leaves the cadence unprovisioned indefinitely.
POST /benecaster/v1/shows/{id}/subscribe/confirm
Finishes a signup that answered requires_action, once the subscriber has passed their bank’s check.
Auth: Public. A new subscriber is not logged in yet, so there is nobody to authenticate. It is safe because it grants nothing Stripe has not been paid for: it re-reads the subscription from Stripe and completes only one the signup form created for this show, and only once it is paid — exactly what the webhook would do moments later.
Body parameters:
| Param | Type | Required | Description |
|---|---|---|---|
subscription_id |
string | yes | The stripe_subscription_id from the 202 answer — a Stripe sub_… id |
Responses:
| HTTP | Body | Meaning |
|---|---|---|
201 |
Same as a completed signup | Paid, and the membership is written |
202 |
{ pending, stripe_subscription_id, status } |
Stripe has not caught up yet — its status moves a moment after the check. The webhook finishes it; do not treat this as a failure |
402 |
benecaster_payment_declined |
The bank’s check failed. Nothing is charged or written. For a guest whose account the signup created, the incomplete Stripe subscription is cancelled and that account is deleted, so they can try again at once. A signed-in member’s subscription stays incomplete until Stripe expires it |
400 |
rest_invalid_param |
No subscription_id, or not a sub_… id |
404 |
benecaster_signup_not_found |
Stripe does not recognise the subscription |
Idempotent. Calling it twice, or racing the webhook, writes the membership once.
A free member who starts a paid signup and abandons it — at the bank’s check or with a declined card — keeps their free membership.
Finishing a 3D Secure signup from your own form
If you build your own signup form instead of using [benecaster_subscribe], it has to handle the bank’s check itself. Post to /subscribe; on requires_action, run Stripe.js handleNextAction() and then post the subscription ID to /subscribe/confirm. Only a 201 means the subscriber is in.
const res = await fetch( `${ rest }/shows/${ showId }/subscribe`, { method: 'POST', headers, body: JSON.stringify( payload ) } );
let body = await res.json();
if ( ! res.ok ) throw new Error( body.message ); // 402 benecaster_payment_declined, 409, …
if ( body.requires_action ) {
const { error } = await stripe.handleNextAction( { clientSecret: body.client_secret } );
if ( error ) throw new Error( error.message ); // failed or abandoned — nothing was written
body = await ( await fetch( `${ rest }/shows/${ showId }/subscribe/confirm`, {
method: 'POST', headers,
body: JSON.stringify( { subscription_id: body.stripe_subscription_id } ),
} ) ).json();
}
if ( body.pending ) {
showMessage( 'Your payment is being processed — we will email you when your membership is ready.' );
} else {
window.location.href = body.redirect_url; // 201: membership written, feed created
}
Treating any 2xx from /subscribe as success is the mistake to avoid. A 202 is a payment still waiting for the bank.
pending is not a failure. Stripe marks the subscription paid a moment after the check, the site finishes it from the webhook, and the welcome email reaches the subscriber. Don’t retry the signup — that would start a second subscription.
POST /benecaster/v1/shows/{id}/subscribe/tax-preview
Works out what a visitor will be charged, with tax, for the address they have entered, so a signup form can show the total before they pay. With tax added on top, the charge is not the price on the tier card. It creates nothing and charges nothing.
Auth: Public, for the same reason as /subscribe: the visitor is usually not logged in.
Rate limit: 60 requests per 5 minutes per IP address, separate from the general limit on Benecaster’s routes. Call it once the address is complete, and debounce it, rather than on every keystroke. Call it again when the visitor changes billing period, since the total depends on the price.
Body parameters:
| Param | Type | Required | Description |
|---|---|---|---|
tier_slug |
string | yes | Slug of a paid tier on this show |
price_id |
int | preferred | The chosen cadence, as on /subscribe. Without it the tier’s month × 1 price is used, falling back to its default |
address |
object | yes | { line1, line2, city, state, postal_code, country }, country an ISO 3166-1 alpha-2 code. The value of Stripe’s Address Element |
Response (200):
{
"currency": "usd",
"subtotal": 500,
"tax": 44,
"total": 544,
"subtotal_display": "$5.00",
"tax_display": "$0.44",
"total_display": "$5.44"
}
subtotal, tax and total are in minor units of currency. The *_display strings are ready to show as they are. [benecaster_subscribe] shows them as Subtotal, Tax and Total.
Errors:
| Code | HTTP | Cause |
|---|---|---|
benecaster_tier_unavailable |
404 | No tier with that slug on this show, or the tier is inactive or free |
benecaster_tax_not_applicable |
409 | This show does not collect sales tax. The normal answer on most shows: no address is needed, and the price on the tier card is the charge |
benecaster_tax_location_invalid |
422 | No usable address, or Stripe cannot work out where it is. Ask the visitor to check it, and never show the untaxed price as the total instead |
benecaster_price_mismatch |
404 | The price_id does not belong to this tier |
stripe_not_configured |
503 | No Stripe keys, or the cadence has no Stripe Price for the active mode yet |
benecaster_stripe_error |
502 | Stripe could not work out the tax just then. Try again |
The address is passed to Stripe and dropped. Benecaster does not store or log it, and a custom form should not store it either.
See Show the taxed total in your own signup form for a worked example.
See Also
- Subscription Billing Cadence — what the subscriber sees, and how cadences are presented
- Built-in Membership — configuring tiers and their prices
MembershipPriceRepository— the server-side price collection
See Also
Need this built rather than just documented? See our services →