# Authentication

Key format, store vs company keys, rotation, revocation.

Every request carries an API key in an `Authorization` header. There is no
token exchange, no refresh, and nothing expires unless you ask it to.

```http
Authorization: Bearer salaf_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm
```

## Key format

A key is the literal prefix `salaf_sk_` followed by 40 random base62
characters — around 238 bits of entropy.

The prefix is not decoration. It earns its place three times: secret scanners
(GitHub and GitLab push protection among them) match on exactly this shape, so
a key committed by accident gets caught; it tells you at a glance which of the
tokens in a config file is ours; and it makes a Salaf key structurally
impossible to confuse with a JWT. Anything that does not start with
`salaf_sk_` is rejected before we even look in the database, so a stolen
dashboard session token can never be replayed here.

> **Danger — We cannot recover a key for you**
>
> Only a SHA-256 hash of your key is stored. Not encrypted — hashed. There is
> no route, no admin screen and no support process that can return the
> plaintext, because it does not exist anywhere after the response that created
> it. Lose it and you rotate.

In the dashboard and in our logs a key appears masked, as its prefix sample and
last four characters:

```text
salaf_sk_7f3kq9p2…OpNm
```

## Store keys and company keys

The key type is chosen at creation and can never change. It decides how wide
the tenant fence is — never how the fence works.

|                     | Store key                                         | Company key                           |
| ------------------- | ------------------------------------------------- | ------------------------------------- |
| Sees                | One store                                         | Every live store in the company       |
| Created by          | Anyone with `developer.manage`                    | Company owners only                   |
| `store_id` on rows  | The key's own store                               | Varies per row — partition on it      |
| `?store_id=` filter | Must match the key's own store                    | Narrows to one store in the company   |
| Best for            | A single storefront, a POS bridge, one shop's ERP | A head office reading across branches |

