REST API
All endpoints accept and return JSON. The base URL depends on your environment:
| Environment | Base URL |
|---|---|
| Production | https://api.kepca.com |
| Self-hosted | Your configured domain |
| Local dev | http://localhost:3001 |
Challenge flow (client-side)
These endpoints are called by the widget automatically. You generally do not call them from your backend.
POST /v1/{site_key}/init
Start a new session and submit initial signals for risk scoring.
Request:
{
"signals": {
"timestamp": 1712700000000,
"time_on_page_ms": 5200,
"submit_time_ms": 12400,
"user_agent": "Mozilla/5.0 ...",
"honeypot_filled": false,
"ip_hash": "sha256_abc...",
"tls_fingerprint": "ja3_xyz...",
"request_count": 1
}
}Response 200:
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"pre_score": 0.15,
"requires_challenge": false
}If requires_challenge is false, the user passed invisibly. If true, proceed to the challenge endpoint.
Errors:
| Code | Body | Cause |
|---|---|---|
| 400 | { "error": "invalid_body" } | Missing or malformed signals |
POST /v1/{site_key}/challenge
Request a challenge for an existing session.
Request:
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"signals": {
"timestamp": 1712700005000
}
}The signals field is optional. If provided, the risk score is recalculated.
Response 200:
{
"challenge": {
"type": "pow",
"difficulty": 15,
"token": "base64_challenge_token...",
"expires_at": 1712700300
},
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"risk_score": 0.45
}Errors:
| Code | Body | Cause |
|---|---|---|
| 400 | { "error": "invalid_body" } | Missing session_id |
| 400 | { "error": "invalid_session" } | Session not found or expired |
POST /v1/{site_key}/solve
Submit a challenge solution. On success, returns a signed verification token.
Request:
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"solution": "a1b2c3d4..."
}Response 200:
{
"token": "eyJhbGciOiJIUzI1NiIsInYiOjF9.eyJzayI6Im1wdF9zaXRlX2FiYzEyMyIsInRzIjoxNzEyNzAwMDAwLCJleHAiOjE3MTI3MDAxMjAsInJzIjowLjQ1LCJjaCI6InBvdyIsImlwIjoic2hhMjU2X2FiYy4uLiIsImp0aSI6InVuaXF1ZV9pZCJ9.signature",
"score": 0.45
}Errors:
| Code | Body | Cause |
|---|---|---|
| 400 | { "error": "invalid_body" } | Missing fields |
| 400 | { "error": "invalid_session" } | Session not found |
| 400 | { "error": "challenge_expired" } | Challenge TTL exceeded (5 min) |
| 400 | { "error": "invalid_solution" } | Wrong PoW nonce |
Verification (server-side)
POST /v1/siteverify
Verify a captcha token from your backend. This is the only endpoint you must call from your server.
Request:
{
"secret": "mpt_secret_your_secret_key",
"token": "eyJhbGciOiJIUzI1NiIsInYiOjF9...",
"ip": "203.0.113.42"
}| Field | Type | Required | Description |
|---|---|---|---|
secret | string | Yes | Your site's secret key |
token | string | Yes | The token from the widget |
ip | string | No | Client IP for additional validation |
Response 200:
{
"success": true,
"score": 0.15,
"challenge": "pow",
"timestamp": "2026-04-09T12:00:00.000Z"
}Error response 200:
{
"success": false,
"score": 0,
"challenge": "pow",
"timestamp": "2026-04-09T12:00:00.000Z",
"error": "token_expired"
}Possible error codes:
| Error code | Description |
|---|---|
invalid_token | Token cannot be decoded or signature invalid |
token_expired | Token exp has passed |
invalid_secret | Secret key does not match the token's site |
ip_mismatch | Provided IP does not match token's IP hash |
already_used | Token JTI was already verified (replay) |
Events
POST /v1/events
Report custom security events for analytics and threat intelligence.
Request:
{
"secret": "mpt_secret_your_secret_key",
"events": [
{
"type": "login_failed",
"ip": "203.0.113.42",
"metadata": {
"email": "attacker@example.com",
"attempts": 5
},
"timestamp": "2026-04-09T12:05:00.000Z"
}
]
}Response 200:
{
"accepted": 1
}Analytics
GET /v1/stats/{site_key}
Retrieve verification statistics for a site. Requires authentication via the Authorization header.
Headers:
Authorization: Bearer <jwt_token>Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
from | string | 24h ago | Start time (ISO 8601) |
to | string | now | End time (ISO 8601) |
granularity | string | hour | minute, hour, day |
Response 200:
{
"site_key": "mpt_site_abc123",
"period": {
"from": "2026-04-08T12:00:00.000Z",
"to": "2026-04-09T12:00:00.000Z"
},
"totals": {
"requests": 12450,
"passed": 11800,
"challenged": 580,
"blocked": 70
},
"by_challenge_type": {
"invisible": 11800,
"pow": 520,
"checkbox": 60,
"grid": 0,
"slider": 0,
"interactive": 0,
"text": 0
},
"timeseries": [
{
"time": "2026-04-08T12:00:00.000Z",
"requests": 520,
"passed": 498,
"challenged": 20,
"blocked": 2
}
]
}Authentication
POST /v1/auth/register
Create a new account.
Request:
{
"email": "admin@example.com",
"password": "strongpassword123",
"name": "Ada Lovelace"
}Response 201:
{
"user_id": "usr_abc123",
"email": "admin@example.com",
"name": "Ada Lovelace",
"created_at": "2026-04-09T12:00:00.000Z"
}Errors:
| Code | Error | Cause |
|---|---|---|
| 400 | invalid_email | Malformed email address |
| 409 | email_already_exists | Account already exists |
POST /v1/auth/login
Authenticate and receive a JWT token.
Request:
{
"email": "admin@example.com",
"password": "strongpassword123"
}Response 200:
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expires_at": "2026-04-10T12:00:00.000Z",
"user": {
"user_id": "usr_abc123",
"email": "admin@example.com",
"name": "Ada Lovelace"
}
}Errors:
| Code | Error | Cause |
|---|---|---|
| 401 | invalid_credentials | Wrong email or password |
Site management
POST /v1/sites
Create a new site and receive a site key + secret key pair.
Headers:
Authorization: Bearer <jwt_token>Request:
{
"name": "My Website",
"domain": "example.com",
"mode": "adaptive",
"active_challenges": ["pow", "checkbox"],
"risk_thresholds": {
"invisible_max": 0.3,
"checkbox_max": 0.6,
"pow_max": 0.8
}
}Response 201:
{
"site_key": "mpt_site_abc123",
"secret_key": "mpt_secret_xyz789",
"name": "My Website",
"domain": "example.com",
"mode": "adaptive",
"active_challenges": ["pow", "checkbox"],
"risk_thresholds": {
"invisible_max": 0.3,
"checkbox_max": 0.6,
"pow_max": 0.8
},
"created_at": "2026-04-09T12:00:00.000Z"
}WARNING
Store the secret_key securely. It is shown only once at creation time.
Errors:
| Code | Error | Cause |
|---|---|---|
| 400 | invalid_domain | Malformed domain |
| 401 | unauthorized | Missing or invalid JWT |
| 409 | domain_already_exists | Domain registered to another site |
Team management
All team endpoints require JWT authentication.
POST /v1/teams
Create a new team (organization).
Request:
{
"name": "Acme Corp",
"slug": "acme-corp"
}Response 201:
{
"team_id": "team_abc123",
"name": "Acme Corp",
"slug": "acme-corp",
"owner_id": "usr_abc123",
"created_at": "2026-04-09T12:00:00.000Z"
}GET /v1/teams
List all teams the authenticated user belongs to.
Response 200:
{
"teams": [
{
"team_id": "team_abc123",
"name": "Acme Corp",
"slug": "acme-corp",
"role": "owner",
"member_count": 5
}
]
}GET /v1/teams/{team_id}/members
List members of a team. Requires admin or owner role.
Response 200:
{
"members": [
{
"user_id": "usr_abc123",
"email": "owner@example.com",
"name": "Ada Lovelace",
"role": "owner",
"joined_at": "2026-04-01T00:00:00.000Z"
}
]
}POST /v1/teams/{team_id}/invites
Invite a user to a team. Requires admin or owner role.
Request:
{
"email": "colleague@example.com",
"role": "admin"
}Response 201:
{
"invite_id": "inv_abc123",
"email": "colleague@example.com",
"role": "admin",
"expires_at": "2026-04-16T12:00:00.000Z"
}PATCH /v1/teams/{team_id}/members/{user_id}
Update a member's role. Requires owner role to promote to admin.
Request:
{
"role": "member"
}DELETE /v1/teams/{team_id}/members/{user_id}
Remove a member from the team. Requires admin or owner role.
Response 204: No content.
Domain management
POST /v1/domains
Add a custom domain for a site. Enterprise plan required.
Request:
{
"domain": "captcha.example.com",
"site_key": "mpt_site_abc123"
}Response 201:
{
"domain_id": "dom_abc123",
"domain": "captcha.example.com",
"status": "pending_verification",
"cname_target": "cname.kepca.com",
"txt_record": "_kepca-verify.captcha.example.com",
"txt_value": "kepca-verify=abc123xyz"
}GET /v1/domains
List all custom domains for the authenticated user's teams.
Response 200:
{
"domains": [
{
"domain_id": "dom_abc123",
"domain": "captcha.example.com",
"site_key": "mpt_site_abc123",
"status": "active",
"created_at": "2026-04-05T00:00:00.000Z"
}
]
}DELETE /v1/domains/{domain_id}
Remove a custom domain.
Response 204: No content.
Webhook management
POST /v1/webhooks
Create a webhook endpoint to receive event notifications.
Request:
{
"url": "https://example.com/webhooks/kepca",
"events": ["verification.failed", "rate_limit.exceeded", "key.rotated"],
"site_key": "mpt_site_abc123"
}Response 201:
{
"webhook_id": "wh_abc123",
"url": "https://example.com/webhooks/kepca",
"events": ["verification.failed", "rate_limit.exceeded", "key.rotated"],
"secret": "whsec_abc123xyz",
"status": "active",
"created_at": "2026-04-09T12:00:00.000Z"
}WARNING
Store the webhook secret securely. It is shown only once and is used to verify webhook signatures.
Available webhook events
| Event | Description |
|---|---|
verification.failed | A token verification failed |
verification.suspicious | High risk score detected (> 0.8) |
rate_limit.exceeded | A rate limit was hit |
key.rotated | An API key was rotated |
key.expired | A rotating API key's grace period ended |
domain.verified | A custom domain passed DNS verification |
billing.limit_warning | Usage reached 80% of plan limit |
billing.limit_reached | Usage reached 100% of plan limit |
GET /v1/webhooks
List all webhooks for the authenticated user.
Response 200:
{
"webhooks": [
{
"webhook_id": "wh_abc123",
"url": "https://example.com/webhooks/kepca",
"events": ["verification.failed"],
"status": "active",
"created_at": "2026-04-09T12:00:00.000Z"
}
]
}DELETE /v1/webhooks/{webhook_id}
Delete a webhook endpoint.
Response 204: No content.
API key management
POST /v1/keys/rotate
Rotate a site's secret key. The old key remains active during a grace period.
Request:
{
"site_key": "mpt_site_abc123",
"key_type": "secret"
}Response 200:
{
"new_key": "mpt_secret_new_xyz789",
"old_key_expires_at": "2026-04-16T12:00:00.000Z",
"grace_period_days": 7
}GET /v1/keys
List API keys for a site.
Query parameters:
| Param | Type | Description |
|---|---|---|
site_key | string | Filter by site key |
Response 200:
{
"keys": [
{
"key_id": "key_001",
"prefix": "mpt_secret_***",
"status": "active",
"created_at": "2026-04-09T12:00:00.000Z"
}
]
}DELETE /v1/keys/{key_id}
Immediately revoke an API key.
Response 204: No content.
Billing
GET /v1/billing/plan
Get the current billing plan.
Response 200:
{
"plan": "pro",
"status": "active",
"current_period_start": "2026-04-01T00:00:00.000Z",
"current_period_end": "2026-05-01T00:00:00.000Z"
}GET /v1/billing/usage
Get current period usage and limits.
Response 200:
{
"period": "2026-04",
"verifications": {
"used": 142500,
"limit": 500000,
"percentage": 28.5
},
"sites": { "used": 5, "limit": 20 },
"team_members": { "used": 4, "limit": 10 }
}GET /v1/billing/limits
Get the rate limits for your current plan.
Response 200:
{
"plan": "pro",
"rate_limits": {
"init": 1000,
"challenge": 600,
"solve": 600,
"siteverify": 2000,
"admin": 60
},
"unit": "requests_per_minute"
}Audit log
GET /v1/audit
Query the audit log. Available on Pro and Enterprise plans.
Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
from | string | 30d ago | Start time (ISO 8601) |
to | string | now | End time (ISO 8601) |
actor | string | -- | Filter by user ID |
action | string | -- | Filter by action (e.g., site.*) |
limit | number | 50 | Results per page (max 100) |
cursor | string | -- | Pagination cursor |
Response 200:
{
"entries": [
{
"id": "aud_001",
"timestamp": "2026-04-09T12:00:00.000Z",
"actor": {
"user_id": "usr_abc123",
"email": "admin@example.com",
"ip": "203.0.113.42"
},
"action": "site.settings_updated",
"resource": { "type": "site", "id": "mpt_site_abc123" },
"changes": {
"mode": { "from": "adaptive", "to": "always-challenge" }
}
}
],
"has_more": true,
"cursor": "aud_001"
}Health check
GET /health
Returns the health status of the API. No authentication required.
Response 200:
{
"status": "healthy",
"version": "1.2.0",
"uptime_seconds": 86400,
"services": {
"postgresql": "connected",
"valkey": "connected"
}
}Response 503:
{
"status": "degraded",
"version": "1.2.0",
"services": {
"postgresql": "connected",
"valkey": "disconnected"
}
}Metrics
GET /metrics
Prometheus-compatible metrics endpoint. See Security > Prometheus metrics for the full list of available metrics.
No authentication required by default. Restrict access via network policy or METRICS_AUTH_TOKEN environment variable in production.
