# Recipes

Short, runnable walkthroughs for the tasks most integrations start with — every request and
response shape below is checked against the API's own OpenAPI document, so it can never drift
from what the API actually does.

## Register a student

Operations used: `BranchesController_list_v1`, `StudentsController_create_v1`, `GuardiansController_create_v1`, `StudentsController_linkGuardian_v1`, `GroupsController_list_v1`, `EnrollmentsController_create_v1`

Creates a student, optionally attaches a guardian, and enrolls the student into an existing teaching group. Four calls, in order — every id after the first comes from the previous call's response, never hand-typed.

### Prerequisites

- An API key with `students.write`, `enrollments.write`, and — only if you attach a guardian — `guardians.write`. Reading the group lookup below also needs `groups.read`; listing branches needs no specific permission — any authenticated key can call `GET /branches`.
- At least one branch and one teaching group must already exist in the tenant (every tenant has at least one branch from onboarding; a group is created under **Academics → Groups** or `POST /api/v1/groups`, outside this recipe's scope).

### Steps

#### 1. Look up a branch and a group to enroll into

`GET /branches` and `GET /groups` — every student belongs to a branch, and enrollment needs a `groupId`. **The two responses do not share a shape**: `/branches` returns a plain array (a tenant's branch list is small and unpaginated — no `cursor`/`limit` query params exist on this endpoint at all), while `/groups` is cursor-paginated like almost everything else in the API.

```bash
BRANCH_ID=$(curl -s "$BASE/branches" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" | jq -r '.[0].id')

GROUP_ID=$(curl -s "$BASE/groups?limit=1" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" | jq -r '.data[0].id')
```

Expected: `200 OK` from both — `/branches` a bare `[ { "id": "...", "name": "...", ... }, ... ]`, `/groups` the usual `{ "data": [...], "nextCursor": string | null }`.

#### 2. Create the student

`POST /students` — only `branchId`, `firstName` and `lastName` are required. `studentNumber` auto-allocates (`S-000001`, sequential, server-assigned) if you omit it; never invent one yourself.

```bash
STUDENT=$(curl -s -X POST "$BASE/students" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"branchId\":\"$BRANCH_ID\",\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"email\":\"ada.lovelace@example.com\",\"phone\":\"+15551234567\"}")
echo "$STUDENT" | jq .
STUDENT_ID=$(echo "$STUDENT" | jq -r '.id')
```

Expected: `201 Created`.

```json response
{
  "id": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
  "branchId": "0190a1b2-0000-7000-8000-000000000001",
  "studentNumber": "S-000042",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "fullName": "Ada Lovelace",
  "status": "lead",
  "createdAt": "2026-03-02T09:00:00.000Z"
}
```

A missing `Idempotency-Key` on this call is `428 IDEMPOTENCY_KEY_REQUIRED`, not a soft warning — every mutating call made with a secret API key requires one.

#### 3. (Optional) Attach a guardian

Only needed if the student is a minor or someone else pays. `POST /guardians` creates the person; `POST /students/:id/guardians` links them, with a relationship and whether they are the primary contact and/or the payer of record.

Give a guardian an `email` as well as a `phone` where you can: email is the one notification channel every tenant can always use, while `whatsapp`/`sms` need the platform's per-tenant approval first (see recipe 05's notes) — a phone-only guardian at a tenant not yet approved for either is unreachable by any channel until one is.

```bash
GUARDIAN=$(curl -s -X POST "$BASE/guardians" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"firstName":"Grace","lastName":"Lovelace","phone":"+15559876543","email":"grace.lovelace@example.com"}')
GUARDIAN_ID=$(echo "$GUARDIAN" | jq -r '.id')

curl -s -X POST "$BASE/students/$STUDENT_ID/guardians" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"guardianId\":\"$GUARDIAN_ID\",\"relationship\":\"mother\",\"isPrimary\":true,\"isPayer\":true}"
```

Expected: `201 Created` for both. The second call's response has no body at all — a link record carries nothing worth returning beyond what you already sent, so don't try to parse it as JSON.

#### 4. Enroll the student in the group

`POST /enrollments` — `priceMinor`/`currency` set what this specific student is charged for this group (a fee schedule can compute this for you instead; see the Billing reference). Omit them to inherit the group's default fee.

```bash
curl -s -X POST "$BASE/enrollments" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"studentId\":\"$STUDENT_ID\",\"groupId\":\"$GROUP_ID\",\"priceMinor\":12000,\"currency\":\"ILS\"}"
```

Expected: `201 Created`.

```json response
{
  "id": "0190a1b2-0000-7000-8000-000000000004",
  "studentId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
  "groupId": "0190a1b2-0000-7000-8000-000000000002",
  "status": "active",
  "priceMinor": 12000,
  "currency": "ILS",
  "enrolledAt": "2026-03-02T09:00:05.000Z"
}
```

From here: `enrollment.created` fires as a webhook event (see recipe 06), and the student shows up on the group's roster for attendance (recipe 03) and billing.

## Record a payment

Operations used: `InvoicesController_list_v1`, `InvoicesController_recordPayment_v1`

Finds an unpaid invoice and records a payment against it — the everyday front-desk flow: a guardian pays cash or by card, and someone types the amount in.

### Prerequisites

- An API key with `invoices.read` (to find the invoice) and `payments.record` (to record the payment against it).
- At least one issued, unpaid invoice in the tenant. `POST /invoices` then `POST /invoices/:id/issue` create one if you don't have one to hand — outside this recipe's scope.

### Steps

#### 1. Find an unpaid invoice

`GET /invoices?status=issued` — cursor-paginated like every list endpoint; `status` also accepts `draft`, `partially_paid`, `paid`, `overdue`, `void`.

```bash
INVOICE=$(curl -s "$BASE/invoices?status=issued&limit=1" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" | jq -r '.data[0]')
INVOICE_ID=$(echo "$INVOICE" | jq -r '.id')
echo "$INVOICE" | jq '{id, number, status, total, paid}'
```

Expected: `200 OK`.

```json response
{
  "data": [
    {
      "id": "0190a1b2-0000-7000-8000-000000000005",
      "branchId": "0190a1b2-0000-7000-8000-000000000001",
      "number": "INV-2026-000042",
      "studentId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
      "status": "issued",
      "issueDate": "2026-03-01",
      "dueDate": "2026-03-15",
      "total": { "amountMinor": 12000, "currency": "ILS" },
      "paid": { "amountMinor": 0, "currency": "ILS" }
    }
  ],
  "nextCursor": null
}
```

Every money field on the **invoice** — `subtotal`, `discount`, `tax`, `total`, `paid` — is a nested `{ amountMinor, currency }` object, per the standard convention.

#### 2. Record the payment

`POST /invoices/:id/payments`. **Read the request DTO carefully**: unlike the invoice above, the *request* body here is flat — `amountMinor` and `currency` sit directly on the body, not nested under an `amount` key. (The *response* to this same call nests them back under `amount`, matching every other money-bearing response in the API — the asymmetry is real, not a typo, and catches integrators who copy the invoice's shape into this request.)

```bash
curl -s -X POST "$BASE/invoices/$INVOICE_ID/payments" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"amountMinor":12000,"currency":"ILS","method":"cash"}'
```

Expected: `201 Created`.

```json response
{
  "id": "0190a1b2-0000-7000-8000-000000000006",
  "invoiceId": "0190a1b2-0000-7000-8000-000000000005",
  "amount": { "amountMinor": 12000, "currency": "ILS" },
  "method": "cash",
  "provider": "manual",
  "receivedAt": "2026-03-02T09:00:00.000Z",
  "receivedByUserId": "0190a1b2-0000-7000-8000-0000000000aa",
  "createdAt": "2026-03-02T09:00:00.000Z"
}
```

A full payment moves the invoice's `status` to `paid`; a partial one to `partially_paid` — re-run step 1 to see the new status. `payment.recorded` fires as a webhook event either way (recipe 06). A payment recorded in error is corrected with `POST /payments/:id/reverse`, never by deleting it — payments are an append-only ledger.

## Mark attendance

Operations used: `GroupsController_generateSessions_v1`, `GroupsController_listSessions_v1`, `SessionAttendanceController_markAttendance_v1`

Generates a group's upcoming class sessions from its schedule, finds the next one, and records attendance for every student on its roster.

### Prerequisites

- An API key with `groups.write` (to generate sessions), `groups.read` (to list them) and `attendance.mark`.
- An existing group with at least one active enrollment and a schedule (`PUT /groups/:id/schedules`) — outside this recipe's scope. Set `$GROUP_ID` to that group's id and `$STUDENT_ID` to one of its enrolled students' id (`GET /groups/:id/enrollments`, or the id captured in recipe 01).

### Steps

#### 1. Generate upcoming sessions

`POST /groups/:id/sessions/generate` reads the group's schedule and creates every occurrence from today through eight weeks out — a rolling window, safe to call repeatedly (an occurrence that already exists is skipped, never duplicated or edited). Pass an explicit `until` (a `YYYY-MM-DD` date, which may be in the past) instead of an empty body if you want a specific window, e.g. to backfill sessions for a group that started before today.

```bash
curl -s -X POST "$BASE/groups/$GROUP_ID/sessions/generate" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{}'
```

Expected: `201 Created`.

```json response
{ "created": 8, "total": 8 }
```

#### 2. Find the next scheduled session

```bash
SESSION_ID=$(curl -s "$BASE/groups/$GROUP_ID/sessions?status=scheduled&limit=1" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" | jq -r '.data[0].id')
```

Expected: `200 OK`, `{ "data": [...], "nextCursor": string | null }` — each entry a session with `scheduledStart`, `scheduledEnd`, and `status: "scheduled" | "held" | "cancelled"`.

#### 3. Mark attendance for the session's roster

`PUT /sessions/:id/attendance` is a bulk upsert — one entry per student, and re-sending the same session's attendance later updates it in place (it is idempotent by design, not just by the `Idempotency-Key` header).

**A session's roster is computed as of the session's own scheduled date, not the request's.** `PUT /sessions/:id/attendance` rejects a `studentId` whose enrollment was created *after* that session's `scheduledStart` (`422 VALIDATION_ERROR`, "Student is not on this session's roster") — enrolling a student today puts them on every session from today onward, but never on one already in the past. `GET /sessions/:id/attendance` returns the current roster if you need to look it up rather than assume it.

```bash
curl -s -X PUT "$BASE/sessions/$SESSION_ID/attendance" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"entries\":[{\"studentId\":\"$STUDENT_ID\",\"status\":\"present\"}]}"
```

Expected: `200 OK`.

```json response
{
  "sessionId": "0190a1b2-0000-7000-8000-000000000003",
  "groupId": "0190a1b2-0000-7000-8000-000000000002",
  "scheduledStart": "2026-03-02T09:00:00.000Z",
  "status": "scheduled",
  "entries": [
    {
      "studentId": "0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "status": "present",
      "markedAt": "2026-03-02T09:05:00.000Z"
    }
  ]
}
```

`attendance.marked` fires once the whole roster has an entry; a student marked `absent`/`late`/ `excused` additionally fires `student.absent` for that one student (recipe 06 covers both).

## List overdue invoices

Operations used: `InvoicesController_list_v1`

Walks every page of a filtered list endpoint to completion — the pattern every list endpoint in the API shares, demonstrated here on overdue invoices.

### Prerequisites

- An API key with `invoices.read`.

### Steps

#### 1. Fetch pages until `nextCursor` is `null`

`GET /invoices?overdue=true` (equivalently `?status=overdue`) is cursor-paginated like every list endpoint: the response is `{ "data": [...], "nextCursor": string | null }`, never an offset or a page number. `limit` defaults to 25 and caps at 100. Pass the previous response's `nextCursor` back as the next request's `cursor` — an opaque token, never decode or construct it yourself — and stop exactly when `nextCursor` comes back `null`.

```bash
CURSOR=""
while :; do
  PAGE_URL="$BASE/invoices?overdue=true&limit=100"
  if [ -n "$CURSOR" ]; then
    PAGE_URL="$PAGE_URL&cursor=$CURSOR"
  fi
  PAGE=$(curl -s "$PAGE_URL" -H "Authorization: Bearer $INSTITFLOW_API_KEY")
  echo "$PAGE" | jq -c '.data[] | {id, number, dueDate, total}'
  CURSOR=$(echo "$PAGE" | jq -r '.nextCursor')
  if [ "$CURSOR" = "null" ]; then
    break
  fi
done
```

Expected: `200 OK` on every page.

```json response
{
  "data": [
    {
      "id": "0190a1b2-0000-7000-8000-000000000005",
      "branchId": "0190a1b2-0000-7000-8000-000000000001",
      "number": "INV-2026-000042",
      "status": "overdue",
      "issueDate": "2026-01-01",
      "dueDate": "2026-01-15",
      "total": { "amountMinor": 12000, "currency": "ILS" },
      "paid": { "amountMinor": 0, "currency": "ILS" }
    }
  ],
  "nextCursor": "eyJpZCI6IjAxOTBhMWIyLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwNSJ9"
}
```

A page with nothing left to return still has the shape above, with `data: []` and `nextCursor: null` — never an error, never a different envelope. An invoice reaches `status: "overdue"` automatically once its `dueDate` passes unpaid; each transition fires `invoice.overdue` as a webhook event (recipe 06). From here, `POST /invoices/:id/remind` (or the batch `POST /invoices/remind-overdue`) asks the notifications module to send a payment reminder for one — or every — overdue invoice.

## Send a broadcast

Operations used: `NotificationsController_send_v1`

Sends a manual broadcast to a group's guardians — previewed first with `dryRun`, then sent for real. The same endpoint powers every one-off announcement (a schedule change, a closure notice).

### Prerequisites

- An API key with `notifications.send`.
- An audience that resolves to at least one recipient: a `groupId`, a `branchId`, or an explicit `studentIds` list, plus `recipients` to say whether the message reaches the students, their guardians, or both.

### Steps

#### 1. Preview with `dryRun`

Setting `dryRun: true` resolves the audience and projects quota usage **without** sending anything or creating any row — safe to call before every real broadcast, especially one to a large audience.

```bash
curl -s -X POST "$BASE/notifications/send" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"subject\":\"Schedule change\",\"body\":\"Saturday's session moves to 10:00.\",\"audience\":{\"groupId\":\"$GROUP_ID\",\"recipients\":\"guardians\"},\"dryRun\":true}"
```

Expected: `201 Created`.

```json response
{
  "dryRun": true,
  "recipientCount": 18,
  "queued": 0,
  "skipped": 0,
  "notificationIds": [],
  "channelCounts": { "email": 18 },
  "quotaImpact": {
    "email": { "projected": 18, "limit": null, "wouldExceed": false }
  }
}
```

#### 2. Send for real

Same body, `dryRun` omitted (or `false`). Reuse a **fresh** `Idempotency-Key` — the preview call above never queued anything, so replaying its key would only replay an empty result, not send.

```bash
curl -s -X POST "$BASE/notifications/send" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"subject\":\"Schedule change\",\"body\":\"Saturday's session moves to 10:00.\",\"audience\":{\"groupId\":\"$GROUP_ID\",\"recipients\":\"guardians\"}}"
```

Expected: `201 Created`.

```json response
{
  "dryRun": false,
  "recipientCount": 18,
  "queued": 18,
  "skipped": 0,
  "notificationIds": ["0190a1b2-0000-7000-8000-000000000007"],
  "channelCounts": { "email": 18 },
  "quotaImpact": {
    "email": { "projected": 18, "limit": null, "wouldExceed": false }
  }
}
```

#### Notes

- **`channels`** (an array, e.g. `["whatsapp", "email"]`) narrows which channels are even considered; omit it to use the tenant's full configured ladder. Naming a channel the tenant is not approved for (below) is rejected with `422 VALIDATION_ERROR` — it never silently sends a smaller broadcast than asked.
- Each recipient is sent on the **first** channel, in ladder order, that is enabled for them, has a known contact address, and — for `whatsapp`/`sms` specifically — the tenant is approved to use at all: unlike every other channel, there is no platform-paid sender for either, so the *institute* must be individually approved (by Institflow, or — self-hosted — by its licence) before it may configure its own provider credentials for them. An unapproved channel is skipped in the ladder exactly like one with no contact address; `sms` is additionally skipped tenant-wide unless the tenant has also turned it on for cost reasons, regardless of a recipient's own preferences or a phone number on file. `email` and the in-app inbox always work — a fresh tenant with neither channel approved yet can still reach every recipient with a login through the inbox, and every recipient with an email on file. A recipient reachable by no channel counts toward `skipped`, not `queued`.
- `urgency: "immediate"` (the default for most events, and worth setting explicitly for a time-sensitive broadcast like this one) sends right away; `"digest"` batches with the recipient's other non-urgent notifications.

## Verify a webhook

Operations used: `WebhooksController_create_v1`, `WebhooksController_testPing_v1`

Registers a webhook endpoint, sends a test ping at it, and verifies the signature on the receiving side — in both TypeScript and Python. Full event/header/retry mechanics are in `reference/webhooks.md`; this recipe is the runnable path from zero to a verified delivery.

### Prerequisites

- An API key with `integration.manage`.
- A **publicly reachable** HTTPS (or plain HTTP) URL for your receiver. Institflow re-validates the target at send time and refuses anything that resolves to a private/loopback/link-local address — `localhost` will not work even for local testing; use a tunnel (ngrok, Cloudflare Tunnel, a webhook-capture service) during development.

### Steps

#### 1. Register the endpoint

`POST /webhooks` — `events` is validated against the real domain-event catalogue (`reference/events.md`); an unknown event name is rejected at creation. The response's `secret` is shown **exactly once** — store it now, it is never retrievable again.

```bash
WEBHOOK=$(curl -s -X POST "$BASE/webhooks" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"url\":\"$RECEIVER_URL\",\"events\":[\"student.created\",\"payment.recorded\"]}")
WEBHOOK_ID=$(echo "$WEBHOOK" | jq -r '.id')
WEBHOOK_SECRET=$(echo "$WEBHOOK" | jq -r '.secret')
```

Expected: `201 Created`.

```json response
{
  "id": "0190a1b2-0000-7000-8000-000000000008",
  "url": "https://example.com/hooks/institflow",
  "events": ["student.created", "payment.recorded"],
  "isActive": true,
  "failureCount": 0,
  "disabledAt": null,
  "createdAt": "2026-03-02T09:00:00.000Z",
  "secret": "whsec_2f8a9c1e4b7d0a3f6c9e2b5d8a1f4c7e0b3a6d9c2f5e8b1a4d7c0f3a6b9e2c5f"
}
```

#### 2. Send a real signed ping

`POST /webhooks/:id/test` fires one real, signed delivery immediately — event type `webhook.test`, body `{"ping": true}` — so you can verify your handler before relying on a real domain event.

```bash
curl -s -X POST "$BASE/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $INSTITFLOW_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

Expected: `201 Created`. Your receiver gets a `POST` within seconds, carrying:

```
content-type: application/json
x-institflow-signature: t=<unix-seconds>,v1=<hex hmac-sha256>
x-institflow-event: webhook.test
x-institflow-delivery: <delivery id>
```

#### 3. Verify the signature on receipt

`v1` is `hex(hmac-sha256(secret, "<t>.<raw request body bytes>"))`, computed over the **exact bytes** received — re-serializing the parsed JSON and hashing that instead silently produces the wrong digest whenever key order or number formatting differs at all. Reject anything whose timestamp is more than 5 minutes old (a replay), and compare in constant time.

**Known test vector** (`secret: whsec_test`, `timestamp: 1700000000`, `body: {"hello":"world"}` → `v1=f592bbf3951cfc94e560eecfb5d9dd4da6b0fff2e626235f8ab4b54860925d0b`) — run either snippet below against it before wiring up your real secret.

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyInstitflowSignature(
  rawBody: string,
  signatureHeader: string,
  secret: string,
  toleranceSeconds = 300,
): void {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
    throw new Error('Signature timestamp missing or outside tolerance — possible replay.');
  }
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const expectedBuf = Buffer.from(expected, 'utf8');
  const actualBuf = Buffer.from(parts.v1 ?? '', 'utf8');
  if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
    throw new Error('Signature mismatch.');
  }
}

// Mount with the RAW body parser for this route — verifying a re-serialized/re-parsed body
// will not match.
app.post('/hooks/institflow', express.raw({ type: 'application/json' }), (req, res) => {
  verifyInstitflowSignature(
    req.body.toString('utf8'),
    req.header('X-Institflow-Signature') ?? '',
    process.env.INSTITFLOW_WEBHOOK_SECRET!,
  );
  const event = JSON.parse(req.body.toString('utf8'));
  // ... handle event.type / event.data, keyed off event.id for idempotency ...
  res.sendStatus(200);
});
```

```python
import hashlib
import hmac
import time


def verify_institflow_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> None:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = int(parts.get("t", 0))
    if not timestamp or abs(time.time() - timestamp) > tolerance_seconds:
        raise ValueError("Signature timestamp missing or outside tolerance — possible replay.")
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        raise ValueError("Signature mismatch.")


@app.post("/hooks/institflow")
def institflow_webhook():
    verify_institflow_signature(request.get_data(), request.headers["X-Institflow-Signature"], os.environ["INSTITFLOW_WEBHOOK_SECRET"])
    event = request.get_json()
    # ... handle event["type"] / event["data"], keyed off event["id"] for idempotency ...
    return "", 200
```

**Never**: compare signatures with `==`/`!=` (timing attack), skip the timestamp check (replay attack), or verify against a re-parsed/re-serialized body. Return `2xx` fast and make your handler idempotent — key every side effect off the delivery id or the domain id inside `data`, since both a retry and a human-triggered replay can deliver the same event twice.

## Connect MCP

Operations used: `OAuthDecisionController_context_v1`, `OAuthDecisionController_decide_v1`

Connects an MCP client (Claude Code, Claude Desktop, Cursor, or a hand-rolled one) to Institflow's `/mcp` server over OAuth 2.1. Most clients need only the one-liner in step 1; the rest of this recipe walks the protocol underneath it for a client that has to drive the flow itself.

**Base URL note**: every other recipe's `$BASE` includes `/api/v1`. The OAuth endpoints in this recipe do **not** — they are served at the bare origin (no `api` prefix, no version segment), since RFC 8414/9728 well-known URIs are resolved relative to the issuer itself and every MCP client hardcodes these exact paths. Set `$ORIGIN` to `$BASE` with `/api/v1` stripped (e.g. `https://your-institute.example` — a placeholder: the host is the institute's own; never assume one, ask the user).

### Prerequisites

- A signed-in Institflow user (first-party JWT `$USER_TOKEN` from `POST /auth/login`) whose membership holds `mcp.access` — that permission gates token issuance itself, before any tool call is even attempted. `mcp.destructive` additionally gates running a destructive tool with `confirm: true`; without it, a destructive tool call returns a preview (`requiresConfirmation: true`) instead of acting.

### Steps

#### 1. The one-liner, if your client supports it

```bash
claude mcp add --transport http institflow "$ORIGIN/mcp"
```

Claude Desktop and Cursor take the same URL in their own MCP client config. This alone triggers the full flow below on first use, in a browser — read on only if you're driving it yourself.

#### 2. Discover the endpoint set

RFC 9728, then RFC 8414 — every real MCP client fetches both before doing anything else.

```bash
curl -s "$ORIGIN/.well-known/oauth-protected-resource"
curl -s "$ORIGIN/.well-known/oauth-authorization-server"
```

Expected: `200 OK` from both.

```http
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: your-institute.example

HTTP/1.1 200 OK
Content-Type: application/json

{
  "issuer": "https://your-institute.example",
  "authorization_endpoint": "https://your-institute.example/oauth/authorize",
  "token_endpoint": "https://your-institute.example/oauth/token",
  "registration_endpoint": "https://your-institute.example/oauth/register",
  "revocation_endpoint": "https://your-institute.example/oauth/revoke",
  "scopes_supported": ["mcp"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}
```

#### 3. Register a client (or use a static one)

`POST /oauth/register` (RFC 7591) if your client supports dynamic registration; otherwise use a `client_id` your integration was given out of band. This is a JSON body, but at the bare origin — not `$BASE` — so it is shown as raw HTTP rather than a `curl` line against `$BASE`:

```http
POST /oauth/register HTTP/1.1
Host: your-institute.example
Content-Type: application/json

{
  "client_name": "My Agent",
  "redirect_uris": ["http://localhost:8090/callback"]
}

HTTP/1.1 201 Created
Content-Type: application/json

{ "client_id": "01a07598-...", "client_name": "My Agent", "redirect_uris": ["http://localhost:8090/callback"], "token_endpoint_auth_method": "none" }
```

#### 4. Authorize with PKCE (S256 — mandatory)

Generate a `code_verifier`/`code_challenge` pair, then either send the signed-in user's browser to `GET $ORIGIN/oauth/authorize?...` (the normal path — it renders Institflow's own consent screen), or, if you're scripting the whole flow with an already-authenticated user token, drive the same decision directly:

