Authentication
Every Leto SEO API request — REST and MCP — is authenticated with a personal API key passed as a Bearer token. This page explains how to generate keys, make requests, understand rate limits, and handle errors.
Generating an API key
API keys are created from the Settings page inside the Leto SEO app (/settings). Navigate to the API keys section and click Generate key.
All keys have the prefix sk_live_ followed by a 32-character hex string. Keys are tied to your account (user ID), not to a specific site.
Making authenticated requests
Pass your API key in the Authorization header using the Bearer scheme on every request:
Authorization: Bearer sk_live_<your-key>The same header format applies to both the REST v1 API and the MCP server. Here are examples in three languages:
curl -X GET https://letoseo.com/api/v1/portfolio \
-H "Authorization: Bearer sk_live_<your-key>"Owner gating
A valid API key can still be rejected with a 403 if the key’s owner account is not yet approved or is in the process of being deleted. This lets you distinguish a wrong key (401) from a right key whose owner is gated (403).
Two distinct error bodies are returned:
{
"error": "owner_pending_approval"
}{
"error": "owner_deletion_pending"
}Source: src/lib/api/with-api-auth.ts:34–45
Rate limits
Rate limits are enforced with sliding windows keyed on the API key owner’s user ID — not on the individual key. If you create multiple keys for the same account they all draw from the same quota.
| Pool | Limit | Window | Applies to |
|---|---|---|---|
| api:hr | 500 requests | 1 hour | REST v1 + MCP (shared) |
| api:min | 30 requests | 1 minute | REST v1 + MCP (shared) |
| mcp:min | 10 requests | 1 minute | MCP only (additional cap) |
MCP requests are subject to all three limits simultaneously — the two shared limits plus the MCP-specific per-minute cap. REST v1 requests are subject only to the two shared limits.
When a limit is exceeded, the API returns a 429 with the following body and headers:
{
"error": "rate_limit_exceeded",
"retryAfterSeconds": 42
}| Header | Value |
|---|---|
| Retry-After | Seconds to wait before retrying (integer) |
| X-RateLimit-Remaining | Always 0 on a 429 |
| X-RateLimit-Reset | Unix timestamp (ms) when the window resets |
Source: src/lib/ratelimit/apply.ts:69–91, src/app/api/mcp/[transport]/route.ts:530–538
REST error catalogue
All error responses are JSON with a top-level error field. Successful responses use 200 OK for reads and updates, 201 Created for creates, and 204 No Content for empty-body deletes.
{
"error": "Invalid or missing API key. Use Authorization: Bearer sk_live_..."
}{
"error": "Missing required field: keywords"
}{
"error": "owner_pending_approval"
}{
"error": "Site not found"
}{
"error": "rate_limit_exceeded",
"retryAfterSeconds": 42
}{
"error": "Internal server error"
}201 Created is returned by POST /api/v1/sites/:siteId/keywords and POST /api/v1/sites/:siteId/campaigns.
204 No Content (empty body) is returned only by DELETE /api/v1/sites/:siteId/keywords. The campaign delete endpoint (DELETE /api/v1/sites/:siteId/campaigns/:campaignId) returns 200 { ok: true } instead.
MCP error behavior
The MCP transport layer does not have a native equivalent of HTTP status codes. In the current V1 implementation, both rate-limit failures and owner-status rejections (pending / deletion_pending) collapse into a single MCP authentication failure — clients see an auth error rather than a distinct rate-limit or forbidden response.
This asymmetry is intentional for V1 and is called out here so integrators know to interpret an MCP auth failure as potentially rate-related, not only key-related. A more granular error surface will be added in a future release.
Source: src/app/api/mcp/[transport]/route.ts:530–545
CORS
The REST v1 API is CORS-enabled for all origins. Every response includes the following headers:
| Header | Value |
|---|---|
| Access-Control-Allow-Origin | * |
| Access-Control-Allow-Methods | GET, POST, PATCH, DELETE, OPTIONS |
| Access-Control-Allow-Headers | Authorization, Content-Type |
PATCH is included in Access-Control-Allow-Methods because browser clients using PATCH /api/v1/sites/:siteId/campaigns/:campaignId require it to pass preflight. Source: src/lib/api/with-api-auth.ts:76–82
API key revocation
Revocation is instant. When you delete a key in /settings, the database marks it with a revoked_at timestamp. The resolution RPC (resolve_api_key_owner_status) filters on revoked_at IS NULL, so the very next request with the revoked key returns a 401.
There is no grace period or cache invalidation step. Revoke a key and it is gone immediately.
Source: src/lib/auth/api-key.ts:63–70