# Apps API reference

> The Hekana Apps API: endpoints, scopes, request bodies and responses, pagination, idempotency keys, API versions, rate limits and error codes.

Source: https://hekana.dev/apps/api-reference

Base URL: `https://api.hekana.com/api/v1/apps-api`. Everything is scoped to the store and installation the Bearer token names. Money is in piastres (EGP minor units, 100 piastres to the pound).

## Conventions

### Authentication

`Authorization: Bearer <access token>`. See [Authentication and scopes](https://hekana.dev/apps/authentication).

### Envelope

Successful replies are `{ "success": true, "data": ..., "requestId": "..." }`. The shapes below describe `data`. Errors are `{ "statusCode", "message", "error" }`, and where a stable code exists it is the `message` (for example `insufficient_funds`).

### Version

Send `Hekana-Api-Version: 2026-10-01` (optional). The version in use is echoed on every `/apps-api` reply except the webhook, data and checkout-extension routes. An unknown version answers `400 unsupported_api_version` with the supported list. Without the header you get the current version.

### Pagination

`GET /orders` and `GET /products` return `{ data, total, page, limit, nextCursor }`. Pass `nextCursor` back as `?cursor=` until it is `null`; do not build cursors yourself. `?limit=` is 1 to 100 (default 20). `GET /customers` returns `{ data }` only, takes `?q=` and `?limit=`, and has no cursor.

### Idempotency

Send `Idempotency-Key: <8 to 80 characters of A-Z a-z 0-9 : _ . ->` on a `POST` to the routes in the table below. The first request runs and its answer is kept 24 hours. The same key with the same body returns that answer without running again; the same key with a different body is `422 idempotency_key_reused`; a repeat while the first is running is `409 request_in_progress`. A failed request is not kept. A malformed key is `400 invalid_idempotency_key`. Keys are per installation.

### Limits

Each endpoint has its own per-minute limit (in the table), and every installation has 5,000 requests an hour across the API. Past that: `429 hourly_request_limit` with a `Retry-After` header. A global per-IP limit also applies.

## Endpoints

| Method and path | Scope | Per minute |
| - | - | - |
| `GET /orders` | `read_orders` | 60 |
| `POST /orders` | `write_orders` | 30 |
| `GET /products`, `GET /products/:id` | `read_products` | 60 |
| `GET /customers`, `GET /customers/:id` | `read_customers` | 60 |
| `GET /analytics` | `read_analytics` | 60 |
| `POST /discounts` | `write_discounts` | 30 |
| `POST /messages` | `send_messages` | 20 |
| `POST /charge` | `billing` | 30 |
| `GET /earnings` | `billing` | 60 |
| `GET /data`, `GET\|PUT\|DELETE /data/:key` | `app_data` | PUT 60 |
| `GET /checkout-extensions`, `PUT\|DELETE /checkout-extensions/:key` | `checkout_extensions` | PUT 30 |
| `GET /webhooks/topics`, `GET\|POST /webhooks`, `DELETE /webhooks/:id`, `GET /webhooks/:id/deliveries`, `POST /webhooks/:id/test` | none | see [Webhooks](https://hekana.dev/apps/webhooks) |

`Idempotency-Key` and the version header apply to the first nine rows (`/orders` through `/earnings`). Ids in paths are UUIDs.

## Orders

`GET /orders` (scope \`read\_orders\`, 60 a minute)

`POST /orders` (scope \`write\_orders\`, 30 a minute)

`GET /orders` returns, per order: `id`, `orderNumber`, `fulfillmentState`, `paymentState`, `deliveryState`, `totalCents`, `customerName`, `createdAt`.

`POST /orders` places a cash-on-delivery order for one of the store's customers, through the same path as a storefront checkout: the store prices each line from its catalogue, takes stock and honours its discounts and limits. You never send a price or a shipping fee.

```json title="POST /orders (request body)"
{
  "reference": "renewal:sub_1042:2026-11",
  "customerId": "<customer uuid>",
  "items": [{ "productId": "<product uuid>", "variantId": "<variant uuid, optional>", "quantity": 2 }],
  "shippingAddressId": "<saved address uuid, optional>",
  "discountCode": "<existing code, optional>"
}
```

- `reference`: 8 to 80 characters of letters, digits and `: _ . -`. Your own id for the order. Sending it again returns the first order instead of a second.
- `items`: 1 to 50 lines, `quantity` 1 to 9999.
- `shippingAddressId` defaults to the customer's default address; a customer with no saved address is a `400`.
- `notes` is accepted by validation but is not applied to the order.

The reply (`201`) is `{ id, orderNumber, status, totalCents }`.

## Products and customers

`GET /products` (scope \`read\_products\`, 60 a minute)

`GET /products/:id` (scope \`read\_products\`, 60 a minute)

`GET /customers` (scope \`read\_customers\`, 60 a minute)

`GET /customers/:id` (scope \`read\_customers\`, 60 a minute)

`GET /products` items: `id`, `sku`, `slug`, `nameAr`, `nameEn`, `status`, `priceCents`. `GET /products/:id` adds `variants`: `id`, `sku`, `name`, `optionValues`, `priceCents` (`null` when the variant sells at the product price), `comparePriceCents`, `imageUrl`, `isActive`. Cost data is never included.

`GET /customers` items: `id`, `nameAr`, `nameEn`, `phone`, `email`, `totalOrders`. `GET /customers/:id` adds `addresses`: `id`, `label`, `recipientName`, `phone`, `governorate`, `city`, `district`, `street`, `building`, `isDefault`.

`GET /analytics` returns `{ totalOrders, totalProducts }`. That is all it returns today.

## Discounts

`POST /discounts` (scope \`write\_discounts\`, 30 a minute)

`POST /discounts` creates a code on the store, through the same rules as a code the owner makes by hand.

```json title="POST /discounts (request body)"
{
  "code": "WINBACK10",
  "type": "percentage",
  "value": 10,
  "productIds": ["<product uuid>"],
  "perCustomerLimit": 1,
  "totalUsageLimit": 100,
  "expiresAt": "2026-12-31T00:00:00.000Z"
}
```

`type` is `percentage` (`value` 1 to 100) or `fixed_amount` (`value` in piastres). `productIds` is required (1 to 500, all from this store), so a code can never cover the whole catalogue. Automatic, free-shipping, buy-one-get-one and tiered discounts cannot be made. Reply (`201`): `{ id, code, type, isActive }`.

## Messages

`POST /messages` (scope \`send\_messages\`, 20 a minute)

`POST /messages` sends one customer a short email or WhatsApp message.

```json title="POST /messages (request body)"
{ "customerId": "<customer uuid>", "channel": "email", "subject": "Your order", "text": "Your reorder is ready." }
```

`text` is 1 to 500 characters; `subject` (email only) up to 100. **Links are refused**, in the text and the subject, including anything that looks like a domain (`400 links_not_allowed`). The message goes through the store's own sending rules and allowance, and carries a footer naming your app. Each installation may send 200 a day (`429 daily_message_limit`). Reply: `{ sent: true }`, or `{ sent: false, reason }` where `reason` is `no_contact` (the customer has no address for that channel) or `not_sent`.

## Charges

`POST /charge` and `GET /earnings` are covered in [Billing and pricing](https://hekana.dev/apps/billing).

## App data

A private key-value store for the installation, deleted when the store uninstalls.

- Keys: 1 to 128 characters of `A-Z a-z 0-9 _ . : -`. Values: any JSON, up to 64 KB each. At most 500 keys and 1 MB in all per installation.
- `PUT /data/:key` with body `{ "value": ... }` replies `{ key, sizeBytes }`. `GET /data/:key` replies `{ key, value, updatedAt }`. `GET /data?prefix=` lists `{ key, sizeBytes, updatedAt }`. `DELETE` replies `204`.
- Errors: `invalid_key`, `invalid_value`, `key_not_found` (404), `value_too_large`, `too_many_keys`, `store_full` (413).

## Checkout extensions

A checkbox on the store's checkout that offers **one of the store's own products**. Ticking it adds that product to the cart as a normal line, so the order path prices it; an extension never sends a price or changes a total.

```json title="PUT /checkout-extensions/gift-wrap (request body)"
{ "labelAr": "<label shown in Arabic>", "labelEn": "Add gift wrap", "productId": "<product uuid>", "enabled": true }
```

- The key is 1 to 64 characters of `A-Z a-z 0-9 _ . -`. `labelAr` is required (up to 120 characters), `labelEn` optional.
- The product must be active, simple (no variants) and priced. Otherwise: `product_not_found`, `product_not_active`, `product_has_variants`, `product_has_no_price`.
- **Three per store across all apps** (`too_many_extensions`). Extensions are removed when the app is uninstalled.
- `GET` lists the app's own extensions: `{ key, labelAr, labelEn, productId, enabled }`.

## Error codes

| Status | `message` | Meaning |
| - | - | - |
| 400 | `unsupported_api_version` | The version header names a version that does not exist. |
| 400 | `invalid_idempotency_key` | The key is not 8 to 80 allowed characters. |
| 400 | `links_not_allowed` | A message contains a link. |
| 401 | `missing_bearer_token`, `invalid_token` | No token, or the token is expired, revoked or unusable. |
| 403 | `insufficient_scope: <scope>` | The installation was not granted that scope. |
| 404 | `Customer not found.` and similar | The id does not exist in this store. |
| 409 | `request_in_progress` | An identical request with this key is still running. |
| 422 | `idempotency_key_reused` | The key was used with a different body. |
| 429 | `hourly_request_limit`, `daily_message_limit` | A budget is spent; see `Retry-After` on the first. |
| 503 | `message_cap_unavailable` | The daily message counter is down; nothing was sent. |

Billing errors are in [Billing and pricing](https://hekana.dev/apps/billing).
