Eformogi Issuer API

The issuer rail: a school, microschool, or counselor that holds the learners enrolls, imports its roster, declares courses, asks teachers to co-sign, and reads its billing. The learner holds the record and the export throughout.

GitHub OpenAPI

Operations 8

POST /api/issuer/enroll Enroll the organization as a founding issuer #
POST /api/issuer/import-roster Put the roster on the record #
POST /api/issuer/invite-family Send, or resend, one family the invitation to claim their learner #
POST /api/issuer/declare-courses Declare courses for learners and ask their teachers to co-sign #
POST /api/issuer/declare-competency Declare one piece of Portrait of a Graduate evidence for a learner and ask the… #
GET /api/issuer/records Every learner the organization holds, every course it declared, and the verify… #
GET /api/issuer/billing What this issuer is billed #
POST /api/issuer/billing Sync the subscription quantity to the active learner count #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/eformogi-com:eformogi-com-issuer-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Calls without X-API-Key need no key. Calls with it need a paid plan — get a key.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

eformogi-com-issuer-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: Eformogi Issuer API
  version: 33.5.1
  description: 'Eformogi is a record of learning the student owns, witnessed by the adults who were there, verifiable at one link with no login, and issued by the schools and counselors who hold the learners. The family-issued receipt (`tr_`) is a Witness Transcript. This document describes the machine-readable surface: verification, witness attestation, consent, the agent protocol, and the issuer rail (enrollment, roster, declared courses, and the family side that folds them into the transcript). The open record format itself is published at https://eformogi.com/spec.'
  contact:
    url: https://eformogi.com
  license:
    name: Proprietary
servers:
- url: https://eformogi.com
  description: Production
tags:
- name: Issuer
  description: 'The issuer rail: a school, microschool, or counselor that holds the learners enrolls, imports its roster, declares courses, asks teachers to co-sign, and reads its billing. The learner holds the record and the export throughout.'
