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_..."

Make a key

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

MethodPathWhat it doesMCP tool
GET/eventsThe host's events, newest first.list_events
POST/eventsMake 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}/invitationsThe parties on one event.list_invitations
POST/events/{id}/invitationsPut one more party on the event.add_invitation
POST/events/{id}/nudgeChase the people who have not answered.nudge
POST/events/{id}/sendPut the invitations on a wire.send_event
GET/events/{id}/summaryThe counts for one event.get_event_summary
POST/events/{id}/updateTell the guests something.send_update
GET/groupsThe host's saved lists.list_groups
POST/groupsMake 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/guestsThe host's address book.list_guests
POST/guestsAdd 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/importBring 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}/resendSend one invitation again.resend_invitation
GET/meWho this key belongs to.whoami
GET/messagesThe host's thread for one event.list_messages
GET/webhooksWhat this host has registered.list_webhooks
POST/webhooksRegister a URL to be told about things.register_webhook
DELETE/webhooksStop 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.