Reference
Vendor API reference
Manage products, plans, licenses, activations, customers, integrations and API keys with a secret key.
The vendor API is what the dashboard uses. Call it from your backend or scripts to create licenses for resellers, look up a customer for support, or automate anything you do in the dashboard.
Authentication#
Create a secret key in the dashboard (API keys → New secret key, see the dashboard guide) or with POST /v1/api-keys. It looks like kr_sk_ followed by 32 characters and is shown once. Keyrail only keeps its SHA-256 hash.
export KEYRAIL_SECRET_KEY="kr_sk_…"
curl https://api.keyrail.tech/v1/account \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"Never ship a secret key inside your app: apps use the client API, which needs no secret. Every object you read or write is scoped to the key’s account.
Conventions#
- Base URL
https://api.keyrail.tech/v1, JSON in and out, timestamps in ISO 8601 UTC (2026-12-01T00:00:00Z). - Lists return
{ "data": [...], "nextCursor": "..." }. Passcursor=<nextCursor>to get the next page;nextCursorisnullon the last one. Pages hold 50 items. - Errors use the same shape as the client API:
{ "error": { "code": "...", "message": "..." } }. Extra codes:401 unauthorized(missing or invalid key),404 not_found,409 conflict(any change to a revoked license, a duplicate plan code or integration),402 plan_limit(creating a product beyond your plan),429 rate_limited(5,000 requests per hour per account). Every code, with fixes: troubleshooting. - Response examples on this page are abridged.
Products#
| Method and path | Purpose |
|---|---|
GET /v1/products | List products. |
POST /v1/products | Create a product. Generates its first signing key, k1. |
GET /v1/products/{id} | Get a product. |
PATCH /v1/products/{id} | Update name, keyPrefix, graceDays, offlineDays. |
curl https://api.keyrail.tech/v1/products \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Notes", "keyPrefix": "ACME", "graceDays": 14, "offlineDays": 30 }'{ "id": "prd_7k2m9q4x1c8d3f6h0j5n", "name": "Acme Notes", "keyPrefix": "ACME", "graceDays": 14, "offlineDays": 30, "createdAt": "2026-10-13T09:00:00Z" }Signing keys#
| Method and path | Purpose |
|---|---|
GET /v1/products/{id}/keys | Public signing keys, oldest first: kid, publicKey (base64url of the raw 32 bytes), status (pending, active or retired), createdAt, activatedAt (null while pending). |
POST /v1/products/{id}/keys | Rotation, phase 1: creates the next key as pending (published, does not sign yet). 409 conflict if a pending key exists. See key rotation. |
POST /v1/products/{id}/keys/{kid}/activate | Rotation, phase 2: the pending key signs from now on and the previous one is retired (still listed and verifiable). Idempotent on the active key; 409 conflict on a retired key. |
POST /v1/products/{id}/keys/export | Every signing key of the product with its private part (seed and PKCS#8), so you can sign certificates yourself. Account owner from the dashboard only: 400 with an API key, 403 forbidden for another role; 10 per hour; audited. See signing without Keyrail. |
curl https://api.keyrail.tech/v1/products/prd_7k2m9q4x1c8d3f6h0j5n/keys \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"{
"data": [
{ "kid": "k1", "publicKey": "pZ3vC0x6hYjQ8m1K4tR9wN2bF7sL5dA0eUqTgHiOcVk", "status": "active",
"createdAt": "2026-10-13T09:00:00Z", "activatedAt": "2026-10-13T09:00:00Z" }
],
"nextCursor": null
}Private keys are never returned here, nor by the account export: only by the owner-only export route above.
Plans#
| Method and path | Purpose |
|---|---|
GET /v1/products/{id}/plans | List a product’s plans. |
POST /v1/products/{id}/plans | Create a plan. |
GET /v1/plans/{id} | Get a plan. |
PATCH /v1/plans/{id} | Update name, maxActivations, entitlements, maxMajor, trialDays, offlineDays (null clears the override). |
Fields: code (lowercase slug, unique per product, 409 conflict otherwise), name (defaults to the code), kind (subscription, perpetual, trial), maxActivations (default 1), entitlements (array of strings), maxMajor (integer or null), trialDays (default 14 for a trial), offlineDays (1 to 365, or null for the product’s value: the default). code and kind cannot change once the plan exists: the code is in certificates and price maps.
curl https://api.keyrail.tech/v1/products/prd_7k2m9q4x1c8d3f6h0j5n/plans \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "pro", "name": "Pro", "kind": "subscription", "maxActivations": 2, "entitlements": ["export", "ocr"], "maxMajor": null }'A plan’s offlineDays overrides the product’s for the certificates of its licenses; the plan returns null when it has no override.
Licenses#
| Method and path | Purpose |
|---|---|
GET /v1/licenses?product=&status=&email=&q=&key=&cursor= | List, newest first, 50 per page. email is exact; q matches part of the customer’s email or name; key takes a complete license key (any case, spaces ignored). Lists never include keys. |
POST /v1/licenses | Create a license manually: plan (a plan ID, or a plan code with product), email, name?, seats?, paidUntil (required for a subscription plan; a trial gets trialDays from now), sendEmail? (true e-mails the key to the customer; default false), locale? (en or fr, the language of the customer's emails; default your account's). The key is returned in clear. |
GET /v1/licenses/{id} | Detail, with its key and activations. |
PATCH /v1/licenses/{id} | Update paidUntil, seats, maxActivations (override; null goes back to the plan’s machines × seats), maxMajor. |
POST /v1/licenses/{id}/suspend | Suspend (reversible). |
POST /v1/licenses/{id}/reinstate | Lift a suspension or a refund: the status before it comes back. |
POST /v1/licenses/{id}/revoke | Revoke (final: any later change is 409 conflict). |
POST /v1/licenses/{id}/send-key | E-mail the key to the customer again (your sender, the license's language). |
Create a license for a reseller or a giveaway:
curl https://api.keyrail.tech/v1/licenses \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "plan": "pln_5d9h3m7q1t4x8c2f6k0n", "email": "jane@example.com", "name": "Jane Doe", "seats": 1, "paidUntil": "2027-10-13T00:00:00Z" }'{
"id": "lic_3n8f2k5m9q1x4c7d0h6j",
"product": "prd_7k2m9q4x1c8d3f6h0j5n",
"plan": "pro",
"planId": "pln_5d9h3m7q1t4x8c2f6k0n",
"planName": "Pro",
"customer": { "id": "cus_0j5n3f6h8d1c4x9q2m7k", "email": "jane@example.com", "name": "Jane Doe" },
"status": "active",
"seats": 1,
"maxActivations": null,
"activationLimit": 2,
"maxMajor": null,
"entitlements": ["export", "ocr"],
"paidUntil": "2027-10-13T00:00:00Z",
"validUntil": "2027-10-27T00:00:00Z",
"provider": null,
"activationCount": 0,
"createdAt": "2026-10-13T09:00:00Z",
"updatedAt": "2026-10-13T09:00:00Z",
"key": "ACME-7K2M9-Q4X1C-8D3F6-H0J5N",
"activations": []
}plan is the plan code, as in certificates. maxActivations is the per-license override (null when none) and activationLimit the effective limit. List items have the same fields without key and activations.
Find a customer’s licenses for a support request:
curl "https://api.keyrail.tech/v1/licenses?email=jane@example.com" \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"Give a perpetual customer access to version 2 after an upgrade purchase:
curl -X PATCH https://api.keyrail.tech/v1/licenses/lic_3n8f2k5m9q1x4c7d0h6j \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "maxMajor": 2 }'Suspend a license:
curl -X POST https://api.keyrail.tech/v1/licenses/lic_3n8f2k5m9q1x4c7d0h6j/suspend \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"Activations#
| Method and path | Purpose |
|---|---|
DELETE /v1/activations/{id} | Free a machine. |
curl -X DELETE https://api.keyrail.tech/v1/activations/act_9c4h7k1m5p8r2t6v0x3z \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"Customers#
| Method and path | Purpose |
|---|---|
GET /v1/customers?email=&q=&cursor= | List customers (with licenseCount), newest first; email exact, q part of the email or name. |
GET /v1/customers/{id} | A customer and their licenses. |
Integrations#
| Method and path | Purpose |
|---|---|
GET /v1/integrations | List integrations. |
GET /v1/integrations/{id} | Get an integration. |
POST /v1/integrations | Create: provider (paddle or lemonsqueezy), product, environment (sandbox or live, default live), webhookSecret?, priceMap?, and for Paddle an optional apiKey. One integration per product, provider and environment. |
PATCH /v1/integrations/{id} | Update environment, webhookSecret (replace), apiKey (null removes it), priceMap (replaces the map). |
DELETE /v1/integrations/{id} | Remove. |
webhookSecret and apiKey are write-only: responses show hasWebhookSecret and hasApiKey instead. The secret can be added after creation, because Paddle only shows it once the notification destination (which needs the webhookUrl) exists; until it is set, every webhook is refused with 401. The Paddle apiKey (read-only customer.read permission is enough) lets Keyrail fetch a customer’s email immediately; see Paddle Billing. priceMap maps a provider price ID (Paddle) or variant ID (Lemon Squeezy) to a plan code: { "pri_01hv8x2monthly000000000000": "pro" }. Responses include the webhookUrl to paste into the provider. Guides: Paddle Billing, Lemon Squeezy.
API keys#
| Method and path | Purpose |
|---|---|
GET /v1/api-keys | List keys (never their value). |
POST /v1/api-keys | Create a key: { "name": "Backend" }. The value is in the key field of this response only; lists show its prefix (first 10 characters) and lastUsedAt. |
DELETE /v1/api-keys/{id} | Revoke a key. |
Account and usage#
| Method and path | Purpose |
|---|---|
GET /v1/account | Your account, Keyrail plan, portalUrl (customer portal) and the sender details of customer emails. |
PATCH /v1/account | Update name and the emails sent to your customers: senderName, senderEmail, replyTo (empty: replies go to the sender address), defaultLocale (en or fr) and customerPortal ({ "mode": "keyrail" }, { "mode": "custom", "url": "https://..." } or { "mode": "none" }). See Custom sender and languages. |
GET /v1/account/sender-domain | Your sending domain, its status and the DNS records to publish. |
POST /v1/account/sender-domain | Claim your sending domain: { "domain": "example.com" } (dashboard, account owner). |
POST /v1/account/sender-domain/verify | Check the DNS records now. |
DELETE /v1/account/sender-domain | Release the domain (dashboard, account owner). |
GET /v1/account/usage | { "plan", "activeLicenses", "licenseLimit", "products", "productLimit", "overLimit" } (limits null when unlimited). |
GET /v1/account/billing | Your Keyrail subscription: plan, interval, status, currentPeriodEnd, cancelAt. Upgrades, plan changes and the billing portal are in the dashboard (Account & usage). |
Export and closure are dashboard actions of the account owner (Account & usage), not available with an API key: Export data downloads everything as JSON (license keys included, secrets excluded). Close account revokes API keys and sessions at once, cancels your Keyrail subscription at the end of the paid period and refuses new activations; machines already activated keep validating for 30 days and your customers' payments keep being applied (renewals extend paidUntil; no license key is e-mailed meanwhile). During those 30 days the dashboard cannot be signed in to (export before closing), and the e-mailed link reopens the account (resuming the subscription when it has not ended); then every piece of data is deleted.
Events#
| Method and path | Purpose |
|---|---|
GET /v1/events?license=&cursor= | Audit log, newest first: { "id", "type", "license", "data", "createdAt" }. Types include license.created, license.status_changed (from, to, source), activation.created, activation.deleted, webhook.received (provider, eventType, result, detail), signing_key.created (pending key), signing_key.rotated (key activated), account.plan_changed (your Keyrail plan). |
curl "https://api.keyrail.tech/v1/events?license=lic_3n8f2k5m9q1x4c7d0h6j" \
-H "Authorization: Bearer $KEYRAIL_SECRET_KEY"Need help? See Troubleshooting or write to support@keyrail.tech.