Skip to content

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-response.json
{
  "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.

validation-error.json
{
  "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.

Rate limiting (429 RATE_LIMITED) will be enforced in a future release. Plan your integration accordingly.

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.

For a 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.
retry.ts
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");
}