API overview
A REST API over JSON. Everything the web app does is available here, and the web app itself uses no private endpoints.
Base URL
https://api.texer.orgThere is no version prefix and no path prefix. If you self-host, this is whatever you set as NEXT_PUBLIC_API_URL, and every path below is relative to it.
Authentication
Two schemes, and most endpoints accept either. A JWT identifies a browser session; an API key identifies a program.
# Session token, from POST /auth/verify-otp
curl https://api.texer.org/projects \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
# API key, from the dashboard or POST /api-keys
curl https://api.texer.org/projects \
-H "x-api-key: txr_live_9f2c..."Which one to use
A handful of endpoints are public and need no credentials at all: POST /auth/login, POST /auth/signup, the OTP verification routes, POST /tikz-compile, the prebuilt template assets, and the Stripe webhook.
Pagination
Every list endpoint is paginated and returns the same envelope. It never returns a bare array.
| Query parameter | Default | Notes |
|---|---|---|
| page | 1 | 1-based. Values below 1 are rejected. |
| limit | 20 | Maximum 100. Larger values are rejected, not clamped. |
| sort | updatedAt | Field to order by. |
| order | desc | asc or desc. |
{
"data": [ /* ... */ ],
"meta": {
"page": 1,
"limit": 20,
"total": 137,
"totalPages": 7,
"hasNext": true,
"hasPrev": false
}
}Errors
Errors use standard HTTP status codes with a JSON body. Validation failures return message as an array, one entry per failed field.
{
"statusCode": 400,
"message": ["limit must not be greater than 100"],
"error": "BadRequestException"
}500s are deliberately vague
timestamp instead of the real error. Internal details are written to the server log, never to the response. Quote that timestamp when reporting a problem and support can find the exact entry.| Status | Means |
|---|---|
| 400 | Validation failed. Check message for the field. |
| 401 | Missing or invalid credentials. |
| 403 | Authenticated, but not allowed to touch this resource. |
| 404 | No such resource, or not yours. |
| 409 | Conflict. On file writes, someone else saved first. |
| 429 | Rate limited. Back off and retry. |
| 500 | Our fault. Details are in the server log. |
Rate limits
Limits are per IP, over a rolling 60-second window, and apply on top of your plan's monthly quota. Expensive endpoints are stricter.
| Endpoint | Per minute |
|---|---|
| POST /auth/send-otp | 3 |
| POST /auth/login, POST /auth/signup | 5 |
| POST /auth/verify-otp, /auth/verify-2fa | 10 |
| POST /pdfs/:id/process | 5 |
| POST /snips/:id/process | 10 |
| POST /compile, POST /projects/:uid/compile | 10 |
| POST /tikz-compile | 10 |
| POST /projects/:uid/ai/chat | 15 |
| POST /projects/:uid/synctex | 120 |
| POST /billing/checkout | 5 |
| POST /billing/cancel | 3 |
Identifiers
Two kinds, and mixing them up is the most common integration mistake.
- Projects are addressed by
uid, a 16-character alphanumeric string such as8kX4mN2vRqZw3pT1. This is what appears in editor URLs. - Everything else uses an integer id: snips, PDFs, files, templates, API keys. These are numbers in JSON, not strings.