# Running an integration in production

Keep a Simple Feedback integration running unattended: keys that outlive people, a startup check, safe retries, errors that need a person, and traceable logs.

The other guides show what to call. This one is about running those calls unattended for months: which key to use,
what to retry, what to hand to a person, and what to log. The shell variables `SF_API` and `SF_KEY` are set up as in
the [Quickstart](https://simplefeedback.de/developer/docs/quickstart.md).

## One key per integration

Create a separate key for every integration and every environment, named so that anyone in the dashboard can tell
what uses it, for example "Booking sync (production)". Then:

- Give it only the scopes it calls. A sync that reads responses needs `surveys:read` and `responses:read`, nothing
  that writes. [Scopes](https://simplefeedback.de/developer/docs/authentication.md#scopes) lists them.
- Limit it to one venue when the integration belongs to one location, such as a single shop's till or one hotel.
- Create it from an account that outlives individual people. A key stops working when the member who created it
  leaves the organization, see [A key acts for the person who created it](https://simplefeedback.de/developer/docs/authentication.md#a-key-acts-for-the-person-who-created-it).
- Keep the token in your secret store. It is shown once and can't be recovered.

**Settings → API keys** shows when each key was last used. A key nothing has used for months can be revoked.

## Replace a key without downtime

A key with an expiry stops working on that day with `401 API_KEY_EXPIRED`, and the API doesn't warn you before.
Put the date in your calendar when you create the key, and replace it a few days ahead:

1. Create a new key with the same scopes and venue.
2. Deploy your integration with the new token.
3. Check in **Settings → API keys** that the new key is in use, then revoke the old one.

Do the same when a token may have leaked, but revoke the old key right away: its next request answers
`401 API_KEY_REVOKED`.

## Check the key on startup

`GET /v1/me` needs no scope and tells you what the key may do. Call it when your integration starts and stop with a
clear message if a scope is missing, instead of failing on the first real call hours later:

```typescript
const REQUIRED_SCOPES = ['surveys:read', 'responses:read'];

const res = await fetch(`${process.env.SF_API}/v1/me`, {
  headers: { Authorization: `Bearer ${process.env.SF_KEY}` },
});
if (!res.ok) throw new Error(`Simple Feedback key rejected: ${res.status} ${await res.text()}`);
const me = (await res.json()) as { scopes: string[]; venueId: string | null };
const missing = REQUIRED_SCOPES.filter((scope) => !me.scopes.includes(scope));
if (missing.length > 0) throw new Error(`Simple Feedback key lacks ${missing.join(', ')}`);
```

## Retry only what can succeed

| Answer                                   | Retry?                                                                         |
| ---------------------------------------- | ------------------------------------------------------------------------------ |
| `429`                                    | Yes, after the `Retry-After` seconds                                           |
| `5xx`, a timeout or a dropped connection | Yes, a few times with growing pauses; a `POST` with the same `Idempotency-Key` |
| Any other `4xx`                          | No. The same request fails the same way                                        |

A `POST` that timed out may still have gone through. Sending it again with the same `Idempotency-Key` returns the
first answer instead of creating a second survey or QR code, see [Retrying safely](https://simplefeedback.de/developer/docs/overview.md#conventions). `PATCH` and
`DELETE` can be repeated as they are.

```typescript
const API = process.env.SF_API;
const KEY = process.env.SF_KEY;

async function call(method: string, path: string, body?: unknown): Promise<Response> {
  const headers: Record<string, string> = { Authorization: `Bearer ${KEY}` };
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  if (method === 'POST') headers['Idempotency-Key'] = crypto.randomUUID();

  for (let attempt = 1; ; attempt++) {
    let res: Response | undefined;
    try {
      res = await fetch(`${API}${path}`, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(30_000),
      });
    } catch (error) {
      if (attempt === 5) throw error;
    }
    if (res !== undefined && res.status !== 429 && res.status < 500) return res;
    if (res?.status === 429) {
      await sleep(Number(res.headers.get('Retry-After')) * 1000);
      continue;
    }
    if (attempt === 5) return res as Response;
    await sleep(2 ** attempt * 1000);
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
```

The `Idempotency-Key` is created once per call, before the first attempt, so every retry of that call carries the
same one.

## Errors that need a person

Some answers mean the integration can't continue until someone changes something. Retrying them only fills your
logs, so stop the job and alert whoever runs it:

| `error.code`                         | What someone has to do                                  |
| ------------------------------------ | ------------------------------------------------------- |
| `API_KEY_INVALID`                    | Check that the whole token was deployed                 |
| `API_KEY_REVOKED`, `API_KEY_EXPIRED` | Create a new key and deploy it                          |
| `INSUFFICIENT_SCOPE`                 | Give the key the scope named in `details.requiredScope` |
| `API_ACCESS_NOT_IN_TIER`             | Move the organization to a plan with API access         |
| `ORG_SUSPENDED`                      | Ask the organization's owner why it isn't active        |
| `TIER_LIMIT_REACHED`                 | Delete something or move to a larger plan               |

Branch on `error.code`, never on the message. [Errors](https://simplefeedback.de/developer/docs/errors.md) lists every code.

## Log what support needs

Every response carries an `X-Request-Id` header. Log it with the status and `error.code` of every failed call;
with that id, support finds your request in our logs.

You can also send your own `X-Request-Id`, up to 128 characters, for example the id of your sync run. The response
returns it unchanged, so your logs and ours share one id:

```bash
curl -i "$SF_API/v1/me" -H "Authorization: Bearer $SF_KEY" -H "X-Request-Id: booking-sync-2026-10-15T06:00"
```

## Pace bulk work

A key may send 60 requests per minute to each route. A nightly job that reads many surveys stays under that by
watching `X-RateLimit-Remaining` and slowing down before it reaches 0. [Rate limits](https://simplefeedback.de/developer/docs/rate-limits.md) has the details.

## Change live surveys carefully

Changes through the API reach respondents with the next page load; there is no review step. Try a new kind of change
on a draft first, and keep `POST /v1/surveys/{id}/undo` in mind: it puts a survey back the way it was before the
last change made through the API, one step per call, see [Change a question](https://simplefeedback.de/developer/docs/building-a-survey.md#change-a-question).
