Vendors

Vendor license keys — integration guide

Packagento delivers signed webhooks to a URL you control whenever a buyer's licence changes. Your endpoint replies with the licence key (or token, seed, signed JWT — whatever your product uses) and Packagento displays it to the buyer next to their licence. This guide covers the envelope shape, each event type, retry behaviour, and a 60-line reference handler in PHP.

On this page

  1. Overview
  2. Lifecycle at a glance
  3. Quick start
  4. Authentication
  5. Request format
  6. Events reference
  7. Response format
  8. Errors
  9. Retries and idempotency
  10. Reference PHP implementation

1. Overview

Packagento is the system of record for the purchase, the buyer, and the entitlement. Your system is the system of record for the actual licence key — the thing that unlocks your product. The webhook is how Packagento asks your system "issue a key for this purchase" and how it learns the key.

For every licence-lifecycle transition Packagento posts an event to your endpoint. Your endpoint reads the event, mints / refreshes / revokes the key on its side, and returns the current key in the response body. Packagento stores the response, shows the key to the buyer next to the licence, and re-delivers if anything else changes about the licence later.

2. Lifecycle at a glance

Seven event types cover the whole licence lifecycle:

Event When it fires
license.created A buyer just bought your package. Mint a fresh key.
license.activated The buyer assigned the licence to a specific project. The event carries the project + domain.
license.renewed A subscription renewed for another period. Refresh the key's validity if your model uses expiring tokens.
license.reassigned The buyer moved a project assignment from one slot to another. The key stays the same; only the project metadata changed.
license.deactivated The buyer un-assigned the licence from a project. The key itself is still alive on the licence; deactivation only releases that activation slot.
license.expired A subscription lapsed. Revoke or downgrade the key per your product's expiration rules.
license.revoked Admin or refund action permanently retired the licence. Revoke the key immediately.

3. Quick start

  1. Sign in to your Packagento vendor account — open Vendor → Webhooks . Enter the production URL your endpoint will live at. The save button publishes the URL immediately; the next licence event fires against it.
  2. Copy the bearer token — the dashboard shows a generated wht_… token. Store it on your server (env var, secret store, whatever your stack uses) and compare it against the X-Packagento-Token header on every incoming POST.
  3. Write a handler — see the reference implementation at the bottom of this page. The handler parses the JSON envelope, picks an action per event_type, and returns the current licence key in the response body so Packagento can show it to the buyer.

4. Authentication

Every webhook POST carries an X-Packagento-Token header. Compare it against the bearer token shown on your Webhooks dashboard and reject any request whose header does not match.

A typical comparison in PHP:

$expected = getenv('PACKAGENTO_WEBHOOK_TOKEN');
$presented = $_SERVER['HTTP_X_PACKAGENTO_TOKEN'] ?? '';
if (!hash_equals($expected, $presented)) {
    http_response_code(401);
    echo json_encode(['error' => ['code' => 'unauthorized', 'message' => 'Bad token']]);
    exit;
}

Token rotation: clicking Rotate in the dashboard issues a fresh token AND keeps the old token valid for 24 hours under the name "previous token". During the grace window, Packagento sends the NEW token in the header; your endpoint should accept either token so a config-push onto your servers does not cause a delivery gap.

5. Request format

Every event uses the same envelope shape:

POST https://your-endpoint.example.com/packagento/webhook
Content-Type: application/json
X-Packagento-Token: wht_…
X-Packagento-Event-Id: 9c4a…-…
X-Packagento-Event-Type: license.created
X-Packagento-Delivery-Attempt: 1
User-Agent: Packagento-Webhook/1.0 (Magento 2.4.8)

{
  "event_id": "9c4a…-…",
  "event_type": "license.created",
  "created_at": "2026-05-25T14:21:09Z",
  "livemode": true,
  "data": {
    "license": {
      "id": 4218,
      "package": "vendor-slug/package-name",
      "license_type": "subscription",
      "expires_at": "2027-05-25T14:21:09Z",
      "max_activations": 3
    },
    "buyer": {
      "customer_id": 1027,
      "email": "[email protected]",
      "company": "Buyer Co."
    },
    "project": null
  }
}

The headers carry the same identifiers as the body — same event_id, same event_type, plus an `X-Packagento-Delivery-Attempt` counter (1 on the first try, 2..7 on retries) so your logs can tell a fresh delivery from a retried one without parsing the body.

6. Events reference

license.created

A buyer just bought your package. The `data.license` block carries the entitlement metadata; `data.buyer` carries the purchaser. There is no project information yet — the buyer assigns the licence to a project later.

Expected response: 200 OK with the newly-minted licence key in the body (see Response format below).

license.activated

The buyer assigned the licence to a specific project. `data.project` is now populated with the project id, name, and the production domain the project will be installed on. Most vendors either no-op on this event (return the existing key unchanged) or bind the key to the domain.

license.renewed

A subscription renewed for another period. `data.license.expires_at` carries the new expiry. If your product uses time-boxed tokens (signed JWT with `exp`, time-limited HMAC, etc.) refresh the token to match the new expiry and return the refreshed token.

license.reassigned

The buyer moved a licence activation from one project slot to another. `data.project` carries the NEW project. The key itself stays the same; only the project metadata changed. Most vendors no-op on this event.

license.deactivated

The buyer un-assigned the licence from a project. The activation slot is now free for the buyer to use elsewhere. The licence itself is still active; only the project binding ended.

license.expired

A subscription lapsed (renewal not paid). Per Packagento's subscription-cutoff policy the buyer keeps access to versions released before the expiry, but no new versions are released to them. Revoke or downgrade the key per your product's expiration rules.

license.revoked

Admin or refund action permanently retired the licence. This is terminal — the licence row will never re-activate. Revoke the key immediately. If your product reports usage telemetry, treat this as the "stop calling home" signal too.

