# Authentication

payitgo has two kinds of credentials:

| Credential | Used for | Who uses it |
|---|---|---|
| **Secret API key** (64 hex characters, a test key or a live key) | The payments API under `/v1` | Your server |
| **Access token** (a JWT from sign-in) | The dashboard API under `/merchant` | People on your team, through the dashboard |

Your shop's backend only ever needs an API key. Access tokens are for the
dashboard: team, keys, webhook secret rotation, balances and payouts.

## API keys

### Sending the key

Send the key on every `/v1` request, in either header:

```
Authorization: Bearer 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f
```

```
X-API-Key: 4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f
```

When both are present, `X-API-Key` wins. A complete request:

```bash
curl -s https://api.payitgo.co/v1/tokens \
  -H "Authorization: Bearer $PAYITGO_KEY"
```

A key is 64 hex characters. Treat it as an opaque string: it does not say
its mode.

### Test keys and live keys

The key decides the mode of everything the request does. There is no
`mode` parameter on `/v1`. You choose the mode when you create a key, and the
dashboard shows it as a Test or Live badge next to the key.

| Key | Mode | What it does |
|---|---|---|
| Test key | Test | Test payments, paid by the outcomes you [simulate](/docs/testing) (no real money), the **test** webhook and its signing secret |
| Live key | Live | Real payments on mainnet, the **live** webhook and its signing secret |

