โ† Raftaar Products | Raftaar Commerce Docs
Sign in
Raftaar Commerce โ€บ General โ€บ API Reference

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

  1. Authentication and Request Signing
  2. Endpoints
  3. Webhook Events (Inbound)
  4. Error Code Reference
  5. 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 body
  • domain โ€” 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.com
  • http://staging.mysite.org:8080 โ†’ staging.mysite.org
  • WWW.EXAMPLE.COM โ†’ example.com
  • localhost โ†’ localhost
  • 127.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, default 600 seconds)
  • 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 Activation record 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_EXPIRED is returned.
  • If reauth_required is true, the license is still technically valid=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 via LICENSING_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 clears reauth_required = false on the license key.
  • The reauth_required flag in the response is the server's current assessment after the heartbeat update. Under normal operation it will be false immediately after a successful heartbeat.
  • update_available is true when product_version (from the request) does not match latest_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: false with error_code: DOMAIN_MISMATCH and success: 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_MISMATCH is returned.
  • Deactivation is recorded in the license event log with source api.
  • The activation slot count returned in activations_remaining reflects 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_FOUND is 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_LICENSE is 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 with success: true and 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 in request-activation, the lookup will return OTP_EXPIRED.
  • After 5 incorrect OTP attempts, the session is invalidated and OTP_MAX_ATTEMPTS is returned. The user must call request-activation again.
  • 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) or transferred (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_invalid and no download URL.
  • Version comparison uses PHP's version_compare() semantics. If current_version >= latest_version, update_available: false is returned even if the versions are different strings.
  • The download_url is 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.metadata for: customer_id, pricing_plan_id, renewal_of_license_id (optional), affiliate_id (optional), affiliate_source (optional), promo_code (optional).
  • If renewal_of_license_id is 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.id first, then by charge.payment_intent as a fallback.
  • If the order is already in refunded status, the event is ignored (idempotency).
  • Revocation sends an email to the customer and dispatches a license.revoked webhook 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.id as the payment_reference (idempotency key). Falls back to resource.supplementary_data.related_ids.order_id if resource.id is not available.
  • Custom metadata is passed via resource.custom_id as 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.value or resource.seller_receivable_breakdown.gross_amount.value.

Signature Verification

PayPal does not use a simple HMAC header. Instead, the server:

  1. Reads PayPal-specific headers: PAYPAL-AUTH-ALGO, PAYPAL-CERT-URL, PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-SIG, PAYPAL-TRANSMISSION-TIME.
  2. Calls the PayPal REST API (POST /v1/notifications/verify-webhook-signature) with these headers and the event body.
  3. 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.
Raftaar Commerce โ€” Terms ยท Privacy