# 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 shopdesk_sk_7f3kq9p2xR4mVnB8tLcYwZ1sD6hJ0gKeQaUiOpNm
```

## Key format

A key is the literal prefix `shopdesk_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 Shopdesk key structurally
impossible to confuse with a JWT. Anything that does not start with
`shopdesk_sk_` is rejected before we even look in the database, so a stolen
dashboard session token can never be replayed here.

> **Note — Keys issued before 2026-09-14 start with salaf\_sk\_**
>
> The prefix changed with the Shopdesk rename. Existing keys keep working
> exactly as they are — the hash we store covers the whole secret, and the API
> accepts both spellings — so nothing needs to change on your side. A key you
> create or rotate from now on carries `shopdesk_sk_`.

> **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
shopdesk_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 SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

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

**JavaScript**

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

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_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.shopdeskbd.com/ext/v1/locations",
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_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('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.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 SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

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

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.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.SHOPDESK_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.shopdeskbd.com/ext/v1/orders",
    params={
        "store_id": "REPLACE_WITH_STORE_ID",
        "limit": "50",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_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('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.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, attributes                                                                                                                    | 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                         |
| `store:read`      | The store profile: name, domain, currency, timezone, contact details, advance-payment plans                                                                           | Yes                         |
| `reports:read`    | Sales summary; comparisons and top products on plans with Advanced Reports                                                                                            | Yes                         |
| `payments:read`   | Payments recorded against an order                                                                                                                                    | Yes                         |
| `products:write`  | Create, update and archive products and their variants; add product images by upload or by link, and manage them                                                      | Yes                         |
| `catalog:write`   | Create and update categories, brands and attributes with their values and images. Granted only together with `products:read`                                          | Yes                         |
| `videos:write`    | Upload product videos or import them from a link, set their covers, place them on products and list or delete this key's own uploads, within the plan's video storage | Yes                         |
| `orders:write`    | Create orders, change status, cancel, edit the note and follow-up                                                                                                     | 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.

Five 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.
- **`catalog:write` comes with `products:read`.** Categories, brands and
  attributes are read through `products:read` endpoints, so a key that can
  create them must be able to list them. Creating a key with `catalog:write`
  but neither `products:read` nor `products:write` is refused, and the
  dashboard ticks `products:read` for you.
- **`videos:write` stands alone.** It is its own opt-in scope, not part of
  `products:write`, because a video uses the plan's video storage and can be
  up to 200 MB. It reads only the key's own uploads (`GET /videos`); a
  product's ready videos are read with the product, under `products:read`.
  See [Upload a product video](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md).
- **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.shopdeskbd.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. The one exception is `GET /me`, which any
valid key may call to describe itself: its scopes, the stores it reaches and
its rate limits.

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/me" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

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

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

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

const object = await response.json();
```

**Python**

```python
import os

import requests

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

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

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/me');
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']}");
}
```

## Rotation

Rotating replaces a key without an outage. Shopdesk 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
  `SHOPDESK_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.