To ask payitgo which mode a key is in, call `GET /v1/webhook` with it: the
answer's `livemode` is the key's mode ([Webhooks](/docs/webhooks#read-the-webhook)).

- `GET /v1/tokens` lists the tokens of the key's mode.
- `POST /v1/payments` creates a payment in the key's mode.
- `GET /v1/payments` and `GET /v1/payments/{id}` only see payments of the
  key's mode. A test key gets `404` for a live payment, and the other way round.
- `/v1/webhook` reads and changes the webhook of the key's mode. Each mode has
  its own URL and its own signing secret ([Webhooks](/docs/webhooks)).

Every payment and every webhook notification carries `"livemode": true` or
`false`, so your code can always tell which mode it is looking at.

### Live mode is off until the owner turns it on

A new account starts in test mode. Until the account owner turns live mode
on, every request with the live key is refused:

```
HTTP/1.1 403 Forbidden

{"error": "live mode is off for this account: the owner can turn it on in the dashboard; until then use a test key"}
```

The owner turns it on with `POST /merchant/live` (see
[Account and team](/docs/account#live-mode)). Turning it off again stops new
live payments; payments already open still complete.

### Storefront keys

A key can belong to a **store** (one of your shops, see
[Stores](/docs/account#stores)). A storefront key works like any other key,
with these differences:

- Every payment it creates carries the store's `store_id`.
- It only sees its own store's payments: `GET /v1/payments` lists that
  store's payments (its `store_id` filter is ignored), and
  `GET /v1/payments/{id}` answers `404 {"error": "payment not found"}` for
  another store's payment.
- It can read the webhook (`GET /v1/webhook`) and send a test
  (`POST /v1/webhook/test`), but it cannot change the account's webhook URL.
  `PUT` and `DELETE /v1/webhook` answer:

  ```
  HTTP/1.1 403 Forbidden

  {"error": "a storefront's key cannot change the account's webhook: set webhook_url on its payments instead"}
  ```

  A store that runs its own backend sets `webhook_url` on each payment it
  creates, and verifies the notifications with the same signing secret
  ([per-payment webhook URL](/docs/webhooks#per-payment-webhook-url)).

### Read-only keys

A key created with `"access": "read"` can only read: `GET /v1/payments`,
`GET /v1/payments/{id}`, `GET /v1/tokens` and `GET /v1/webhook`. Anything
else answers:

```
HTTP/1.1 403 Forbidden

{"error": "this api key is read-only: it can only read (GET)"}
```

Give one to a service that reports on payments (an accounting export, a
mobile app's order history) and should never create them.

### API key errors

| Status | Body | Cause |
|---|---|---|
| `401` | `{"error": "missing api key"}` | Neither `Authorization: Bearer …` nor `X-API-Key` was sent |
| `401` | `{"error": "invalid api key"}` | The key is unknown, mistyped or revoked |
| `403` | `{"error": "live mode is off for this account: …"}` | A live key while live mode is off |
| `403` | `{"error": "merchant account is suspended"}` | payitgo suspended the account |
| `403` | `{"error": "this api key is read-only: it can only read (GET)"}` | A [read-only key](#read-only-keys) used to create or change something |
| `429` | `{"error": "rate limit exceeded"}` | More than 20 requests a second with one key ([limits](/docs/errors#rate-limits)) |

## Managing keys

Your first test key and live key are issued at signup. Owners and
developers can create more (one per environment or service, say) and revoke
old ones. These routes take a dashboard access token (see below), not an API
key.

### The API key object

| Field | Type | Description |
|---|---|---|
| `id` | string | The key's id. Treat it as an opaque string |
| `merchant_id` | string | Your account's id |
| `livemode` | boolean | `false` for a test key, `true` for a live key: the only way to tell them apart |
| `store_id` | string | The store the key belongs to. Absent for an account-wide key |
| `label` | string | Your name for the key (`default` for the two signup keys) |
| `access` | string | `full` (everything `/v1` offers) or `read` ([read-only](#read-only-keys)) |
| `last4` | string | The last four characters of the secret, to tell keys apart |
| `created_by` | string | The team member who created it (their id) |
| `created_at` | string | When it was created (RFC 3339, UTC) |
| `last_used_at` | string | When it last authenticated a request. Absent until first used |
| `revoked_at` | string | When it was revoked. Absent while it works |

payitgo stores only a hash of the secret. The secret is returned once, when the
key is created, and can never be shown again.

### Create a key

`POST /merchant/api-keys?mode=test` · owner or developer

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `mode` | query | string | no | `test` for a test key. Anything else, or nothing, makes a **live** key |
| `label` | body | string | no | Your name for the key |
| `store_id` | body | string | no | A store's id: the key becomes that store's key |
| `access` | body | string | no | `full` (the default) or `read` ([read-only](#read-only-keys)) |

```bash
curl -s 'https://api.payitgo.co/merchant/api-keys?mode=test' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label": "staging"}'
```

`201 Created`:

```json
{
  "api_key": {
    "id": "5e1a9c3d7b2f40e8a6c4d2b1",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": false,
    "label": "staging",
    "access": "full",
    "last4": "c0da",
    "created_by": "9b2e4c71d8a05f3e6c1a7b24",
    "created_at": "2026-10-10T09:02:33.526679611Z"
  },
  "secret": "e919a75364398a449f860aeadddc57fa0502145a4e63959ddb33c417a48dc0da"
}
```

Store `secret` on your server now: it is not shown again.

| Status | Body | Cause |
|---|---|---|
| `400` | `{"error": "unknown store_id"}` | `store_id` is not one of your stores |
| `400` | `{"error": "access must be \"full\" or \"read\""}` | |
| `401` | `{"error": "missing token"}` · `{"error": "invalid or expired token"}` | No access token, or an expired one |
| `403` | `{"error": "insufficient permissions"}` | Your role is `finance` or `viewer` |

### List keys

`GET /merchant/api-keys?mode=test` · owner or developer

Lists the keys of one mode, revoked ones included. Without `mode=test` you
get the live keys.

```bash
curl -s 'https://api.payitgo.co/merchant/api-keys?mode=test' -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "api_keys": [
    {
      "id": "0e7c5a3f9b1d2846a1f3c5e7",
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "livemode": false,
      "store_id": "6d2a9e4c1b7f30a58e2c4d91",
      "label": "eu-shop",
      "last4": "d05e",
      "created_by": "9b2e4c71d8a05f3e6c1a7b24",
      "created_at": "2026-10-10T08:59:52.982Z",
      "last_used_at": "2026-10-10T08:59:53.054Z"
    },
    {
      "id": "4f2a9c1e7b3d5a08c6e1d7b2",
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "livemode": false,
      "label": "default",
      "last4": "5a8f",
      "created_by": "9b2e4c71d8a05f3e6c1a7b24",
      "created_at": "2026-10-10T08:57:25.904Z",
      "last_used_at": "2026-10-10T08:58:44.128Z"
    }
  ]
}
```

### Revoke a key

`DELETE /merchant/api-keys/{id}` · owner or developer

The key stops working at once: its next request gets
`401 {"error": "invalid api key"}`.

```bash
curl -s -X DELETE https://api.payitgo.co/merchant/api-keys/5e1a9c3d7b2f40e8a6c4d2b1 -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{"message": "api key revoked"}
```

An unknown id answers `404 {"error": "not found"}`.

**Rotating a key:** create the new key, deploy it, check that requests
succeed, then revoke the old one.

## Dashboard access tokens

The `/merchant/…` routes are for people. Sign in with email and password to
get an access token:

`POST /merchant/login`

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes | Your email address |
| `password` | string | yes | Your password |

```bash
curl -s https://api.payitgo.co/merchant/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@acme.example", "password": "a-long-password"}'
```

`200 OK`:

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJraW5k…",
  "expires_at": "2026-10-10T09:17:33.92212983Z",
  "role": "owner"
}
```

| Field | Description |
|---|---|
| `access_token` | Send it as `Authorization: Bearer <access_token>` on `/merchant` routes |
| `expires_at` | When it stops working: 15 minutes after it was issued |
| `role` | Your role in the account: `owner`, `developer`, `finance` or `viewer` |

The response also sets an httpOnly cookie, `refresh_token`, on the path
`/merchant/auth`. `POST /merchant/auth/refresh` exchanges it for a new access
token (same response as login) for up to 7 days. `POST /merchant/auth/logout`
ends the session (`{"message": "logged out"}`), and
`POST /merchant/auth/logout-all` ends every session of yours
(`{"message": "all sessions revoked"}`).

| Status | Body | Cause |
|---|---|---|
| `400` | `{"error": "email and password are required"}` | A field is missing |
| `400` | `{"error": "invalid email address"}` | `email` is not an email address |
| `401` | `{"error": "invalid credentials"}` | Wrong email or password |
| `401` | `{"error": "no refresh token"}` | Refresh without the cookie |
| `401` | `{"error": "missing token"}` | A `/merchant` route without an access token |
| `401` | `{"error": "invalid or expired token"}` | An expired or malformed access token: refresh, or sign in again |
| `403` | `{"error": "insufficient permissions"}` | Your role may not use this route |
| `403` | `{"error": "merchant account is suspended"}` | payitgo suspended the account |
| `429` | `{"error": "rate limit exceeded"}` | More than 5 sign-in attempts a second from one IP address |

## Keep keys secret

- Use secret keys from your server only. Never put them in a browser, a
  mobile app, a public repository or a support ticket. The checkout routes
  your customers' browsers call need no key ([Checkout](/docs/checkout)).
- If a key may have leaked, revoke it at once. An API key cannot withdraw
  money (payouts need a signed-in team member with the `owner` or `finance`
  role), but it can create payments and change your webhook URL in your name.
- A lost secret cannot be shown again: create a new key and revoke the lost
  one.
