Error Handling
Consistent error format across all endpoints.
Error format
All API errors return a JSON object with an error field containing code and message.
Validation errors include an additional details field with per-field errors.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"actor": ["actor is required"],
"action": ["action is required"]
}
}
}Error codes
| Status | Code | Description | Resolution |
|---|---|---|---|
400 | VALIDATION_ERROR | Request body or query parameters failed validation | Check the details field for specific field errors |
400 | INVALID_CURSOR | The pagination cursor is malformed or expired | Use a fresh cursor from a previous response |
401 | UNAUTHORIZED | Missing or invalid API key | Include a valid API key in the Authorization header |
403 | FORBIDDEN | API key does not have permission for this action | Check your key permissions in the dashboard |
404 | NOT_FOUND | The requested resource does not exist | Verify the event or chain ID is correct |
409 | CONFLICT | The write could not take its turn on the hash chain — it waited too long, the chain was being modified, or a position was claimed concurrently | Retry the request as-is. Nothing was written, and the retry is assigned the next available position |
400 | EXPORT_TOO_LARGE | Export exceeds the 10,000 event limit for a single export | Narrow your date range with after/before parameters, or add filters (actor, action, resource) |
403 | ORG_REQUIRED | Request requires an organization context | Ensure your Clerk session or API key is associated with an organization |
404 | TENANT_NOT_FOUND | No tenant found for the given organization | Run POST /v1/tenants/setup to provision your tenant first |
429 | RATE_LIMITED | Too many requests (coming soon — not yet enforced) | Back off and retry with exponential delay |
429 | QUOTA_EXCEEDED | Monthly event quota exceeded for your plan | Upgrade your plan or wait for the next billing cycle |
500 | INTERNAL_ERROR | Unexpected server error | Retry once, then contact support with the request ID |
Validation errors
Request validation is powered by Zod.
When validation fails, the details object maps field names to arrays of error messages.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"actor": ["String must contain at least 1 character(s)"],
"context": ["Expected object, received string"]
}
}
}Quotas and limits
Each plan has a monthly event quota:
| Plan | Monthly events | Retention |
|---|---|---|
| Free | 2,500 | 30 days |
| Pro | 100,000 | 1 year |
| Business | Unlimited | Unlimited |
When you exceed your quota, the API returns 429 QUOTA_EXCEEDED.
Retry strategy
Each event must reference the previous event's hash, so writes to the same chain are
serialised server-side: concurrent requests queue and each one is assigned the next
position. Under normal concurrency they all succeed, so 409 CONFLICT should
be rare. A writer waits up to 3 seconds for its turn on a chain; past that
it gives up with a 409 rather than holding the request open indefinitely.
That bound covers the wait for the chain, not the total round-trip.
Whatever the cause — the wait elapsed, the chain was being modified, or a position was
claimed concurrently — a 409 always means the same thing:
nothing was written, and the request is safe to retry as-is. Sustained
409s on one chain mean you are writing to it faster than a single hash chain
can absorb; split the traffic across chains.
How to retry: simply resubmit the same request. The API will automatically fetch the latest chain state and assign the next available position. No need to re-fetch chain status yourself — the retry is transparent.
409, retry once immediately — the retry is assigned the next available
chain position. If it conflicts again, back off: repeated conflicts mean the chain is
saturated, and retrying harder makes it worse. For 429 rate limits, use
exponential backoff starting at 1 second.
async function logEventWithRetry(payload: object, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch("https://api.sealtrail.dev/v1/events", {
method: "POST",
headers: {
"Authorization": "Bearer stl_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (res.status === 409) continue; // Retry immediately
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
continue;
}
return res.json();
}
throw new Error("Max retries exceeded");
}