Skip to content

Licensing

How AD Form decides what a site may do.

Companion documents: docs/ENTITLEMENTS.md (the gate), docs/LICENSE_API.md (the wire contract), docs/DOWNGRADE_POLICY.md (what happens when a plan shrinks), docs/STAGING_AND_INSTALLATIONS.md (identity and environments), docs/SECURITY_LICENSING.md (trust boundary).

The short version

  1. The platform is authoritative. The plugin sends signals and consumes signed answers. It never computes a plan, never reads a price and never contacts Stripe.
  2. A payload is only used after its Ed25519 signature verifies. An invalid signature discards the whole payload, never part of it.
  3. Features are unlocked by entitlement keys, never by comparing plans.
  4. Anything less than a verified active license lands on exactly the Free baseline. Never on "nothing", never on "everything".
  5. Nothing is ever deleted. A downgrade locks features; it does not remove forms, entries or configuration.

Two payment systems, kept apart

System A — the AD Form licenseSystem B — payments inside a form
Who paysThe customer pays Dingfelder DigitalWebsite visitors pay the customer
Stripe accountThe platform operator'sThe customer's own
NamespaceDingfelder\AdForm\LicensingDingfelder\AdForm\Payments
StorageOptions ad_form_license_*Table ad_form_payments
Identifierslicense_id, installation_id, plangateway, gateway_payment_id

Enforced structurally, not by convention: the Licensing namespace declares no dependency on Payments, writes nothing to ad_form_payments, and stores no gateway identifier. Payments depends on Licensing only through EntitlementManager. No Stripe customer id, price id, API key or webhook secret appears anywhere in the licensing code.

Platform contract

ConcernValue
APIhttps://api.andredingfelder.com, version /v1/
Dashboardhttps://dashboard.andredingfelder.com
Upgrade routehttps://dashboard.andredingfelder.com/products/ad-forms
Product idad_forms
SignatureEd25519, sodium_crypto_sign_verify_detached
Transportwp_remote_post() only

All of it lives in Licensing\LicenseConfig. No domain string appears anywhere else in the plugin.

Object graph

Registered by LicensingServiceProvider, inserted after DatabaseServiceProvider (it needs options and the logger) and before the field, action, REST, frontend and integration providers, so each of those can resolve entitlements while it boots.

ClassResponsibility
LicenseConfigURLs, timeouts, intervals, supported payload versions
ApiTransportInterface / WpHttpTransportThe only outbound HTTP in the plugin
LicenseApiClientHeaders, encoding, endpoints, error normalisation
ApiResponseDistinguishes a transport failure from an HTTP verdict
RequestPayloadFactoryThe exact set of transmitted fields, in one file
InstallationIdentityUUID, installation token, masked accessors
EnvironmentDetectorEnvironment signals. Reports, never decides
LicenseEnvelopeThe signed wrapper, holding the exact signed bytes
PayloadCanonicalizerDeterministic JSON when the payload arrives as an object
PublicKeyRegistryPinned public keys with kid and retirement
SignatureVerifierEd25519 verification. Fails closed
LicensePayloadImmutable verified payload
LicenseValidatorProduct and installation binding, clock sanity
LicenseCacheVerify-on-read, memoised per request
LicenseState / LicenseStateKindThe resolved state machine
ActivationService / ValidationService / DeactivationServiceThe three endpoints
LicenseSchedulerJittered daily cron plus opportunistic triggers
LicenseManagerFaçade. The only class the rest of the plugin talks to
LicenseLogger / MaskThe license log channel, with mandatory masking

Lifecycle

Activation

POST /v1/licenses/activate

The license key is sent exactly once, here. The response carries a signed payload plus an installation_token. Order of operations, and nothing from the response is used before step 3 succeeds:

  1. LicenseEnvelope::from_response() — structural shape only
  2. SignatureVerifier::verify() — Ed25519 over the exact signed bytes
  3. LicensePayload::from_array() — required members, sane timestamps
  4. LicenseValidator::assert_acceptable() — product match, not expired, not future-dated
  5. store token, installation id, license id and the masked key
  6. store the envelope

Validation

POST /v1/licenses/validate

Authenticated with the installation token. The license key is never sent again. Three outcomes, deliberately kept apart:

OutcomeEffect
Signed successStore the new payload, whatever it says
Transport failure or 5xxKeep the existing payload, record the failure, retry in an hour
Documented credential refusalDrop the local credential and fall to Free

Only the first can grant entitlements, because only the first carries a signature. The third can only reduce access, which is why it does not need one. Granting requires proof; withdrawing does not. That asymmetry is intentional.

Deactivation

POST /v1/licenses/deactivate

Only ever from an explicit administrator action on the License screen. Local state is cleared even when the API cannot be reached, and the installation UUID is kept so a reconnect maps to the same installation instead of consuming another activation slot.

Plugin deactivation is not license deactivation. WordPress deactivates plugins during updates, migrations, staging refreshes and debugging; silently freeing a slot in those cases would be surprising and abusable. Deactivator only clears the cron event.

