API guide
Use your own agent.
Your agent can read public school information and send one complete enrolment application.
Read school information
Public information includes contacts, sections, required documents and dated fees. Each subject links to its source page.
updatedAt records the editorial update date of public information. It does not establish school verification or fee publication.
fees[].year states the applicable year. fees[].publishedAt gives the publication date stated by the source. null means this date is unknown.
- Check the fee year before you use an amount.
- Read each uncertainty before you act.
- Use the source page for the full conditions.
- Join each grade document type to documentTypes[].id.
- Use label for its name and hint for its description.
- Read requiredWhen with the grade-specific required flag.
- Use admissions.uploads for supported formats and size limits.
- Read named links through their url field.
- Read links.documents as a list of document links.
Join document metadata and follow links
This example uses Grade 1. Conditional requirements remain in requiredWhen and uncertainties.
const baseUrl = 'http://127.0.0.1:4399';
const response = await fetch(`${baseUrl}/api/v1/school.json`);
if (!response.ok) throw new Error('School information unavailable.');
/** @type {{
admissions: {
grades: { id: string, documents: { type: string, required: boolean }[] }[],
documentTypes: { id: string, label: string, hint: string, requiredWhen: string }[],
sourceUrl: string,
},
links: { calendar: { url: string }, documents: { url: string }[] },
}} */
const school = await response.json();
const grade = school.admissions.grades.find(entry => entry.id === 'grade-1');
if (!grade) throw new Error('Grade metadata unavailable.');
const documents = grade.documents.map(document => {
const type = school.admissions.documentTypes.find(type => type.id === document.type);
if (!type) throw new Error('Document metadata unavailable.');
return { ...type, required: document.required };
});
const calendarUrl = school.links.calendar.url;
const documentUrls = school.links.documents.map(document => document.url);
const sourceUrl = school.admissions.sourceUrl;Send one complete enrolment
Your agent obtains an intake key automatically. Basic enrolment needs no school login or browser approval.
- Prepare the child, parent or guardian details and all required files.
- Check the complete packet before your agent sends it.
- Ask your agent to save its permit and retry key privately.
- Keep the application reference after the school records the packet.
- Wait for school correspondence after its intake review.
One permit receives one new application. It cannot read or edit existing applications, download documents, take assessments or make school decisions.
The packet records the caller’s assertion that a parent or guardian instructed submission. This assertion does not prove parent approval.
The school records the reference before intake review. Uncertain or unavailable review holds school correspondence for manual review.
Check a packet before submission
Validation is optional. validateApplication makes one anonymous request without a permit. submitApplication does not run validation automatically.
- Use POST /api/v1/intake/validate for JSON descriptors or multipart files.
- Send the API origin in the Origin header for multipart requests.
- Read fileContentsChecked before you interpret a successful result.
- Read error.fields for invalid or incomplete packets.
- Match documents.<type> errors to the corresponding document field.
- Correct the packet before you request an intake permit.
JSON descriptors check fields, document rules and declared sizes. The complete JSON validation request must fit within 32 KiB. fileContentsChecked is false.
Multipart validation also checks actual byte counts and supported file signatures. fileContentsChecked is true. It does not establish document authenticity.
HTTP 200 returns valid: true. A failed check returns the standard error object. Validation stores no files and creates no application or email.
Validation shares the two packet slots with submission. At most one validation can run. The request limits below apply.
Validation success reserves no capacity and does not guarantee later submission.
An invented manifest
These details belong to an invented family. Use them only with a disposable local database and captured mail.
/** @type {import('./sdk.mjs').IntakeManifest} */
const manifest = {
student: {
givenNames: 'Example', familyName: 'Family',
dateOfBirth: '2017-05-10', gender: 'female',
province: 'Eastern Highlands', grade: 'grade-1',
entryYear: 2026, entryTerm: '1', attendance: 'day',
},
guardian: {
name: 'Example Parent', relationship: 'mother',
phone: '7123 4567', email: 'parent@example.invalid',
address: 'Invented local-test address',
},
guardianAuthorityAsserted: true,
};Validate metadata or files with the SDK
Use complete, invented document fixtures. The SDK sends file bytes only when you pass an IntakePacket.
import { readFile, stat } from 'node:fs/promises';
import { validateApplication } from './sdk.mjs';
const baseUrl = 'http://127.0.0.1:4399';
/** @type {readonly { type: import('./sdk.mjs').DocumentType, fileName: string, format: import('./sdk.mjs').DocumentFormat }[]} */
const documentFiles = [
{ type: 'birth-certificate', fileName: 'birth-certificate.pdf', format: 'pdf' },
{ type: 'photo', fileName: 'photo.png', format: 'png' },
{ type: 'fee-receipt', fileName: 'fee-receipt.pdf', format: 'pdf' },
{ type: 'school-report', fileName: 'school-report.pdf', format: 'pdf' },
];
const documents = await Promise.all(documentFiles.map(async document => ({
...document, bytes: (await stat(`./fixtures/${document.fileName}`)).size,
})));
const metadataCheck = await validateApplication(
{ manifest, documents }, { baseUrl },
); // fileContentsChecked: false
const files = await Promise.all(documents.map(async document => ({
type: document.type,
fileName: document.fileName,
file: new Blob([await readFile(`./fixtures/${document.fileName}`)], {
type: document.format === 'png' ? 'image/png' : 'application/pdf',
}),
})));
const packet = { ...manifest, documents: files };
const fileCheck = await validateApplication(packet, { baseUrl });
// fileContentsChecked: trueValidate multipart data with HTTP
The manifest field contains JSON text. File fields use document type identifiers, such as birth-certificate.
const form = new FormData();
form.set('manifest', JSON.stringify(manifest));
for (const document of packet.documents) {
form.set(document.type, document.file, document.fileName);
}
const response = await fetch(`${baseUrl}/api/v1/intake/validate`, {
method: 'POST', headers: { Origin: new URL(baseUrl).origin }, body: form,
});
const result = await response.json();
if (!response.ok) {
// Read result.error.code and result.error.fields.
// A field can be documents.school-report.
}Connect an agent
Use ordinary HTTP or the small JavaScript SDK. The normal SDK path makes two API requests: permit, then complete packet.
- Download the SDK and TypeScript declarations.
- Use validateApplication only when you want a separate check.
- Use submitApplication with a persistAttempt callback.
- Finish saving the attempt before the callback returns.
- Send one JSON manifest and the required files as multipart data.
- Send the API origin in the Origin header for multipart requests.
- Set guardianAuthorityAsserted only when a parent or guardian instructed the submission.
- Keep permits and retry keys outside URLs and logs.
- Use the same permit, key and packet with resumeAttempt.
- Use getReceipt to recover the reference without uploading files again.
- Keep the saved attempt after a lost response or uncertain error.
- Never request a replacement permit after an uncertain packet outcome.
- Contact the school after receipt recovery expires.
New creation expires after 24 hours. Receipt-only recovery lasts seven days after submission, using the same permit.
The manifest limit is 32 KiB. Each file can use 10 MiB. Seven files can use 28 MiB in total.
The complete multipart request must fit within 30 MiB. Each document type permits one file.
Save an attempt before submission
This local example saves the secret before the packet request. Keep the private directory until the receipt is recovered.
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { submitApplication } from './sdk.mjs';
const privateDirectory = await mkdtemp(join(tmpdir(), 'ggs-attempt-'));
const attemptFile = join(privateDirectory, 'attempt.json');
const receipt = await submitApplication(packet, {
baseUrl,
persistAttempt: async attempt => {
await writeFile(attemptFile, JSON.stringify(attempt), {
mode: 0o600, flag: 'wx',
});
},
});Recover an uncertain attempt
Recover a receipt first. Retry receipt_not_found only while creation permission remains valid. Keep the same packet and saved attempt.
import { readFile } from 'node:fs/promises';
import { getReceipt, IntakeSdkError, resumeAttempt } from './sdk.mjs';
const saved = JSON.parse(await readFile(attemptFile, 'utf8'));
let receipt;
try {
receipt = await getReceipt(saved);
} catch (error) {
if (!(error instanceof IntakeSdkError)
|| error.code !== 'receipt_not_found') throw error;
if (Date.now() >= Date.parse(saved.expiresAt)) {
throw new Error('Creation expired. Keep the saved attempt and contact the school.');
}
receipt = await resumeAttempt(saved, packet);
}
// Keep permits and packet details outside logs.Correct errors and pace retries
Errors use error.code and error.message. Field errors also provide error.fields. An origin-check rejection can return plain text.
Retry-After gives seconds before another attempt. It controls retry pacing. It does not guarantee that an hourly or daily quota has reset.
- Use error.fields to identify the details or documents that need correction.
- Wait for Retry-After after rate_limited, in_progress or server_busy.
- Keep the saved attempt after an uncertain submission.
- Recover its receipt before any new submission.
- Never obtain a replacement permit for an uncertain submission.
| Code | HTTP status | Action |
|---|---|---|
invalid_ | 400 or 422 | Correct error.fields when supplied. Send the documented request shape. |
incomplete | 422 | Add the missing details or documents identified by error.fields. |
idempotency_ | 400 | Send the saved Idempotency-Key for this attempt. |
invalid_ | 401 | Check the saved permit. Recover any recorded receipt before another submission. |
revoked_ | 401 | Stop using this permit. Contact the school about the submission. |
expired_ | 401 | Creation permission expired. Recover any recorded receipt with the saved permit. |
receipt_ | 410 | Contact the school before submitting again. |
permit_ | 409 | Recover this permit’s receipt. It cannot receive another application. |
idempotency_ | 409 | Restore the original permit, retry key and unchanged packet. |
in_ | 409 | Wait for Retry-After. Recover the receipt with the same saved attempt. |
connected_ | 409 | Complete the connected family application on the website. |
input_ | 413 | Reduce the request, manifest or files within their applicable limits. |
unsupported_ | 415 | Send application/json or multipart/form-data as documented for the operation. |
rate_ | 429 | Wait for Retry-After. The relevant hourly or daily allowance can remain exhausted. |
server_ | 503 | Wait for Retry-After. Retain any saved submission attempt. |
api_ | 503 | The API is suspended. Retain any saved attempt and contact the school. |
internal_ | 500 | Submission can be uncertain. Retain the saved attempt and recover its receipt. |
receipt_ | 404 | An earlier request can still finish. Retry the unchanged attempt only before creation permission expires. |
validation_ | 500 | Validation did not finish. Retry validation when the service recovers. |
Correct a document field error
This example omits the invented photo descriptor. It restores that descriptor after reading documents.photo.
import { IntakeSdkError, validateApplication } from './sdk.mjs';
const missingPhoto = documents.filter(document => document.type !== 'photo');
try {
await validateApplication({ manifest, documents: missingPhoto }, { baseUrl });
} catch (error) {
if (!(error instanceof IntakeSdkError)) throw error;
const photoIssues = error.fields?.['documents.photo'] ?? [];
if (error.code !== 'incomplete' || photoIssues.length === 0) throw error;
const corrected = { manifest, documents };
await validateApplication(corrected, { baseUrl });
}Read the scope of each limit
These are proposed technical pilot settings. They are not adopted school policy. School approval, payment and document authenticity remain separate.
Request guards
Request guards are process-local sliding windows. Each process counts requests separately. These guards reset when that process restarts.
| Control | Limit | Scope |
|---|---|---|
| Intake HTTP requests | 120 requests | Per client address per rolling minute. |
| Submission and receipt requests | 60 requests | Per permit per rolling minute. |
| Validation requests | 10 requests | Per client address per rolling minute, within the shared HTTP allowance. |
| Permit requests | 30 requests | Per client address per rolling hour, within the shared HTTP allowance. |
Durable intake budgets
Durable budgets apply across processes and restarts. UTC-day budgets count from 00:00 UTC. The per-address permit budget uses a rolling hour.
| Control | Limit | Scope |
|---|---|---|
| Issued permits | 30 permits | Per client address per rolling hour. |
| Issued permits | 300 permits | Globally per UTC day. |
| New applications | 100 applications | Globally per UTC day. A matching receipt replay uses no additional application allowance. |
| Attempted staged files | 1 GiB | Globally per UTC day. Attempted bytes count even when writes become uncertain. |
| Intake correspondence | 100 emails | Per audience per UTC day. The parent and office audiences count separately. |
| Parent receipts | 6 emails | Per recipient per UTC day. |
Email limits postpone correspondence. A recorded receipt does not prove email delivery. Submission and school review remain separate.
Test locally with invented details
Use GGS_RUNTIME_MODE=local with a temporary data directory. Local mode uses private local storage and captures mail without sending it.
- Remove external database, storage and mail credentials from the test environment.
- Use a new temporary directory for GGS_DATA_DIR.
- Run the explicit migrate command before you start the local server.
- Use the local server origin in each example.
- Download the SDK beside your local example file.
- Set entryYear to a year currently accepted by the validator.
- Use complete invented PDF or photo fixtures.
- Inspect captured mail in the temporary local outbox.
- Remove your temporary data and private attempts after verification.
Keep invented tests away from staging and production. Never place real family details, files, permits or saved attempts in logs.