# Edara API v3: Guide

Read this page before any reference page. It covers how to call the API and the behaviours
that most often cause wrong results. Last verified against the live API on 2026-09-28.

## Hosts

| | URL |
|---|---|
| API | `https://edara-api.edara.io/v3/...` (HTTPS only) |
| Interactive docs | `https://edara-api.edara.io/swagger/ui/index` |
| OpenAPI spec (Swagger 2.0) | `https://edara-api.edara.io/swagger/docs/v3` |

`https://api.edara.io` is the separate v2 API (`/v2.0/...`). It has a different response
format. Do not mix the two.

## Authentication

Send the token in the `Authorization` header. Two kinds of token work:

- **Integration token.** Create it in Edara under Data, then Integration, then Rest APIs.
  The value you copy already starts with `Bearer `, so send it as is.
- **A signed-in user's Edara access token (JWT).** Send it as `Bearer <token>` and add the
  `TenantId` header with the organization's id.

```http
GET /v3/customers?offset=0&limit=100 HTTP/1.1
Host: edara-api.edara.io
Authorization: Bearer <token>
TenantId: <organization id>      # only with a user JWT
Accept-Language: ar              # optional: localizes error detail text
Content-Type: application/json
```

Each endpoint requires one permission, shown on its reference entry (for example
`read:customer`). A 403 response names the missing permission in `detail`.

## Request and response format

- JSON only. Property names are camelCase.
- Enums are sent and returned as names (`"Confirmed"`), not numbers.
- Ids are integers. Requests refer to other records by id (`customerId`, `warehouseId`).
  To look up by code, use the explicit `code/{code}` routes.
- Responses embed related records as reference objects: `{ "id": 1, "code": "...", "name": "..." }`.
- Null values are included in responses.
- A successful create returns 201 with a `Location` header and the created object.

## Paging

```json
{ "items": [], "totalCount": 142, "page": 2, "pageSize": 100, "totalPages": 2 }
```

- `offset` defaults to 0. `limit` defaults to 100 and the maximum is 1000.
- Use offsets that are multiples of `limit`.
- Some endpoints reject a `limit` outside 1 to 1000 with a 400. Others silently fall back
  to 100. Each reference table says which.
- A few endpoints have a different cap, and a few are not paged at all. Check the endpoint.
- Always loop until `offset >= totalCount`. Do not stop because a page came back short.

## Errors

Errors use `application/problem+json`:

```json
{ "title": "Conflict", "status": 409, "detail": "localized text",
  "instance": "/v3/brands", "traceId": "...", "errorCode": "DupplicatedName",
  "errors": { "name": ["..."] } }
```

| Status | Meaning | What to do |
|---|---|---|
| 400 | Validation failed (`errors` lists each field), or a referenced id does not exist | Fix the input |
| 401 | Missing or invalid token, or a user JWT without `TenantId` | Sign in again or add the header |
| 403 | The token lacks the permission named in `detail` | Use a token that has it |
| 404 | Record not found, or the route does not exist | Check the path spelling first |
| 409 | A business rule refused the request | Match on `errorCode`, listed per endpoint |
| 429 | Rate limit reached | Wait for `Retry-After` seconds |
| 500 | Unexpected error | Report it with the `traceId` |

- Match on `errorCode`, never on `detail`. The `detail` text is localized.
- Error codes keep their original spelling: `DupplicatedName` and `DupplicatedCode` have a double p.
- `PhantomRead` (409) means the record changed while you were editing it. Reload and retry.
- Batch endpoints return 200 with `{ "succeeded": [], "failed": { "<key>": "message" } }`.
  Partial success is normal, so always inspect `failed`.

## Rate limits

- 5 requests per second, 250 per minute and 5,500 per hour.
- A limit can apply per user or per organization, so several integrations in one
  organization can share one budget. Leave room for the others.
- Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
  and `X-RateLimit-Scope`. The scope names the limit closest to being reached.
- On 429, wait for the number of seconds in `Retry-After`. Do not send large parallel bursts.

## Behaviours that cause wrong results

