Core concepts

Making requests

JSON bodies, required headers and the standard response envelope.

The API speaks JSON over HTTPS with conventional REST verbs: GET to read, POST to create, PATCH to update, and DELETE to remove. Send Content-Type: application/json on any request with a body.

The response envelope

Every successful response is wrapped in a consistent envelope. The resource you asked for is always under data; meta carries the request id (also returned in the X-Request-Id response header) and a server timestamp.

json · response envelope
{
  "data": { /* the resource, or an array of resources */ },
  "meta": {
    "requestId": "req_9f3a...",
    "timestamp": "2026-09-06T09:00:00.000Z"
  }
}

Errors use a different, flat shape (see errors) — they are not wrapped in data.

Request headers

HeaderRequiredPurpose
AuthorizationYes (protected routes)Bearer <access token> from login.
Content-TypeOn writesAlways application/json.
X-Company-IdRecommendedSelects the active company for tenants with more than one. Data is isolated per company; without it the request falls back to the caller's default scope.
Idempotency-KeyOptionalMakes a write safely retryable — see below.

Choosing the active company

The active company can also be passed as a ?companyId=query parameter for simple links and GETs that cannot set a header. The header wins when both are present. The server still verifies the caller's membership actually grants access to that company.

Pagination & filtering

List endpoints accept a common set of query parameters. Results are returned newest-first by default.

ParameterDefaultNotes
page11-based page number.
limit20Rows per page, maximum 1000.
search—Free-text match across the resource's searchable fields.
sortBy / sortDirdescSort column (whitelisted per resource; an unknown column falls back to newest-first) and direction (asc or desc).
status—Filter documents by status, e.g. Draft / Posted.
customerId / vendorId—Filter rows by their linked customer or vendor (ledger views).
paymentType—Cash / Credit — sales and purchase invoices.
curl · filtered list
curl "https://gulfpay-production.up.railway.app/api/v1/customers?page=1&limit=20&search=acme&sortDir=desc" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "X-Company-Id: cmp_123"

For the full set of paging, sorting and filter parameters — and how to iterate every page — see pagination & filtering.

Idempotent writes

Any mutating request (POST, PATCH, PUT, DELETE) may carry an Idempotency-Key header — a unique value you generate per logical operation (a UUID is ideal). If a request is retried with the same key — for example after a dropped connection where you never saw the response — the server recognises the key and does not apply the change a second time.

curl · idempotent create
curl -X POST https://gulfpay-production.up.railway.app/api/v1/customers \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "X-Company-Id: cmp_123" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5f0b2b1e-8a1c-4c2b-9f3a-0d1e2f3a4b5c" \
  -d '{ "name": "Acme Trading LLC", "email": "ap@acme.example" }'

The first request with a given key is processed normally and the key is recorded once it succeeds (a failed request stays retryable). A replay of an already-applied key returns 409 Conflict with { "idempotentReplay": true } — treat that as success and stop retrying. Keys are retained for 7 days.

Generate a fresh key per operation

Reuse the same key only when retrying the very same request. Use a new key for each distinct create/update so unrelated operations never collide. See idempotent requests for a full retry loop.

Body size

JSON bodies may be up to 50 MB, which comfortably covers base64-encoded attachments such as document PDFs and item images.