Skip to main content

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": "…" }
FieldRequiredNotes
idempotency_keyyesYour unique id for this sale — an order id works well. See below.
external_refyesYour identifier for the customer. Stored hashed, never as PII.
plan_idsend itThe plan to provision. Always send it explicitly — deterministic beats matching.
amountyesMinor units (pence/cents) — 4900 is £49.00.
currencynoDefaults to usd.
occurred_atnoWhen 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 gets 401 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

ResponseWhy
400 invalid_requestMissing idempotency_key/external_ref, or malformed signature header.
401 signature_expiredThe t timestamp is outside the 5-minute window.
401 bad_signatureThe HMAC doesn't match — usually a body-serialization mismatch.
403 forbiddenThe key belongs to a different application than the route.
409 idempotency_conflictSame idempotency_key, different payload.