Darstellung
License API contract
What the plugin sends and what it requires back. Version v1.
Base URL https://api.andredingfelder.com, product id ad_forms.
All requests are POST with Content-Type: application/json; charset=utf-8, performed through wp_remote_post() with a bounded timeout, no redirects and sslverify on.
Headers
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
Accept | application/json |
X-AD-Product | ad_forms |
X-AD-Api | v1 |
X-AD-Plugin-Ver | plugin version, e.g. 0.11.0 |
User-Agent | AD-Form/<plugin> (WordPress/<wp>; PHP/<php>) |
POST /v1/licenses/activate
The only request that carries the license key.
json
{
"product": "ad_forms",
"api_version": "v1",
"installation_uuid": "6f1c9e2a-....",
"site_url": "https://kunde.example.com",
"home_url": "https://kunde.example.com",
"environment": "production",
"wordpress_version": "6.4.3",
"php_version": "8.2.20",
"plugin_version": "0.11.0",
"environment_signals": {
"declared_type": "production",
"host_matches_stage": false,
"host_matches_local": false,
"managed_host_match": false,
"wp_debug": false,
"is_multisite": false,
"blog_id": 1,
"network_home_url": ""
},
"license_key": "ADFORM-XXXX-XXXX-XXXX"
}The field list is exact and asserted by RestGateTest. environment and environment_signals are hints, not claims the server should trust — see docs/STAGING_AND_INSTALLATIONS.md.
Success — 200
json
{
"installation_token": "<opaque secret>",
"payload": {
"version": 1,
"license_id": "lic_...",
"installation_id": "inst_...",
"product": "ad_forms",
"plan": "pro_plus",
"billing_interval": "year",
"license_status": "active",
"entitlements": [ "payments", "payments.stripe", "pdf" ],
"issued_at": 1787000000,
"refresh_after": 1787086400,
"expires_at": 1787172800
},
"signature": "<base64 Ed25519>",
"kid": "adf-2026-01",
"algorithm": "Ed25519"
}installation_token is required. A success response without one is rejected: the plugin would otherwise have no way to validate later.
POST /v1/licenses/validate
Same envelope, authenticated with the token instead of the key. The license key is never sent again after activation.
json
{
"product": "ad_forms",
"api_version": "v1",
"installation_uuid": "6f1c9e2a-....",
"site_url": "https://kunde.example.com",
"home_url": "https://kunde.example.com",
"environment": "production",
"wordpress_version": "6.4.3",
"php_version": "8.2.20",
"plugin_version": "0.11.0",
"environment_signals": { "...": "..." },
"installation_token": "<opaque secret>"
}Response shape is identical to activation, minus installation_token.
POST /v1/licenses/deactivate
json
{
"product": "ad_forms",
"installation_uuid": "6f1c9e2a-....",
"installation_token": "<opaque secret>",
"plugin_version": "0.11.0"
}Any 2xx counts as confirmation. Local state is cleared regardless of the outcome.
The signed payload
| Member | Type | Required | Notes |
|---|---|---|---|
version | int | yes | Envelope version. Only 1 is understood today |
license_id | string | yes | Non-empty |
installation_id | string | yes | Bound on activation, must match afterwards |
product | string | yes | Must be ad_forms |
plan | string | no | free, pro, pro_plus, business. Unknown values become free |
billing_interval | string | no | month or year. Irrelevant to entitlements |
license_status | string | yes | active, suspended, revoked, expired. Unknown becomes expired |
entitlements | string[] | yes | Feature keys. Unknown keys are honoured |
issued_at | int | yes | Unix seconds |
refresh_after | int | no | Defaults to issued_at + 86400 |
expires_at | int | yes | Must be greater than issued_at |
Unknown-value handling fails safe in both directions: an unrecognised plan lands on Free, an unrecognised status lands on expired. Neither can accidentally grant.
billing_interval never affects entitlements. Pro monthly and Pro yearly are the same product.
Signature
Ed25519 detached, base64 in signature, key identified by kid.
The plugin verifies the signature over the exact byte string the server signed. Two wire formats are accepted.
Preferred: payload_encoded
The server sends base64url of the exact JSON it signed:
json
{
"payload_encoded": "eyJ2ZXJzaW9uIjoxLC4uLn0",
"signature": "...",
"kid": "adf-2026-01",
"algorithm": "Ed25519"
}The decoded bytes are authoritative and no canonicalisation is involved. This is the format to use, because it removes an entire class of "we disagree about whitespace" bug from the trust path.
Accepted: payload object
When the payload arrives as a JSON object, the plugin canonicalises it and verifies against that. The server must sign the identical canonical form:
- object members sorted by key, byte-wise ascending, recursively
- list order preserved
- no insignificant whitespace
- slashes and unicode unescaped
JSON_PRESERVE_ZERO_FRACTION
In PHP terms that is exactly PayloadCanonicalizer::encode(). Wire key order is irrelevant, asserted in BypassAttemptTest::test_key_order_does_not_affect_verification.
Verification rules
Every one of these fails the whole payload:
algorithmis not exactlyEd25519kidis unknown, retired, or still a placeholder in this build- the pinned key is not 32 bytes
- the signature is not 64 bytes
- the signature does not verify
- sodium is unavailable
No partial use, no fallback to a weaker algorithm.
Key rotation
kid selects a pinned key. Each entry carries an algorithm and an optional retires_at, so a rotation ships the new key and retires the old one after a window rather than breaking every unrenewed installation at once. The registry is not filterable and not settable through a constant, deliberately.
Error responses
The platform's documented envelope is:
json
{
"error": {
"code": "invalid_license_key",
"message": "Unknown license key."
}
}A flat { "code", "message" } body is still accepted so older fixtures and manual probes keep working.
Codes the plugin treats specially:
| Code | Statuses | Effect |
|---|---|---|
installation_not_found | 401, 403, 404 | Drop the local credential, fall to Free |
installation_token_invalid | 401, 403, 404 | Same |
installation_revoked | 401, 403, 404 | Same |
license_not_found | 401, 403, 404 | Same |
license_revoked | 401, 403, 404 | Same |
| anything else | any | Keep the existing payload, record the failure, retry |
The special-cased codes can only reduce access, which is why they are honoured without a signature. Granting always requires one.
A license_limit_reached code on activation is surfaced to the administrator with guidance to remove an installation in the dashboard. The plugin never decides an activation limit itself.
Behaviour the server can rely on
- The plugin retries a failed check after one hour, not immediately.
- The plugin honours
refresh_afteras the next check time. - The plugin will not accept a payload issued more than 300 seconds in the future, to allow for clock skew without allowing replay from the future.
- The plugin keeps using a signed payload for 7 days past
expires_atwhile the API is unreachable, then degrades to Free. - The plugin never sends form definitions, entry data, entry values, field labels, visitor IPs or visitor email addresses. Asserted in
RestGateTest.
Prepared, not implemented
A v2 API must not break older plugins, which is why api_version is in every request body as well as in a header, and why version in the payload is checked against a supported list rather than assumed.
Updates and download
Connected installations can ask POST /v1/updates/check with the installation token. A newer published release returns a short-lived signed GET /v1/downloads/{product}/{version} URL. WordPress uses that URL as the package; the plugin never talks to wordpress.org for AD Form updates.
GET /v1/licenses/public-keys is unauthenticated and returns the pinned Ed25519 public keys by kid. The plugin still verifies against its bundled copy; the endpoint exists so a key rotation can be inspected without a dashboard login.