```bash
curl -s "$BASE/oauth/authorize/context?clientId=$CLIENT_ID&redirectUri=$REDIRECT_URI&scope=mcp" \
  -H "Authorization: Bearer $USER_TOKEN"
```

Expected: `200 OK` — the client name, scope, and every tenant membership eligible to grant `mcp` access (a user with more than one membership picks which tenant the token is bound to).

```json response
{
  "clientName": "My Agent",
  "scope": "mcp",
  "memberships": [
    { "tenantId": "0190a1b2-...", "tenantSlug": "acme-institute", "tenantName": "Acme Institute", "membershipId": "0190a1b2-...-m1" }
  ]
}
```

```bash
curl -s -X POST "$BASE/oauth/authorize/decision" \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"clientId\":\"$CLIENT_ID\",\"redirectUri\":\"$REDIRECT_URI\",\"state\":\"$STATE\",\"codeChallenge\":\"$CODE_CHALLENGE\",\"codeChallengeMethod\":\"S256\",\"scope\":\"mcp\",\"membershipId\":\"$MEMBERSHIP_ID\",\"approve\":true}"
```

Expected: `200 OK`.

```json response
{ "redirectTo": "http://localhost:8090/callback?code=...&state=..." }
```

`redirectTo` carries the authorization code as a query parameter — the same place a real browser redirect would deliver it.

