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.
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.
Every API here is available over the APIs.io API and to AI agents over MCP.
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
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.curl "https://apis.io/api/v1/apis/eformogi-com:eformogi-com-issuer-api"
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.
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: 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.