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.

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 /enrollmentspriceMinor/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.

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.

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 invoicesubtotal, 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.

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.

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.

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.

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.

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.

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 /webhooksevents 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.

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:

plaintext
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://api.institflow.com).

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: 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:

http
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:

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).

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.

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: 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

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).