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:
| Status | Meaning | How it gets there |
|---|---|---|
active | Valid; tokens refresh normally | Issued via checkout, sale event, or the API |
suspended | Paused, reversibly | Manual suspend, plan archive, failed subscription payments |
revoked | Killed, permanently | Manual revoke, full refund or chargeback |
expired | Ran past its expires_on | Automatic sweep |
retired | Removed from service, terminal | Manual retire |
downgraded | Reduced after a partial refund | Refund 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
| Verb | Transition | Webhook |
|---|---|---|
…/suspend | active → suspended | licence.suspended |
…/reactivate | suspended → active | licence.reactivated |
…/revoke | any → revoked | licence.revoked |
…/retire | any → retired | licence.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.