# 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](/docs/dashboard/#api-keys)) 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.

```bash
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](/docs/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": "..." }`. Pass `cursor=<nextCursor>` to get the next page; `nextCursor` is `null` on 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](/docs/troubleshooting/#vendor-api-errors).
- 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`. |

```bash
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 }'
```

```json
{ "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](/docs/offline-certificates/#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](/docs/offline-certificates/#signing-without-keyrail). |

```bash
curl https://api.keyrail.tech/v1/products/prd_7k2m9q4x1c8d3f6h0j5n/keys \
  -H "Authorization: Bearer $KEYRAIL_SECRET_KEY"
```

```json
{
  "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.

```bash
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:

```bash
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" }'
```

```json
{
  "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:

```bash
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:

```bash
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:

```bash
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. |

```bash
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](/docs/paddle/#3-customer-emails-and-the-optional-api-key). `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](/docs/paddle/), [Lemon Squeezy](/docs/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](/docs/custom-sender/). |
| `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). |

```bash
curl "https://api.keyrail.tech/v1/events?license=lic_3n8f2k5m9q1x4c7d0h6j" \
  -H "Authorization: Bearer $KEYRAIL_SECRET_KEY"
```
