KEYFLOW

Documentation

Integrating Keyflow

Beta

Keyflow issues and validates your software's licence keys. Two ways to use it, and the first needs no code at all.

Getting started — 5 minutes

01

Create your account

On the sign-up page, or through the API. The response contains your API key: it is shown only once.

curl -X POST https://appkeyflow.com/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"toi@exemple.fr","password":"un-mot-de-passe-solide"}'
{
  "user": { "id": "bb60bdd1-…", "email": "toi@exemple.fr",
            "subscription_status": "free" },
  "api_key": {
    "token_prefix": "kf_live_BBrZRi",
    "token": "kf_live_BBrZRiTv50MnbcD09Hk0LCwn5ciXCAhhd5jjpQIGOFI"
  }
}
02

Declare your product

curl -X POST https://appkeyflow.com/v1/products \
  -H "X-API-Key: kf_live_…" -H "Content-Type: application/json" \
  -d '{"name":"Mon Logiciel","default_activation_limit":1}'

The slug is derived from the name if you do not supply one. That is what you will use everywhere afterwards.

03

Issue your first key

curl -X POST https://appkeyflow.com/v1/licenses \
  -H "X-API-Key: kf_live_…" -H "Content-Type: application/json" \
  -d '{"product_slug":"mon-logiciel","customer_email":"acheteur@exemple.fr"}'
{
  "id": "c06ee4ba-…",
  "key": "P914-F9Q3-J01F-WHP1",
  "key_masked": "P914-****-****-WHP1",
  "status": "active",
  "activation_limit": 1,
  "activations_used": 0
}
The key in the clear appears only here. After that, the API returns only key_masked. Keyflow keeps an encrypted copy: to retrieve it you must go through POST /v1/licenses/{id}/reveal.

Main route — keys go out on their own

You point your payment provider's webhook at a Keyflow address. On every completed purchase the key is issued and shown to your buyer. You write nothing: no server, no email, no scheduled job.

What you do, once

  • In Keyflow, Sales tab: create a sales channel on your product.
  • In Stripe, create an endpoint pointing at the address shown, subscribed to checkout.session.completed.
  • Paste that endpoint's whsec_ into Keyflow.
  • Set your payment's return URL to:
https://appkeyflow.com/r/{CHECKOUT_SESSION_ID}

Stripe replaces {CHECKOUT_SESSION_ID} with the sale's identifier. Your buyer lands on a page showing their key, right after paying.

Two Stripe accounts, two secrets. The whsec_ asked for here is the one from your account, where your customers pay. It has nothing to do with your Keyflow subscription.

Selling several offers of the same software

Create one sales channel per offer, on the same product, overriding the machine count: one for “1 seat”, one for “3 seats”. Nothing else to configure.

If a sale fails

The Sales tab shows the issue counter and the last error. A sale that exceeds your plan's limits is issued anyway — your customer paid, they should not bear the cost — then flagged.

Alternative route — from your own server

If you already have a backend, or if you sell somewhere other than Stripe, call the API after each payment. That is five lines in your order handler:

import requests

def livrer_licence(email_acheteur):
    reponse = requests.post(
        "https://appkeyflow.com/v1/licenses",
        headers={"X-API-Key": CLE_API},
        json={"product_slug": "mon-logiciel", "customer_email": email_acheteur},
        timeout=10,
    )
    reponse.raise_for_status()
    return reponse.json()["key"]        # → "P914-F9Q3-J01F-WHP1"
const reponse = await fetch("https://appkeyflow.com/v1/licenses", {
  method: "POST",
  headers: { "X-API-Key": CLE_API, "Content-Type": "application/json" },
  body: JSON.stringify({ product_slug: "mon-logiciel",
                         customer_email: emailAcheteur }),
});
const { key } = await reponse.json();   // → "P914-F9Q3-J01F-WHP1"

It is then up to you to send that key to your customer. With the automatic route, Keyflow does it for you.

Validating the key inside your software

This is the only public call: no API key, since it comes from your customer's machine.

curl -X POST https://appkeyflow.com/v1/validate \
  -H "Content-Type: application/json" \
  -d '{"key":"P914-F9Q3-J01F-WHP1","device_id":"poste-bureau-01"}'
{
  "valid": true,
  "reason": "ok",
  "message": "Licence valide.",
  "license": {
    "product_slug": "mon-logiciel", "product_name": "Mon Logiciel",
    "status": "active", "expires_at": null,
    "activation_limit": 1, "activations_used": 1
  }
}
/validate always answers 200, even when it refuses. The verdict is in valid, the cause in reason.

This is deliberate: a 403 would be indistinguishable from a network outage, and your software could not tell “licence refused” from “server unreachable”. A real failure shows up as a 5xx, or as no answer at all.

The eight possible reasons

  • ok — valid
  • malformed_key — invalid format, refused without querying the database
  • not_found — unknown key
  • revoked — revoked for good
  • suspended — suspended, reversible
  • expired — past its date
  • product_mismatch — key belongs to another product
  • activation_limit_reached — all machine slots are taken