Plugin deletion removes the local options when the customer opted into data deletion. The API is not contacted: uninstall runs in a short-lived request that may have no network access, so the architecture never depends on reaching the server at delete time. The customer removes the installation from the dashboard.

Scheduling

wp_schedule_event, daily, hook ad_form_license_validate. The initial offset is derived deterministically from the installation UUID, so a given site keeps a stable slot in the day and thousands of installations do not hit the API in the same minute. Deterministic rather than random so the slot survives a reschedule and stays diagnosable in support.

WP-Cron fires on requests, not on a clock. Nothing here depends on running at an exact moment: every freshness decision compares timestamps, and a missed run simply means the next request that triggers cron catches up.

Additional triggers, none of them on a frontend page view:

  • opening the License screen, but only when a check is actually due
  • a manual "Check now", rate limited to once a minute
  • a cache miss before a premium action

The two clocks

Conflating these would be the single worst thing this system could do, so they are separate types.

ClockMeaningValue
Refresh intervalHow often we askrefresh_after from the server, ~24 h, plus jitter
Offline graceHow long a validly signed payload stays usable when the API is unreachable7 days past expires_at
Business graceGrace for non-paymentZero. There is none

An unreachable API is a transport failure and must never remove paid features. A signed payload saying plan: free is an answer and takes effect immediately. LicenseStateKind models both, and the admin wording distinguishes them, because telling a paying customer their subscription lapsed when the API merely timed out is unacceptable.

States

LicenseStateKind is wider than the platform's license_status because the plugin also has to express "no license here" and "we cannot reach the server".

StateCausePaid entitlementsCustomer-facing meaning
unlicensedNo license connectednoRunning on Free. Not an error
activeSigned, fresh, activeyesEverything works
offline_gracePayload aged out while the API was unreachableyesNothing is wrong with the subscription
offline_expiredGrace ran outnoTechnical, not billing
suspendedPlatform said sonoBilling
revokedPlatform said sonoBilling, strictest enforcement
expiredPlatform said sonoBilling
untrustedStored payload failed re-verificationnoTampering or corruption
crypto_unavailableNo sodium, or no pinned key in this buildnoServer or packaging problem

is_technical() separates the states caused by our side from the states caused by billing, which is what drives the wording in the admin UI.

Storage

Three options, none autoloaded. Licensing is read on licensing paths and on the first entitlement lookup of a request, never on every page view.

OptionContent
ad_form_license_identityinstallation_uuid, installation_token, installation_id, license_id, masked key
ad_form_license_payloadThe exact signed bytes, signature, kid, algorithm
ad_form_license_runtimechecked_at, next_check_at, last_error

Nothing goes into ad_form_settings: that option is autoloaded, editable from the settings screen, and its allow-list sanitiser would silently drop unknown keys.

Verify on read

The signature is stored next to the payload and re-checked on every read. That is the entire reason the signature is persisted at all: editing ad_form_license_payload by hand yields untrusted, which means the Free baseline, rather than new entitlements. Verification is memoised per request, so an entitlement lookup inside a render loop costs an array read.

Key rotation

PublicKeyRegistry pins keys by kid, each with an algorithm and an optional retirement timestamp. Rotating means shipping a plugin update that adds the new key and sets retires_at on the old one, so payloads signed with the previous key stay verifiable for a defined window.

The registry is deliberately not filterable and not configurable through a constant. A site owner who could define a constant could otherwise self-sign payloads, which would make the whole system decorative.

A build whose only entry is still the placeholder cannot verify anything and therefore stays on Free. LicenseManager::can_verify() surfaces that on the License screen so a packaging mistake looks like a packaging mistake rather than a licensing failure.

Multisite

The plugin is uniformly per-site: Core\WpOptions wraps get_option exclusively, there is no *_site_option call anywhere in src/, Activator installs schema per blog and hooks wp_initialize_site, and Uninstaller purges per blog. Licensing follows that rather than introducing the only network-scoped state in the plugin.

AspectPolicy
Network activationSupported. Each blog activates independently
Site-specific activationSupported, identical code path
License scopePer blog
Installation UUID scopePer blog
Signals sentis_multisite, blog_id, network_home_url, so the API can recognise a network and bill it as one customer if the product decides to

Rejected alternative: a network-wide license in wp_sitemeta. It would break the per-blog uninstall path and let one blog's administrator revoke a sibling's entitlements.

What the plugin never does

  • contact Stripe to determine its own license
  • query the dashboard database
  • decide a price
  • decide which paid plan a customer holds
  • enforce its own activation limit as the authority
  • compare site_url for equality to decide anything
  • treat wp_get_environment_type() as proof
  • delete customer data on downgrade
  • make an API call on a frontend page view
  • contain a private key or any platform secret

Logging

Channel license, through LicenseLogger, which strips credential keys before they can reach Logger::redact(). Two layers on purpose: the layer closest to the secret should be the one that removes it.

Recorded: activation, deactivation, validation success and failure, API unreachable, invalid signature, rejected cache, plan and status transitions.

License keys appear as ADFORM-****-****-1234. Installation tokens are never logged, not even partially — only as **** or as a 12-character fingerprint for correlating log lines.

Digitale Lösungen. Persönlich. Zukunftssicher.