API Clients¶
An ApiClient represents a registered external service that authenticates via the M2M flow. Each client has its own identity, public keys, capabilities, and security configuration.
The ApiClient model¶
| Field | Type | Description |
|---|---|---|
client_id |
str |
Unique client identifier (auto-generated). |
name |
str |
Human-readable name. |
description |
str |
What the client does. |
keys |
list[ApiClientKey] |
Authentication keys (public key, algorithm, status). |
capabilities |
list[str] |
Granted capabilities — resource:action strings. |
scope_level |
str |
system, partner, or tenant. |
ip_whitelist |
list[str] |
Allowed IP addresses and CIDR blocks. Empty = no restriction. |
dpop_required |
bool |
Require DPoP proof on every request. |
access_token_ttl_seconds |
int |
Token lifetime (default: server-side setting). |
is_enabled |
bool |
Active state. Disabled clients cannot authenticate. |
revoked_at |
datetime |
Revocation timestamp. Set when the client is permanently decommissioned. |
Management endpoints¶
| Method | Path | Purpose |
|---|---|---|
POST |
/api-clients |
Create a new client. |
GET |
/api-clients |
List clients (filtered by tenant). |
GET |
/api-clients/{id} |
Get a single client. |
PATCH |
/api-clients/{id} |
Update client settings. |
DELETE |
/api-clients/{id} |
Soft-delete a client. |
POST |
/api-clients/{id}/keys |
Upload or generate a new key. |
POST |
/api-clients/{id}/keys/{key_id}/revoke |
Revoke a specific key. |
All management endpoints require a user token with the api_clients:create, api_clients:read, api_clients:update, or api_clients:delete capability.
Creating a client¶
curl -X POST http://localhost:5001/api-clients \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Batch Worker",
"description": "Nightly settlement and reconciliation service",
"capabilities": ["payments:read", "settlements:create", "settlements:read"],
"scope_level": "tenant",
"ip_whitelist": ["10.0.0.0/8"],
"dpop_required": false
}'
Response:
{
"id": "664abc...",
"client_id": "batch-worker-a1b2c3",
"name": "Batch Worker",
"capabilities": ["payments:read", "settlements:create", "settlements:read"],
"scope_level": "tenant",
"ip_whitelist": ["10.0.0.0/8"],
"dpop_required": false,
"is_enabled": true,
"keys": [],
"created_at": "2026-04-06T10:00:00Z"
}
The client has no keys yet — it cannot authenticate until you upload or generate one.
Key generation¶
Craft Easy supports RSA (RS256) and ECDSA (ES256) keys. Generate a key pair locally and upload the public half.
With OpenSSL¶
With Python¶
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives import serialization
# EC P-256 (recommended)
private_key = ec.generate_private_key(ec.SECP256R1())
# Or RSA 2048
# private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# Save private key (keep this secret)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
# Extract public key (upload this to Craft Easy)
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
with open("private.pem", "wb") as f:
f.write(private_pem)
with open("public.pem", "wb") as f:
f.write(public_pem)
Uploading a key¶
PUBLIC_KEY=$(cat ec-public.pem)
curl -X POST http://localhost:5001/api-clients/664abc.../keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"public_key\": $(echo "$PUBLIC_KEY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),
\"algorithm\": \"ES256\"
}"
Response:
{
"key_id": "key-1a2b3c",
"algorithm": "ES256",
"status": "active",
"uploaded_at": "2026-04-06T10:05:00Z"
}
Use the key_id as the kid header in your JWT assertions.
Key rotation¶
Zero-downtime rotation follows this pattern:
- Generate a new key pair on the client side.
- Upload the new public key — the client now has two active keys.
- Switch the client to sign assertions with the new private key, using the new
kid. - Revoke the old key once all clients have switched.
# Step 2 — upload new key
curl -X POST http://localhost:5001/api-clients/664abc.../keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"public_key": "-----BEGIN PUBLIC KEY-----\n...", "algorithm": "ES256"}'
# Step 4 — revoke old key
curl -X POST http://localhost:5001/api-clients/664abc.../keys/key-old123/revoke \
-H "Authorization: Bearer $ADMIN_TOKEN"
The server tries each active key in order when verifying a client assertion. Once a key is revoked, it is never used for verification again.
Capabilities¶
An ApiClient's capabilities list uses the same resource:action strings as human roles. Capabilities are validated against the Capability Registry — assigning a capability that does not exist in the registry is rejected.
When the client requests a token, the granted capabilities are the intersection of:
- The client's registered capabilities.
- The requested
scopeparameter (if provided).
This prevents a client from ever exceeding its registered permissions.
# Update capabilities
curl -X PATCH http://localhost:5001/api-clients/664abc... \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"etag-value\"" \
-d '{"capabilities": ["payments:read", "payments:create", "settlements:read"]}'
IP allowlist¶
Requests from IPs outside the allowlist are rejected with 403. The list supports exact IPv4/IPv6 addresses and CIDR notation.
curl -X PATCH http://localhost:5001/api-clients/664abc... \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"etag-value\"" \
-d '{"ip_whitelist": ["203.0.113.42", "10.0.0.0/8"]}'
An empty list means no IP restriction.
Rate limiting and lockout¶
M2M token requests are rate-limited per client:
| Setting | Default | Meaning |
|---|---|---|
M2M_AUTH_MAX_PER_MINUTE |
10 |
Sliding-window cap on token requests per client per minute. |
M2M_AUTH_LOCKOUT_THRESHOLD |
5 |
Failed attempts within the lockout window before the client is locked. |
M2M_AUTH_LOCKOUT_MINUTES |
15 |
Lockout duration once the threshold is reached. |
| Error | HTTP status | Client action |
|---|---|---|
| Invalid assertion | 401 |
Check key, claims, and signature. |
| Client disabled | 403 |
Contact admin. |
| IP denied | 403 |
Check ip_whitelist configuration. |
| Rate limited | 429 + Retry-After |
Wait and retry. |
| Locked out | 429 + Retry-After |
Wait for lockout to expire. |
Disabling and revoking¶
Disable a client to temporarily prevent authentication (reversible):
curl -X PATCH http://localhost:5001/api-clients/664abc... \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"etag-value\"" \
-d '{"is_enabled": false}'
Delete a client to permanently decommission it (soft delete):
curl -X DELETE http://localhost:5001/api-clients/664abc... \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "If-Match: \"etag-value\""
Both actions are recorded in the audit log.