Skip to content

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 capabilitiesresource: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

# Generate private key (keep this secret)
openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem

# Extract public key (upload this to Craft Easy)
openssl ec -in ec-private.pem -pubout -out ec-public.pem
# Generate private key (keep this secret)
openssl genrsa -out rsa-private.pem 2048

# Extract public key (upload this to Craft Easy)
openssl rsa -in rsa-private.pem -pubout -out rsa-public.pem

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:

  1. Generate a new key pair on the client side.
  2. Upload the new public key — the client now has two active keys.
  3. Switch the client to sign assertions with the new private key, using the new kid.
  4. 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 scope parameter (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.