#### 5. Exchange the code for tokens

`POST /oauth/token` accepts `application/x-www-form-urlencoded`, at the bare origin:

```bash
curl -s -X POST "$ORIGIN/oauth/token" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$AUTH_CODE" \
  --data-urlencode "redirect_uri=$REDIRECT_URI" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Expected: `200 OK`, `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "..." }`. The access token is a JWT with `typ: "mcp"` — it is **rejected on every REST route** and a REST token is rejected on `/mcp`, by design (recipe-independent, integration-tested).

#### 6. Call the MCP server

The MCP wire protocol is JSON-RPC 2.0, not a REST call the OpenAPI document describes — shown as raw HTTP accordingly:

```http
POST /mcp HTTP/1.1
Host: your-institute.example
Authorization: Bearer $MCP_ACCESS_TOKEN
Content-Type: application/json

{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
```

Expected: `200 OK`, the 51-tool catalogue (`reference/mcp-tools.md`), each entry carrying its required permission and whether it is destructive. Calling a destructive tool without both `mcp.destructive` on the membership and `confirm: true` in the call returns a preview (`requiresConfirmation: true`) instead of acting — read the tool's description for what it's about to do, then call it again with `confirm: true`.

#### 7. Revoke, when you're done

```bash
curl -s -X POST "$ORIGIN/oauth/revoke" --data-urlencode "token=$MCP_ACCESS_TOKEN"
```

Expected: `200 OK` (RFC 7009 — revocation always reports success, even for an already-invalid token, so as not to leak which tokens exist).
