Issuing on the standard: a guide for another platform
Draft 0.1, written 2026-09-14 for a developer at a homeschool record-keeper, a microschool platform, or any software that already holds learners and wants them on the record. Every endpoint, field, cap, and status code below exists in the reference implementation on the day of writing and is cited to its file; the gate test tests/test-spec-pages.js checks that every /api/ path named here has a handler. Nothing in this guide is a promise about what will exist. The chapters are normative; this page is the walk-through.
1. Three ways in, and which one you want
| you are | the way in | what you get | what you do not get |
|---|---|---|---|
| a school, microschool, or independent counselor with learners on a roster | the issuer rail (sections 3 to 7): enroll, import the roster, declare course rows, invite the teacher who taught each one to co-sign | every row is a public receipt at eformogi.com/verify/<id>, in the public log, signed, and folded into the family's own Witness Transcript™ when the family claims the learner | ownership. The family holds the record and the export (Rule 1); you declare rows, you never own them |
| a family-side tool (a planner, a portfolio app, a record-keeper) | hand the family the door (section 8): your data goes to the family, the family issues in the Eformogi app | the family's issued transcript, verifiable with no login | a delegated issuance path. There is no API a third party can call on a family's behalf today; the family's own session is the only credential that publishes a tr_ |
| a platform that wants the standard without the operator | implement the standard (section 10): derive ids, sign, run a log, publish a key, verify with the reference verifier against your key | a record format receivers can check with software that is Apache-2.0 and already published | resolution at eformogi.com/verify, and the Witness Transcript name until your records pass the conformance suite (chapter 10, section 10.8) |
The rest of this page walks the first way end to end, then says what the family must hold, what the keys mean, and how to verify what you issued with no network.
2. Before the first call
Accounts and tokens. Every /api/issuer/* call is authenticated with a Supabase session access token (Authorization: Bearer <access token>) belonging to the account that administers your organization (api/issuer/_org.js, requireIssuerOrg: the token's user must be the admin_user_id of an institutional_orgs row that is not switched off). You obtain that token by signing in to eformogi.com as that account. There is no API-key path for the issuer rail: the efg_org_ key an organization is issued at registration authenticates the receiver and agent surfaces (/api/verify-receipt attribution, /api/verify-consent, /api/profile-read, /api/agent-action), not /api/issuer/*. If you need a machine credential for a server-to-server integration, say so at hello@eformogi.com; it is the first thing a platform will ask for and it is not built.
Register the organization (once, api/institutional/dashboard.js, registerOrg):
POST /api/institutional/dashboard
Authorization: Bearer <access token>
{ "action": "register-org", "name": "Cedar Microschool", "contact_email": "office@cedar.example", "org_type": "microschool", "contact_name": "R. Ortiz" }
201 { "success": true, "org": { "id": "<uuid>", "name", "slug", "plan": "free", "api_key": "efg_org_...", "family_limit", "org_type", "contact_email" }, "message": "..." }
400 name or contact_email missing
409 this account already administers an organization
org_type is allowlisted server side (LIVE_ORG_TYPES); a value outside the list is stored as other. The api_key is shown once and stored hashed; keep it if you will call the receiver surfaces, ignore it for issuing.
Read the terms. Enrollment means accepting the founding terms at /issuers/terms. The version string in force is the TERMS_VERSION constant in api/issuer/_org.js; a body carrying any other version is refused, so a client that hard-codes a stale string fails loudly rather than binding you to terms you did not read. The offer the terms describe: free to issue through June 30, 2027, then $49 per learner per year, billed to the issuer and never to the family; a learner keeps the record and the export whatever the issuer's billing does.
Rate limits and caps, in one place. Per IP per minute: enroll 10, import 10, declare 20, invite 30, records 60. Per organization per day: 200 outbound invitations, counted on the rows the sends leave behind (family claims on plan_claims, teacher invitations on attestation_requests), so the budget holds across IPs and restarts. Per call: 500 roster rows, 200 courses. A row past a cap is refused with a plain reason and the rest of the batch goes through; nothing is ever half-written.
3. Enroll
POST /api/issuer/enroll
Authorization: Bearer <access token>
{ "accept_terms": true, "terms_version": "2026-09-09" }
200 { "ok": true, "existed": false, "org": { "id", "name" },
"issuer": { "status": "founding", "founding_until": "2027-06-30", "set_by": "self", "set_at", "enrolled_at",
"terms_version", "terms_accepted_at", "contact_email", "learner_price_usd": 49,
"stripe_customer_id": null, "stripe_subscription_id": null },
"audit_logged": true }
200 { "existed": true, ... } already founding or active: nothing changes, nothing resets
400 accept_terms missing, or terms_version is not the version this deploy serves (the body says which)
401 no or invalid token
403 this account administers no organization, or the organization is switched off
503 enrollment is not switched on in this environment yet
Idempotent (api/issuer/enroll.js). Re-accepting never resets a founding window and never overwrites a status the operator set. enrolled_at is the date the founding price lock reads; a return from inactive is a fresh enrollment dated then.
4. Put the learners on the record
POST /api/issuer/import-roster
Authorization: Bearer <access token>
{ "rows": [ { "name": "Ana Silva", "family_email": "silva@example.com", "grade_level": "10", "notes": "" } ],
"send_invites": true }
200 { "ok": true,
"created": [ { "profile_id": "<uuid>", "name": "Ana Silva" } ],
"matched": [ { "profile_id", "name", "matched_reason": "email" | "name", "grade_level"?, "notes"? } ],
"invited": 1,
"errors": [ { "row": 3, "reason": "name is required" } ] }
400 rows missing, empty, or more than 500
403 not enrolled (the body says so and names /issuers/terms)
A learner is a row on your caseload (institutional_received_profiles), the same row the counselor surface uses (api/issuer/import-roster.js). Dedupe runs inside your organization only: a lowercased family_email that matches an existing learner is a match, then an exact name. A match never overwrites the existing learner; anything on the incoming row that was not applied comes back on the matched entry so you can apply it deliberately. Re-uploading the same CSV creates nothing the second time. name is clipped to 120 characters, notes to 500, grade_level to 40; family_email must be a valid address of at most 254 characters.
With send_invites and a family email, each created learner is sent one record claim: a single-use rc_ token (plan_claims, kind record, 30 days), the record_claim email, and record_invited_at on the learner. A matched learner is never re-invited by import; that is a deliberate act:
POST /api/issuer/invite-family
{ "profile_id": "<uuid>" }
200 { "ok": true, "profile_id", "record_invited_at", "email_sent": true, "email_reason": null }
400 profile_id malformed, or no family email on file
403 that learner is not on this organization's caseload (never a 404, so ids cannot be probed)
409 the family already claimed the learner
There is no reminder sequence on either path and none will be added (api/issuer/invite-family.js): a family that ignores an invitation is making a decision, not missing one.
5. Declare course rows
This is the call that puts learning on the standard.
POST /api/issuer/declare-courses
Authorization: Bearer <access token>
{ "courses": [
{ "profile_id": "<uuid>", "course_title": "Algebra II", "term": "2025-26 Year", "grade": "A", "credits": "1.0",
"teacher_email": "r.kim@cedar.example", "teacher_name": "R. Kim", "note": "You taught this section." }
] }
200 { "ok": true,
"receipts": [ { "profile_id", "cr_receipt_id": "cr_0ad5487e3ca4", "existed": false, "attestation_request_sent": true } ],
"errors": [ { "index": 2, "reason": "That learner is not on this organization caseload.", "status": 403 } ] }
400 courses missing, empty, or more than 200
403 not enrolled
503 declared courses are not switched on in this environment yet (the whole call is refused; nothing is minted)
What is minted is an ordinary course receipt (api/issuer/declare-courses.js, shared/courseReceipt.js): same table, same hash domain, same signing, same log as a row the family issues. The id is the content:
cr_ id = "cr_" + first 12 hex of sha256( canonical({ kind: "course", student_name, course_title, term, grade, credits }) )
with student_name taken from the learner's caseload row (never from your request), course_title clipped to 300 characters, term to 100, grade to 32, and credits hashed as the string you posted ("1" and "1.0" are different receipts; null when absent). You can compute the id before you call, with the published verifier's deriveCr (section 9), and the response will carry the same id. Three columns say where the row came from and sit outside the hash: issuer_org_id, profile_id, declared_by: "issuer". That is why a course you declare and the same course the family later issues are one row with one set of signatures.
Per-row outcomes, all in errors[] with a status: 400 (a required field missing, a bad teacher_email), 403 (a learner outside your caseload; or a row somebody else issued when you asked for a teacher invitation on it), 409 (an id collision with a different hash, which the content-addressed design makes practically impossible), 429 (the day's 200 invitations are spent; the course is still declared, only the invitation waits), 500 and 502 (the row or the email could not be written or delivered), 503 (invitations are not available in this environment). A row that already exists comes back existed: true; when someone else issued it, the entry carries reason: "this course is already on the record and was issued elsewhere" and no invitation is sent, because only a receipt's issuer may ask for signatures on it.
The teacher's co-signature is the witness flow of chapter 03 with no new primitive: an attestation_requests row with a 64-hex single-use token, deduplicated on (cr_receipt_id, teacher_email), an email naming your organization, and the link /witness/<token>. The teacher opens it and signs with no login; the signing page resolves the token with GET /api/resolve-witness-token?token= and posts the attestation to POST /api/publish-attestation. The token expires at 60 days and is never chased. Because the invited address is yours to choose, the identity level of chapter 03, section 3.9, is earned automatically when that address belongs to a school, district, government, or military domain: level 1, "invited at a school or organization address and signed through the link sent there."
6. Read back what you hold
GET /api/issuer/records
Authorization: Bearer <access token>
200 { "ok": true, "enrolled": true, "org": { "id", "name" }, "issuer": { ... }, "issuer_columns_present": true,
"totals": { "learners", "claimed", "courses", "co_signed", "pending_invites" },
"learners": [ { "profile_id", "person_name", "claimed": false, "family_email_masked": "s***@example.com", "record_invited_at",
"courses": [ { "cr_receipt_id", "course_title", "term", "grade", "credits", "declared_by": "issuer",
"verify_url": "https://eformogi.com/verify/cr_...", "co_signed": 1, "dissenting": 0,
"pending_invites": [ { "witness_email_masked": "r***@cedar.example", "created_at" } ] } ] } ] }
Readable by any organization admin, enrolled or not (an unenrolled organization reads enrolled: false and an empty list). No whole email address ever leaves this endpoint (api/issuer/_org.js, maskEmail). co_signed is a count of signatures on the ledger and dissenting counts signers who recorded a different grade; both are counts, never ranks. Caps: 500 learners, 2,000 courses per read.
GET /api/issuer/billing
200 { "ok": true, "org", "issuer": { ..., "founding_lock": true }, "learners_active", "price_usd_per_learner_year": 49,
"founding_until": "2027-06-30", "billing": { "status", "subscription_id_masked", "next_invoice_hint" } }
POST /api/issuer/billing { "action": "sync-quantity" }
200 { "ok": true, "action", "learners_active", "previous_quantity", "quantity", "changed", "proration_behavior", "subscription_id_masked" }
api/issuer/billing.js reports the offer and keeps the subscription quantity equal to the active learner count; it never changes what an issuer status means for a family.
7. What the family must hold
Rule 1 is not a slogan here; it is the shape of the endpoints. The family, not your platform and not the operator, holds four things.
The claim. The rc_ invitation lands at /claim/<token>. GET /api/claim-plan?token= (public) returns a summary that names your organization and the learner, and a uniform "no longer valid" for any bad, expired, or used token so tokens cannot be enumerated. POST /api/claim-plan { token } with the family's session binds family_user_id to the authenticated account and to nothing the client sent; a learner another account already holds answers 409 (api/claim-plan.js). From that moment the learner, and every row you declared for the learner, is theirs to fold into a transcript they issue. You can see claimed: true on /api/issuer/records; you cannot see who claimed.
The transcript. The family issues the Witness Transcript in the app (/app/transcript). The app publishes the transcript first (POST /api/publish-transcript, session required, body carries only what the family printed: id, sha256, issued_at, student_name, issuer, program, grad_year, person_id, course_count, total_credits, tier_counts; 201 { ok, existed: false, id }, 200 with existed: true on a repeat, 409 on the same id with a different hash), then its own course rows (POST /api/publish-course-receipts, per-row results, same cr_ derivation as yours), then folds your declared rows in (api/link-declared-courses.js):
GET /api/declared-courses (family session)
200 { "courses": [ { "cr_receipt_id", "profile_id", "person_name", "org_name", "course_title", "term", "grade", "credits", "co_signed", "tr_receipt_id" } ] }
POST /api/link-declared-courses (family session)
{ "tr_receipt_id": "tr_...", "cr_receipt_ids": [ "cr_..." ] }
200 { "ok": true, "linked": 2, "skipped": [ { "cr_receipt_id", "reason": "withdrawn" | "different_learner" | "already_linked" | "linked_elsewhere" } ] }
403 a named row is not bound to the caller's claimed learners, or the transcript is not the caller's; logged as a security event
Linking sets tr_receipt_id on your row, provenance outside the hash, so /verify/<tr_ id> folds the row into the transcript's public per-course rollup with every teacher co-signature it already carries. Nothing is re-minted; the id you were given is the id on the family's transcript. A row for a different learner in the same household (siblings invited to one address) is skipped by name, never guessed.
The keys. The family's receipts are signed with a key bound to the family account and publicly established as a kb_ receipt (chapter 04). The family does not manage that key today; it is held in escrow by the operator, and every verify page says so.
The export and the withdrawal. GET /api/data-export (family session; ?format=json or csv) returns the family's whole record including the six public receipt tables, and it is scoped to the authenticated account. POST /api/revoke-family-receipt { receipt_id } withdraws a receipt the caller issued; a withdrawn receipt is served as withdrawn, never as a 404. For a row you declared, the issuing account is your admin account, so the withdrawal right on that row is yours; the family's transcript still carries the fold, and the verify page says which school withdrew.
8. If you are on the family's side
There is no delegated issuance. A tr_ is published only by the family's own session, and a cr_ by the family's session or an enrolled organization's. A family-side tool has two honest moves: give the family a clean, complete record to type or paste into /app/transcript (the builder takes course rows as year, term, subject, title, credit, grade, tier; chapter 01, section 1.4), or enroll as an issuer if you are in fact the school of record for those learners. Do not build a screen that asks a family for their Eformogi password so your server can publish for them; the operator will treat that as the credential theft it is.
9. Verify what you issued, with no network
Every row you declared is a public receipt. A receiver, or you, checks it in three steps, and none of them needs an account.
# 1. the public verify payload (10 per minute per IP anonymously; send your efg_org_ key as a Bearer for 60)
curl -s "https://eformogi.com/api/verify-receipt?id=cr_0ad5487e3ca4"
# { "verified": true, "kind": "course_receipt", "id", "issued_at", "hash", "student_name", "course_title", "term",
# "family_stated_grade", "credits", "tr_receipt_id", "declared_by": "issuer", "issuer_org_name": "Cedar Microschool",
# "transcript_eligible": true, "attestations": [ ... ], "hidden_attestation_count": 0, "issuer_signature": { ... } | null }
# 2. the inclusion proof (the file behind "Download proof (JSON)" on the verify page)
curl -s "https://eformogi.com/api/receipt-proof?id=cr_0ad5487e3ca4" > proof.json
# 200 the proof, or 200 { "status": "pending", ... } until the next daily signed head covers the leaf
# 3. the offline check, against a key you fetched yourself
curl -s https://eformogi.com/.well-known/did.json > did.json
npx verify-offline proof.json --jwk did.json --sha256 <the sha256 you derived for this row>
The --sha256 value is the second half of the cr_ derivation of section 5, which the package computes for you:
const { deriveCr, verifyProof } = require('@eformogi/verify-offline');
const { id, sha256 } = deriveCr({ student_name: 'Ana Silva', course_title: 'Algebra II', term: '2025-26 Year', grade: 'A', credits: '1.0' });
// id === 'cr_...' as returned by declare-courses; sha256 is what the log's leaf carries
const result = verifyProof(JSON.parse(fs.readFileSync('proof.json', 'utf8')), { jwk: JSON.parse(fs.readFileSync('did.json', 'utf8')), sha256 });
// result.verified, result.pending, result.steps[], result.notes[]
What that proves and does not: the bytes with this hash were sealed under this id, the leaf is in the signed log by the head in the proof, the head is signed by the published organization key, and, when present, the family or issuing account's key signed the same leaf. It does not prove the grade was evaluated by anyone but the account that declared it; the co-signatures on the verify page are where that claim lives, and each one names how far the signer's identity was checked. The heads are also at GET /api/tree-heads and /.well-known/tree-heads.json, and the anchor repository named in chapter 05 is where a receiver checks that the head you hold is the one everyone else holds.
10. If you would rather implement the standard than call us
You may. The chapters are CC BY 4.0 and the verifier and vectors are Apache-2.0 (LICENSING). The list is short and every item has a chapter:
- Canonicalize and derive ids exactly as chapter 02 says; the package exports
canonicalizeand the per-prefix derivations so you can test yours against ours on your own data before you publish anything. - Sign leaves with an Ed25519 key per issuing party under the context prefix of chapter 04, and publish the key as a
kb_binding or a DID document. - Run an append-only log with the domain-separated hashing of chapter 05, sign a tree head on a cadence, and anchor the heads somewhere you cannot rewrite.
- Serve proofs in the shape of chapter 06, section 6.4.1, and a verify surface that carries the honesty text of section 6.6.
- Run the conformance suite (
npm testin the package, orsh run-vectors.sh) and say, exactly, "passes N of 24 vectors in draft 0.1". Your proofs verify withverify-offline proof.json --jwk <your did.json>.
Your receipts will not resolve at eformogi.com/verify, and your artifact may be called a Witness Transcript only under chapter 10, section 10.8. Everything else the receiver needs is in the files.
11. What is not built, said plainly
- An API key or client-credential flow for
/api/issuer/*. The session token of the admin account is the only credential. - An organization key of your own. A row you declare is signed by a
did:keyminted for your admin account and held in escrow, the same as a family's; an organizationdid:webwith a rotation statement is planned (chapter 04, section 4.2). - Entries for
/api/issuer/*in the OpenAPI document at/.well-known/openapi.json. This page and the handler files are the contract. - Witness-initiated revocation, and offline visibility of a withdrawal (chapter 03, section 3.7; chapter 06, section 6.5).
- A bridge export to Open Badges 3.0 or CLR 2.0 (chapter 11 fixes the shape; no code ships it).
- A delegated path for a family-side tool to publish on a family's behalf (section 8).