Conventions
Cursor pagination, filters, idempotency, request IDs, rate limits, content types, and versioning across the API.
Every collection that returns the { data, meta } envelope follows the same rules. The API reference lists the filters and sort fields each resource supports; this page covers the mechanics they share.
Pagination
Cursor only
limit 1–100, default 20
Total count
includeTotal, default true
Set it false to skip the count query
Ordering
Newest first by UUIDv7
Unless a collection has a domain order
Correlation
X-Request-ID
Echoed as requestId in problems
Rate limit
429 with Retry-After
Fixed windows, chosen by route class
Version
/api/v1 in the path
Not a header
Cursor pagination
Every operation that returns the { data, meta } envelope is cursor-paginated, and there is no offset parameter. Operations that return a named list instead, such as GET /api/v1/oauth/clients returning clients, are not paginated.
| Parameter | Meaning |
|---|---|
limit | Page size from 1 to 100; default 20 |
cursor | Opaque value from meta.nextCursor |
includeTotal | Default true; set false to skip the count query |
The response envelope is always the same shape:
{
"data": [],
"meta": {
"limit": 20,
"total": 128,
"hasMore": true,
"nextCursor": "opaque-cursor-from-the-server"
}
}| Field | Behavior |
|---|---|
data | Always an array, empty rather than null on an empty or final page |
meta.hasMore | Always present |
meta.total | Omitted when includeTotal=false |
meta.nextCursor | Omitted on the last page |
Cursors are opaque, versioned, and bound to a collection's sort signature. A malformed cursor, a cursor from another collection, or one whose sort no longer matches fails with 400 invalid_cursor rather than returning wrong or cross-tenant data.
Ordering is deterministic: newest-first by UUIDv7 unless a collection has a domain order; EventSessions keep their agenda order.
Filters
Filters are server-side, applied before pagination, and reflected in meta.total. Never filter a fetched page; it was already sized.
Send multi-value filters as one comma-separated value. A repeated parameter is not merged; the server reads only the first.
A filter change starts a fresh cursor; do not carry one across filter sets.
Filter names follow cardinality:
| Filter kind | Naming |
|---|---|
| Multi-value | Plural, sent as one comma-separated value |
| Resource id | Ends in Id or Ids |
| Timestamp bound | Named for the field it bounds, such as createdFrom/createdTo or scheduledFrom/scheduledTo |
| Free-text search | q, where a resource supports it |
A filtered collection request:
curl -s "https://api.ulsav.com/api/v1/organizations/$ULSAV_ORG_ID/events?statuses=draft,published&modes=in_person"Idempotency
Send Idempotency-Key on any command a retry must not duplicate. These operations read it:
| Operation | Requirement |
|---|---|
POST /api/v1/organizations | Required |
POST /api/v1/registrations/checkout | Required by the handler |
POST /api/v1/registrations/payment-intents | Required by the handler |
POST /api/v1/organizations/{organization_id}/registrations/bulk/cancel | Optional |
POST /api/v1/organizations/{organization_id}/registrations/bulk/switch | Optional |
POST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay | Required |
Refunds and registration imports ignore the header, so a retried refund is a second refund request. Keys are stored per Organization and operation; checkout and payment intents also bind the key to the payload, so a different payload is a conflict.
Reused with the same payload
- The stored response is returned
- The action is not performed twice
Reused with a different payload
- The call fails instead of replaying
409 idempotency_conflicton Organization creation409 idempotency_key_conflictwhen a replay key belongs to another delivery409 request_invalidon checkout and payment intents
Generate a new key per logical attempt and reuse it when retrying that attempt. Do not derive it from the payload: two intentional identical Orders are still two Orders.
Request IDs
Every response carries X-Request-ID. If you send one, the server reuses it; otherwise it generates one. Problem responses echo the same value as requestId.
Log it with every failure and include it in support requests; it correlates your call with server logs across the API, worker, and webhook path.
Rate limits
Limits are fixed-window and chosen by route class:
| Class | Bucket |
|---|---|
| Anonymous public reads | Client IP |
| Organization API key | The key |
| Browser session | Client IP |
An exceeded limit returns 429 with Retry-After in seconds and a rate_limited body. Back off for at least that long. The defaults are 120 requests per minute per API key, 60 on public read routes, and 300 on other browser traffic; the deployment can override them.
Content types
| Message | Media type |
|---|---|
| Request body | application/json, sent as Content-Type |
| Response | UTF-8 JSON |
| Error | application/problem+json |
| File and stream endpoint | Documented per endpoint |
The generated schemas are closed (additionalProperties: false), so a new field is a schema change. Ignore fields you do not recognize rather than rejecting a response.
Versioning
The version is the path segment: /api/v1.
| Change | Inside /api/v1 |
|---|---|
| Add a response field | Allowed |
| Add an optional request field | Allowed |
| Add an endpoint | Allowed |
| Add a webhook event type | Allowed |
| Remove or rename a field | New API version or documented migration |
| Change a status code | New API version or documented migration |
| Change documented behavior | New API version or documented migration |
The OpenAPI diff is a CI gate, so the published document and the running API do not drift silently.
Webhooks carry their own versions: api_version on the envelope and version on each event payload. A breaking payload change bumps the per-type version; additive fields do not.