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 needsgroups.read; listing branches needs no specific permission — any authenticated key can callGET /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.
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.
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.
{
"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.
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.
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.
{
"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) andpayments.record(to record the payment against it). - At least one issued, unpaid invoice in the tenant.
POST /invoicesthenPOST /invoices/:id/issuecreate 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.
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.
{
"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.)
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.
{
"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) andattendance.mark. - An existing group with at least one active enrollment and a schedule (
PUT /groups/:id/schedules) — outside this recipe's scope. Set$GROUP_IDto that group's id and$STUDENT_IDto 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.
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.
{ "created": 8, "total": 8 }2. Find the next scheduled session
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.
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.
{
"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.
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
doneExpected: 200 OK on every page.
{
"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, abranchId, or an explicitstudentIdslist, plusrecipientsto 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.
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.
{
"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.
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.
{
"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 with422 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/smsspecifically — 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;smsis 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.emailand 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 towardskipped, notqueued. 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 —
localhostwill 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.
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.
{
"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.
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.
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);
});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 "", 200Never: 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://api.institflow.com).
Prerequisites
- A signed-in Institflow user (first-party JWT
$USER_TOKENfromPOST /auth/login) whose membership holdsmcp.access— that permission gates token issuance itself, before any tool call is even attempted.mcp.destructiveadditionally gates running a destructive tool withconfirm: 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
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.
curl -s "$ORIGIN/.well-known/oauth-protected-resource"
curl -s "$ORIGIN/.well-known/oauth-authorization-server"Expected: 200 OK from both.
GET /.well-known/oauth-authorization-server HTTP/1.1
Host: api.institflow.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"issuer": "https://api.institflow.com",
"authorization_endpoint": "https://api.institflow.com/oauth/authorize",
"token_endpoint": "https://api.institflow.com/oauth/token",
"registration_endpoint": "https://api.institflow.com/oauth/register",
"revocation_endpoint": "https://api.institflow.com/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:
POST /oauth/register HTTP/1.1
Host: api.institflow.com
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:
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).
{
"clientName": "My Agent",
"scope": "mcp",
"memberships": [
{ "tenantId": "0190a1b2-...", "tenantSlug": "acme-institute", "tenantName": "Acme Institute", "membershipId": "0190a1b2-...-m1" }
]
}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.
{ "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:
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:
POST /mcp HTTP/1.1
Host: api.institflow.com
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
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).