---
title: "Signed responses"
description: "Understand the exact Ed25519 envelope, PHP canonical bytes, trusted keys, time checks and replay limits before accepting a license."
slug: "/docs/v1/security/signed-responses"
section: "Security"
order: 13
audience: "all"
level: "advanced"
last_updated: "2026-10-17"
version: "v1"
related: ["/docs/v1/security/verify-ed25519","/docs/v1/security/test-vectors","/docs/v1/security/offline-mode"]
draft: false
---
Verify the signed payload with a trusted public key, then independently check whether its [license](/docs/v1/start/glossary#license) state permits the requested action.

## What you will learn
- Reproduce the exact signed bytes without losing numbers or Unicode.
- Bind a response to your [product](/docs/v1/start/glossary#product), environment and installation.
- Handle key changes, expiry, clock changes and replay honestly.

## Prerequisites
- Read [authentication](/docs/v1/start/authentication) and [the activation flow](/docs/v1/start/activation-validation-deactivation).
- Retain the original UTF-8 JSON response before a normal parser changes numeric tokens.
- Configure a trusted public key separately from the response being verified.

## Envelope and trust boundary
Activation and validation return a license envelope. A successful HTTP request alone is not proof of authenticity or permission. The signed part is only payload; activation_token, success, valid and request_id in the outer API response are not covered by this signature.

| Envelope field | Type | Required meaning |
| --- | --- | --- |
| payload | object | The license snapshot whose canonical UTF-8 bytes are signed |
| signature | string | Unpadded base64url representation of a 64-byte detached signature |
| algorithm | string | Must equal Ed25519; do not negotiate a different algorithm from the response |
| key_id | string | Must match the independently trusted key, not authorize downloading an arbitrary key URL |

The public key is 32 raw bytes, not a PEM file. The signature is 64 raw bytes, not a hex string. Ed25519 signs the message directly; do not pre-hash the payload or use Ed25519ph. See [RFC 8032](https://www.rfc-editor.org/rfc/rfc8032.html).

## Exact signed bytes
The server recursively sorts associative PHP-array keys with ksort using SORT_STRING, preserves list order, then encodes the payload with JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE and JSON_PRESERVE_ZERO_FRACTION. The result has no indentation or separating whitespace. Strings and object names use UTF-8 byte ordering; JavaScript and .NET default UTF-16 ordering can differ for non-BMP keys.

Strings use literal Unicode, except U+2028 and U+2029 are escaped. Quotes, backslashes and JSON controls are escaped; forward slashes remain literal. There is no Unicode normalization. A composed character and its decomposed form are different bytes. Duplicate object names are not a supported wire format; reject them before signature checking.

An integral float such as 19.0 remains 19.0. Numeric encoding is PHP-specific and also depends on its serialize_precision setting. This contract is not RFC 8785 / JSON Canonicalization Scheme. Normal JSON.parse followed by JSON.stringify changes some signed numbers; sorting a normal parsed JavaScript object is insufficient. The [PHP JSON documentation](https://www.php.net/manual/en/function.json-encode.php) describes these flags and precision behavior.

The verification samples retain numeric tokens from the original server JSON and canonicalize its keys and strings. They do not reconstruct PHP floating-point formatting from rounded language numbers. Keep those original numeric tokens in any local cache too. An intermediary that changes a numeric representation can cause a safe verification failure. Do not bypass that failure.

## Signed payload field reference
Nullable means the field may explicitly contain null. A missing required field is not the same as null. Your application must validate the fields it uses after verifying the signature.

| Field | Type | Meaning and handling |
| --- | --- | --- |
| license_id | string | Public license identifier; compare with the expected license when known |
| product_id | string | [Public App ID](/docs/v1/start/glossary#public-app-id); must equal your configured product |
| product | string | Product display name; not an authorization identifier |
| plan | string | Product-plan slug; not the [developer](/docs/v1/start/glossary#developer)'s [platform subscription](/docs/v1/start/glossary#platform-subscription) |
| status | string | Current license state; require active for normal licensed access |
| environment | string | [sandbox](/docs/v1/start/glossary#sandbox) or [live](/docs/v1/start/glossary#live); must match the configured environment |
| license_key_mask | string | Display mask; never a credential |
| starts_at | string or null | UTC ISO 8601 start date; do not grant use before a future start |
| expires_at | string or null | UTC ISO 8601 expiry; null has no license expiry deadline |
| created_at | string or null | Creation display timestamp; not a freshness proof |
| max_devices | integer | Concurrent active-device limit |
| max_activations | integer or null | Cumulative activation limit; null means no configured cumulative limit |
| activation_count | integer | Cumulative [activation count](/docs/v1/start/glossary#activation-count) |
| active_devices | integer | Active count at snapshot time; not a live counter offline |
| min_version | string or null | Configured lower application-version restriction |
| max_version | string or null | Configured upper application-version restriction |
| [customer](/docs/v1/start/glossary#customer) | object | Customer information; treat as personal data |
| customer.id | string | Customer's public identifier |
| customer.name | string or null | Optional name |
| customer.email | string | Customer email associated with the license |
| customer.company | string or null | Optional company |
| customer.country | string or null | Optional country |
| product_details | object | Product display metadata |
| product_details.id | string | Public App ID, consistent with product_id |
| product_details.name | string | Product display name |
| product_details.platform | string | Lowercase configured application platform |
| plan_details | object | Product-plan snapshot |
| plan_details.id | string | Product-plan identifier |
| plan_details.name | string | Product-plan display name |
| plan_details.slug | string | Product-plan slug |
| plan_details.billing_type | string | Lowercase product-plan billing type |
| plan_details.price | number | PHP float; 19.0 and 19 have different signature bytes |
| plan_details.currency | string | Product-plan currency; not evidence of a payment |
| plan_details.duration_days | integer or null | Configured license duration |
| plan_details.trial_days | integer or null | Configured trial duration |
| [features](/docs/v1/start/glossary#feature) | object or empty array | Dynamic feature keys; empty PHP feature arrays can serialize as [] |
| features[key] | JSON value | Boolean or configured JSON value; validate its type before use |
| [device](/docs/v1/start/glossary#device) | object | Bound installation metadata |
| device.id | string | Device's public identifier |
| device.installation_hash | string or null | SHA-256 of the persisted [installation ID](/docs/v1/start/glossary#installation-id); require a match |
| device.name | string or null | Optional installation label |
| device.platform | string or null | Optional platform description |
| device.app_version | string or null | Optional application version description |
| server_time | string | UTC ISO 8601 snapshot time; signed, but still replayable |
| next_validation_at | string | Suggested online-validation deadline |
| offline_grace_until | string | Absolute offline-fallback deadline |

Activation can return empty device-description strings; validation can return null for the same absent descriptions. Null is signed data, not a reason to remove a property before verification. Omitting X-Installation-ID during validation can produce a null installation hash; always send the persisted ID and reject an unbound response in your client.

## Fetch and cache the trusted public key
1. Fetch GET /api/v1/public-key from the configured KDPika HTTPS origin, using normal certificate and hostname verification.
2. Read key.algorithm, key.key_id, key.public_key and key.encoding from the successful response.
3. Require Ed25519, base64url and a decoded 32-byte key.
4. Calculate key_id as ed25519_ followed by the first 16 lowercase hexadecimal characters of SHA-256 of those raw public bytes.
5. Pin the approved key in your application or save the HTTPS-fetched key in protected application state according to your trust policy.
6. Cache the current key for at most the endpoint's one-hour cache lifetime before an intentional refresh.
Expected result: a response cannot choose its own key, host or algorithm. An untrusted file beside the application is not a secure pin merely because its filename is key.json.

Trust on first use from HTTPS depends on the origin, certificate validation and the client's local environment. A compiled pin reduces dependence on a later key fetch, but a fully controlled client machine can still replace the program. Do not fetch a key using a URL supplied in an envelope.

## Key changes and rotation
The platform currently exposes one configured public key and reads one signing-key pair. There is no public historical key ring, overlapping-signature period, key-validity schedule or rotation-history endpoint.

1. Treat an unknown key_id as untrusted, not as permission to skip verification.
2. If your policy allows refresh, fetch once from the fixed HTTPS public-key endpoint and validate its format and identity.
3. Accept a new key only through your intended trust-update policy; a strictly pinned client needs an application/configuration rollout.
4. Retry verification once with the approved key or return a clear connection/update-required error.
5. Discard old cached signed states according to the rollout and expiry policy.
Expected result: rotation is deliberate and failures remain closed. Any client-managed overlap is your integration policy, not an existing server guarantee. Compromise of a signing key requires a separate coordinated response with KDPika support.

## Time, binding and replay
After authenticity succeeds, require the intended product, environment and installation hash, active status, acceptable application version, valid start/expiry and the appropriate signed validation/grace deadlines. Use the [official SDK](/docs/v1/start/glossary#official-sdk)'s valid result as the first gate before feature helpers.

Both next_validation_at and offline_grace_until are calculated from server_time. Grace is not automatically added after the [validation interval](/docs/v1/start/glossary#validation-interval). A valid signature can belong to an expired license: the expired vector intentionally demonstrates this distinction.

The payload contains no signed request nonce, client challenge or monotonic sequence number. A previously valid signed snapshot can be replayed until your time/binding policy rejects it. The SDKs use a five-minute local clock-rollback tolerance, but local files and clocks are mutable. Revocation on the server cannot instantly invalidate a disconnected cached response. Shorter intervals and server-side authorization reduce exposure; they do not make a local binary unpatchable.

The current source has a recorded database-timezone discrepancy for created_at on a non-UTC local database. Do not use created_at as proof of freshness. This documentation does not change stored timestamps or signing behavior.

## Expected result
The positive vector verifies. A changed payload or wrong key fails. The expired vector verifies cryptographically but must fail your time-based access decision. A response for another product, environment or installation does not unlock your application.

## Common mistakes
- Signing the outer response, or signing an already rounded JavaScript number tree.
- Using a public key embedded in the same untrusted response as the trust anchor.
- Removing nulls, normalizing Unicode or rewriting numbers before checking the signature.
- Treating signature_valid as permission to use an expired or revoked snapshot.

## Security notes
The public signature prevents undetected edits under a trusted key. It does not encrypt customer information, confirm a buyer payment, make the local clock trustworthy or prove a response is the newest available state. Keep raw caches private and avoid logging customer payloads or [activation tokens](/docs/v1/start/glossary#activation-token).

## Troubleshooting
| Symptom | Likely cause | Safe next step |
| --- | --- | --- |
| Only integral-price responses fail | A JSON round trip changed 19.0 to 19 | Preserve original numeric tokens; run the integral-float vector |
| Unicode works online but not from cache | The cache changed string escaping or the client canonicalizer preserved escaped raw strings | Run Unicode vectors; reject the cached result and investigate the known SDK discrepancy |
| New responses use an unknown key_id | A key changed or the response is untrusted | Follow the deliberate key-refresh/pin-update policy; never bypass verification |
| Signature passes but access fails | Expiry, status, environment, product or installation checks rejected it | Explain the actual license state to the customer and validate online when appropriate |

## Next steps
Use [the complete verification samples](/docs/v1/security/verify-ed25519), then run [every test vector](/docs/v1/security/test-vectors).

## Related
- [Offline mode and grace](/docs/v1/security/offline-mode)
- [Entitlements and features](/docs/v1/security/entitlements-and-features)
- [Threat model](/docs/v1/security/threat-model)