paths:
  /api/issuer/enroll:
    post:
      summary: Enroll the organization as a founding issuer
      description: 'A school, microschool, or independent counselor accepts the founding terms at /issuers/terms and becomes an issuer, self-serve. Writes `institutional_orgs.metadata.issuer` with `status: founding` through the end of the founding cohort (2027-06-30) and the enrollment date the founding price lock reads. Idempotent: an org already `founding` or `active` gets its stored issuer object back unchanged with `existed: true`. An org previously `inactive` enrolls again on a fresh date. Caller: the org admin (bearer Supabase session). Rate limit: 10 requests per minute per IP.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - accept_terms
              - terms_version
              properties:
                accept_terms:
                  type: boolean
                  const: true
                  description: Must be the literal `true`. Anything else is a 400.
                terms_version:
                  type: string
                  enum:
                  - '2026-09-20'
                  description: The terms version this deploy serves. A stale version is refused with a 400 that names the current one.
      responses:
        '200':
          description: 'Enrolled, or already enrolled. Body: `ok` (true), `existed` (boolean), `org` ({ id, name }), `issuer` (the stored object: `status`, `founding_until`, `set_by`, `set_at`, `enrolled_at`, `terms_version`, `terms_accepted_at`, `contact_email`, `learner_price_usd` 49, `stripe_customer_id`, `stripe_subscription_id`, plus any billing fields carried from a previous enrollment), and on a fresh enrollment `audit_logged` (boolean).'
        '400':
          description: '`accept_terms` is not `true`, or `terms_version` is not the current version. Body: `error`; the version mismatch also carries `terms_version` (the version to accept).'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The account administers no organization, or the organization is switched off. Body: `error`.'
        '429':
          description: 'Rate limited (10 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization lookup or the enrollment write failed. Body: `error`.'
        '503':
          description: 'The `institutional_orgs.metadata` column is not on the table yet (docs/sql-migrations/2026-09-06-issuer-status.sql not applied). Body: `error`.'
      operationId: postApiIssuerEnroll
      x-operation-id-source: derived
  /api/issuer/import-roster:
    post:
      summary: Put the roster on the record
      description: 'Creates one caseload learner (institutional_received_profiles) per row, deduplicated inside this org: a row whose lowercased `family_email` matches an existing learner is a match, failing that an exact `name` match is a match, and a match never overwrites the existing learner. Importing the same roster twice creates nothing the second time. With `send_invites: true`, each CREATED learner with a family email is mailed a single-use record claim (`rc_` token, 30 days). Matched learners are never re-invited here; invite-family is the deliberate resend. Caps: 500 rows per call; 200 outbound invitations per org per rolling 24 hours, counted on the claim rows the sends leave behind. A row past the cap is still put on the record and its invitation is reported in `errors` with reason `daily invite cap reached`. Requires an enrolled issuer (`founding` inside its window, or `active`). Rate limit: 10 requests per minute per IP. Per-row failures never sink the batch, and no error string carries an email address.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rows
              properties:
                rows:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    type: object
                    required:
                    - name
                    properties:
                      name:
                        type: string
                        maxLength: 120
                        description: The learner. Trimmed; longer values are clipped to 120 characters.
                      family_email:
                        type: string
                        format: email
                        maxLength: 254
                        description: Lowercased. An invalid address fails the row.
                      grade_level:
                        type: string
                        maxLength: 40
                      notes:
                        type: string
                        maxLength: 500
                send_invites:
                  type: boolean
                  default: false
                  description: Mail a record claim to each created learner that has a family email.
      responses:
        '200':
          description: 'The batch result. Body: `ok` (true), `created` (array of { profile_id, name }), `matched` (array of { profile_id, name, matched_reason: `email` | `name`, grade_level?, notes? }; the unapplied fields come back for the org to apply deliberately), `invited` (number of invitation emails delivered), `errors` (array of { row, reason }; reasons: `name is required`, `family_email is not a valid address`, `could not be added`, `daily invite cap reached`, `added, but the claim invite could not be created`, `added, but the invite email could not be delivered`).'
        '400':
          description: '`rows` is not an array, is empty, or has more than 500 entries. Body: `error`.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The account administers no organization, the organization is switched off, or it is not an enrolled issuer (never enrolled, `inactive`, or a `founding` window that has closed). Body: `error`; the not-enrolled refusals also carry `enrolled: false`.'
        '429':
          description: 'Rate limited (10 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization lookup or the caseload read failed. Body: `error`.'
      operationId: postApiIssuerImportRoster
      x-operation-id-source: derived
  /api/issuer/invite-family:
    post:
      summary: Send, or resend, one family the invitation to claim their learner
      description: 'Mints a NEW single-use record claim (`rc_` token, 30 days) for the learner and mails it to the family email on file. Earlier tokens stay valid; whichever arrives first claims the learner and the rest then read as already claimed. One invitation per call and no reminder sequence. This endpoint does not itself check the per-org daily invite cap; the claim rows it writes do count toward the 200 a day budget that import-roster enforces. Requires an enrolled issuer. Rate limit: 30 requests per minute per IP. No response carries the family address.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - profile_id
              properties:
                profile_id:
                  type: string
                  format: uuid
                  description: The learner id from import-roster or records.
      responses:
        '200':
          description: 'Invitation created. Body: `ok` (true), `profile_id`, `record_invited_at` (ISO timestamp, or null when the stamp column is absent), `email_sent` (boolean), `email_reason` (string or null).'
        '400':
          description: '`profile_id` is not a UUID, or no family email is on file for the learner. Body: `error`.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The learner is not on this organization caseload (an unknown id reads the same, never a 404), the account administers no organization, the organization is switched off, or it is not an enrolled issuer. Body: `error`.'
        '409':
          description: 'The family already claimed this learner. Body: `error`, `claimed: true`.'
        '429':
          description: 'Rate limited (30 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization lookup, the learner read, or the claim mint failed. Body: `error`.'
      operationId: postApiIssuerInviteFamily
      x-operation-id-source: derived
  /api/issuer/declare-courses:
    post:
      summary: Declare courses for learners and ask their teachers to co-sign
      description: 'Mints ordinary course receipts (`cr_`) through the shared derive: same hash domain, same signing, same append-only log, so the id is the one the family would mint for the identical course. Three provenance columns outside the hash say where it came from (`issuer_org_id`, `profile_id`, `declared_by: issuer`). `student_name` is the learner''s caseload name, never a caller value. A receipt that already exists is returned as it stands with `existed: true` and is never overwritten; when somebody else issued it the entry also carries `reason` and no teacher is invited on it. With `teacher_email`, an attestation request (64 hex token, deduplicated on receipt and address) is created and the teacher signs at /witness/ with no login. Caps: 200 courses per call; 200 teacher invitations per org admin per rolling 24 hours, counted on attestation_requests. A course past the cap is still declared and its invitation is reported in `errors` with status 429 and reason `daily invite cap reached`. Refuses the whole call with 503 until docs/sql-migrations/2026-09-07-issuer-rail.sql is applied, deliberately: a content-addressed receipt minted without its provenance columns could never be given them later. Requires an enrolled issuer. Rate limit: 20 requests per minute per IP.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - courses
              properties:
                courses:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items:
                    type: object
                    required:
                    - profile_id
                    - course_title
                    - term
                    - grade
                    properties:
                      profile_id:
                        type: string
                        format: uuid
                        description: A learner on this org caseload.
                      course_title:
                        type: string
                        maxLength: 300
                      term:
                        type: string
                        maxLength: 100
                      grade:
                        type: string
                        maxLength: 32
                      credits:
                        type:
                        - number
                        - string
                        description: Optional. Parsed as a number; anything that is not a non-negative number is stored as null.
                      teacher_email:
                        type: string
                        format: email
                        maxLength: 320
                        description: Optional. Invites this teacher to co-sign the course.
                      teacher_name:
                        type: string
                        maxLength: 120
                      note:
                        type: string
                        maxLength: 500
                        description: Optional note carried in the teacher invitation.
      responses:
        '200':
          description: 'The batch result, per row. Body: `ok` (true), `receipts` (array of { profile_id, cr_receipt_id, existed (boolean), attestation_request_sent (boolean), reason? }), `errors` (array of { index, reason, status }). Per-row `status` values: 400 (`profile_id` not a UUID, a required field missing, `teacher_email` invalid), 403 (learner not on this caseload; course issued elsewhere so this org may not invite on it), 409 (id collision with a different hash), 429 (daily invite cap reached), 500 (a lookup, insert, or invitation write failed), 502 (invitation email not delivered), 503 (attestation_requests table absent).'
        '400':
          description: '`courses` is not an array, is empty, or has more than 200 entries. Body: `error`.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The account administers no organization, the organization is switched off, or it is not an enrolled issuer. Body: `error`; the not-enrolled refusals also carry `enrolled: false`.'
        '429':
          description: 'Rate limited (20 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization lookup or the learner read failed. Body: `error`.'
        '503':
          description: 'course_receipts or its issuer columns are not on the table yet (docs/sql-migrations/2026-09-07-issuer-rail.sql not applied). Nothing further is minted. Body: `error`, plus `receipts` and `errors` for the rows processed before the refusal.'
      operationId: postApiIssuerDeclareCourses
      x-operation-id-source: derived
  /api/issuer/declare-competency:
    post:
      summary: Declare one piece of Portrait of a Graduate evidence for a learner and ask the…
      description: 'New York Portrait plugin (Sep 15 2026). Mints an ORDINARY witness invitation (attestation_requests) through the same path a course invitation takes, with one additive field: `competency`, the claim, validated by shared/portraitTaxonomy.js (jurisdiction `ny`, taxonomy version `2025-07-14-regents`, one of the six attributes the Board of Regents adopted on July 14, 2025, an evidence kind, and one line saying what the evidence was). No receipt is minted and no hash domain is touched. The evidence hangs on a course row this organization declared for this learner (`cr_receipt_id` with the org as issuer and the learner as `profile_id`). A row somebody else declared is refused with one neutral sentence; a withdrawn row is refused. The witness signs at /witness/ with no login; the grade is optional for them because the ask is about the evidence. `rubric_level` is always null: the state has not published its rubrics, and a non-null value is refused. Deduplicated on the whole ask (course row, address, attribute, evidence), so the same teacher can be asked about two attributes on one course and a re-ask returns the existing link. Caps: 200 invitations per org admin per rolling 24 hours, counted on attestation_requests. Refuses with 503 until docs/sql-migrations/2026-09-15-portrait-competency.sql is applied. Requires an enrolled issuer. Rate limit: 20 requests per minute per IP.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - profile_id
              - cr_receipt_id
              - competency
              - witness_email
              properties:
                profile_id:
                  type: string
                  format: uuid
                  description: A learner on this org caseload.
                cr_receipt_id:
                  type: string
                  pattern: ^cr_[a-f0-9]{12}$
                  description: A course row this organization declared for that learner.
                competency:
                  type: object
                  required:
                  - attribute_id
                  - evidence_kind
                  - evidence_ref
                  properties:
                    jurisdiction:
                      type: string
                      enum:
                      - ny
                      description: Optional; defaults to ny.
                    taxonomy_version:
                      type: string
                      enum:
                      - 2025-07-14-regents
                      description: Optional; must equal the adopted version for the jurisdiction.
                    attribute_id:
                      type: string
                      enum:
                      - academically_prepared
                      - creative_innovator
                      - critical_thinker
                      - effective_communicator
                      - global_citizen
                      - reflective_future_focused
                    evidence_kind:
                      type: string
                      enum:
                      - capstone
                      - work_sample
                      - oral_defense
                      - observation
                      - project
                      - hours_logged
                      - industry_credential
                      - work_based_placement
                      - supervisor_observation
                      description: 'The last four (Sep 16 2026) are for career and technical education and the trades: hours on a site or in a shop counted by the supervising adult; a credential or license as issued, with the issuing body; an internship, pre-apprenticeship, apprenticeship, or job placement witnessed by the sponsor or employer; what a job-site or shop supervisor saw.'
                    evidence_ref:
                      type: string
                      maxLength: 200
                      description: A ws_ receipt id or a short description of the evidence.
                    rubric_level:
                      type: 'null'
                      description: Always null until the state publishes its rubrics.
                witness_email:
                  type: string
                  format: email
                  maxLength: 320
                witness_name:
                  type: string
                  maxLength: 120
                note:
                  type: string
                  maxLength: 500
                  description: Optional note carried in the invitation.
      responses:
        '200':
          description: 'Body: `ok` (true), `cr_receipt_id`, `competency` (the stored claim), `existed` (true when the same ask was already on file and its link stands), `attestation_request_sent` (boolean).'
        '400':
          description: '`profile_id` not a UUID, `cr_receipt_id` malformed, `competency` refused by the taxonomy (unknown attribute, kind, jurisdiction, or version; a non-null `rubric_level`; an empty or over-long `evidence_ref`), or `witness_email` invalid. Body: `error`.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The account administers no organization, the organization is switched off or not an enrolled issuer (`enrolled: false`), the learner is not on this caseload, or the course row is not one this organization declared for this learner. Body: `error`.'
        '404':
          description: '`cr_receipt_id` is not on the public ledger. Body: `error`.'
        '409':
          description: 'The course row was withdrawn. Body: `error`.'
        '429':
          description: 'Rate limited (20 per minute per IP), or the daily invitation cap is reached. Body: `error`.'
        '500':
          description: 'A lookup or the invitation write failed. Body: `error`.'
        '502':
          description: 'The invitation email could not be delivered. Body: `error`.'
        '503':
          description: 'attestation_requests.competency is not on the table yet (docs/sql-migrations/2026-09-15-portrait-competency.sql not applied), or course_receipts lacks its issuer columns. Nothing is minted. Body: `error`.'
      operationId: postApiIssuerDeclareCompetency
      x-operation-id-source: derived
  /api/issuer/records:
    get:
      summary: Every learner the organization holds, every course it declared, and the verify…
      description: 'Readable by ANY org admin, enrolled or not: an org that has not enrolled (or is `inactive`) reads `enrolled: false`, `issuer` as stored or null, and an empty `learners` list, so the dashboard can render the enrollment door without a second call. Only active caseload rows are listed (alumni and archived are off the caseload). Caps: 500 learners, 2000 courses. Privacy: every email address leaves masked (`p***@example.com`), and the family address only while the learner is unclaimed. `co_signed` is a count of signatures, never a rank; `dissenting` counts signers who recorded a different grade. Degrades on every absent column: a pre-migration schema reads null or empty and the page still renders (`issuer_columns_present: false` when course_receipts lacks the issuer columns). Rate limit: 60 requests per minute per IP.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      responses:
        '200':
          description: 'The records view. Body: `ok` (true), `enrolled` (boolean), `org` ({ id, name }), `issuer` (the stored metadata.issuer object, or null), `issuer_columns_present` (boolean), `totals` ({ learners, claimed, courses, co_signed, pending_invites, portrait_signed, portrait_pending }), `learners` (array of { profile_id, person_name, claimed (boolean), family_email_masked (string or null), record_invited_at (ISO timestamp or null), courses: array of { cr_receipt_id, course_title, term, grade, credits, declared_by, verify_url, co_signed (number; signatures that carry a grade), dissenting (number), pending_invites: array of { witness_email_masked, created_at }, portrait_evidence: array of { attribute_id, attribute_name, evidence_kind, evidence_kind_label, evidence_ref, signed (boolean), signed_at, witness_email_masked, created_at } } }). `portrait_signed` and `portrait_pending` count Portrait evidence rows across the roster (signed, and invited but not signed); never a level or a rank.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'The account administers no organization, or the organization is switched off. Body: `error`.'
        '429':
          description: 'Rate limited (60 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization lookup, the caseload read, or the course read failed. Body: `error`.'
      operationId: getApiIssuerRecords
      x-operation-id-source: derived
  /api/issuer/billing:
    get:
      summary: What this issuer is billed
      description: 'The founding offer as it applies to this org: free to issue through 2027-06-30, then $49 per learner per year, billed to the issuer and never to a family. A learner is an active caseload row (alumni and archived are never billed), counted through the same helper the checkout and the reconcile cron use. `issuer` carries every stored metadata.issuer field except the Stripe ids and the alert invoice id, plus one computed field, `founding_lock`: true when the org enrolled inside the founding cohort and so keeps $49 per learner per year for as long as it issues. The subscription id is returned masked (`sub_****abcd`). Caller: the org admin (bearer Supabase session; the org is the institutional_orgs row whose admin_user_id is the caller and that is not switched off). Rate limit: 30 requests per minute per IP, shared with POST.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      responses:
        '200':
          description: 'Body: `ok` (true), `org` ({ id, name }), `issuer` (object, see the description), `learners_active` (number), `caseload_capped` (boolean: true when the read came back at the 2000 row cap, so the count is a subset), `price_usd_per_learner_year` (49), `founding_until` (YYYY-MM-DD or null), `billing` ({ status: `founding` | `active` | `inactive` | `none`, subscription_id_masked (string or null), next_invoice_hint (one plain sentence) }).'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'No organization is administered by this account, or it is switched off. Body: `error`.'
        '429':
          description: 'Rate limited (30 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization or caseload read failed. Body: `error`.'
        '503':
          description: 'The `institutional_orgs.metadata` column is not on the table yet (docs/sql-migrations/2026-09-06-issuer-status.sql not applied). Body: `error`.'
      operationId: getApiIssuerBilling
      x-operation-id-source: derived
    post:
      summary: Sync the subscription quantity to the active learner count
      description: 'The one write. Sets the per-learner subscription item quantity to today''s active learner count (floored at 1, because a licensed per-unit item needs at least one), so the school is charged for what it issues rather than the number it had on the day it subscribed. Proration follows the direction of the change on a yearly subscription: an increase invoices now (`always_invoice`), a decrease keeps the credit for renewal (`create_prorations`). No change writes nothing and answers `changed: false`. Refuses rather than guesses: no subscription on the org, an unset STRIPE_ISSUER_LEARNER_PRICE_ID (the per-learner line cannot be identified, and there is no single-item fallback), a caseload at the 2000 row cap, or a subscription without the per-learner line are each a 409. Same auth and rate limit as GET.'
      tags:
      - Issuer
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action
              properties:
                action:
                  type: string
                  enum:
                  - sync-quantity
                  description: The only action.
      responses:
        '200':
          description: 'Body: `ok` (true), `action` (`sync-quantity`), `learners_active`, `previous_quantity` (number or null), `quantity`, `changed` (boolean), `subscription_id_masked`; when changed, also `proration_behavior` (`always_invoice` | `create_prorations`) and `audit_logged` (boolean).'
        '400':
          description: '`action` is not `sync-quantity`. Body: `error`.'
        '401':
          description: 'Missing, invalid, or expired session. Body: `error`.'
        '403':
          description: 'No organization is administered by this account, or it is switched off. Body: `error`.'
        '409':
          description: 'No issuer subscription on this organization; STRIPE_ISSUER_LEARNER_PRICE_ID unset; the caseload is at the 2000 row cap; or the per-learner line is not on the subscription. Body: `error`.'
        '429':
          description: 'Rate limited (30 per minute per IP). Body: `error`, `retryAfterMs`.'
        '500':
          description: 'The organization or caseload read failed. Body: `error`.'
        '502':
          description: 'Stripe could not be read or updated. Body: `error` (never a Stripe message verbatim).'
        '503':
          description: 'The metadata column is absent (see GET), or payments are not configured (STRIPE_SECRET_KEY unset). Body: `error`.'
      operationId: postApiIssuerBilling
      x-operation-id-source: derived
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Supabase JWT access token
    orgApiKey:
      type: apiKey
      in: header
      name: Authorization
      description: Institutional org API key. Use "Bearer efg_org_..." or "Api-Key efg_org_..." header. Keys are scoped to specific data categories.