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.
Die Entwickler-Dokumentation gibt es nur auf Englisch, wie die API selbst.
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.
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:readandresponses:read, nothing that writes. 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.
- 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:
- Create a new key with the same scopes and venue.
- Deploy your integration with the new token.
- 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:
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. PATCH and
DELETE can be repeated as they are.
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 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:
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 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.