Skip to main content

LicensePushController

\Benecaster\REST\LicensePushController

Class Free

Inbound receiver for POST /benecaster/v1/license-push. The license server calls it when a license-state change happens out of band — an order paid, a subscription upgraded, a plan cancelled, a referral credit applied — and the site should pick the change up sooner than the daily validation cycle. The handler runs a validation cycle immediately and leaves the daily schedule in place.

HMAC is the sole authentication. There is no cookie auth and no nonce: the server signs the raw request body with the site’s push secret and sends the result in X-Benecaster-Signature, and the plugin rejects on mismatch.

The push secret’s only source is a /validate response. The server includes it and LicenseValidationCron stores it on the way past. Connecting a show over OAuth does not carry it, and nothing provisions it at activation.

The body is signed but not read. Re-running the validation cycle is the canonical “re-read state from the server” action and always fetches the freshest state, so a payload schema would only duplicate /validate.

Constructor Dependencies

Type Description
\Benecaster\License\LicenseValidationCron Run immediately on a verified push. The daily wp-cron event is deliberately left scheduled.

Methods

Method Visibility Since Description
register_routes(): void Public — Registers POST /license-push with permission_callback '__return_true' — deliberately public, since HMAC is the sole auth mechanism and there is no WordPress session to check.
handle( WP_REST_Request $request ): WP_REST_Response|WP_Error Public — Verifies the signature, collapses repeat deliveries, and runs a validation cycle. 503 not_activated when no push secret is stored (this install has never completed a /validate ping — the only event that caches the secret). 401 invalid_signature when the X-Benecaster-Signature header is missing or its HMAC-SHA256 (computed over the raw request body with the stored secret, compared via hash_equals()) doesn't match — no body detail is returned on mismatch. When the JSON body decodes to an object carrying scalar timestamp and reason fields, builds an idempotency key via extract_idempotency_key() and short-circuits with {ok: true, deduplicated: true} if that key was already seen within the last 5 minutes (a transient, TTL chosen to exceed the server's retry window) — the server delivers at-least-once, so identical payloads legitimately arrive more than once. Otherwise runs LicenseValidationCron::run() immediately (leaving the daily wp-cron schedule untouched — it is never cleared here) and returns 200 {"ok":true}. The request body itself is never read for its content beyond the idempotency fields — a fresh /validate call is always the source of truth for what actually changed.
extract_idempotency_key( string $body ): ?string — — Returns null (meaning: process unconditionally, don't dedupe) for an empty body, a body that isn't valid JSON decoding to an array, a body missing either timestamp or reason, or either field being non-scalar. The non-scalar check matters specifically: a non-scalar value casts to the literal string "Array", which would give every malformed payload an identical key and let the first one suppress the rest for 5 minutes — falling through to null instead means an unrecognisable body is processed rather than silently swallowed. Returns sha1(timestamp . reason) otherwise.

Notes

A 503 does not mean the site is unlicensed. It means this install has never completed a validation ping — the only event that caches the push secret. A freshly OAuth-connected show is fully activated and still returns 503 until its first /validate lands. The behaviour is correct and is not a bug to fix: the server treats the 503 as a known retriable state, drops the push, and the daily validation self-heals the install.

**The error code on that response is the literal string not_activated, and renaming it would break the server's handling.** It is narrower than it reads, which is what this note exists to explain — treat the mismatch as documented rather than as a typo.

The reason a push carries is an operator breadcrumb, never an instruction. The receiver is deliberately body-opaque, so a push announcing plan_upgraded answered by a /validate saying cancelled leaves the site cancelled. Nothing in the push can move state on its own.

Delivery is at-least-once and per site token. An install running N shows receives N pushes carrying the same payload; repeats are collapsed so one validation cycle runs, and that cycle already iterates every connected show.