Skip to content

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

HeaderValue
Content-Typeapplication/json; charset=utf-8
Acceptapplication/json
X-AD-Productad_forms
X-AD-Apiv1
X-AD-Plugin-Verplugin version, e.g. 0.11.0
User-AgentAD-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

MemberTypeRequiredNotes
versionintyesEnvelope version. Only 1 is understood today
license_idstringyesNon-empty
installation_idstringyesBound on activation, must match afterwards
productstringyesMust be ad_forms
planstringnofree, pro, pro_plus, business. Unknown values become free
billing_intervalstringnomonth or year. Irrelevant to entitlements
license_statusstringyesactive, suspended, revoked, expired. Unknown becomes expired
entitlementsstring[]yesFeature keys. Unknown keys are honoured
issued_atintyesUnix seconds
refresh_afterintnoDefaults to issued_at + 86400
expires_atintyesMust 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:

  • algorithm is not exactly Ed25519
  • kid is 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:

CodeStatusesEffect
installation_not_found401, 403, 404Drop the local credential, fall to Free
installation_token_invalid401, 403, 404Same
installation_revoked401, 403, 404Same
license_not_found401, 403, 404Same
license_revoked401, 403, 404Same
anything elseanyKeep 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_after as 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_at while 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.

Digitale Lösungen. Persönlich. Zukunftssicher.