Skip to main content

Licence operations

Six months in, the day-to-day questions aren't about selling — they're "a customer charged back, now what?", "support wants to pause an account", "we're migrating ten years of old keys". This guide covers the licence lifecycle and the operations you'll actually run.

Lookups need a key with the read scope; the lifecycle verbs need revoke; issuing and importing need issue.

The lifecycle

A licence's status moves through:

StatusMeaningHow it gets there
activeValid; tokens refresh normallyIssued via checkout, sale event, or the API
suspendedPaused, reversiblyManual suspend, plan archive, failed subscription payments
revokedKilled, permanentlyManual revoke, full refund or chargeback
expiredRan past its expires_onAutomatic sweep
retiredRemoved from service, terminalManual retire
downgradedReduced after a partial refundRefund handling

The rules: only an active licence can be suspended; only a suspended one can be reactivated; revoke works from any state (except retired) and there is no un-revoke. Suspend when you might want the licence back — dunning, disputes in progress, plan archival all use it. Revoke when it's over.

The verbs

All are POST with no body, and each emits the matching webhook event:

curl -X POST https://api.licet.app/v1/licences/{licence_id}/suspend \
-H "Authorization: Bearer sk_live_…" # scope: revoke
VerbTransitionWebhook
…/suspendactive → suspendedlicence.suspended
…/reactivatesuspended → activelicence.reactivated
…/revokeany → revokedlicence.revoked
…/retireany → retiredlicence.retired

An illegal transition returns 409 conflict with an explanation. One guard to know about: reactivating a licence whose plan has been archived is refused — restore the plan first.

When does the customer's install notice?

This matters for expectations:

  • Revoked — the SDK polls the revocation list on the cadence the token prescribes, so a revocation lands within that check interval even though the cached token hasn't expired.
  • Suspended / retired / expired — these take effect when the install next refreshes its token: the refresh is refused and the token simply isn't renewed, so access ends at the cached token's own expiry. That's typically fine for a pause; if you need it dead now, revoke.

Refunds and chargebacks

You don't call an API for Stripe-side refunds — Licet watches your connected account. A full refund or dispute revokes the licence; a partial refund downgrades it. Both paths emit refund.processed (with the amount and charge reference) plus the licence event, so your webhook handler sees the whole story. A full refund within the first 7 days also credits back Licet's transaction fee.

If you sell through your own gateway instead, refunds are yours to act on — revoke or suspend the licence yourself when you refund the customer.

Issuing licences directly

Checkout and sale events issue licences for you, but sometimes you need one by hand — a comped licence, an enterprise deal done on an invoice:

curl -X POST https://api.licet.app/v1/apps/{app_id}/licences \
-H "Authorization: Bearer sk_live_…" \
-H "Idempotency-Key: comp-acme-2026" \
-H "Content-Type: application/json" \
-d '{
"external_ref": "customer-123",
"plan_id": "pln_01ABC…",
"restrictions": { "domains": ["acme.example"], "max_activations": 3 },
"expires_on": "2027-07-14T00:00:00Z"
}'
{ "id": "lic_…", "key": "lic_key_…", "status": "active", "plan_id": "pln_…" }

The Idempotency-Key header is required: retrying with the same key and body returns the original licence instead of minting a second one; the same key with a different body is refused (409). Omit expires_on for a perpetual licence. Restrictions — allowed domains, max_activations, max_seats — are optional.

Migrating from an old licensing system

POST /v1/apps/{app_id}/licences/import takes up to 500 items per call, each carrying the old system's key as legacy_key:

{
"items": [
{
"legacy_key": "OLD-KEY-123",
"external_ref": "customer-123",
"plan_id": "pln_…"
}
]
}

The response reports each item as created, existing, or failed with a summary — and because items converge on legacy_key, re-running the same batch is safe. Installs in the field then exchange their legacy key for a Licet licence via the SDK's MigrateAsync(legacyKey) — no customer action needed.

Finding licences

# Filterable list: status, plan, domain, last_seen; cursor pagination
curl "https://api.licet.app/v1/apps/{app_id}/licences?status=active&limit=50" \
-H "Authorization: Bearer sk_live_…" # scope: read

# Everything about one licence: entitlements, restrictions, activations, call-home
curl https://api.licet.app/v1/licences/{licence_id} \
-H "Authorization: Bearer sk_live_…"

The detail view includes recent call-home activity — which hosts checked in, with what product version, and when — useful both for support ("is their install actually running?") and for spotting a licence being used somewhere it shouldn't be.