---
title: Authentication
description: Create an API key, send it with each request, choose its scopes, and rotate or revoke it.
product: gps
section: api
order: 2
---

# Authentication

Every request to the GPS REST API carries an API key. A key belongs to one GPS organization, and every request made with it reads and writes that organization's data only.

## Who can create keys

Members with the **Owner**, **Super Admin** or **Admin** role. The **Developer** tab in Settings, where keys are managed, is for those roles.

## Create a key

1. In GPS, open **Settings → Developer → Keys & Webhooks**.
2. Under **API Keys**, select **New API Key**.
3. Fill in the form:
   - **Name** (required, up to 100 characters), for example "Production integration"
   - **Description** (optional, up to 500 characters)
   - **Expires At** (optional). After this date the key stops working.
   - **Scopes** (at least one). See [Scopes](#scopes).
4. Select **Create Key**.

GPS then shows two values. Copy both straight away: **the secret is shown only once** and GPS stores only a one-way hash of it.

| Value | Looks like | Sent as |
|-------|-----------|---------|
| API key (key ID) | `emp_` followed by 28 characters | `X-API-Key` header |
| Secret | A 43-character random string | `X-API-Secret` header |

The **Keys & Webhooks** tab also shows your **Organization ID**. You don't need it to call the API, because the key already identifies your organization.

## Send the key

Use either form. If a request has an `X-API-Key` header, GPS reads the key from the two `X-API-…` headers and ignores `Authorization`.

**Two headers (preferred)**

```http
X-API-Key: emp_AbCdEfGhIjKlMnOpQrStUvWxYz12
X-API-Secret: <your secret>
```

**Bearer token**: the key ID and secret joined by a colon.

```http
Authorization: Bearer emp_AbCdEfGhIjKlMnOpQrStUvWxYz12:<your secret>
```

```bash
curl https://plan.empiraa.app/api/v1/goals \
  -H "Authorization: Bearer $GPS_API_KEY_ID:$GPS_API_SECRET"
```

```js
const res = await fetch('https://plan.empiraa.app/api/v1/goals', {
  headers: {
    Authorization: `Bearer ${process.env.GPS_API_KEY_ID}:${process.env.GPS_API_SECRET}`,
  },
});
```

Keep the secret on your server. The API accepts requests from browsers (it allows cross-origin requests from any origin), but anyone who can read your page's code can then use the key.

## Scopes

A key can call an endpoint only if it has that endpoint's scope. Choose the fewest scopes your integration needs.

A key's access comes from its scopes alone. It isn't narrowed by the GPS role of the person who created it, and it sees every record in the organization, including Goals, Objectives and Actions marked private in GPS.

| Scope | Lets the key |
|-------|--------------|
| `organization:read` | `GET /organization` |
| `organization:write` | `PATCH /organization` |
| `users:read` | `GET /users`, `GET /users/{id}` |
| `users:write` | `POST /users`, `PATCH /users/{id}`, `DELETE /users/{id}` |
| `goals:read` | `GET /goals`, `GET /goals/{id}` |
| `goals:write` | `POST /goals`, `PATCH /goals/{id}`, `DELETE /goals/{id}` |
| `objectives:read` | `GET /objectives`, `GET /objectives/{id}`, `GET /goals/{goalId}/objectives` |
| `objectives:write` | `POST /objectives`, `PATCH /objectives/{id}`, `DELETE /objectives/{id}` |
| `progress:read` | `GET /objectives/{objectiveId}/progress` |
| `progress:write` | `POST /objectives/{objectiveId}/progress` |
| `actions:read` | `GET /actions`, `GET /actions/{id}`, `GET /objectives/{objectiveId}/actions`, `GET /goals/{goalId}/actions` |
| `actions:write` | `POST /actions`, `PATCH /actions/{id}`, `DELETE /actions/{id}` |
| `meetings:read` | `GET /meetings`, `GET /meetings/{id}` |
| `meetings:write` | `POST /meetings`, `PATCH /meetings/{id}`, `DELETE /meetings/{id}` |
| `ideas:read` | `GET /ideas`, `GET /ideas/{id}` |
| `ideas:write` | `POST /ideas`, `PATCH /ideas/{id}`, `DELETE /ideas/{id}` |
| `comments:read` | `GET` on the `/comments` list of a Goal, Objective, Meeting, Action or Idea |
| `comments:write` | `POST` on those `/comments` lists, and every comment `DELETE` |

`GET /api/v1` needs no scope: any valid key can call it to see its own scopes. See [API info](reference/organization.md#get-api-info).

## Rotate a key

GPS has no in-place rotation. To rotate:

1. Create a new key with the same scopes.
2. Deploy the new key ID and secret to your integration.
3. Confirm requests succeed with the new key.
4. Revoke the old key.

Use the same approach to change a key's scopes: the settings screen doesn't edit scopes on an existing key.

## Revoke a key

In **Settings → Developer → Keys & Webhooks**, select the **Revoke** icon next to the key, then **Revoke Key**. The key stops working on its next request. Revoked keys stay listed under the active ones for your records; the settings screen can't reactivate them, so create a new key instead.

A key that reaches its **Expires At** date stops working the same way.

## 401 and 403 responses

Authentication failures return the standard [error body](conventions.md#errors).

| Status | `code` | Meaning |
|--------|--------|---------|
| 401 | `MISSING_CREDENTIALS` | No `X-API-Key` header and no `Authorization: Bearer <keyId>:<secret>` header. |
| 401 | `MISSING_SECRET` | `X-API-Key` was sent without `X-API-Secret`. |
| 401 | `INVALID_CREDENTIALS` | The key ID doesn't exist, or the secret doesn't match it. |
| 401 | `KEY_INACTIVE` | The key has been revoked. |
| 401 | `KEY_EXPIRED` | The key is past its expiry date. |
| 403 | `INSUFFICIENT_SCOPE` | The key is valid but lacks the endpoint's scope. |
| 403 | `ORG_MISMATCH` | The request named a different organization (see [Organization isolation](conventions.md#organization-isolation)). |

```json
{
  "error": "This API key does not have the 'goals:write' scope.",
  "code": "INSUFFICIENT_SCOPE",
  "docs": "https://empiraa.com/docs/api"
}
```

## Who a key acts as

Records a key creates are attributed to the member who created the key, unless the request names another member with `onBehalfOf`. See [Attribution](conventions.md#attribution-with-onbehalfof).
