API for agents
Everything in ExcuseMe can be driven without the UI: surveys, email check-ins, triggered sends, results. If you are a human, this page is for you. If you are an agent, fetch the raw reference instead:
curl https://excuseme.app/llms.txtOr connect the MCP server: EXCUSEME_API_KEY=<key> npx excuseme-mcp. API keys are minted in the dashboard under Settings, then API keys.
The full reference
Rendered from /llms.txt, the same file agents read.
# ExcuseMe
> ExcuseMe is a drop-in feedback widget (emoji, star, or thumbs) you embed on any
> website with one script tag. It is built to be driven entirely by AI agents: an
> agent can create a survey, show a live preview, hand over the embed snippet, read
> the responses, and start an upgrade, with no human login. The owner confirms once
> by email before any real responses are stored.
## If you are setting this up for a human, do this
They will never see an onboarding tour, because you are doing the setup. So the telling
is your job:
1. **Create and show.** Create the account with THEIR email, create the survey, and show
them the `previewUrl` in the same reply so they can see the widget immediately.
2. **Say what must happen next.** They have to click the confirmation email before
anything is collected. Until they do, responses are HELD for up to 30 days (max 100
per website) and appear the moment they confirm. Re-send with
`POST /websites/{id}/consent-email`. Poll `confirmedAt` to know when it went live.
3. **Tell them what else it can do, then offer to set it up.** In your own words:
- **Slack:** every response posted into a Slack channel, as ExcuseMe. Works on every
plan. `POST /surveys/{id}/slack` gives a one-time link they click to pick the
channel. This is the one thing they must click themselves; everything else you do.
- **Email check-ins:** recurring anonymous questions to their team, weekly or
triggered after an event (`POST /checkins`).
- **Paid extras:** unlimited responses on a survey, account-wide Slack and signed
webhooks, CSV export, team seats (`GET /billing`, `POST /surveys/{id}/upgrade`).
4. **Hand over links, do the rest yourself.** Only the Slack install and payment need a
human. Account, survey, embed, check-ins and reading results are all yours.
5. **Report results as meaning, not rows.** Read `GET /surveys/{id}/summary` or
`/responses` and tell them what it says.
Creates return a `nextSteps` array saying the same thing in context. It is guidance for
you, not text to paste at the human.
There are two equally supported ways to use ExcuseMe. Pick whichever fits your agent.
Neither is a fallback for the other.
## Option A: the HTTP API (works with plain curl or fetch)
Base URL: `https://app.excuseme.app/api/v1/manage`. Auth: `Authorization: Bearer <apiKey>`.
Creating an account needs no key. Send `Idempotency-Key` on creates to make retries safe.
Quickstart:
```bash
# 1. Create an account and get an API key (no key needed). Save the apiKey.
curl -sX POST https://app.excuseme.app/api/v1/manage/accounts \
-H 'content-type: application/json' \
-d '{"email":"owner@example.com"}'
# -> {"accountId":"...","apiKey":"exm_live_...","defaultWebsiteId":"...","nextSteps":[...]}
KEY=exm_live_...
# 2. Create a survey. Returns a preview URL and the embed snippet.
curl -sX POST https://app.excuseme.app/api/v1/manage/surveys \
-H "authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"type":"emojis","question":"How do you like the new dashboard?"}'
# -> {"surveyId":"...","status":"pending_confirmation","previewUrl":"https://app.excuseme.app/preview/...","embedSnippet":"<script src=\"https://hey.excuseme.app/excuseme.js?id=...\" defer></script>"}
# 3. Read results (summarize them yourself).
curl -s https://app.excuseme.app/api/v1/manage/surveys/SURVEY_ID/summary -H "authorization: Bearer $KEY"
curl -s https://app.excuseme.app/api/v1/manage/surveys/SURVEY_ID/responses -H "authorization: Bearer $KEY"
```
Endpoints: `POST /accounts`, `GET|POST /websites`, `POST /websites/{id}/consent-email`,
`GET|POST /surveys`, `GET|PATCH|DELETE /surveys/{id}`, `GET /surveys/{id}/responses`,
`GET|POST|DELETE /surveys/{id}/slack`,
`GET /surveys/{id}/summary`, `POST /surveys/{id}/upgrade` (returns a Stripe checkout URL),
`GET /billing` (plan, limits, and unlocked features).
Survey types: `emojis`, `stars`, `thumbs`. Errors are `{"error":{"code","message"}}`.
Every survey payload carries `status` (`"active"` or `"pending_confirmation"`) and
`confirmedAt` (ISO timestamp, or null until the owner confirms). Poll `GET /surveys/{id}`
to learn when collection goes live; it also returns `pendingCount`, the responses held
for this survey while unconfirmed. `GET /websites` returns each website's `confirmedAt`
and `pendingCount` (the held-response limit counts per website).
`POST /websites/{id}/consent-email` re-sends the confirmation email to the account owner
(unconfirmed websites only, 3 per hour; `409 already_confirmed` otherwise).
`GET /surveys/{id}/responses` returns `{"responses":[{"id","score","comment","externalId",
"metadata","createdAt"}],"limit","offset"}`, newest first. Add `?externalId=<id>` to look up
one response by your own id.
### Sending responses from your own code
The widget posts to `POST https://app.excuseme.app/api/v1/feedback` (no auth). A server
can post there too:
```json
{
"surveyId": "uuid",
"score": 4, // 1 to 5 (thumbs: 1 = down, 5 = up)
"comment": "optional, max 2000 chars",
"externalId": "your-id-123", // optional idempotency id, unique per survey
"metadata": { "type": "idea", "email": "ada@example.com" } // optional, see limits
}
```
- `externalId`: 1 to 128 of letters, digits and `. _ : @ / -`. Retrying with the same
`externalId` never creates a second response: you get the ORIGINAL `feedbackGuid` back
with `"duplicate": true` (the first submission wins, even if the retry's body differs).
`409 in_progress` means the first attempt is still being processed: retry in a few
seconds.
- `metadata`: a flat object, at most 20 keys (`[A-Za-z0-9_.-]`, 1 to 40 chars), values
string (max 500 chars), number, boolean or null, at most 4 KB as JSON. It is shown in the
dashboard, returned by the responses API, exported as `meta.<key>` CSV columns and
included in webhooks. Put structured fields here instead of inside `comment`.
- Responses:
- `200 {"success":true,"feedbackGuid"}`: stored and live.
- `202 {"success":true,"pending":true,"stored":"held","feedbackGuid","warning":{"code":"pending_confirmation","message"}}`:
the owner has not confirmed yet. The response is HELD (not shown, not counted) for up
to 30 days, max 100 per website, and appears when the owner confirms. Treat it as
saved, and tell your human to click the confirmation email.
- `409 pending_confirmation_limit`: not stored. 100 responses are already held, or the
website is more than 30 days old and still unconfirmed. The owner must confirm first
(re-send the email with `POST /websites/{id}/consent-email`).
- `409 limit_reached`: the Free survey hit its 100-response cap; upgrade it.
- `429 rate_limited`: 5 submissions per minute per IP per survey.
- Before 2026-09-19 an unconfirmed survey answered `200` and discarded the response; it
now answers `202` and holds it.
### Plan and features
`GET /billing` tells you what the account's plan includes:
```json
{
"tier": "free", // "free" | "business" | "enterprise"
"businessUntil": null, // ISO timestamp while a Business subscription runs
"features": [], // unlocked Business features, see below
"activeLimit": 1, // active surveys + check-ins the plan allows (null = unlimited)
"activeUnpaid": 0 // how many of those slots are in use
}
```
`features` lists the unlocked Business features: `reminders` (automatic check-in reminder
emails), `branding` (the account's logo on the widget, emails, and share pages),
`integrations` (Slack and webhook notifications, CSV export), and `seats` (team members).
Business and Enterprise unlock all four. An endpoint behind a feature the plan lacks fails
with `403 {"error":{"code":"feature_locked","message":"...","feature":"<name>"}}`: do not
retry, suggest the Business upgrade instead.
### Email check-in endpoints
Check-ins are recurring email surveys. Each send is a **wave** that rotates through the
check-in's question list. Check-ins default to anonymous; anonymous waves lock aggregated
results until 3 responses arrive. `sendWeekday` is 0 (Monday) to 6 (Sunday).
**POST /checkins**: create a check-in.
```json
// request
{
"name": "Weekly team pulse",
"anonymity": "anonymous", // or "identified"
"frequency": "weekly", // "weekly" | "biweekly" | "monthly" | "manual"
"sendWeekday": 0, // Monday; required unless frequency is "manual"
"sendHour": 9, // 0-23; required unless frequency is "manual"
"timezone": "America/New_York", // IANA timezone; default "UTC"
"questions": [
{ "type": "emojis", "question": "How did this week go?" }
],
"recipients": [ // optional; cap depends on plan (see below)
{ "email": "alice@example.com", "name": "Alice" }
]
}
// response 201
{
"checkinId": "uuid",
"publicId": "short-id",
"shareUrl": "https://app.excuseme.app/s/<publicId>",
"nextSendAt": "2024-01-08T09:00:00.000Z", // null when frequency is "manual"
"recipients": { "added": 1, "skipped": 0 } // null if none supplied at create
}
```
Recipient caps per plan: Free 25, Pro-paid check-in 150, Business or Enterprise 500.
`400 recipient_limit` if the recipients list exceeds the cap (the check-in is still
created; `checkinId` is included in the error body). Send `Idempotency-Key` to make retries
safe.
**GET /checkins**: list all check-ins for the account. Returns an array of check-in objects.
**POST /checkins/{id}/recipients**: add recipients to an existing check-in.
```json
// request
{ "recipients": [{ "email": "bob@example.com", "name": "Bob" }] }
// response 200
{ "added": 1, "skipped": 0 }
```
Recipient caps: Free 25, Pro-paid check-in 150, Business or Enterprise 500.
`409 limit` if adding the batch would push the total over the cap. The error body is
`{"error":{"code":"limit","message":"...","limit":<number>}}` where `limit` is the cap
that applies to this check-in.
`GET /checkins/{id}/recipients` lists current recipients (`id`, `email`, `name`, `status`,
`createdAt`). `DELETE /checkins/{id}/recipients` with body `{"recipientId":"<uuid>"}` removes
one.
**POST /checkins/{id}/send**: triggered send, email one address the current wave's question
right now. Use this for CSAT-style flows (order shipped, ticket closed, etc.).
```json
// request
{ "email": "alice@example.com", "name": "Alice" } // name optional
// response 200
{ "sent": true, "waveId": "uuid", "recipientId": "uuid" }
```
Errors: `402 limit_reached` (check-in reached its free cap; upgrade first),
`409 recipient_blocked` (address bounced or unsubscribed; do not retry),
`409 already_responded` (recipient already answered in the current wave).
**GET /checkins/{id}/summary**: per-wave results, oldest wave first.
```json
{
"checkinId": "uuid",
"waves": [
{
"waveId": "uuid",
"question": "How did this week go?",
"type": "emojis", // "emojis" | "stars" | "thumbs"
"kind": "scheduled", // or "triggered"
"anonymity": "anonymous",
"scheduledFor": "2024-01-08T09:00:00.000Z",
"closedAt": null,
"sentCount": 12,
"respondedCount": 5,
"reminded": false,
"locked": false, // true when anonymous and respondedCount < 3
"lockedRemaining": 0, // responses still needed to unlock
"avg": 3.8, // null when locked
"breakdown": { "1": 0, "2": 1, "3": 2, "4": 1, "5": 1 }, // null when locked
"comments": [
{ "score": 4, "comment": "Good week overall", "createdAt": "2024-01-09T..." }
], // null when locked
"sources": { "email": 4, "link": 1 }
}
]
}
```
### Slack (every plan)
A survey can post each response to a Slack channel, as the ExcuseMe Slack app: the
rating emoji, the comment (if any), and a link to the results. Adding a Slack app needs
a human to click through Slack's install screen, so an agent gets a link to hand over:
- `POST /surveys/{id}/slack` -> `{"connectUrl","expiresAt"}`: a one-time link (1 hour).
Give it to your human; they pick the channel on Slack's own screen.
- `GET /surveys/{id}/slack` -> `{"connected":false}` or
`{"connected":true,"channel","team","configurationUrl","connectedAt","broken"}`.
`broken` is set when Slack stopped accepting posts (app removed, channel archived);
hand over a new connect link to fix it.
- `DELETE /surveys/{id}/slack` disconnects.
- `503 slack_unavailable` means Slack is not enabled on this server yet.
Posts arrive about a minute or two after the response, so a comment added right after
the tap is included. If `metadata.name` / `metadata.email` were sent with a response,
the post says who it is from, visible to everyone in the channel.
### Webhooks (Business)
Business and Enterprise accounts can configure a Slack webhook and a signed generic
webhook in the dashboard under Settings, then Integrations. Every confirmed response
POSTs a JSON event to the generic URL:
```json
{
"event": "checkin.response", // or "survey.response"
"entity": { "type": "checkin", "id": "uuid", "name": "Weekly team pulse" },
"response": { "score": 4, "comment": "Good week overall", "createdAt": "2024-01-09T..." },
"recipient": { "email": "alice@example.com", "name": "Alice" }, // identified check-ins only
"accountId": "uuid"
}
```
`survey.response` events also carry `response.id`, `response.externalId` and
`response.metadata`.
When the owner confirms a website, a `website.confirmed` event is sent:
```json
{
"event": "website.confirmed",
"website": { "id": "uuid", "name": "My website" },
"confirmedAt": "2026-09-19T15:38:19.847Z",
"releasedResponses": 12, // held responses that just went live
"accountId": "uuid"
}
```
Held responses released by the confirmation each get their own `survey.response` event;
Slack gets one summary line instead of one message per response.
Verify every delivery before trusting it. Each request carries two headers:
`X-ExcuseMe-Timestamp` (unix seconds) and `X-ExcuseMe-Signature`. The signature is
`sha256=` followed by hex `HMAC_SHA256("<timestamp>.<rawBody>", secret)`, where
`<rawBody>` is the exact request body bytes and `secret` is the webhook secret shown
in the dashboard. Recompute it, compare in constant time, and reject any timestamp
older than 5 minutes (replay protection).
Delivery is at-least-once with up to 3 attempts: a failed attempt retries after 30
seconds, then after 5 minutes, then the event is dropped. Anonymity holds for webhooks
too: events from anonymous check-in waves never include `recipient`, and they are held
back until the wave reaches its 3-response unlock, then flushed together. Events
snapshot the response as it was stored: a comment the respondent adds later does not
re-dispatch or update an already-sent event.
## Option B: the MCP server
`npx excuseme-mcp` exposes the same operations as MCP tools (`excuseme_create_survey`,
`excuseme_preview`, `excuseme_get_summary`, ...). Set `EXCUSEME_API_KEY` in its env.
See the package README for client config.
## How consent works
A new survey previews immediately, but real responses only go live once the owner clicks
the confirm link emailed at account or website creation (the link is valid for 30 days;
re-send it with `POST /websites/{id}/consent-email`). Until then, responses are HELD for
up to 30 days (max 100 per website): not shown, not counted, and released into the results
the moment the owner confirms. Show the human the `previewUrl` in the same turn and ask them
to click the email; watch `confirmedAt` on the survey to know when it is live.
## Pricing
Free: 1 active survey, 100 responses per survey. Paid: 1.99 USD or EUR per survey per
month for unlimited responses. Use `POST /surveys/{id}/upgrade` to get a checkout link to
hand to the human (an agent cannot pay on its own).