**Start with a store key.** A company key is a wider blast radius if it leaks,
and most integrations only ever touch one store. Reach for a company key when
you genuinely need one credential to read several stores.

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS "https://api.salafems.com/ext/v1/locations" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/locations");

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SALAF_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.salafems.com/ext/v1/locations",
    headers={
        "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.com/ext/v1/locations');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

### Narrowing a company key

Pass `store_id` to restrict a read to one store. The value is validated against
your company before any query runs — an id belonging to someone else's company
returns `404`, not `403`, so the parameter can never be used to probe for which
store ids exist.

**cURL**

```bash
export SALAF_API_KEY="salaf_sk_YOUR_API_KEY"

curl -sS "https://api.salafems.com/ext/v1/orders?store_id=REPLACE_WITH_STORE_ID&limit=50" \
  -H "Authorization: Bearer $SALAF_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.salafems.com/ext/v1/orders");
Object.entries({
  store_id: "REPLACE_WITH_STORE_ID",
  limit: "50",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SALAF_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.salafems.com/ext/v1/orders",
    params={
        "store_id": "REPLACE_WITH_STORE_ID",
        "limit": "50",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SALAF_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SALAF_API_KEY');

$ch = curl_init('https://api.salafems.com/ext/v1/orders?store_id=REPLACE_WITH_STORE_ID&limit=50');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

> **Note — Locations are the exception**
>
> Locations belong to the **company**, not to a store: several stores share one
> warehouse. So a location carries `company_id` instead of `store_id`, and
> passing `store_id` does not narrow the list. This is the one resource where a
> store key and a company key see the same rows.

## Scopes

A key carries a set of scopes, fixed when it is created. Scopes are named
`resource:action` and there are only two verbs — `read` and `write`.

| Scope             | Covers                                 | Endpoints today             |
| ----------------- | -------------------------------------- | --------------------------- |
| `products:read`   | Products, variants, categories, brands | Yes                         |
| `orders:read`     | Orders, their items and payment state  | Yes                         |
| `customers:read`  | Customers and their addresses          | Yes                         |
| `inventory:read`  | Stock levels and the movement ledger   | Yes                         |
| `locations:read`  | Warehouses and stock locations         | Yes                         |
| `channels:read`   | Sales channels                         | Yes                         |
| `payments:read`   | Payments recorded against an order     | Yes                         |
| `products:write`  | Create, update and archive products    | Yes                         |
| `orders:write`    | Create orders, change status, cancel   | Yes                         |
| `customers:write` | Create and update customers            | Yes                         |
| `inventory:write` | Stock adjustments                      | Yes                         |
| `payments:write`  | Record a payment against an order      | Yes                         |
| `webhooks:manage` | Manage webhook endpoints via the API   | Declared, **not grantable** |

> **Note — Why webhooks:manage cannot be granted**
>
> Webhook endpoints are created and edited in the dashboard only. A leaked API
> key must not be able to point a merchant's event stream at somebody else's
> server, and no scope granularity makes that risk acceptable. The scope exists
> in the catalogue so the day it becomes grantable is an additive change rather
> than a new name.

Three rules govern them:

- **Write implies read of the same resource.** A key with `orders:write` also
  satisfies `orders:read`. The reverse is never true, which is the direction
  that actually matters.
- **Scopes are immutable.** No endpoint changes a key's scopes, and the
  dashboard does not offer it either. Need different scopes? Create a new key
  and revoke the old one.
- **You can only grant what you hold.** A staff user can only tick scopes their
  own role can back. A cashier who cannot read orders in the dashboard cannot
  mint a key that reads them over the API.

Every operation in the [reference](https://www.salafems.com/developers/reference.md) shows the scope it
requires. A key missing it gets `403 FORBIDDEN_SCOPE` — the endpoint is never
silently reduced to an empty result.

## Rotation

Rotating replaces a key without an outage. Salaf issues a **new** key and sets
an expiry on the old one instead of killing it, so both work during the
overlap:

1. Rotate in the dashboard and copy the new secret.
2. Both keys are valid for the grace window — **24 hours by default**.
3. Deploy the new key wherever the old one lives.
4. The old key stops working when the window closes. Nothing to remember, no
   second visit to the dashboard.

If you rotate because a key **leaked**, choose the immediate option instead:
the old key dies at once and the overlap is skipped. A brief outage is the
correct trade when the alternative is a live credential in someone else's
hands.

## Revocation

Revoking is permanent — a revoked key never comes back, and there is no
"delete" at all, because the row is audit history.

**Revocation takes effect immediately.** Authentication is cached for speed,
but every revocation and rotation explicitly evicts that cache rather than
waiting for it to expire, so there is no window in which a killed key still
works.

## Why a request was rejected

All authentication failures return `401` and are told apart by their `code`:

| Code           | Meaning                                                                                                                             |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHORIZED` | The header is missing or malformed, the key is unknown, your plan does not include API access, or the store or company is not live. |
| `KEY_REVOKED`  | The key existed and was deliberately killed.                                                                                        |
| `KEY_EXPIRED`  | The key's clock ran out — either its own expiry date, or the end of a rotation grace window.                                        |

> **Note — Only revoked and expired are distinguished**
>
> Everything else — an unknown key, a plan without API access, a suspended
> store — answers plain `UNAUTHORIZED`. The two that are named are named
> deliberately: someone at the merchant chose to kill that key, so telling the
> caller why is a kindness rather than a leak. The rest stay
> indistinguishable, so the endpoint cannot be used to work out whether a
> guessed key exists.

Two more you will see:

- `403 FORBIDDEN_SCOPE` — the key is fine, but it was not granted this scope.
- `404 NOT_FOUND` — for an id in another tenant. Never `403`; a `403` would
  confirm the record exists.

> **Warning — Plan and store changes take about a minute**
>
> Revocation and rotation are instant. Plan changes and store suspensions are
> read through a short-lived cache, so those propagate within roughly a
> minute rather than on the very next request. If you are testing an upgrade,
> give it a moment before concluding it did not work.

## Keeping keys safe

- **Server-side only.** A key grants everything its scopes allow across a whole
  store. It has no business in a browser, a mobile app, or anything else a
  customer can open.
- **Environment variables, not source.** Every sample in these docs reads
  `SALAF_API_KEY` from the environment for exactly this reason.
- **One key per integration.** Separate keys mean you can revoke the one that
  leaked without taking down the other four, and `last_used_at` in the
  dashboard tells you which is which.
- **Only the scopes it needs.** A reporting tool that reads orders should not
  hold `customers:read` as well.