### Filters that are on by default

Send these explicitly. If you omit them, results shrink without any error.

- `GET /v3/sales-orders`: `onlyMyOrders` defaults to `true`, which returns only orders the
  calling user created. `warehouseOnly` defaults to `true`, which leaves out sales-store
  (point of sale) orders. Sales returns appear in the same list.
- `GET /v3/work-orders`: `onlyMyOrders` defaults to `true`, including for `code` and
  `paperNumber` lookups. To find one document reliably, query with `false`, then with
  `true`, and remove duplicates by id.
- `GET /v3/journal-entries` and `GET /v3/accounts/{id}/balance`: `isPostedOnly` defaults to
  `true`. Journal entries created through the API are saved unposted, so they do not appear
  until they are posted.

### Matching differs per endpoint

Never assume `items[0]` is the record you asked for.

- `GET /v3/customers`: `code`, `name`, `phone`, `mobile` and `email` match partially.
  `externalId` matches exactly. `updateDate` returns records changed strictly after the value.
  `search` behaves differently: active customers only, at most 50 matches per field.
- `GET /v3/stock-items`: only one filter applies per call, in this order: `code`, `sku`,
  `partNumber`, `externalId`. A filtered call ignores `offset` and `limit` and returns
  every match. The unfiltered list returns 404 when there are no items. Inactive items are included.
- `GET /v3/stock-items/search`: returns at most 1000 items, in no guaranteed order, with no
  `totalCount`. Inactive items are left out.

### An empty list can mean missing data permissions

Lists of warehouses, customers, stock items and sales orders are filtered by the calling
user's data permissions in Edara. A user without them gets an empty 200, not a 403. If a
list is unexpectedly empty, check that user's data permissions first.

### Date range filters

Most `...To` filters compare a full date and time. `dateTo=2026-09-22` means midnight at
the start of that day, so the whole day is left out. Send `2026-09-22T23:59:59.997`.

### Slow calls

- `GET /v3/warehouses/{id}/balance` and `GET /v3/sales-orders` are slow on large data.
  Do not use them to copy data in bulk.
- For balances, prefer `GET /v3/warehouses/stock-items-balances` or
  `GET /v3/stock-items/warehouse-summary` (limit up to 5000).

### Writing data

- **PUT replaces the whole record.** Fields you omit are cleared or reset. For example, a
  tax update without `active` deactivates the tax. Always GET the record, change it, then PUT it.
- **Unknown ids** in a create or update often return 400 without an `errorCode`, or
  sometimes 500, instead of 404. Validate ids before you write.
- **Exchange rate.** If you omit it on a work order, it is 1, even for a foreign currency.
  On a journal entry, it is today's rate, not the rate on the document date. More than 6
  decimals is refused with 409 `ExchangeRateDecimalsExceedLimit`.
- **Always send `externalId` when you create a sales order.** A retry with the same
  `externalId` is refused with a 400 that has no `errorCode`. Treat that as "already
  created" and look the order up. Without `externalId`, a retry creates a duplicate.
- **Sales-order line prices are not filled in from the item.** A missing price becomes 0,
  which is refused unless the organization allows zero prices.
- **Sales-order status calls do not check the current status.** Issue, un-issue and status
  updates change the status only, and issue does not create a stock issue or an invoice.
  Enforce the allowed transitions in your own code.
- **Confirm deletes.** After deleting or deactivating a stock item, read it again to
  confirm. A 204 does not always mean the item changed.

### Known response issues

- `GET /v3/stock-items/serials` returns `serialNumber` as null.
- `GET /v3/sales-orders/brand-sales` with an unknown brand name returns all brands.
- Items in `GET /v3/customers` carry `balance: 0`. Use `GET /v3/customers/{id}/balance`.

## Shared shapes

- `PagedResult<T>`: see Paging.
- `BatchResult`: `{ "succeeded": [], "failed": {} }`, see Errors.
- `ProblemDetails`: see Errors.
- `*Reference` (for example `CustomerReference`, `WarehouseReference`): `{ id, code, name }`.