7. Response format

On a 2xx Packagento parses the JSON body and stores the following fields against the licence:

{
  "license_key": "PKG-…-….-….",
  "key_expires_at": "2027-05-25T14:21:09Z",
  "reference_id": "lic_8f2a…"
}
  • license_key — the value displayed to the buyer. Free-form string, ~255 chars max. Pass anything your product accepts as input: hex, base64, JWT, signed envelope.
  • key_expires_at — optional ISO 8601 timestamp; when present, Packagento renders "Expires {date}" next to the key on the buyer's licence page.
  • reference_id — optional opaque id (your internal licence id, perhaps). Stored against the licence so cross-system audits can correlate Packagento and your records.

All three fields are optional. If you return an empty body on a 2xx, Packagento records the success and shows nothing key-related to the buyer.

Customer-fixable errors inside a 2xx

Sometimes your system needs to surface a per-licence problem to the buyer — domain blocked, quota exhausted, requires support contact. Embed an `error` block in the 2xx response:

{
  "error": {
    "code": "domain_blocked",
    "message": "This domain is on our block list. Contact support."
  }
}

Packagento stores the error_code + message on the licence row but, per the v1 spec, does NOT surface them to the buyer. The next successful (error-free) webhook clears them. Treat this as a "leave a breadcrumb in our audit log" channel rather than a buyer-facing UX.

8. Errors

HTTP status decides Packagento's next move:

Status What Packagento does
2xx Success. Body is parsed for licence_key / key_expires_at / reference_id / error. Circuit breaker resets.
4xx Deterministic application error. Event is moved to the errored queue immediately; no retries are attempted. Operator-replay via the dashboard is the recovery path.
5xx / transport error Transient failure. Event is requeued with exponential backoff (see Retries below).
Timeout Treated as a transport error. The request timeout is 10 seconds; design your handler to respond within that window.

9. Retries and idempotency

On a 5xx or transport error, Packagento retries with the following backoff:

Attempt Delay before next try
1 (initial)immediate
260 seconds
35 minutes
430 minutes
52 hours
66 hours
7 (final)24 hours

After the 7th attempt fails Packagento stamps `errored_at` on the event, emails you a "needs attention" notification, and stops retrying. You can replay any errored event from the webhooks dashboard once your endpoint is healthy.

Idempotency

At-least-once delivery means a single event may be POSTed multiple times — retries, manual replays, or a 5xx response from your side that crossed paths with a successful side effect. Use the event_id in the body (or the matching X-Packagento-Event-Id header) as your idempotency key. Persist seen event_ids and short-circuit duplicates with a 200 + the same body as the original response.

Circuit breaker

After five consecutive failures across all events for your vendor, Packagento opens a circuit breaker against your endpoint — new events queue, but Packagento stops POSTing. Every five minutes a single probe re-sends the oldest errored event; a 2xx response closes the breaker and replays everything else queued behind it. Pausing + resuming from the dashboard runs the same flush.

10. Reference PHP implementation

A complete ~60-line handler. Drop it behind a router that maps your webhook URL to this file, or adapt the per-event-type branch to whatever your storage shape is.

<?php
declare(strict_types=1);

// 1. Authenticate the request
$expected = getenv('PACKAGENTO_WEBHOOK_TOKEN') ?: '';
$presented = $_SERVER['HTTP_X_PACKAGENTO_TOKEN'] ?? '';
if (!hash_equals($expected, $presented)) {
    http_response_code(401);
    echo json_encode(['error' => ['code' => 'unauthorized', 'message' => 'Bad token']]);
    exit;
}

// 2. Parse + dedupe
$body = file_get_contents('php://input');
$envelope = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
$eventId = (string) ($envelope['event_id'] ?? '');
$eventType = (string) ($envelope['event_type'] ?? '');
$data = (array) ($envelope['data'] ?? []);

if (alreadyProcessed($eventId)) {
    http_response_code(200);
    echo json_encode(replayCachedResponse($eventId));
    exit;
}

// 3. Dispatch by event_type
$license = (array) ($data['license'] ?? []);
$packagentoLicenseId = (int) ($license['id'] ?? 0);

switch ($eventType) {
    case 'license.created':
        $key = mintKey($packagentoLicenseId, $license);
        $response = [
            'license_key' => $key,
            'key_expires_at' => $license['expires_at'] ?? null,
            'reference_id' => "lic_{$packagentoLicenseId}",
        ];
        break;

    case 'license.renewed':
        $key = refreshKey($packagentoLicenseId, $license['expires_at'] ?? null);
        $response = ['license_key' => $key, 'key_expires_at' => $license['expires_at']];
        break;

    case 'license.activated':
    case 'license.reassigned':
        $project = (array) ($data['project'] ?? []);
        bindKeyToProject($packagentoLicenseId, $project);
        $response = ['license_key' => currentKey($packagentoLicenseId)];
        break;

    case 'license.deactivated':
        // Slot freed; key itself unchanged.
        $response = ['license_key' => currentKey($packagentoLicenseId)];
        break;

    case 'license.expired':
    case 'license.revoked':
        revokeKey($packagentoLicenseId);
        $response = []; // No key to show.
        break;

    default:
        http_response_code(400);
        echo json_encode(['error' => ['code' => 'unknown_event', 'message' => $eventType]]);
        exit;
}

// 4. Persist for replay-safety
markProcessed($eventId, $response);

http_response_code(200);
header('Content-Type: application/json');
echo json_encode($response);

The four helper functions (alreadyProcessed, markProcessed, mintKey, etc.) are stubs — wire them against your database or licensing service. The shape of `$response` is the contract that matters; everything inside the switch is your implementation choice.

See also