API Reference
Version: v1
Base URL: configured via LICENSING_API_BASE env variable (e.g., https://licensing.example.com)
Content-Type: All request bodies must be application/json. All responses are application/json.
Table of Contents
- Authentication and Request Signing
- Endpoints
- Webhook Events (Inbound)
- Error Code Reference
- Rate Limits
1. Authentication and Request Signing
All /api/v1/* routes (except the signed download URL) require HMAC-SHA256 request signing. There is no API key header or Bearer token; the signature itself proves both identity and request integrity.
1.1 Credentials
Each product registered in the admin panel has two values the client needs:
| Credential | Where to find it | Notes |
|---|---|---|
product_id |
Admin panel โ Products โ slug field | URL-safe lowercase string, e.g. raftaar-commerce |
product_secret |
Admin panel โ Products โ copy secret button | 64-character hex string. Never expose in responses or logs. |
The product_secret is an HMAC signing key. It proves the request was generated by a party that holds the secret for that specific product. Treat it like a private key.
1.2 Payload Format
The canonical payload is a pipe-delimited string built from request fields. Two forms exist:
Without nonce (minimum viable):
{product_id}|{domain}|{timestamp}
With nonce (recommended โ provides per-request replay prevention):
{product_id}|{domain}|{timestamp}|{nonce}
Where:
product_idโ the product slug exactly as sent in the request bodydomainโ the normalised domain exactly as sent in the request body (the server normalises it the same way before verifying; see Section 1.5)timestampโ Unix timestamp as a decimal integer string (not milliseconds)nonceโ an arbitrary unique string per request (UUID v4 or random hex recommended)
1.3 Required HTTP Headers
| Header | Type | Required | Description |
|---|---|---|---|
X-Timestamp |
integer | Yes | Unix timestamp (seconds since epoch). Must be within ยฑ5 minutes of server time. |
X-Signature |
string | Yes | Lowercase hex-encoded HMAC-SHA256 digest of the canonical payload. |
X-Nonce |
string | No | Per-request unique token. When provided, it is incorporated into the payload AND stored server-side to block replay attacks. |
Content-Type |
string | Yes | Must be application/json |
1.4 Timestamp Tolerance Window
The server rejects requests where abs(server_time - X-Timestamp) > 300 seconds (5 minutes). This is configurable via LICENSING_TIMESTAMP_WINDOW but defaults to 300.
Clients must keep their system clocks reasonably synchronised (NTP). A drift of more than 5 minutes will cause all requests to fail with INVALID_SIGNATURE.
1.5 Domain Normalisation
Before building the payload, the client must normalise the domain the same way the server does:
domain = lowercase(raw_domain)
domain = strip_prefix(domain, "https://")
domain = strip_prefix(domain, "http://")
domain = strip_prefix(domain, "www.")
domain = split(domain, "/")[0] // remove any path
domain = split(domain, ":")[0] // remove any port
domain = trim(domain)
Examples:
https://www.example.com/shopโexample.comhttp://staging.mysite.org:8080โstaging.mysite.orgWWW.EXAMPLE.COMโexample.comlocalhostโlocalhost127.0.0.1โ127.0.0.1
Note: localhost and 127.0.0.1 are valid domains and DO consume activation slots.
1.6 Nonce Purpose and TTL
When X-Nonce is provided:
- The server performs an atomic compare-and-store using
Cache::add()(Redis or similar) - The nonce is stored for 10 minutes (configurable via
LICENSING_NONCE_TTL, default600seconds) - A second request with the same nonce for the same product within 10 minutes returns
INVALID_SIGNATURE - This prevents an attacker who captures a valid signed request from replaying it
If X-Nonce is omitted, only the timestamp window provides replay protection (a 10-minute attack window exists). Always provide X-Nonce in production clients.
1.7 Step-by-Step Signing Algorithm
FUNCTION build_signed_headers(product_id, product_secret, domain, nonce=null):
// Step 1: Normalise the domain
normalised_domain = normalise_domain(domain)
// Step 2: Get current Unix timestamp (integer seconds)
timestamp = unix_timestamp_now()
// Step 3: Build the canonical payload string
IF nonce IS NOT NULL AND nonce IS NOT EMPTY:
payload = product_id + "|" + normalised_domain + "|" + str(timestamp) + "|" + nonce
ELSE:
payload = product_id + "|" + normalised_domain + "|" + str(timestamp)
// Step 4: Compute HMAC-SHA256
// Key: product_secret (raw bytes of the hex string, OR the hex string itself โ
// use the hex string directly as the HMAC key, not decoded to bytes)
signature = lowercase_hex( HMAC_SHA256(key=product_secret, message=payload) )
// Step 5: Return the three headers
RETURN {
"X-Timestamp": str(timestamp),
"X-Signature": signature,
"X-Nonce": nonce // omit header entirely if nonce is null
}
FUNCTION normalise_domain(raw):
d = lowercase(raw)
d = strip_leading("https://", d)
d = strip_leading("http://", d)
d = strip_leading("www.", d)
d = split_on("/", d)[0]
d = split_on(":", d)[0]
RETURN trim(d)
Important implementation note about the HMAC key: The product_secret is used as-is as the HMAC key string. Do not hex-decode it before use. The server calls hash_hmac('sha256', $payload, $product->product_secret) in PHP, which uses the secret as a raw string key.
1.8 Verifying Your Implementation
To verify your signing implementation before integrating, construct a known test vector:
product_id = "test-product"
domain = "example.com"
timestamp = 1700000000
nonce = "abc123"
product_secret = "mysecret"
payload = "test-product|example.com|1700000000|abc123"
expected = HMAC_SHA256(key="mysecret", message=payload)
Compute expected using a trusted tool in your language. Then build a request with X-Timestamp: 1700000000 and verify the server accepts it (note: the server's clock check will reject old timestamps โ use a live timestamp for real verification).
2. Endpoints
2.1 POST /api/v1/license/validate
Validates whether a license is active for a given domain. This is the primary check a product makes to determine if the current installation is licensed.
Authentication: HMAC signature required (see Section 1)
Rate limit: 60 requests per minute per IP
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
product_id |
string | Yes | Product slug, e.g. raftaar-commerce. Must match the product whose secret was used to sign the request. |
domain |
string | Yes | The domain of the installation being validated. Will be normalised server-side. |
product_version |
string | No | Version string of the installed product, e.g. 2.1.0. Stored in metadata but does not affect validation outcome. |
Response Body โ Success (valid license)
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | true |
valid |
bool | true โ license is active and valid |
status |
string | License key status: "active" |
type |
string | License type: "production", "staging", "tester", "developer", or "nfr" |
expires_at |
string|null | ISO-8601 expiry datetime, or null for lifetime licenses |
reauth_required |
bool | true if the user must re-authenticate via portal. See enforcement notes below. |
grace_days_remaining |
int|null | Days remaining in the heartbeat grace period before enforcement kicks in. null if not applicable. |
message |
string | Human-readable status, e.g. "License is valid." |
Response Body โ Failure (invalid license)
HTTP 200 (the HTTP status is always 200 for business-logic failures; check valid and success)
| Field | Type | Description |
|---|---|---|
success |
bool | false |
valid |
bool | false |
error_code |
string | Machine-readable error code. See Section 4. |
message |
string | Human-readable description. |
Business Logic Notes
- The server resolves which license key is associated with the given domain by looking for an active
Activationrecord matching the normalised domain for the given product. - If no activation exists for the domain, the response is
DOMAIN_MISMATCH(the domain has never been activated, or was deactivated). - If the license key is expired, it is automatically marked expired and
KEY_EXPIREDis returned. - If
reauth_requiredistrue, the license is still technicallyvalid=true, but the product must block all features and prompt the user to re-authenticate via the customer portal. The re-auth requirement is triggered either by the admin flagging the key OR by the heartbeat not being received for more than 14 days (configurable viaLICENSING_GRACE_DAYS). - Responses are cached server-side for 5 minutes per license key. A status change (suspend, revoke) will invalidate the cache immediately.
Example Request
POST /api/v1/license/validate HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: a3f2c1d4e5b6a7f8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
X-Nonce: 7f3a8b2c1d4e5f6a
{
"product_id": "raftaar-commerce",
"domain": "example.com",
"product_version": "2.1.0"
}
Example Success Response
{
"success": true,
"valid": true,
"status": "active",
"type": "production",
"expires_at": "2025-12-31T23:59:59+00:00",
"reauth_required": false,
"grace_days_remaining": null,
"message": "License is valid."
}
Example Error Responses
{
"success": false,
"valid": false,
"error_code": "DOMAIN_MISMATCH",
"message": "No active license found for this domain."
}
{
"success": false,
"valid": false,
"error_code": "KEY_EXPIRED",
"message": "License has expired."
}
{
"success": false,
"valid": false,
"error_code": "KEY_SUSPENDED",
"message": "License is suspended."
}
2.2 POST /api/v1/license/heartbeat
Records a heartbeat from an active installation. The heartbeat serves two purposes: it resets the re-authentication countdown, and it returns an update_available flag so the product knows whether to prompt for an update.
Authentication: HMAC signature required
Rate limit: 60 requests per minute per IP
Server-side, heartbeat DB writes are rate-limited to once per 10 minutes per license key regardless of how often this endpoint is called. Calling it more frequently is safe but redundant.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
product_id |
string | Yes | Product slug. |
domain |
string | Yes | Domain of the installation. |
product_version |
string | No | Current installed version. Used to compute update_available. |
metadata |
object | No | Arbitrary key-value pairs stored with the heartbeat record (e.g., PHP version, WooCommerce version). |
Response Body
HTTP 200
The heartbeat response contains all the same fields as the validate response, plus:
| Field | Type | Description |
|---|---|---|
success |
bool | true if the license is valid, false otherwise. |
valid |
bool | Whether the license is currently valid. |
status |
string | License key status. |
type |
string | License type. |
expires_at |
string|null | ISO-8601 expiry or null. |
reauth_required |
bool | Whether re-auth is required. Calling heartbeat with a valid, active installation clears this flag if it was set by the grace period. |
grace_days_remaining |
int|null | Days remaining in grace period. |
update_available |
bool | true if the latest published version differs from product_version in the request. |
latest_version |
string|null | The latest published version string, or null if no version is published. |
message |
string | Human-readable status. |
Business Logic Notes
- A successful heartbeat call sets
last_heartbeat_at = now()and clearsreauth_required = falseon the license key. - The
reauth_requiredflag in the response is the server's current assessment after the heartbeat update. Under normal operation it will befalseimmediately after a successful heartbeat. update_availableistruewhenproduct_version(from the request) does not matchlatest_version(from the product's published version record) AND a latest version exists. The comparison is string equality, not semantic version comparison.- If the domain has no active license, the response contains
valid: falsewitherror_code: DOMAIN_MISMATCHandsuccess: false.
Example Request
POST /api/v1/license/heartbeat HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5
X-Nonce: 9a1b2c3d4e5f6a7b
{
"product_id": "raftaar-commerce",
"domain": "example.com",
"product_version": "2.0.0",
"metadata": {
"php_version": "8.2.0",
"wp_version": "6.4.0"
}
}
Example Success Response
{
"success": true,
"valid": true,
"status": "active",
"type": "production",
"expires_at": "2025-12-31T23:59:59+00:00",
"reauth_required": false,
"grace_days_remaining": null,
"update_available": true,
"latest_version": "2.1.0",
"message": "License is valid."
}
Example Error Response
{
"success": false,
"valid": false,
"error_code": "DOMAIN_MISMATCH",
"message": "Domain not activated.",
"update_available": false,
"latest_version": null
}
2.3 POST /api/v1/license/deactivate
Deactivates the license for a domain, freeing the activation slot. After this, the domain will receive DOMAIN_MISMATCH on subsequent validate/heartbeat calls.
Authentication: HMAC signature required
Rate limit: 60 requests per minute per IP
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
product_id |
string | Yes | Product slug. |
domain |
string | Yes | Domain to deactivate. |
Response Body โ Success
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | true |
activations_remaining |
int | Number of activation slots remaining on the license key after this deactivation. |
message |
string | "Domain deactivated." |
Response Body โ Failure
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | false |
error_code |
string | DOMAIN_MISMATCH โ no active license found for this domain. |
message |
string | Human-readable description. |
Business Logic Notes
- The domain must have an active activation for the given product. If no activation exists (never activated, already deactivated, or wrong product),
DOMAIN_MISMATCHis returned. - Deactivation is recorded in the license event log with source
api. - The activation slot count returned in
activations_remainingreflects the state after deactivation:max_activations - current_active_activations.
Example Request
POST /api/v1/license/deactivate HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
X-Nonce: 1a2b3c4d5e6f7a8b
{
"product_id": "raftaar-commerce",
"domain": "example.com"
}
Example Success Response
{
"success": true,
"activations_remaining": 1,
"message": "Domain deactivated."
}
Example Error Response
{
"success": false,
"error_code": "DOMAIN_MISMATCH",
"message": "No active license found for this domain."
}
2.4 POST /api/v1/license/request-activation
Step 1 of the OTP-based self-service activation flow. The product sends the customer's email address and the target domain. The server finds the best eligible license key for that customer and product, then emails a 6-digit OTP to the customer's registered email address.
This endpoint supports two scenarios:
- Fresh activation: The license has no active domain. The OTP confirms activation on the new domain.
- Transfer: The license is already active on a different domain. The OTP confirms transferring the license to the new domain (deactivating the old one atomically).
Authentication: HMAC signature required
Rate limit: 3 requests per 5 minutes per IP (stricter than other endpoints to prevent OTP spam)
Request Body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
product_id |
string | Yes | โ | Product slug. |
domain |
string | Yes | โ | Target domain for activation. |
email |
string | Yes | Valid email format | Email address of the customer who owns the license. Must match an account in the system. |
Response Body โ Success
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | true |
type |
string | "activate" โ fresh activation; "transfer" โ moving from another domain; "already_active" โ this domain is already active on the license (no OTP needed). |
current_domain |
string|null | The domain currently active on the license. null if no domain is currently active. For "already_active", this is the same as the requested domain. |
message |
string | Human-readable description, e.g. "A 6-digit code has been sent to user@example.com." |
Response Body โ Failure
HTTP 422
| Field | Type | Description |
|---|---|---|
success |
bool | false |
error_code |
string | See Section 4. |
message |
string | Human-readable description. |
Business Logic Notes
- The server looks up the customer by exact email match (case-insensitive). If no customer account exists,
CUSTOMER_NOT_FOUNDis returned. - The server selects the best eligible license key using a priority order: production โ staging โ tester โ developer โ nfr. Among equal-priority keys, it prefers keys with no active domain (ready to activate immediately without a transfer).
- If no active, non-expired license exists for this customer and product,
NO_ELIGIBLE_LICENSEis returned. - The OTP is a 6-digit zero-padded numeric string (e.g.
042817), cached for 10 minutes with a maximum of 5 attempts before it is invalidated. - If the domain is already active on the selected license,
type: "already_active"is returned withsuccess: trueand no OTP is sent. The caller can treat this as an already-complete activation.
Example Request
POST /api/v1/license/request-activation HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7
X-Nonce: 2b3c4d5e6f7a8b9c
{
"product_id": "raftaar-commerce",
"domain": "newsite.com",
"email": "customer@example.com"
}
Example Responses
Fresh activation:
{
"success": true,
"type": "activate",
"current_domain": null,
"message": "A 6-digit code has been sent to customer@example.com. Enter it to activate your license on newsite.com."
}
Transfer:
{
"success": true,
"type": "transfer",
"current_domain": "oldsite.com",
"message": "A 6-digit code has been sent to customer@example.com. Enter it to transfer your license from oldsite.com to newsite.com."
}
Already active:
{
"success": true,
"type": "already_active",
"current_domain": "newsite.com",
"message": "This domain is already active on your license."
}
Customer not found:
{
"success": false,
"error_code": "CUSTOMER_NOT_FOUND",
"message": "No account found with that email address."
}
2.5 POST /api/v1/license/confirm-activation
Step 2 of the OTP flow. The customer enters the 6-digit code they received by email. The server verifies it and completes the activation or transfer.
Authentication: HMAC signature required
Rate limit: 10 requests per 5 minutes per IP
Request Body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
product_id |
string | Yes | โ | Product slug. |
domain |
string | Yes | โ | Target domain (must match the domain used in request-activation). |
email |
string | Yes | Valid email format | The same email used in request-activation. |
otp |
string | Yes | Exactly 6 characters | The 6-digit code from the email. |
Response Body โ Success
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | true |
type |
string | "activated" or "transferred" |
domain |
string | The domain that was activated. |
expires_at |
string|null | ISO-8601 expiry datetime, or null for lifetime licenses. |
message |
string | Human-readable confirmation. |
Response Body โ Failure
HTTP 422
| Field | Type | Description |
|---|---|---|
success |
bool | false |
error_code |
string | See Section 4. |
message |
string | Human-readable description, including remaining attempts where applicable. |
Business Logic Notes
- OTP lookup is keyed on
SHA-256(product_slug + ":" + email + ":" + normalised_domain). If any of these three values do not match what was used inrequest-activation, the lookup will returnOTP_EXPIRED. - After 5 incorrect OTP attempts, the session is invalidated and
OTP_MAX_ATTEMPTSis returned. The user must callrequest-activationagain. - On success:
- For transfers: the old domain is deactivated first, then the new domain is activated. This is not atomic in the database sense, but the deactivation happens before the activation attempt.
- The event is logged as
activated(new activation) ortransferred(domain swap). - The client should clear any locally cached validation state after a successful confirmation.
- The OTP expires after 10 minutes regardless of attempt count.
Example Request
POST /api/v1/license/confirm-activation HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8
X-Nonce: 3c4d5e6f7a8b9c0d
{
"product_id": "raftaar-commerce",
"domain": "newsite.com",
"email": "customer@example.com",
"otp": "042817"
}
Example Success Response
{
"success": true,
"type": "activated",
"domain": "newsite.com",
"expires_at": "2025-12-31T23:59:59+00:00",
"message": "License activated on newsite.com."
}
Example Error Responses
{
"success": false,
"error_code": "OTP_INVALID",
"message": "Incorrect code. 3 attempt(s) remaining."
}
{
"success": false,
"error_code": "OTP_EXPIRED",
"message": "Code expired or not found. Please request a new one."
}
{
"success": false,
"error_code": "OTP_MAX_ATTEMPTS",
"message": "Too many incorrect attempts. Please request a new code."
}
2.6 GET /api/v1/product/integrity
Returns the file integrity manifest for a specific product version. Products can use this to verify that their installed files have not been tampered with.
Authentication: HMAC signature required (sent as query parameters or headers โ headers are required since GET requests have no body; send product_id and domain as query parameters alongside the signed headers)
Rate limit: 60 requests per minute per IP
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
product_id |
string | Yes | Product slug. Also used by the signature middleware to resolve the product. |
domain |
string | Yes | Domain of the installation. Used by the signature middleware for verification. |
version |
string | No | Version string to retrieve the manifest for. Defaults to the product's latest_version if not provided. |
Note on signing GET requests: Since there is no JSON body, product_id and domain must be sent as query parameters. The middleware reads $request->input('product_id', '') and $request->input('domain', ''), which works for both query strings and JSON bodies in Laravel. The HMAC payload is still {product_id}|{normalised_domain}|{timestamp}[|{nonce}] built from these query parameter values.
Response Body โ Success
HTTP 200
| Field | Type | Description |
|---|---|---|
success |
bool | true |
version |
string | The version this manifest applies to. |
generated_at |
string | ISO-8601 timestamp when the manifest was generated. |
files |
object | Map of relative file path โ SHA-256 hash of that file's contents. |
Response Body โ Failure
HTTP 400 โ version not specified and product has no latest version
HTTP 404 โ no integrity manifest exists for the specified version
{
"success": false,
"message": "No integrity manifest found for this version."
}
Example Request
GET /api/v1/product/integrity?product_id=raftaar-commerce&domain=example.com&version=2.1.0 HTTP/1.1
Host: licensing.example.com
X-Timestamp: 1700000000
X-Signature: f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
X-Nonce: 4d5e6f7a8b9c0d1e
Example Success Response
{
"success": true,
"version": "2.1.0",
"generated_at": "2024-11-15T10:30:00+00:00",
"files": {
"functions.php": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"includes/core.php": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
"style.css": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4"
}
}
2.7 POST /api/v1/update-check
Checks whether a newer version of the product is available for the given domain's license. Returns a short-lived signed download URL if an update is available.
Authentication: HMAC signature required
Rate limit: 12 requests per hour per IP (stricter than other endpoints)
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
product_id |
string | Yes | Product slug. |
domain |
string | Yes | Domain of the installation. Must have an active license. |
current_version |
string | Yes | Currently installed version string. |
Response Body โ No Update Available
HTTP 200
{
"update_available": false,
"latest_version": "2.1.0"
}
Or if already on the latest:
{
"update_available": false,
"latest_version": "2.1.0"
}
Response Body โ Update Available
HTTP 200
| Field | Type | Description |
|---|---|---|
update_available |
bool | true |
latest_version |
string | The latest published version string. |
release_name |
string|null | Human-readable release name, e.g. "Winter 2024 Update". |
released_at |
string|null | Date string (YYYY-MM-DD) when this version was published. |
changelog_url |
string|null | URL to the changelog page for this product. |
download_url |
string | A signed URL for downloading the update package. Valid for 1 hour from the time this response was generated. |
requires |
null | Reserved for future use. Currently always null. |
tested |
null | Reserved for future use. Currently always null. |
Response Body โ Errors
HTTP 403 โ No active license for this domain:
{
"update_available": false,
"error": "no_active_license"
}
HTTP 403 โ License exists but is not valid:
{
"update_available": false,
"error": "license_invalid"
}
HTTP 404 โ Product not found or not active:
{
"update_available": false,
"error": "product_not_found"
}
Business Logic Notes
- The endpoint independently validates the license before returning update information. A suspended, revoked, or expired license will receive
license_invalidand no download URL. - Version comparison uses PHP's
version_compare()semantics. Ifcurrent_version >= latest_version,update_available: falseis returned even if the versions are different strings. - The
download_urlis a Laravel temporally-signed URL. It embeds a cryptographic signature and expiry in the URL itself. It does not require HMAC headers when accessed โ the URL signature is the sole auth mechanism. - The download URL expires in 1 hour. If the client needs to download after expiry, it must call this endpoint again to get a fresh URL.
Example Request
POST /api/v1/update-check HTTP/1.1
Host: licensing.example.com
Content-Type: application/json
X-Timestamp: 1700000000
X-Signature: a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
X-Nonce: 5e6f7a8b9c0d1e2f
{
"product_id": "raftaar-commerce",
"domain": "example.com",
"current_version": "2.0.0"
}
Example Success Response (update available)
{
"update_available": true,
"latest_version": "2.1.0",
"release_name": "Winter 2024 Update",
"released_at": "2024-11-15",
"changelog_url": "https://licensing.example.com/docs/changelog/raftaar-commerce",
"download_url": "https://licensing.example.com/api/v1/update/download/raftaar-commerce/2.1.0/42?expires=1700003600&signature=abc123...",
"requires": null,
"tested": null
}
2.8 GET /api/v1/update/download/{product}/{version}/{license}
Downloads the update package (ZIP file) for a given product version. This URL is obtained from the download_url field in the update-check response and is valid for 1 hour.
Authentication: Laravel signed URL (embedded in the URL itself via signature and expires query parameters). No HMAC headers required.
Rate limit: No explicit rate limit beyond the signed URL's 1-hour TTL.
URL Parameters
| Parameter | Type | Description |
|---|---|---|
{product} |
string | Product slug. |
{version} |
string | Version string to download. |
{license} |
integer | Internal license key ID. |
These parameters are embedded in the URL returned by update-check. Clients should not construct this URL manually โ always use the URL provided in the update-check response.
Query Parameters (auto-appended by server)
| Parameter | Description |
|---|---|
expires |
Unix timestamp when the URL expires. |
signature |
URL signature hash. |
Response โ Success
HTTP 200 โ Binary file download
The response is a file download (streamed). The Content-Disposition header will contain the filename (e.g. raftaar-commerce-2.1.0.zip).
Response โ Errors
HTTP 403 โ "Download link has expired. Please check for updates again." โ Returned when the URL signature is invalid or the expiry time has passed.
HTTP 403 โ Returned if the license is not active.
HTTP 404 โ Returned if the product version or file does not exist.
Example Usage
Use the download_url returned by update-check directly in an HTTP GET request. No additional headers are needed.
GET /api/v1/update/download/raftaar-commerce/2.1.0/42?expires=1700003600&signature=abc123... HTTP/1.1
Host: licensing.example.com
3. Webhook Events (Inbound)
These endpoints receive payment events from Stripe and PayPal respectively. They are called by the payment processors, not by product clients.
3.1 POST /webhooks/stripe
Receives and processes webhook events from Stripe.
Authentication: Stripe webhook signature verification using the Stripe-Signature header and the configured STRIPE_WEBHOOK_SECRET.
Idempotency: The server checks for an existing Order record with payment_reference = session.id before processing. Duplicate events are silently ignored (returns HTTP 200).
Events Handled
| Stripe Event | Action |
|---|---|
checkout.session.completed |
Creates an Order record, dispatches IssueLicenseJob to issue a new license key. |
charge.refunded |
Full refunds only: marks the order as refunded, revokes the associated license key, rejects any pending affiliate commission. |
All other event types are silently ignored with HTTP 200.
Event Processing Details
checkout.session.completed:
- Requires
payment_status === "paid"on the session object; otherwise the event is ignored. - Reads
session.metadatafor:customer_id,pricing_plan_id,renewal_of_license_id(optional),affiliate_id(optional),affiliate_source(optional),promo_code(optional). - If
renewal_of_license_idis present, the license issuance logic will auto-transfer the active domain from the old key to the new key and mark the old key expired. - License issuance is dispatched as a background job (
IssueLicenseJob). The webhook returns 200 immediately; the license is issued asynchronously.
charge.refunded:
- Partial refunds (
amount_refunded < amount) are ignored. - Looks up the order by
charge.idfirst, then bycharge.payment_intentas a fallback. - If the order is already in
refundedstatus, the event is ignored (idempotency). - Revocation sends an email to the customer and dispatches a
license.revokedwebhook to any registered listener.
Signature Verification
Stripe sends a Stripe-Signature header containing the HMAC-SHA256 signature of the raw request body, plus a timestamp to prevent replays. The server uses the official Stripe SDK's Webhook::constructEvent() which handles verification automatically. Invalid signatures return HTTP 400.
Response
HTTP 200 โ empty body, on success or silently-ignored events.
HTTP 400 โ "Invalid signature" or "Invalid payload".
Example Headers (from Stripe)
POST /webhooks/stripe HTTP/1.1
Stripe-Signature: t=1700000000,v1=abc123...,v0=def456...
Content-Type: application/json
3.2 POST /webhooks/paypal
Receives and processes webhook events from PayPal.
Authentication: PayPal webhook signature verification via the PayPal REST API (/v1/notifications/verify-webhook-signature). Requires PAYPAL_WEBHOOK_ID to be configured.
Idempotency: The server checks for an existing Order with payment_reference = resource.id before processing.
Events Handled
| PayPal Event | Action |
|---|---|
CHECKOUT.ORDER.APPROVED |
Creates an Order record, dispatches IssueLicenseJob. |
PAYMENT.CAPTURE.COMPLETED |
Same as above โ handles the capture completion event. |
All other event types are silently ignored with HTTP 200.
Event Processing Details
PAYMENT.CAPTURE.COMPLETED / CHECKOUT.ORDER.APPROVED:
- Uses
resource.idas thepayment_reference(idempotency key). Falls back toresource.supplementary_data.related_ids.order_idifresource.idis not available. - Custom metadata is passed via
resource.custom_idas a JSON string containing:customer_id,pricing_plan_id,renewal_of_license_id(optional),affiliate_id(optional),affiliate_source(optional),promo_code(optional). - Amount is read from
resource.amount.valueorresource.seller_receivable_breakdown.gross_amount.value.
Signature Verification
PayPal does not use a simple HMAC header. Instead, the server:
- Reads PayPal-specific headers:
PAYPAL-AUTH-ALGO,PAYPAL-CERT-URL,PAYPAL-TRANSMISSION-ID,PAYPAL-TRANSMISSION-SIG,PAYPAL-TRANSMISSION-TIME. - Calls the PayPal REST API (
POST /v1/notifications/verify-webhook-signature) with these headers and the event body. - Accepts the event only if the API returns
verification_status: "SUCCESS".
In local development environments, if PAYPAL_WEBHOOK_ID is not configured, verification is skipped.
An OAuth 2 access token for the PayPal API is cached for 8 hours to avoid redundant authentication calls on every webhook.
Response
HTTP 200 โ empty body, on success or silently-ignored events.
HTTP 400 โ "Invalid signature".
4. Error Code Reference
All error codes appear in the error_code field of the response JSON. HTTP status codes for API endpoints are noted in the table below; note that business-logic errors from the license endpoints typically return HTTP 200 with success: false and valid: false.
| Error Code | HTTP Status | Meaning | Client Action |
|---|---|---|---|
PRODUCT_MISMATCH |
401 | The product_id in the request body does not match any active product. |
Verify the product slug is correct. |
INVALID_SIGNATURE |
401 | The HMAC signature is incorrect, the timestamp is outside the ยฑ5 minute window, or the nonce has already been used. | Verify the signing implementation. Check system clock synchronisation. Generate a new nonce per request. |
DOMAIN_BLACKLISTED |
403 | The domain has been added to a server-side blacklist by an administrator. | Contact the licensing server operator. |
DOMAIN_MISMATCH |
200 | No active activation exists for this domain under the given product. The domain was never activated, or was previously deactivated. | Prompt the user to activate via the portal or use the OTP activation flow. |
KEY_EXPIRED |
200 | The license key has passed its expires_at date. |
Prompt the user to renew. Do not treat as a hard block if an offline grace period applies. |
KEY_SUSPENDED |
200 | The license has been administratively suspended. | Block features silently (gradual degradation). Do not show "License invalid" to end users. Contact support. |
KEY_REVOKED |
200 | The license has been permanently revoked (e.g., after a refund). | Block features silently (gradual degradation). Do not show "License invalid" to end users. |
REAUTH_REQUIRED |
200 | Re-authentication via the customer portal is required. Returned when valid: true but reauth_required: true. |
Show the re-auth prompt banner. Block all features. Do not use the phrase "not licensed." |
CUSTOMER_NOT_FOUND |
422 | The email address provided in request-activation does not match any customer account. |
Show a message asking the user to check their email or register. |
NO_ELIGIBLE_LICENSE |
422 | The customer has no active, non-expired license for this product. | Direct the user to purchase or renew. |
OTP_EXPIRED |
422 | The OTP session has expired (10-minute TTL) or was never initiated for this email/domain combination. | Ask the user to request a new code. |
OTP_INVALID |
422 | The submitted OTP is incorrect. The response includes the number of remaining attempts. | Show the remaining attempts to the user. |
OTP_MAX_ATTEMPTS |
422 | The maximum of 5 OTP attempts has been exhausted. The session is invalidated. | Ask the user to request a new code from the beginning. |
LICENSE_UNAVAILABLE |
422 | The license key was deactivated or changed status between the OTP request and the OTP confirmation. | Ask the user to contact support. |
5. Rate Limits
Rate limiting is applied per IP address. When a rate limit is exceeded, the server returns HTTP 429 with a JSON body:
{
"message": "Too Many Requests."
}
The response includes Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining headers where supported by the Laravel rate limiter.
| Endpoint | Limit | Window | Notes |
|---|---|---|---|
POST /api/v1/license/validate |
60 requests | 1 minute | Standard license check rate |
POST /api/v1/license/heartbeat |
60 requests | 1 minute | Server-side DB writes are additionally throttled to once per 10 min per license key |
POST /api/v1/license/deactivate |
60 requests | 1 minute | โ |
POST /api/v1/license/request-activation |
3 requests | 5 minutes | Strict to prevent OTP email spam |
POST /api/v1/license/confirm-activation |
10 requests | 5 minutes | Per-session OTP attempt limit (5) is separate from this IP rate limit |
GET /api/v1/product/integrity |
60 requests | 1 minute | โ |
POST /api/v1/update-check |
12 requests | 60 minutes | Intentionally low โ update checks should not be frequent |
GET /api/v1/update/download/{...} |
None | โ | Protected by 1-hour signed URL TTL instead |
Advisory for client implementers:
- Schedule heartbeats at 24-hour intervals; calling more often does not update the server more often (10-minute internal throttle) and wastes the 60/min budget.
- Cache validate responses locally for up to 24 hours. The license state rarely changes mid-day.
- Call update-check at most once per day. The 12/hour limit means a site calling update-check every 5 minutes would exhaust the limit in 1 hour.
- Implement exponential back-off when you receive 429 responses. Start at 60 seconds, double on each retry, cap at 30 minutes.