Constraints

  • device_id8 characters minimum. Below that, the API answers 422.
  • It must be stable across restarts, otherwise every launch consumes a machine slot.
  • Optional fields: device_label, platform, app_version, product_slug.

Refusal: one machine too many

{
  "valid": false,
  "reason": "activation_limit_reached",
  "message": "Limite d'activations atteinte (1/1 machines). Libere une machine avant d'en activer une nouvelle.",
  "license": { "status": "active", "activation_limit": 1, "activations_used": 1 }
}

Refusal: revoked licence

{
  "valid": false,
  "reason": "revoked",
  "message": "Cette licence a ete revoquee. Contacte le support.",
  "license": { "status": "revoked", "activation_limit": 1, "activations_used": 1 }
}

The SDKs — what they save you writing

Calling /validate by hand works, but leaves you three problems: building a stable machine identifier, not cutting your customer off when their connection drops, and stopping them editing a local cache to extend their licence. The SDKs handle all three.

Python

from keyflow import Keyflow

licences = Keyflow(
    base_url="https://appkeyflow.com",
    product_slug="mon-logiciel",
    app_name="MonLogiciel",
    grace_days=7,
)

resultat = licences.check(cle_saisie_par_le_client)
if not resultat.allowed:
    print(resultat.message)      # message prêt à afficher
    raise SystemExit(1)

JavaScript · Node and Electron

const { Keyflow } = require("./keyflow");

const licences = new Keyflow({
  baseUrl: "https://appkeyflow.com",
  productSlug: "mon-logiciel",
  appName: "MonLogiciel",
  graceDays: 7,
});

const resultat = await licences.check(cleSaisie);
if (!resultat.allowed) { console.error(resultat.message); process.exit(1); }

What they do for you

  • Stable machine identifierMachineGuid on Windows, IOPlatformUUID on macOS, /etc/machine-id on Linux. Hashed with your product before sending: Keyflow never receives the raw identifier.
  • Offline tolerance — the last verdict is cached. Adjustable through grace_days, 7 days by default. At 0, a connection is required at every start.
  • Signed cache — with HMAC. Editing the file to extend a licence invalidates the signature.
  • release() — frees the current machine, for a customer changing computer.

C# · .NET 8, WPF, WinForms

using Keyflow;

using var licences = new KeyflowClient(new KeyflowOptions
{
    BaseUrl = "https://appkeyflow.com",
    ProductSlug = "mon-logiciel",
    AppName = "MonLogiciel",
    GraceDays = 7,
});

var resultat = await licences.CheckAsync(cleSaisie);
if (!resultat.Allowed) { MessageBox.Show(resultat.Message); return; }

No NuGet package to install: HttpClient and System.Text.Json are part of the runtime. Copy the file into your project.

Reference

Every management route expects the X-API-Key header. Only POST /v1/validate is public.

EndpointPurpose
POST /v1/auth/signupCreate an account, receive the first API key
GET /v1/meCheck your token
POST /v1/productsDeclare a product
GET /v1/productsList your products
POST /v1/licensesIssue a key
GET /v1/licensesList, filter, paginate
GET /v1/licenses/{id}Read one
PATCH /v1/licenses/{id}Change limit, expiry, notes
POST /v1/licenses/{id}/revokeRevoke, permanent
POST /v1/licenses/{id}/suspendSuspend, reversible
POST /v1/licenses/{id}/restoreReactivate
POST /v1/licenses/{id}/revealRecover a lost key
GET /v1/licenses/{id}/activationsMachine tracking
POST /v1/activations/releaseFree a machine
POST /v1/validateValidate — public

Machine tracking

{
  "items": [{
    "id": "8c652356-…",
    "device_fingerprint": "c078bdb4",
    "device_label": null, "platform": null, "app_version": null,
    "first_seen_at": "2026-08-29T01:25:29.787498Z",
    "last_seen_at":  "2026-08-29T01:25:29.787498Z",
    "last_ip": "127.0.0.1",
    "released_at": null, "is_active": true
  }],
  "total": 1, "limit": 50, "offset": 0
}

device_label, platform and app_version stay null until your software sends them at validation time. The SDKs fill them in.

Error format

{ "error": { "code": "validation_error",
             "message": "device_id: String should have at least 8 characters" } }

Usual codes: 401 token missing or invalid, 404 not found, 409 conflict, 422 malformed request, 429 rate exceeded, 402 subscription to settle, 403 plan limit reached.
The last two call for different actions: pay, or delete something.

Rate limits

  • /v1/validate — 60 requests/minute per key, 300/minute per IP address

The full OpenAPI schema is available in the interactive reference, once signed in.

Worth knowing

  • Your customers are never cut off. Even if your Keyflow subscription falls into arrears, /validate keeps answering for licences already sold. Only the issuing of new keys is suspended.
  • Revocation stays open even on a suspended account: a security control never depends on a payment.
  • Key format: XXXX-XXXX-XXXX-XXXX, alphabet without I, L, O or U to avoid mistyping. Reading tolerates lowercase, spaces, and O for 0.