Before you start
The API is included on some plans and not others, and it is not part of the free trial. If your plan does not include it, every request returns 403 with a message saying so, and the key management screen offers an upgrade instead of a create button.
Only an owner or an admin can create a key. An admin assigned to a single location can create a key for that location only.
Create your key in the dashboard under Settings → API keys. The secret is shown once, on the screen that creates it. Starloop stores only a hash of it, so nobody — including Starloop support — can show it to you again. Save it into a secrets manager or an environment variable before you close the dialog. If you lose it, rotate the key and use the new one.
Never put the secret in front-end code, a mobile app, or anything a customer's browser downloads. It carries the same authority as a signed-in admin of your account.
Authentication
Send your key as a Bearer token. There is no session, no cookie and no login step — the key is the credential.
Authorization: Bearer sl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
All requests go to https://engine.starloop.com over HTTPS. Every path below is relative to that.
A key is scoped when you create it:
- Business-level — can send for every location you have, including ones you add later.
- Location-level — can send for that one site only, and cannot touch another site or anything above site level.
Three things return 401, and the message says which:
- no
Authorizationheader - a key Starloop does not recognise
- a key that has expired — the message gives the date it expired, so you can tell a rotated key from a mistyped one
A valid key whose location has been switched off returns 403.
Your business and locations
Start here. Your key already knows which business it belongs to, but sending for a particular site needs that site's id, and this is how you find it without anybody copying ids out of the dashboard.
GET /api/v1/integrations/account
{
"business": { "id": "biz_7Hq2", "name": "Odds-On Barbers" },
"locations": [
{ "id": "loc_3Kd9", "name": "Downtown", "isActive": true },
{ "id": "loc_8Pw1", "name": "Airport", "isActive": false }
],
"scope": { "level": "business", "locationId": null }
}
scope tells you what your own key can reach. A business-level key lists every site; a location-level key lists only its own and reads { "level": "location", "locationId": "loc_3Kd9" }.
isActive: false means the site is switched off. A location-level key for an inactive site is refused, and a business-level key cannot send for one either.
Location ids are stable, so fetch them once at setup rather than before every send.
Sending an invitation
One customer per call.
POST /api/v1/integrations/invitations
{
"email": "jane@example.com",
"mobile": "+15550001111",
"firstName": "Jane",
"lastName": "Reed",
"petName": "Biscuit",
"locationId": "loc_3Kd9"
}
| Field | Required | Notes |
|---|---|---|
email | one of the two | |
mobile | one of the two | Any format you hold it in; it is normalised for you |
firstName | no | Used in the message copy |
lastName | no | |
petName | no | Only used by businesses whose category includes it, such as vets |
locationId | no | See below |
firstSendDelayDays | no | 0 sends in the next send window; omit to use the site's default |
testMode | no | See Test mode |
Either email or mobile must be present. Sending neither returns 400.
Leave locationId out and a location-level key uses its own site, while a business-level key sends at business level — which site the customer visited is then filled in only if they say so on the feedback page. Name it explicitly if you know it; the reporting is better.
A success returns 200:
{
"invitation": {
"id": "inv_9Rt4",
"status": "SCHEDULED",
"channels": ["EMAIL", "SMS"],
"locationId": "loc_3Kd9",
"firstSendAt": "2026-10-27T18:00:00.000Z",
"createdAt": "2026-10-26T09:14:02.413Z"
}
}
A 200 means stored and scheduled, not delivered. Starloop retries a message its provider rejects rather than failing your request, so a success is never a delivery guarantee.
channels is what the contact details allowed: both if you sent an email and a mobile, one if you sent one. firstSendAt is when the first message is due — invitations are sent in an evening window in the site's own timezone, not the moment you call.
Test mode
Send "testMode": true and Starloop checks the whole request and does nothing else.
{ "email": "jane@example.com", "testMode": true }
Everything is validated: your plan, the location, whether this person has opted out, whether they were invited inside the last 90 days, which channels their details allow, and when the first message would go. Nothing is written. No contact record is created, no sequence starts, no message is sent, and it does not count against your daily allowance.
{
"invitation": {
"id": null,
"testMode": true,
"status": "SCHEDULED",
"channels": ["EMAIL"],
"locationId": "loc_3Kd9",
"firstSendAt": "2026-10-27T18:00:00.000Z"
}
}
id is null because there is nothing to fetch. Refusals come back exactly as they would on a real call — same status, same code, same retryAfter — so you can build against test mode and see production's answers.
Two things it cannot tell you: whether the address or number is reachable, which is only known at send time, and whether another request is inviting the same person at that moment.
Use it while wiring up, and in your own test suite. It is a per-request flag, so you can flip it per call rather than holding a separate key.
Errors
| Status | Meaning |
|---|---|
200 OK | Everything worked as expected. |
400 Bad Request | The request was unacceptable, often due to missing a required parameter. |
401 Unauthorized | No valid API key provided. |
402 Request Failed | The parameters were valid but the request failed. |
403 Forbidden | The API key doesn't have permissions to perform the request. |
404 Not Found | The requested resource doesn't exist. |
409 Conflict | The request was valid, but this customer cannot be invited right now. |
429 Too Many Requests | A rate limit was reached. |
500 502 503 504 Server Errors | Something went wrong on our end. |
Every error carries a code you can branch on and a message written to be shown to a person. Branch on code, never on the message text — wording changes, codes do not.
{
"code": "COOLDOWN",
"message": "This recipient was invited recently and can't be invited again until 2027-01-18 (90-day protection window).",
"retryAfter": "2027-01-18T00:00:00.000Z"
}
Why a customer cannot be invited — 409
| Code | What it means | Retry |
|---|---|---|
UNSUBSCRIBED | They opted out of review invitations. | Never. Only they can undo it. |
COOLDOWN | Invited inside the 90-day protection window. | After retryAfter. |
ALREADY_IN_PROGRESS | A sequence is already running for them. | Later; no date given. |
RECENTLY_DELETED | Removed from your contacts inside the protection window. | After retryAfter. |
IDENTITY_CONFLICT | The email and the mobile belong to two different people on your list. | Send one or the other. |
SMS_DISABLED | Only a mobile was given, and this site has texts switched off. | Send an email address, or turn texts on. |
retryAfter is an ISO timestamp, and is only present where waiting can actually help. Do not invent one for UNSUBSCRIBED.
Other codes
| Code | Status | What to do |
|---|---|---|
FEATURE_NOT_AVAILABLE | 403 | Your plan does not include the API, or the subscription has lapsed. |
ACCOUNT_PAUSED | 403 | The account is on hold. Starloop support can lift it. |
PLAN_LIMIT_REACHED | 402 | This period's invitation allowance is spent. |
RATE_LIMITED | 429 | Wait retryAfterSeconds. |
Retrying safely
A call that timed out is safe to retry. Starloop claims the customer before the sequence starts, so a duplicate request cannot message anybody twice — the second attempt comes back 409 ALREADY_IN_PROGRESS. Treat that code as "the first one worked" rather than as a failure.
Keeping your key safe
Store the secret in a secrets manager, or failing that an environment variable. Not in source control, not in a config file you commit, not in a ticket or a chat message. Starloop keys begin sl_live_, which means code scanners can spot one that leaks — and will.
Rotating
Use Rotate in the dashboard when you want a new secret without downtime. It issues the replacement immediately and lets you choose how long the old key keeps working: now, 24 hours, or 7 days.
With a window, both keys work at once, so you can deploy the new secret on your own release schedule. The replacement keeps the old key's name, note and scope — only the secret changes.
The keys list shows last used for every key. Before letting an old key expire, check that it has gone quiet; that reading is what turns rotation from a guess into a decision.
If you need longer than 7 days, create a second key by hand, migrate, then revoke the first.
If a key leaks
Use Revoke immediately, not Rotate with a window. The key stops working at once and anything still using it breaks — which is the right trade when a secret is public. Then create a new one and deploy it.
Revoking cannot be undone. A key revoked by mistake costs seconds to replace.
Rotate as a matter of course when somebody with access to the key leaves your team. The key does not know they have gone.
Expired keys
An expired key keeps its row in the list, and requests using it get a 401 saying it expired and when — deliberately different from "invalid key", which tells you nothing about whether it was a typo, the wrong environment, or a rotation you forgot to finish.
If your plan changes
Your keys are not deleted if you move to a plan without the API. They stop working, and they work again if you move back. You can always see and revoke the keys you hold, whatever your plan — a credential you cannot revoke would be worse than one you cannot use.
