Build on SimpleInvite.
Everything a host can do by hand, a program can do with a key: make an event, add people, send it, and read who is coming. There is a REST API and an MCP server, and they do the same things under the same rules.
Get a key
Keys live in your settings, under Keys. A key is shown once, when you make it — copy it then, because nothing stores the whole thing afterwards. If you lose it, make another and revoke the old one.
Send it on every request as `Authorization: Bearer si_live_...`. A key carries a read scope and a write scope; a read-only key gets a 403 on anything that changes something.
curl https://app.simpleinvite.app/api/v1/me \
-H "Authorization: Bearer si_live_..."How it behaves
- Everything is scoped to your key
- There is no account or organisation parameter. A key belongs to one host, and every list is that host's rows. An id from anywhere else returns a 404, not a 403 — the two answers would tell you something exists.
- Every failure has one shape
- Every failure is `{ error: { code, message } }`. Branch on `code`, which is stable; `message` is a sentence for a person and may be reworded. A 422 adds `details`, one entry per field that did not pass.
- Lists page by cursor
- Pass `limit` (up to 200) and the `next_cursor` from the page before. An offset would re-send rows when something is inserted mid-scan and skip rows when something is deleted; a cursor anchored on the last row cannot.
- Reads carry an ETag
- Send it back as `If-None-Match` and an unchanged read answers 304 with no body. Worth doing on the two heavy reads — an event's invitations, and the guest book.
- Rate limits are per key
- 120 reads and 30 writes a minute. Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so you can back off before you are refused; a 429 carries `Retry-After` in seconds. Writes are the tighter budget because each one can put a message in front of a person.
- Sending is separate from changing
- Creating an event sends nothing. Adding a guest sends nothing. `POST /events/{id}/send` is the call that puts invitations on a wire, and it is idempotent — a retry after a timeout reports the first call's result rather than inviting everyone twice.
- The rules are not negotiable through the API
- Nudges are capped, quiet hours are honoured, someone who opted out is never messaged, and an answer goes through the same state machine a guest's tap does. Nothing here can do something a host could not have done by hand.
Reference
Generated from the same schemas the routes validate with. The machine-readable document is at /api/openapi.json. openapi.json
| Method | Path | What it does | MCP tool |
|---|---|---|---|
GET | /events | The host's events, newest first. | list_events |
POST | /events | Make an event. | create_event |
GET | /events/{id} | One event. | get_event |
PATCH | /events/{id} | Change an event. Nothing goes out. | update_event |
DELETE | /events/{id} | Call the event off. | cancel_event |
GET | /events/{id}/invitations | The parties on one event. | list_invitations |
POST | /events/{id}/invitations | Put one more party on the event. | add_invitation |
POST | /events/{id}/nudge | Chase the people who have not answered. | nudge |
POST | /events/{id}/send | Put the invitations on a wire. | send_event |
GET | /events/{id}/summary | The counts for one event. | get_event_summary |
POST | /events/{id}/update | Tell the guests something. | send_update |
GET | /groups | The host's saved lists. | list_groups |
POST | /groups | Make a saved list. | create_group |
GET | /groups/{id} | One saved list, with who is in it. | get_group |
PATCH | /groups/{id} | Rename a list, or replace who is in it. | update_group |
DELETE | /groups/{id} | Drop a saved list. | remove_group |
GET | /guests | The host's address book. | list_guests |
POST | /guests | Add a person to the address book. | add_guest |
GET | /guests/{id} | One person. | get_guest |
PATCH | /guests/{id} | Correct a number, add a household, write a note. | update_guest |
DELETE | /guests/{id} | Take a person out of the address book. | remove_guest |
POST | /guests/import | Bring a list of people in. | import_guests |
GET | /invitations/{id} | One party. | get_invitation |
PATCH | /invitations/{id} | Record an answer on someone's behalf. | mark_answer |
DELETE | /invitations/{id} | Take a party off an event. | remove_invitation |
POST | /invitations/{id}/resend | Send one invitation again. | resend_invitation |
GET | /me | Who this key belongs to. | whoami |
GET | /messages | The host's thread for one event. | list_messages |
GET | /webhooks | What this host has registered. | list_webhooks |
POST | /webhooks | Register a URL to be told about things. | register_webhook |
DELETE | /webhooks | Stop telling a URL. | remove_webhook |
Webhooks
Register a URL and we will POST to it when something happens. Three events today: `rsvp.changed` when someone answers, `message.received` when a guest texts back, and `invitation.delivered` when we see a delivery confirmed.
The signing secret comes back once, from the call that registers the webhook. Every delivery carries a `Simpleinvite-Signature` header shaped `t=<unix seconds>,v1=<hex>`, computed as an HMAC-SHA256 of `<timestamp>.<body>` with that secret.
Verify the signature before you trust the body, and check the timestamp: signing the time with the payload is what stops a captured delivery being replayed at you tomorrow. Compare in constant time.
curl -X POST https://app.simpleinvite.app/api/v1/webhooks \
-H "Authorization: Bearer si_live_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.org/hooks/simpleinvite","events":["rsvp.changed"]}'{
"id": "evt_m1x8k2q4b7",
"type": "rsvp.changed",
"created_at": "2026-09-11T18:04:22.117Z",
"data": {
"event_id": "evt_gamenight",
"invitation_id": "inv_kims",
"status": "accepted",
"maybe": false,
"headcount": 4,
"response_channel": "web",
"responded_at": "2026-09-11T18:04:22.101Z"
}
}import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(secret: string, body: string, header: string): boolean {
const parts = new Map(
header.split(",").map((part) => {
const [name, value] = part.split("=");
return [name, value] as const;
}),
);
const timestamp = Number(parts.get("t"));
const presented = parts.get("v1") ?? "";
if (!Number.isFinite(timestamp) || !presented) return false;
// Reject a delivery older than the window, or one dated in the future.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
if (expected.length !== presented.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(presented));
}Deliveries are not a queue. We try twice, a second apart, and record what happened — a receiver that is down for a minute misses that delivery. Answer 2xx as soon as you have the body; do your work after.
MCP
The same surface speaks the Model Context Protocol at `/api/mcp`, over streamable HTTP, authenticated with your API key the same way. Every REST route has a tool with the same job and a `snake_case` name — `list_events`, `get_event_summary`, `send_event`, `mark_answer`.
A read-only key sees only the read tools. That is deliberate: an agent should not have a tool in its list it is going to be refused on.
Two resources come with it. `guest-book://` is the whole address book, and `event://{id}` is one event with its counts and the parties on it — the thing to put in front of a model before asking it who is coming.
Claude Code
claude mcp add simpleinvite \
--transport http \
https://app.simpleinvite.app/api/mcp \
--header "Authorization: Bearer si_live_..."Claude Desktop
{
"mcpServers": {
"simpleinvite": {
"type": "http",
"url": "https://app.simpleinvite.app/api/mcp",
"headers": {
"Authorization": "Bearer si_live_..."
}
}
}
}Tools whose description begins SENDS REAL MESSAGES put texts or email in front of actual guests. Read a tool's description before you let an agent call it unattended.