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.
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 |
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.
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.
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. A tier that sells only quarterly has neither a month × 1 nor a year × 1 price, so billing_interval cannot reach any of its prices and the request fails with price_not_found. 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/account/",
"status": "active"
}
Errors:
| Code | HTTP | Cause |
|---|---|---|
tier_not_found |
404 | No tier with that slug on this show, or the tier is inactive |
price_not_found |
404 | The price_id does not belong to this tier, or billing_interval matched no cadence on it |
price_not_provisioned |
409 | The cadence exists but has no Stripe Price ID for the active keyset — the amount was changed recently and the new Price has not been minted yet |
already_subscribed |
409 | The user already has an active subscription to this tier |
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 |
benecaster_billing_unavailable |
409 | This install is a copy of another site, so billing is refused. See below |
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.
price_not_provisioned 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 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.
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