Ulsav Docs

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.

ParameterMeaning
limitPage size from 1 to 100; default 20
cursorOpaque value from meta.nextCursor
includeTotalDefault 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"
  }
}
FieldBehavior
dataAlways an array, empty rather than null on an empty or final page
meta.hasMoreAlways present
meta.totalOmitted when includeTotal=false
meta.nextCursorOmitted 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 kindNaming
Multi-valuePlural, sent as one comma-separated value
Resource idEnds in Id or Ids
Timestamp boundNamed for the field it bounds, such as createdFrom/createdTo or scheduledFrom/scheduledTo
Free-text searchq, 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:

OperationRequirement
POST /api/v1/organizationsRequired
POST /api/v1/registrations/checkoutRequired by the handler
POST /api/v1/registrations/payment-intentsRequired by the handler
POST /api/v1/organizations/{organization_id}/registrations/bulk/cancelOptional
POST /api/v1/organizations/{organization_id}/registrations/bulk/switchOptional
POST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replayRequired

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_conflict on Organization creation
  • 409 idempotency_key_conflict when a replay key belongs to another delivery
  • 409 request_invalid on 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:

ClassBucket
Anonymous public readsClient IP
Organization API keyThe key
Browser sessionClient 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

MessageMedia type
Request bodyapplication/json, sent as Content-Type
ResponseUTF-8 JSON
Errorapplication/problem+json
File and stream endpointDocumented 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.

ChangeInside /api/v1
Add a response fieldAllowed
Add an optional request fieldAllowed
Add an endpointAllowed
Add a webhook event typeAllowed
Remove or rename a fieldNew API version or documented migration
Change a status codeNew API version or documented migration
Change documented behaviorNew 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.

On this page