curl -X POST https://api.liddie.io/api/v1/api-keys \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{
"label": "backend-prod",
"permissions": ["payments:create"],
"totpCode": "123456"
}'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys', {
method: 'POST',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({
label: 'backend-prod',
permissions: ['payments:create'],
totpCode: '123456',
}),
});
const key = await response.json();
{
"id": "<keyId>",
"rawKey": "lid_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"label": "backend-prod",
"permissions": ["payments:create"],
"expiresAt": null,
"message": "Store the rawKey securely. It will not be shown again."
}
API keys
Create an API key
Create a secret Liddie API key (lid_live_*) with exactly the scopes you choose — MFA required, and the raw key is shown exactly once.
POST
/
api
/
v1
/
api-keys
curl -X POST https://api.liddie.io/api/v1/api-keys \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{
"label": "backend-prod",
"permissions": ["payments:create"],
"totpCode": "123456"
}'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys', {
method: 'POST',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({
label: 'backend-prod',
permissions: ['payments:create'],
totpCode: '123456',
}),
});
const key = await response.json();
{
"id": "<keyId>",
"rawKey": "lid_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"label": "backend-prod",
"permissions": ["payments:create"],
"expiresAt": null,
"message": "Store the rawKey securely. It will not be shown again."
}
Mint a new secret API key (
MFA is required on this very call. The calling user must have TOTP enabled or a registered passkey — otherwise you get
Backup codes are single-use and consumed atomically (two concurrent uses of the same code: only one wins).
lid_live_*) with exactly the scopes you choose.
The returned
rawKey appears exactly once — only its SHA-256 hash is stored server-side. Store it the moment the call returns; losing it means revoking the key and minting a new one.Authorization
This endpoint is dashboard-only. An API key can never create, list, or revoke API keys — a leaked key must not be able to clone itself or mint a more powerful one. Call it with a dashboard JWT with rolemerchant_admin, merchant_member (with team permission api-keys:manage) or super_admin. API-key callers are rejected with 401 {"error":"Invalid or expired token"} — the JWT guard runs first and an API key is not a session token, so the call never reaches the role check. The key is not revoked; it simply cannot authenticate a dashboard-only route. Rate limit: 5/min.
A
super_admin passes the role guard, but platform accounts carry no merchant context, so the handler
short-circuits with 400 {"ok":false,"error":{"code":"NO_MERCHANT_CONTEXT","message":"No merchant context"}}.
In practice this endpoint is for merchant accounts.403 {"ok":false,"error":{"code":"MFA_SETUP_REQUIRED",...}}.
Anti-escalation for team members: a merchant_member can only put scopes on a key that their own team role already holds (minting a refunds:create key requires holding refunds:manage). Violations return 403 PERMISSION_ESCALATION. Owners (merchant_admin) skip this check.
curl -X POST https://api.liddie.io/api/v1/api-keys \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{
"label": "backend-prod",
"permissions": ["payments:create"],
"totpCode": "123456"
}'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys', {
method: 'POST',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({
label: 'backend-prod',
permissions: ['payments:create'],
totpCode: '123456',
}),
});
const key = await response.json();
{
"id": "<keyId>",
"rawKey": "lid_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"label": "backend-prod",
"permissions": ["payments:create"],
"expiresAt": null,
"message": "Store the rawKey securely. It will not be shown again."
}
Parameters
string
required
1–100 characters. Unique among your active keys (duplicate →
409 DUPLICATE_LABEL).string[]
required
At least one entry. Any of the merchant scopes. Unknown values →
400 INVALID_PERMISSIONS.string[]
Plain IPv4/IPv6 addresses (no CIDR). Invalid entry →
400 INVALID_IP. Empty/omitted = any IP.integer
1–3650. Omit for a non-expiring key. Expired keys are rejected at verification time.
MFA fields (dashboard sessions)
MFA fields (dashboard sessions)
One strong factor is required on this call: either
totpCode or passkeyResponse + challengeKey.string
6–8 characters: TOTP or backup code. Required unless you pass a passkey assertion instead.
object
WebAuthn assertion (alternative to TOTP). Sent together with
challengeKey.string
Accompanies
passkeyResponse.Response fields
string
The key’s ID.
string
The secret key material. Appears exactly once — only its SHA-256 hash is stored server-side.
string
The key’s label.
string[]
The granted scopes.
string | null
Expiry timestamp, or
null for a non-expiring key.Errors
| Status | Body | Why |
|---|---|---|
| 403 | {"ok":false,"error":{"code":"MFA_SETUP_REQUIRED",...}} | The calling user has neither TOTP enabled nor a registered passkey. |
| 409 | DUPLICATE_LABEL | Label already used by one of your active keys. |
| 400 | INVALID_PERMISSIONS | Unknown scope value in permissions. |
| 400 | INVALID_IP | Invalid entry in ipWhitelist (plain IPs only, no CIDR). |
All error responses
All error responses
| Status | Body | Why |
|---|---|---|
| 403 | {"ok":false,"error":{"code":"MFA_SETUP_REQUIRED",...}} | The calling user has neither TOTP enabled nor a registered passkey. |
| 400 | MFA_REQUIRED | No MFA factor provided in the body. |
| 409 | DUPLICATE_LABEL | Label already used by one of your active keys. |
| 400 | INVALID_PERMISSIONS | Unknown scope value in permissions. |
| 400 | INVALID_IP | Invalid entry in ipWhitelist (plain IPs only, no CIDR). |
| 403 | PERMISSION_ESCALATION | A merchant_member requested a scope their own team role does not hold. |
| 401-family | — | Wrong TOTP code. |
| 429 | — | Repeated MFA failures → lockout with replay protection. |
See also
- List API keys: confirm the new key’s metadata (never the raw key) after minting.
- Update key IP binding: tighten the key to a single IP after creation.
- Revoke an API key: kill a key whose
rawKeywas lost or leaked. - Authentication: how the minted key authenticates API requests.