curl -X PATCH https://api.liddie.io/api/v1/api-keys/<keyId>/ip \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{ "ip": "203.0.113.10", "totpCode": "123456" }'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys/<keyId>/ip', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ip: '203.0.113.10', totpCode: '123456' }),
});
const result = await response.json();
{"success":true,"ipWhitelist":["203.0.113.10"]}
API keys
Update key IP binding
Bind a Liddie API key to a single IP address or clear the restriction so any IP can use it — MFA (TOTP or passkey) required to change the binding.
PATCH
/
api
/
v1
/
api-keys
/
{keyId}
/
ip
curl -X PATCH https://api.liddie.io/api/v1/api-keys/<keyId>/ip \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{ "ip": "203.0.113.10", "totpCode": "123456" }'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys/<keyId>/ip', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ip: '203.0.113.10', totpCode: '123456' }),
});
const result = await response.json();
{"success":true,"ipWhitelist":["203.0.113.10"]}
Restrict an existing API key to a single IP address, or clear the restriction so any IP can use it.
Authorization
This endpoint is dashboard-only: 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.
MFA is required — same factor rules as Create an API key.
curl -X PATCH https://api.liddie.io/api/v1/api-keys/<keyId>/ip \
-H "Authorization: Bearer <dashboard JWT>" \
-H "Content-Type: application/json" \
-d '{ "ip": "203.0.113.10", "totpCode": "123456" }'
// Dashboard-session endpoint: send the JWT as a Bearer token.
const response = await fetch('https://api.liddie.io/api/v1/api-keys/<keyId>/ip', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer <dashboard JWT>',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ip: '203.0.113.10', totpCode: '123456' }),
});
const result = await response.json();
{"success":true,"ipWhitelist":["203.0.113.10"]}
Parameters
string
required
ID of the key to update (from List API keys).
string | null
A single IP to bind to (e.g.
203.0.113.10), or null (or "") to clear the binding. Omitting it also clears the binding.MFA fields (dashboard sessions)
MFA fields (dashboard sessions)
One strong factor is required on this call: either
totpCode or passkeyResponse + challengeKey — same factor rules as Create an API key.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
boolean
true on success.string[]
The key’s resulting IP whitelist.
Errors
| Status | Body | When |
|---|---|---|
| 400 | {"ok":false,"error":{"code":"NO_MERCHANT_CONTEXT","message":"No merchant context"}} | The session carries no merchant. Checked first. |
| 400 | {"ok":false,"error":{"code":"INVALID_ID","message":"Invalid ID format"}} | keyId is not a valid 24-hex id. |
| 400 | {"ok":false,"error":{"code":"INVALID_IP","message":"Invalid IP address: <ip>"}} | ip is not a valid IPv4/IPv6 literal (the offending value is appended to the message) — validated before MFA. Send null or "" to clear instead. |
| 403 | {"ok":false,"error":{"code":"MFA_SETUP_REQUIRED","message":"Enable TOTP or register a passkey to modify API key IP binding"}} | The caller has neither TOTP nor a passkey enrolled. |
| 400 | {"ok":false,"error":{"code":"MFA_REQUIRED","message":"OTP code or passkey required"}} | The body carried no totpCode and no passkey assertion. |
| 401 | {"error":"Invalid OTP code"} — bare string, not the envelope | Wrong TOTP code. |
| 401 | {"error":"Passkey verification failed"} — bare string | A passkey assertion that did not verify. |
| 403 | {"error":"TOTP is not enabled on this account"} — bare string | A passkey-only account submitted totpCode. |
| 429 | {"error":"Too many failed attempts. Try again later."} — bare string | TOTP lockout after repeated wrong codes. |
| 404 | {"ok":false,"error":{"code":"API_KEY_NOT_FOUND","message":"API key not found"}} | Unknown keyId, a key belonging to another merchant, or your own key that has been revoked — the same 404 either way, so the endpoint is not an existence oracle. |
| 401 | {"error":"Invalid or expired token"} | Called with an API key. This route is dashboard-only and jwtAuth runs first. |
Two shapes, and the handler order. Checks run: merchant context →
keyId format → ip format → MFA enrolled → MFA verified → key lookup. So an invalid ip returns INVALID_IP before any MFA step, and the 404 is reached only after MFA succeeds — a valid one-time factor (including a backup code) is consumed even when keyId is wrong. The MFA-failure rows (401/403/429 above) send a bare {"error":"..."} string, not the {"ok":false,"error":{"code":…}} envelope the other rows use, and the codes INVALID_OTP/VERIFICATION_FAILED do not exist — branch on the HTTP status for those, not on error.code.See also
- List API keys: find the
keyIdand currentipWhitelistof each key. - Create an API key: set the IP whitelist at mint time instead.
- Revoke an API key: remove a key entirely rather than re-binding it.