# API keys, responses and limits

Every call to the public API carries an **API key**. All routes start with `https://dashboard.azimea.com/api/v1`; the [API reference](/build/reference/) lists each one.

## Make a key

[Section titled “Make a key”](#make-a-key)

Owners and admins make keys in **Settings › API keys**:

1.  Give the key a name you will recognise later (2–100 characters), such as the system that will use it.
2.  Tick only the permissions that system needs (below).
3.  Choose when it expires: in 30, 90 or 365 days, or never (chosen explicitly).
4.  Copy the key. **It is shown once**; afterwards the list shows only its ends (`azm_live_7f3c…5e0c`) and when it was last used.

A company can have up to 10 active keys. A key stops working the moment it is revoked or expires; Azimea answers the same way for a revoked key as for one that never existed.

### Permissions

[Section titled “Permissions”](#permissions)

| Permission | What it allows |
| --- | --- |
| `contacts:read` | Read contacts, segments, topics and contact fields: `GET /contacts`, `GET /contacts/{reference}`, `GET /segments`, `GET /topics`, `GET /contact-attributes` |
| `contacts:write` | Create and update contacts and contact fields: `POST /contacts`, `POST /contacts/batch`, `POST /contact-attributes`, `PATCH /contact-attributes/{key}` |
| `contacts:erase` | Erase a contact for good (GDPR): `DELETE /contacts/{reference}`. Not included in any other permission |
| `events:write` | Report what people did in your product: `POST /events` |
| `emails:send` | Send transactional email: `POST /sending/emails` |
| `items:read` | Read a catalogue item you sent: `GET /items/{externalId}` |
| `items:write` | Write your catalogue: `POST /items`, `POST /items/batch`, `DELETE /items/{externalId}` |
| `orders:read` | Read orders: `GET /commerce/orders` |

A call the key has no permission for is answered `403`.

### Keys for testing

[Section titled “Keys for testing”](#keys-for-testing)

Keys made inside a **sandbox** start with `azm_test_`. They work exactly like live keys, against the sandbox’s own data. No email sent from a sandbox reaches anyone: it lands in the sandbox’s inbox, and its delivery webhooks are still sent, so the whole integration can be tried end to end.

## Send the key

[Section titled “Send the key”](#send-the-key)

Put it in the `Authorization` header as a bearer token, on every call:

(() => { class StarlightTabsRestore extends HTMLElement { connectedCallback() { const starlightTabs = this.closest('starlight-tabs'); if (!(starlightTabs instanceof HTMLElement) || typeof localStorage === 'undefined') return; const syncKey = starlightTabs.dataset.syncKey; if (!syncKey) return; const label = localStorage.getItem(\`starlight-synced-tabs\_\_${syncKey}\`); if (!label) return; const tabs = \[...starlightTabs?.querySelectorAll('\[role="tab"\]')\]; const tabIndexToRestore = tabs.findIndex( (tab) => tab instanceof HTMLAnchorElement && tab.textContent?.trim() === label ); const panels = starlightTabs?.querySelectorAll(':scope > \[role="tabpanel"\]'); const newTab = tabs\[tabIndexToRestore\]; const newPanel = panels\[tabIndexToRestore\]; if (tabIndexToRestore < 1 || !newTab || !newPanel) return; tabs\[0\]?.setAttribute('aria-selected', 'false'); tabs\[0\]?.setAttribute('tabindex', '-1'); panels?.\[0\]?.setAttribute('hidden', 'true'); newTab.removeAttribute('tabindex'); newTab.setAttribute('aria-selected', 'true'); newPanel.removeAttribute('hidden'); } } customElements.define('starlight-tabs-restore', StarlightTabsRestore); })()

-   [curl](#tab-panel-0-0)
-   [C#](#tab-panel-0-1)
-   [Node](#tab-panel-0-2)
-   [PHP](#tab-panel-0-3)
-   [Python](#tab-panel-0-4)

Terminal window

```
curl https://dashboard.azimea.com/api/v1/segments \  -H "Authorization: Bearer $AZIMEA_API_KEY"
```

var e=class e extends HTMLElement{static#e=new Map;tabs;panels;#t;#n=\`starlight-synced-tabs\_\_\`;constructor(){super();let t=this.querySelector(\`\[role="tablist"\]\`);if(this.tabs=\[...t.querySelectorAll(\`\[role="tab"\]\`)\],this.panels=\[...this.querySelectorAll(\`:scope > \[role="tabpanel"\]\`)\],this.#t=this.dataset.syncKey,this.#t){let t=e.#e.get(this.#t)??\[\];t.push(this),e.#e.set(this.#t,t)}this.tabs.forEach((e,n)=>{e.addEventListener(\`click\`,e=>{e.preventDefault();let r=t.querySelector(\`\[aria-selected="true"\]\`);e.currentTarget!==r&&this.switchTab(e.currentTarget,n)}),e.addEventListener(\`keydown\`,e=>{let t=this.tabs.indexOf(e.currentTarget),n=e.key===\`ArrowLeft\`?t-1:e.key===\`ArrowRight\`?t+1:e.key===\`Home\`?0:e.key===\`End\`?this.tabs.length-1:null;n!==null&&this.tabs\[n\]&&(e.preventDefault(),this.switchTab(this.tabs\[n\],n))})})}switchTab(t,n,r=!0){if(!t)return;let i=r?this.getBoundingClientRect().top:0;this.tabs.forEach(e=>{e.setAttribute(\`aria-selected\`,\`false\`),e.setAttribute(\`tabindex\`,\`-1\`)}),this.panels.forEach(e=>{e.hidden=!0});let a=this.panels\[n\];a&&(a.hidden=!1),t.removeAttribute(\`tabindex\`),t.setAttribute(\`aria-selected\`,\`true\`),r&&(t.focus(),e.#i(this,t),window.scrollTo({top:window.scrollY+(this.getBoundingClientRect().top-i),behavior:\`instant\`}))}#r(e){this.#t&&typeof localStorage<\`u\`&&localStorage.setItem(this.#n+this.#t,e)}static#i(t,n){let r=t.#t,i=e.#a(n);if(!r||!i)return;let a=e.#e.get(r);if(a){for(let n of a){if(n===t)continue;let r=n.tabs.findIndex(t=>e.#a(t)===i);r!==-1&&n.switchTab(n.tabs\[r\],r,!1)}t.#r(i)}}static#a(e){return e.textContent?.trim()}};customElements.define(\`starlight-tabs\`,e);

Keep keys on your server. A key in a browser, a mobile app or a public repository can be used by anyone who finds it: revoke it and make a new one.

## What every answer looks like

[Section titled “What every answer looks like”](#what-every-answer-looks-like)

Every answer from the API has the same envelope:

```
{  "isSuccess": false,  "value": null,  "status": 5,  "errors": [{ "code": "audience.contact_email_required", "message": "An email address is needed to create a new contact.", "params": null }],  "fieldErrors": {    "email": [{ "code": "audience.contact_email_required", "message": "An email address is needed to create a new contact.", "params": null }]  }}
```

-   `isSuccess` and the HTTP status say whether it worked. `value` holds the result when it did.
-   `errors` lists what went wrong. Each error has a **`code`** — stable, meant for your code — and an English `message` for logs; `params` carries the numbers in the message (a minimum, a maximum) when there are any. Every code is in the [error codes catalog](/catalogs/error-codes/).
-   `fieldErrors` groups validation errors by the request field that caused them.
-   `status` is an internal result number; rely on the HTTP status instead.

| HTTP status | Meaning |
| --- | --- |
| `200` / `201` | Done; `value` holds the result |
| `400` | The request is not valid: see `errors[].code` and `fieldErrors` |
| `401` | No key, an unknown or revoked key, or an expired one |
| `403` | The key lacks the permission this call needs |
| `404` | What you asked for does not exist in your account |
| `409` | It conflicts with what exists (for example, a contact field with that key already exists) |
| `429` | Too many calls for this key (below) |
| `503` | Azimea cannot answer right now; try again later |

`401`, `403` and `429` are answered before the call reaches Azimea’s logic: rely on the status code, not on a body.

## Rate limit

[Section titled “Rate limit”](#rate-limit)

Each key may make **600 calls per minute**, counted together across every public endpoint, in fixed one-minute windows. Two systems with two keys never share a limit. A batch call (`POST /contacts/batch`, `POST /items/batch`, `POST /events` with up to 100 events) counts as one call, so batches are the way to move volume.

Past the limit, the answer is `429 Too Many Requests` with a `Retry-After` header: the number of seconds to wait before the next call is allowed.

## Retries

[Section titled “Retries”](#retries)

Retry on a network error, a timeout, `429` (after `Retry-After`) or `503`. Do not retry a `400`, `401`, `403` or `404` unchanged: the same request gets the same answer. Whether a retry is safe depends on the call:

| Call | Safe to retry? |
| --- | --- |
| `POST /events` | Yes. An event’s `id` is yours and unique in your account: a second delivery is counted as a duplicate, not recorded twice. |
| `POST /contacts`, `POST /contacts/batch` | Yes. They create or update the same contact (by id, then external id, then email). |
| `POST /items`, `POST /items/batch` | Yes. They create or update the item with that `externalId`. |
| `PATCH /contact-attributes/{key}`, `DELETE /items/{externalId}` | Yes. Doing it twice ends in the same state. |
| `POST /contact-attributes` | Yes: the second call is refused with `409`, because the field already exists. |
| `POST /sending/emails` | **Only with an `Idempotency-Key` header.** With it, a second call with the same key returns the first message and sends nothing more. Without it, each call queues a new email, and a retry after a timeout can send the person the same email twice. See [send your first email](/build/send-your-first-email/#make-retries-safe). |
| `GET` calls | Yes. |
