Bring your own payments (sale events)
If you already run payments through your own gateway — any provider, any
checkout — Licet can still do the licensing. Instead of Stripe Checkout, your
backend tells Licet about each completed sale with a signed sale event,
and Licet provisions the licence exactly as it would for an integrated
checkout: same provisioning core, same licence.created webhook, same SDK
experience for your customers.
Licet never touches the money in this lane; its fee is metered against the sale amounts you report.
:::caution Early access The request-signing secret for this lane isn't self-service in the portal yet. The contract below is stable to build against, but talk to us before going live on it. :::
The request
curl -X POST https://api.licet.app/v1/apps/{app_id}/sale-events \
-H "Authorization: Bearer sk_live_…" \
-H "Webhook-Signature: t=1752537600,v1=<hex>" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "order-8912-paid",
"external_ref": "your-customer-id",
"plan_id": "pln_01ABC…",
"amount": 4900,
"currency": "usd",
"occurred_at": "2026-07-14T10:00:00Z"
}'
{ "licence_id": "lic_…", "token": "…" }
| Field | Required | Notes |
|---|---|---|
idempotency_key | yes | Your unique id for this sale — an order id works well. See below. |
external_ref | yes | Your identifier for the customer. Stored hashed, never as PII. |
plan_id | send it | The plan to provision. Always send it explicitly — deterministic beats matching. |
amount | yes | Minor units (pence/cents) — 4900 is £49.00. |
currency | no | Defaults to usd. |
occurred_at | no | When the sale happened, ISO 8601. |
The API key needs the issue scope, and the {app_id} in the route must be
the application the key belongs to.
Signing the request
The body is authenticated with the same Stripe-style HMAC scheme Licet uses
for outbound webhooks, in a Webhook-Signature header:
t=<unix seconds>,v1=HMAC-SHA256(secret, "<t>." + raw request body) # hex
- The signature covers the exact raw bytes you send — sign the serialized string, then send that same string.
- The timestamp must be within 5 minutes of Licet's clock; stale requests
get
401 signature_expired, a bad MAC gets401 bad_signature.
string Sign(string secret, string body)
{
var t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var v1 = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{body}")))
.ToLowerInvariant();
return $"t={t},v1={v1}";
}
Idempotency — lean on it
Payment providers deliver their own webhooks at-least-once, so your code will sometimes try to report the same sale twice. That's the design, not a bug:
- Same
idempotency_key, same payload → Licet returns the original result. Safe to retry forever. - Same key, different payload →
409 idempotency_conflict. That's a bug on your side — a key being reused for a different sale.
Use something stable from your payment provider (order id, charge id) rather than a random value, so retries of the same sale naturally converge.
After the sale event
Provisioning emits the same webhooks as the integrated lane — a new customer
produces licence.created, a repeat purchase for the same external_ref
produces licence.plan_changed. Fulfil from those events exactly as in
Handling webhooks, fetching the licence key from
GET /v1/licences/{licence_id} when you need to deliver it.
Refunds have no reporting endpoint in this lane — if you refund a customer on your gateway, act on the licence yourself (revoke or suspend it); the corresponding Licet fee is handled as a billing adjustment.
Errors
| Response | Why |
|---|---|
400 invalid_request | Missing idempotency_key/external_ref, or malformed signature header. |
401 signature_expired | The t timestamp is outside the 5-minute window. |
401 bad_signature | The HMAC doesn't match — usually a body-serialization mismatch. |
403 forbidden | The key belongs to a different application than the route. |
409 idempotency_conflict | Same idempotency_key, different payload. |