---
title: "Globus Portal API Reference"
slug: globus-portal-api
section: Portals
status: stable
description: "The verified portal-facing surface of the Globus Hub (Alliance-Strategies/BackOfficePrototype), extracted from live route registrations on 2026-07-04."
last-updated: 2026-10-07
---

# Globus Portal API Reference

The verified portal-facing surface of the Globus Hub (Alliance-Strategies/BackOfficePrototype), extracted from live route registrations on 2026-07-04. A frozen machine-readable contract lives in the repo at server/versioning/portal-contract-snapshot.json (284 endpoints, last regenerated 2026-09-29) — this page is the human/agent-readable companion.

Consumers: Applicant Portal endpoints , Host Portal endpoints , Integration Contract .

## BackOffice Portal / Globus Hub Context

The **BackOffice Portal (BOP)** is the sponsor and operations control plane as well as the Hub service that exposes portal-facing integration surfaces. The current `development` branch is a TypeScript full-stack application with a React staff client, an Express service, PostgreSQL/Drizzle tooling, and the portable form-engine package. [1] [2]

| Layer | Current code-backed responsibility | Relationship to portal consumers |
| --- | --- | --- |
| Staff client | The route registry mounts operational workspaces for applicants, document review, programs/seasons, SEVIS, queues/reporting, host companies, partners, job orders, matching, placements, workflow/forms, RBAC, integrations, and rules/compliance. [2] | BOP owns the sponsor-side decision and management surfaces. |
| Hub service | The server registers API routes, correlation/error middleware, migration/seeding paths, RBAC gate coverage checks, and background workers for events, notifications, reservations/agreements, SLAs, references, seasons, webhooks, n8n, and rules. [3] | Portal services call bounded Hub routes; they do not replace Hub operational authority. |
| Identity and access | The repository contains Entra, local session/password, API-key, user management, and access-revocation modules, and the service performs RBAC route-gate coverage checks. [3] [4] | Route integrations must use the mechanism documented for that route rather than assuming one BOP-wide identity provider. |
| Form/workflow composition | BOP has workflow, step, phase, operational-form, reference-template, and agreement surfaces and depends on `@alliance-strategies/form-engine-portable`. [1] [2] | The form engine is a delivery component; BOP remains the source of staff-side workflow and governance controls. |

> **Boundary rule**
>
> BOP/Globus Hub supplies the authoritative operational and integration boundary. Host and Participant portals can provide scoped, authenticated experiences but must not independently make final eligibility, compliance, agreement, placement, or sponsor-governance decisions. [2] [3]

## Auth Mechanisms

| Mechanism | Credential | What it checks |
| --- | --- | --- |
| createIntegrationAuth(scopes) | x-api-key or Bearer | Tri-modal: n8n system key (scope check skipped) → portal session/magic-link token → scoped API key. The primary post-onboarding portal auth. |
| validateApiKey(scopes) | x-api-key | Hashed lookup in api_keys; rejects inactive/revoked/expired; requires * or all listed scopes. |
| portalAuth | Bearer / X-Portal-Token / ?token= | Signed portal-session JWT (stateless) or raw magic-link token (DB hash). Sets applicant context; ownership-scoped. |
| portalOrIntegrationAuth(scopes) | either | API key present → integration path + X-Applicant-User-Id; else portal session. Both normalise onto the same context. Accepts an optional { requireVerifiedToken: true } (see the verified-token gate below). |
| validateApplicantUserId | X-Applicant-User-Id header | Identifies which applicant a list/read is for; paired with API-key auth. |
| partnerAuth / portalServiceAuth | x-api-key (+ org headers) | PartnerContext in direct, portal_proxy (X-Partner-Org-Id) or host_proxy (X-Host-Org-Id) mode; proxy scopes portal.partner_proxy / portal.host_proxy. |
| staff session + RBAC | session or scoped API key | isAuthenticatedOrApiKey + requirePermission — staff/admin gate; a few /api/portal/* paths use this and are NOT applicant-reachable. |

> **A portal client needs BOTH credentials**
>
> Most reads are on the API-key surface, but workflow-snapshot, notifications, embassy-consulates, applicant-photos and recurring check-ins require a portal session token; documents and POST /api/portal/applications accept either (the typed create is subject to the verified-token gate). A stateless BFF (PPPToo) holds the API key and covers session-gated needs via alternate API-key routes (e.g. /api/checkins/recurring/applicant/...) — see the flags below.

> **Verified portal identity (X-Portal-Access-Token) and the fail-closed gate**
>
> The BFF forwards the applicant's Keycloak access token as X-Portal-Access-Token. When the Hub has APPLICANT_KC_ISSUER configured it verifies the token (RS256 via the realm JWKS; issuer, audience and expiry checked) and binds identity from the signed globusApplicantId claim; the spoofable X-Applicant-User-Id header is then ignored. APPLICANT_KC_AUDIENCE is mandatory whenever the issuer is set — with no audience configured, verification fails closed and no token verifies. Environments with no issuer configured have verification inert and fall back to the header exactly as before (the fallback logs a deprecation warning on every use, and a burst of more than 25 previously-unseen applicant ids from one API key inside 5 minutes raises a SECURITY ALERT log line; blocking on that anomaly is opt-in via PORTAL_HEADER_FALLBACK_BLOCK_ON_ANOMALY=true). In the repository's k8s manifests the issuer and audience are set for the development and QA deployments only; none is set for production.
>
> - Token and header that disagree: a verified token for applicant A sent alongside X-Applicant-User-Id for applicant B (compared case-insensitively) is rejected 403 FORBIDDEN instead of being silently resolved to A. A verified token with no header, or with an agreeing header, authenticates.
> - Duplicate X-Portal-Access-Token (repeated header, comma-joined) is rejected 400 when verification is enabled, rather than degrading to the header.
> - Fail-closed gate: where verification is required in the environment (APPLICANT_KC_ISSUER configured, or PORTAL_REQUIRE_VERIFIED_TOKEN=true as a kill-switch), routes carrying the gate refuse any caller without a verified token, portal session or onboarding token with 401 PORTAL_TOKEN_REQUIRED — including header-only callers and the n8n system-key path. An unknown token or a JWKS outage is refused the same way, not downgraded to the header. Where verification is not required (environments with no issuer and no kill-switch) the gate is a no-op and header fallback is unchanged.
> - Routes carrying the gate include the applicant document routes (list, by-step, required-documents, document-requirements, upload, delete, versions, download), step-media, portal agreements, POST /api/portal/name-mismatches/:id/reason, the /api/integrations/portal/notifications* mirrors, the two profile mutation routes (PATCH /api/integrations/applicant-portal/profile/by-id/:id and PUT .../by-id/:id/photo) and the two SWT portal status routes below. On the profile mutation routes a caller who supplies neither header nor token asserts no identity (the path :id is the integrity check for M2M callers) and is unaffected; a caller who volunteers a header without a verified token is refused. The academic-verification status route additionally rejects header-bound identity in every environment, gate or no gate.
> - Typed create gate: `POST /api/portal/applications` has its own switch. Before the body is parsed it refuses an identity taken from the unverified `X-Applicant-User-Id` header, including the n8n system-key path, with 401 `PORTAL_TOKEN_REQUIRED` (`details.identitySource`) whenever Keycloak portal verification is available (`APPLICANT_KC_ISSUER` configured) or the environment variable `PORTAL_CREATE_REQUIRE_VERIFIED_TOKEN` is `true`. It is optional and defaults to `false`; setting it forces the requirement where no issuer is configured. A verified Keycloak token (`X-Portal-Access-Token`), portal session or onboarding token passes.
> - Ownership is enforced against the bound identity. The profile reads GET by-id/:id, GET by-email/:email and GET by-portal-user/:portalUserId are dual-auth (session Bearer → verified token → header fallback) and assert the caller owns the resolved applicant; a mismatch is a non-disclosing 404 (byte-identical to an unknown id or email, so the routes are not an existence oracle). The n8n system key may still read unowned on these three GETs.

## Portal API (/api/portal)

### Session & Config

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| POST | /api/portal/session | Public | Redeem magic-link token for a short-lived signed session token |
| POST | /api/portal/session/refresh | Public (signature-only) | Exchange a still-valid session token for a fresh one |
| GET | /api/portal/runtime-config | Public | Non-secret runtime config (public PostHog key etc.) |
| GET | /api/portal/host-user/profile | X-Onboarding-Token | Host-user invite landing profile (non-consuming lookup) |

Staff mint and revoke applicant access tokens (the opaque onboarding token accepted by `portalAuth`): `POST /api/admin/applicants/:applicantId/access-token` (`applicant.manage`; optional body `applicationId` to scope the token and `expiresInDays`, default 90) returns `token`, `tokenId` and `expiresAt`. The raw token is shown once and only its hash is stored. `DELETE /api/admin/access-tokens/:tokenId` (`applicant.manage`) revokes one and returns `success: true`. Both are audited (`access_token.create`, `access_token.delete`). `GET /api/admin/applicants/:applicantId/access-tokens` (`applicant.view`) lists token metadata without the token value.

### Applicant, Applications, Programs

API-key surface (createIntegrationAuth("integrations.portal")); list/read routes for a specific applicant take X-Applicant-User-Id.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | /api/portal/applicant/profile | api-key + X-Applicant-User-Id | Applicant's own profile + first application's program/season |
| GET | /api/portal/applicants | api-key | List portal applicants (limit/search) |
| GET | /api/portal/applicants/:userId | api-key | Applicant by portal user id (int or Keycloak UUID) |
| GET | /api/portal/applications | api-key + X-Applicant-User-Id | List the applicant's applications (enriched) |
| GET | /api/portal/applications/:appId | api-key | Single application detail |
| GET | /api/portal/applications/:appId/progress | api-key | Progress counts + current step/phase |
| GET | /api/portal/applications/:appId/step-progress | api-key | Per-step progress array (scope=program or library) |
| GET | /api/portal/applications/:appId/program-config | api-key | Phases + steps from the application's pinned workflow version (applications.workflow_version_id), including previously published archived versions; never substitutes the program's current workflow. Step labels/schemas prefer the pinned version's publish snapshot. Steps whose conditional visibility evaluates false for this applicant are omitted. An application with no pinned workflow version returns 400 `VALIDATION_ERROR` ("Application has no pinned workflow version"). |
| GET | /api/portal/applications/:appId/available-flows | api-key | Renewal/extension availability |
| GET | /api/portal/applications/:appId/step-access | api-key | Per-step lock/access snapshot + applicant copy + doc-review badges |
| GET | /api/portal/applications/:appId/step-reviews | api-key | Step-review decisions (read mirror) |
| GET | /api/portal/applications/:appId/payments | api-key | Finance payment refs (read mirror) |
| GET | /api/portal/applications/:appId/journey-position | api-key | Journey-position snapshot (read mirror) |
| POST | /api/portal/applications | portalOrIntegrationAuth(`integrations.portal`) | Applicant self-service create renewal/extension; create-for-self only (403 when the body applicant is not the authenticated one). The typed create gate described above can refuse a header-only identity with 401 `PORTAL_TOKEN_REQUIRED` |
| GET | /api/portal/programs | api-key | Program list (+ published workflowVersionId) |
| GET | /api/portal/programs/:programId/config | api-key | Program metadata + portal step ordering |
| GET | /api/portal/program-steps | api-key | Global step library (label/order only) |
| GET | /api/portal/step-library | api-key | Full step library WITH fieldsConfig |
| GET | /api/portal/step-library/:stepKey | api-key | Single step-library entry |
| GET | /api/portal/countries | api-key | ISO 3166 country reference list |
| GET | /api/portal/brands | api-key | Brand list (id/slug/name) |
| GET | /api/portal/brands/:id | api-key | Brand's public configuration by id — brand-specific theming/content without embedding brand data in the initial bundle (Aug 11 2026 release capsule — Workflows 2, PR #1786) |
| GET | /api/portal/branding | api-key | Branding/theming by ?slug |

### Steps, Documents, Workflow

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | /api/portal/steps/:stepKey/validation-schema | api-key | Server field-validation descriptor for a step |
| POST | /api/portal/steps/:stepKey/submit | api-key | Submit step form data. When the request's portal context carries an applicant id, the snapshot save is flagged as an applicant mutation (`applicantMutation`); for an existing step snapshot this advances `lastApplicantChangedAt` only when the answers or completion state differ from what is stored |
| GET | /api/portal/workflow-snapshot | portalAuth (session) | Published workflow step graph (?applicationId or ?seasonId&applicationType). Application-scoped reads resolve the exact workflow version pinned on the application, including previously published archived versions, not the season's current workflow. Phases carry `phaseKey`, `name`, `sortOrder` (not `phaseOrder`) and `stepKeys`; steps carry `displayName`, `sortOrder`, `phaseKey`, `fields` and `conditions`. |
| GET | /api/portal/applications/:appId/documents | portal-or-integration | List documents for the application |
| GET | /api/portal/applications/:appId/documents-by-step | portal-or-integration | Documents grouped by step. Every row carries a same-origin `downloadUrl` (`/api/portal/documents/:id/download`). DS-2019 and signed-agreement documents are grouped under the synthetic stepKey `official-program-documents` (ordered after workflow steps, before `unassigned`) regardless of workflow step mapping; documents whose type maps to no step fall into `unassigned`. Each row also carries `official` (true only for signed-agreement documents), `createdAt` (the acceptance `accepted_at` for signed agreements, the upload time otherwise) and, for DS-2019 and signed-agreement rows, `displayName` (`DS-2019` or `Signed Agreement`) and `presentationGroup` `official-program-documents` (presentation only; it does not change `official`) |
| GET | /api/portal/applications/:appId/required-documents | portal-or-integration | Required-document checklist |
| POST | /api/portal/applications/:appId/documents | portal-or-integration | Upload a document |
| GET | /api/portal/documents/:id/versions | portal-or-integration | Document version history |
| GET | /api/portal/documents/:id/download | portal-or-integration | Stream document bytes (ownership-scoped) |
| DELETE | /api/portal/applications/:appId/documents/:docId | portal-or-integration (verified identity required) | Applicant withdraws a document that has not been approved. 404 if the document is not on that application; 409 `SIGNED_AGREEMENT_NOT_DELETABLE` for a signed-agreement artifact; 409 `DOCUMENT_NOT_DELETABLE` for an approved or verified document; 409 `DOCUMENT_WITHDRAWAL_STALE` if a police-clearance document changed during withdrawal (that delete retires the active package and keeps its artifacts). Audited as `portal.delete_document`; returns `success` and the document `id`. |
| GET | /api/portal/applications/:appId/document-requirements | portal-or-integration (verified identity required) | The application's workflow-driven document checklist for the owning applicant: `applicationId`, `workflowDefinitionId`, `source`, `usedGlobalFallback`, `phases`, `totalRequired`, `totalUploaded` and per-person `persons`, sanitized for portal use. 404 if the application is not found. |
| DELETE | /api/portal/step-media/:id | portal-or-integration + verified-token gate | Hard-delete an in-form step media file (for example a Showcase photo). Ownership-gated: the media's application must belong to the calling applicant, otherwise a non-disclosing 404 (as for a missing id). Evicts the stored object first (best-effort, a missing object is ignored), then hard-deletes the `step_media_files` row; there is no soft-delete flag. Writes a `delete_step_media` audit log entry and bumps applicant activity. Returns 204 |
| DELETE | /api/step-media/:id | staff session + `applications.manage` | Staff counterpart of the portal delete: same storage eviction, hard row delete and `delete_step_media` audit entry, no ownership check. 404 when the row does not exist, 204 on success |
| GET | /api/portal/applications/:applicationId/reference-templates/:templateKey/pdf | verified portal token + integrations.portal | Application-bound, branded blank reference/student-status template PDF (binary application/pdf; ?disposition=attachment forces download). The caller must own :applicationId; Globus resolves the application's current program and assigned brand server-side and renders the stable template key with that brand's name, safe logo and accessible colors, using a neutral fallback when assets are missing or unusable. Callers cannot select a brand. A missing or foreign application is a non-disclosing 404. |
| GET | /api/portal/swt/enrollment-proof/status | portalOrIntegrationAuth + verified-token gate | Processing state of the student-status enrollment-proof AI pipeline for ?applicationId (owned applications only; foreign/missing is 404). Returns only an applicant-safe assessment: assessmentId, documentFileId, documentType, status (pending/processing/completed/failed), decision (approved/rejected/under_review/null; a staff decision wins over the rule decision) and timestamps — no confidence, reasoning, OCR/translated text or rule reason codes. |
| GET | /api/portal/swt/academic-verification/status | portalOrIntegrationAuth + verified-token gate (header-bound identity always rejected) | Applicant-safe vacation-date verification state and academic-verification requests for ?applicationId (owned applications only; foreign/missing is 404). Omits staff notes, one-time submission tokens, the verifier's raw response and reviewer data. Requests are created by staff; there is no portal create endpoint. |

The student-status step lets the applicant choose the document type (step field student_status_document_type: TRANSCRIPT, ENROLLMENT_LETTER or OTHER), which is persisted on the enrollment-proof assessment. The enrollment-proof AI pipeline (OCR, then translation for non-English documents, extraction, confidence scoring, rule decision) now auto-triggers on the applicant's upload through both the step-media and portal step-submit doors; the staff route POST /api/applications/:applicationId/enrollment-proof-assessment/process re-runs the same pipeline in place (assessments are unique per application and document).

### Notifications, Check-ins, References

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | /api/portal/notifications | portalAuth | List applicant notifications |
| GET | /api/portal/notifications/count | portalAuth | Unread count |
| PATCH | /api/portal/notifications/:id/read | portalAuth | Mark one notification read |
| POST | /api/portal/notifications/read-all | portalAuth | Mark all read |
| GET | /api/portal/checkins/recurring/applicant/:applicantId/season/:seasonId | portalAuth | Recurring check-ins for a season |
| GET | /api/portal/checkins/recurring/applicant/:applicantId/current-season | portalAuth | Recurring check-ins, current season |
| GET | /api/portal/embassy-consulates | portalAuth | Embassy/consulate reference list |
| GET | /api/portal/applicant-photos/:id | portalAuth | Serve applicant photo (ownership-scoped) |
| POST | /api/portal/travel-requests/:id/withdraw | portalServiceAuth (proxy scope) | Withdraw a pending travel request |
| GET | /api/portal/reference-submissions/lookup | api-key | Referee lookup of a reference submission |
| POST | /api/portal/reference-submissions/respond | api-key | Referee submits a reference response |
| GET | /api/portal/reference-templates | api-key (portal.read) | List active reference templates |
| GET | /api/portal/reference-templates/resolve | api-key (portal.read) | Resolve templates by programKey |
| GET | /api/portal/reference-templates/:idOrKey | api-key (portal.read) | Single reference template |
| GET | /api/portal/reference-templates/:idOrKey/pdf | api-key (portal.read) | Latest blank PDF for a template id or key, generated on first read if none is stored (binary application/pdf; ?disposition=attachment forces download). Unscoped and unbranded, with no applicant ownership check; 404 when the template or its PDF is missing. The application-bound branded variant is listed under Steps, Documents, Workflow above |

### Staff-Gated /api/portal Paths

These live under the /api/portal prefix but are staff/admin routes — not applicant-reachable. Don't wire portal clients to them.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | /api/portal/check-in-templates/:programId | staff session/API key + dashboard.view | Active check-in template |
| POST | /api/portal/resolve-placeholder | staff + dashboard.view | Resolve template placeholder |
| POST/PATCH/DELETE | /api/portal/step-library(/:id) | staff + programs.manage | Step-library CRUD |
| POST | /api/portal/step-library/sync | staff + programs.manage | Sync step library |

## Integrations API (/api/integrations)

The n8n and portal-backend surface. Onboarding routes need no X-Applicant-User-Id (the applicant doesn't exist yet). Job-order/housing-lead ingestion (~13 routes) and NetSuite finance sync are registered here too but sit outside the applicant-portal scope.

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| POST | /api/integrations/onboarding/check-user | integrations.onboarding | Check whether an applicant already exists (by email) |
| POST | /api/integrations/onboarding/provision | integrations.onboarding | Provision applicant + application + invite token (idempotent on hubspot_contact_id + program). A `partnerOrgId` supplied by the caller in `application_data` wins over the channel default. Otherwise the application inherits the partner organization that owns the `hubspot` intake channel (`partnerOrgId`); this resolution is best-effort, and a missing partner row leaves the application unattributed. When the created application has status `cancelled`, provisioning also calls `reflectEndInLifecycleStage` with outcome `cancel`, change source `integration_onboarding` and the application's terminal reason code (or null) |
| POST | /api/integrations/hubspot/communication-reply | integrations.hubspot.inbound | Inbound HubSpot comms reply |
| GET / PATCH | /api/integrations/applicant-portal/profile/by-id/:id | integrations.portal | Applicant profile read / update by Hub id. GET is ownership-checked (see the verified-token callout); PATCH carries the verified-token gate. |
| PUT | /api/integrations/applicant-portal/profile/by-id/:id/photo | integrations.portal | Applicant profile photo upload by Hub id (verified-token gate) |
| GET | /api/integrations/applicant-portal/profile/by-email/:email | integrations.portal | Profile by email (ownership-checked; non-disclosing 404) |
| GET | /api/integrations/applicant-portal/profile/by-portal-user/:portalUserId | integrations.portal | Profile by portal user id (ownership-checked; non-disclosing 404) |
| POST | /api/integrations/applicant-portal/link-portal-user | integrations.portal | Link Keycloak/portal user to applicant. Matches by `applicantId`, `email`, or name plus date of birth. Idempotent: an already-linked pair returns 200 with `alreadyLinked: true`. 409 `AMBIGUOUS_APPLICANT_MATCH` when name plus date of birth match several applicants (send `applicantId` or `email`); 409 `PORTAL_USER_ALREADY_LINKED` when the `portalUserId` belongs to a different applicant (`details.existingApplicantId`), including a race caught by the unique index |
| POST | /api/integrations/applicant-portal/mark-activated | integrations.portal | Activation callback after the applicant completes onboarding. Body needs at least one of applicantId, portalUserId or email (otherwise 400); 404 if no applicant matches. Stamps portal_activated_at first-write-wins and returns 200 with portalActivatedAt and alreadyActivated (true on repeat calls, timestamp never moves). |
| POST | /api/integrations/applicant-portal/password-reset-invite | integrations.portal | Password-reset invite for an applicant with no portal login, called by the Applicant Portal BFF when forgot-password finds no Keycloak user. Body `email` (trimmed and lowercased); a malformed address is the only 400. The Hub looks the applicant up itself and answers 204 whether or not an applicant matches or the send succeeds, so it is not an email-exists oracle. For a known applicant, unless an invite was already sent inside the 10-minute per-applicant cooldown (then nothing is minted and the answer is still 204), it mints a fresh 30-day token, revokes the applicant's other outstanding tokens, queues the branded invite, emits `applicant.invite_resent` and writes a `password_reset_invite` audit entry. `portal_activated_at` is not consulted |
| POST | /api/admin/applicants/:applicantId/resend-invite | integrations.portal or admin.write, plus applicant.manage or invite.resend | Revokes the applicant's prior access tokens and mints a fresh 30-day onboarding token, emitting `applicant.invite_resent`. 404 for an unknown applicant; 409 `APPLICANT_ALREADY_ACTIVATED` (`details.portalActivatedAt`) once the portal account is activated, re-checked under a row lock |
| POST | /api/admin/applicants/:applicantId/change-email | integrations.portal or admin.write, plus applicant.manage | Body `email` and optional `resendInvite`. 400 when the email equals the current one; 409 `DUPLICATE_EMAIL` when another applicant owns it (`details.conflictingApplicantId` and `details.conflictingApplicantShortCode`, the latter null when that applicant has no short code); 409 `CONFLICT` (`details.code` `APPLICANT_EMAIL_CHANGED_CONCURRENTLY`) when it changed while the correction ran. For an activated applicant the Applicant Portal login email is updated first: 409 `CONFLICT` (`details.code` `PORTAL_IDENTITY_LINK_REQUIRED`) when there is no linked portal identity, 409 `CONFLICT` (`details.code` `PORTAL_IDENTITY_CONFLICT`) when the portal login changed concurrently or already uses the email, 502 when the portal update fails (`details.code` carries the portal error code, or `PORTAL_IDENTITY_UPDATE_FAILED`). If the Globus write then fails the portal email is reverted; a failed revert returns 502 with `PORTAL_IDENTITY_COMPENSATION_FAILED` or the portal error code |
| POST | /api/integrations/applicant-portal/events | integrations.portal | Inbound portal event. The route runs the `bindVerifiedPortalIdentity()` middleware. When the request's portal context carries an applicant id, the event is handled with the `applicantMutation` flag set; in step-completed handling the snapshot save is flagged as an applicant mutation only when the step data carries no `source` |
| POST | /api/integrations/applicant-portal/login | integrations.portal | Records a verified Portal password login. The body must be exactly `{ "source": "ppptoo.password_login.v1" }`; any other field is rejected. The request needs a single `X-Portal-Access-Token` header; a missing or comma-joined value is refused as forbidden (403). Returns `success: true` with `data.recorded` and `data.duplicate`. Audited as `portal.login.recorded` |
| POST | /api/integrations/applicant-portal/files (+/base64) | integrations.portal | File upload (multipart or base64) |
| POST | /api/integrations/applicant-portal/ai/execute \| text-review \| text-enhance | integrations.portal | Portal AI surface. On `ai/execute`, a payload carrying `imageBase64` or `imageUrl` is capped at 20,000,000 characters of serialized JSON (PORTAL_AI_MAX_VISION_PAYLOAD_SIZE); other payloads and the text routes keep the smaller PORTAL_AI_MAX_PAYLOAD_SIZE limit. Over the limit returns 400 |
| POST | /api/integrations/applicant-portal/ai/extract-passport | integrations.portal (+ X-Applicant-User-Id, per-applicant and AI rate limits) | Portal passport OCR on the internal Document AI service (same service as the staff POST /api/extract-passport-data). Body takes `fileUrl` or `imageUrl`, or `fileBase64` or `imageBase64`, plus optional `mimeType` and `applicationId`. Success returns a single flat envelope with the extracted fields under `data`. Soft failure (OCR unreachable, nothing extracted) returns 200 with `success: false` and `data: null` so the portal leaves fields for manual entry. Base64 input whose decoded file size exceeds 15,000,000 bytes (measured from the base64 string length, counting `=` padding, without decoding it) returns 400 with `error: "OCR_PAYLOAD_TOO_LARGE"` and a human-readable `message` stating the limit in bytes (`max 15000000 bytes`) and telling the applicant to enter passport details manually; the upload itself is unaffected. Missing image data is 400; disabled `ai_extraction_enabled` or an unconfigured OCR service is 503 |
| GET | /api/integrations/applicant-portal/applicants/:applicantId/available-flows | integrations.portal | Dashboard flows (renewal/extension/J2) |
| GET | /api/integrations/applicant-portal/applications/:applicationId/workflow-snapshot | integrations.portal (+rate limit) | Pinned workflow snapshot. Response carries workflowVersionId and workflowDefinitionId (same immutable id — cache by applicationId plus this pin, never by program), pinned phases ordered by phaseOrder ({ phaseKey, name, phaseOrder }, including empty phases), and the steps applicable to this application (conditions are evaluated against its answers) each with stepKey, phaseKey (null for valid historical unassigned steps), stepName, position, stepOrder (alias of position), nodeType, hubKey, isHub, validationSchemaRef and required. Archived previously published pins stay readable. A pin whose steps reference phases with no stored phase metadata returns 422 WORKFLOW_PHASE_METADATA_UNAVAILABLE rather than guessed phases. Step names use the pinned customName/displayName first. Without a historical library snapshot they then fall back to the stepKey, except that publications restored by a data migration or created by the portal import (ordered step-key list) fall back to the live step_library name before the stepKey. An applicant identity that does not own the application gets 403 FORBIDDEN. |
| POST | /api/integrations/portal/documents/receive | integrations.portal | Receive a portal-uploaded document |
| GET/POST | /api/integrations/events(/:id, /:id/ack) | integrations.events | n8n ops-event poll/claim/ack (Event Outbox consumer) |
| GET / POST / PATCH | /api/integrations/job-orders (+ /:id, /:id/status, /:id/submit) | integrations.job_orders.read (GET), integrations.job_orders.write (POST, PATCH) | POST creates a job order: body requires `title`, `programId` and at least one `seasonIds`; optional `hostOrgId`, `brandId`, `description`, `locationCountry`, `wageAmount`, `wageFrequency`, `positionsTotal`, `requirements`. Source is set to `host_portal`. With a `hostOrgId` the host org lifecycle and agreement scope are checked first; a site visit is auto-created when required; the job-order workflow is evaluated. Returns 201 with the job order plus `siteVisitRequired` and `workflowFlags`. |
| GET / POST / PATCH | /api/integrations/housing-leads (+ /:id) | integrations.job_orders.read (GET), integrations.job_orders.write (POST, PATCH) | POST creates a housing lead (`propertyName` required; address, city, state, country, postalCode, housingType, bed counts, cost, contact and availability fields optional) with status `active`, audits it and emits `housing_lead.created`; returns 201. GET lists leads (default status filter `active`). PATCH updates a lead and, when `costPerBed` or `costFrequency` changed, resets linked job orders to draft. |
| GET / POST / DELETE | /api/integrations/job-orders/:jobOrderId/housing-leads (/link, /:housingLeadId) | integrations.job_orders.read (GET), integrations.job_orders.write (POST, DELETE) | POST `/link` body requires `housingLeadId`; optional `isPrimary`, `notes`. 404 for an unknown job order or lead. Returns 201 with `link`, `complianceReset` (true when the job order was reset to draft because of the new lead) and `jobOrder`, and emits `housing_lead.linked`. DELETE unlinks a lead and emits `housing_lead.unlinked`. |
| POST/GET | /api/integrations/comms/log, /api/integrations/notification-logs/:id(/deliveries) | integrations.comms | Comms/notification logging + delivery attempts |
| PUT | /api/integrations/applicants/:applicantId/finance-link | integrations.finance | Link applicant to finance system (NetSuite) |

Portal sync kill switch: when the Hub environment variable `PORTAL_SYNC_DISABLED` is exactly `true`, `POST /api/integrations/host-portal/events`, `POST /api/integrations/applicant-portal/events`, `POST /api/integrations/applicant-portal/files` and `.../files/base64` answer 503 with `error: "Portal sync is disabled"` before authentication runs. Any other value leaves them enabled.

## Applicant Create and Sync (POST /api/applicants)

POST /api/applicants is the create-or-sync door for applicants. It accepts an `applicants.write` API key or a staff session, and the route is gated by the `applicant.notes.edit` permission (for API keys, checked against the key's scopes). The caller is matched to an existing applicant first by Globus id (`id`, `globusId` or `globus_id` in the body), then by email.

- **No match:** a new applicant is created and the response is 201. Staff sessions must send `programId`, `seasonId` and `partnerOrgId`; bare creation without a program is limited to API-key callers that pass an allow-listed `withoutProgramReason`.
- **Create validation:** 400 `PROGRAM_REQUIRED` when a staff session omits `programId` or `seasonId`, or when an API-key caller omits them without an allow-listed `withoutProgramReason` (`portal_self_registration`, `hubspot_onboarding_pre_application`, `partner_invite_pre_application`, `external_integration_pre_application`; listed in `details.allowedReasons`). 400 `PARTNER_REQUIRED` when a staff session sends no `partnerOrgId`, and 400 `PARTNER_NOT_FOUND` when the given partner does not exist. 409 `PROGRAM_CLOSED_TO_NEW_INTAKE` when a new applicant targets a program flagged closed to new intake (`details.programId`, `details.programName`); existing applicants, renewals and extensions are unaffected.
- **Match from a staff session:** 409 `DUPLICATE`.
- **Match from an API key or integration caller:** the existing applicant is synced and the response is 200. Portal self-reported values win: `phone`, `nationality`, `dateOfBirth`, `gender`, `countryOfBirth` and `passportNumber` are overwritten whenever the incoming value is non-empty and differs from the stored one. Empty incoming values never clear a field. The profile photo is the exception and stays fill-only: it is fetched and stored only when the applicant has no photo yet.
- **Side effects of a sync:** a `sync_applicant` audit log entry records the fields updated; if any field changed, exception queues are reconciled for every application of the applicant; when the payload carries a passport number, the open passport data-quality alert is resolved.

`POST /api/applicants/sync-portal` (`applicants.write` API-key scope or a staff session, no further permission gate) is now a stub: it always answers 501 `NOT_IMPLEMENTED` ("Portal sync has been removed. Inbound sync is handled via webhooks."). Use the create-or-sync route above.

## Partner / Host Surface (/api/partner)

Roughly 200 route registrations across 31 files, all gated by partnerAuth() (direct, partner-proxy or host-proxy mode; host routes additionally take X-Host-Org-Id / X-Host-User-Email). Domains: host org profile, contacts, addresses, arrivals, cultural activities, documents, housing (+leads), reservations, talent pool, job orders, offer details, operating agreements, users, notes, and partner applicants/references. The acceptance flow for operating agreements now handles commercial revisions; if terms have changed, the host client must submit details of the current revision to accept. The authoritative per-route list is the frozen snapshot server/versioning/portal-contract-snapshot.json (keys /api/partner/host/*). Host branding is served by GET /api/partner/host/branding (resolved brand, full palette and portal theme, absolute logo URL; host roles admin, recruiter, finance, and readonly), and GET /api/partner/host/context carries a resolved brand block. See [Host Portal Branding](/docs/host-portal-branding) for the resolution order and response shape.

### Recruiter Partner Routes (/api/partner, non-host)

Recruiter-scoped routes registered in server/routes/partner.ts, partner.applicants.ts and partner.references.ts. All use partnerAuth. Routes with an `:applicantId` also run applicant attribution and the active-agreement scope check (403 `SCOPE_VIOLATION` for an applicant outside every active agreement scope).

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | /api/partner/applicants/:applicantId/form-schema | applicants.read | Read-only form reflection for the Recruiter Partner Portal. Query needs one of `templateKey`, `templateId` or `templateVersionId`, otherwise a validation error; an unknown template or version is 404. `templateVersionId` pins the exact version the applicant filled; `templateId` and `templateKey` resolve the template's active version (404 when it has none). Returns `templateKey`, `templateId`, `templateVersionId`, `name`, `context`, `version`, `layoutType` and `config`. |
| GET | /api/partner/applicants/:applicantId/form-values | applicants.read | The applicant's form submissions, gathered from submissions keyed to the applicant and to the application, each with `templateKey` added so the portal can call form-schema. Optional filters `templateKey` (404 when unknown), `templateId`, `templateVersionId`. There is no write counterpart. |
| GET | /api/partner/reference/programs | applicants.read | Id, name and code of the programs inside the org's active agreement scopes (status active and inside the date window); empty list when there are none. No host role needed. |
| GET | /api/partner/reference/programs/:programId/seasons | applicants.read | Seasons (id, name, programId, startDate, endDate) for the program. 403 `SCOPE_VIOLATION` when no active agreement covers the program. Season-level agreements return only their seasons; a program-level agreement returns the program's seasons with status open, active, closing, closed or archived, so planned seasons are not disclosed. |
| POST | /api/partner/agreements/:agreementId/accept | agreements.write | Accepts an agreement of the calling org and activates it. When `X-Partner-User-Email` is sent that user must be an active admin or owner, else 403 `FORBIDDEN_ROLE`. Unknown or foreign agreement is 404; operating and trial agreements are 409 (they are accepted through the host portal); a body `termsVersion` that differs from the current terms is 400 `TERMS_VERSION_STALE`; an already-active agreement returns 200 unchanged. Audited as `partner_agreement.accepted`. |
| POST | /api/partner/applicants/:applicantId/stage-transition | applicants.write | Recruiter-initiated stage move. Body `toStageKey` (required) and optional `reason`; an unknown stage key is 400 `VALIDATION_ERROR`. The canonical stage writer applies forward-only ordering and Rules Engine phase gates, and its coded errors are returned as-is. Returns 200 with `applicantId`, `applicationId`, `from`, `to` and the refreshed `stepAccess`. Carries the lifecycle gate. |
| GET | /api/partner/reservations | reservations.read | The org's reservation holds, newest first. Query `page`, `pageSize` (default 25, max 100) and optional `status`. |
| POST | /api/partner/reservations | reservations.write | Create a hold. Body requires `jobOrderId`, `applicantId`, `applicationId` and `agreementId`; optional `expiresAt`, `programId`, `seasonId`, `notes`. Creating a hold does not change the participant's account manager. A job order outside the active agreement scopes is 403 `SCOPE_VIOLATION`; `expiresAt` is bounded by the rule-library hold duration. Returns 201, or 200 when the create was idempotent. POST /api/partner/reservations/:id/cancel cancels an active hold (409 `INVALID_TRANSITION` otherwise). |
| GET | /api/partner/reports/summary | applicants.read | Agreement-scoped rollup: `totals` (applicants, `byStage`, active reservations, confirmed placements), `reservations`, `placements` and a daily `trend` of created applicants. Optional ISO `from` and `to` (default last 30 days); an invalid date, or `from` after `to`, is 400 `VALIDATION_ERROR`. |

**LIFECYCLE_GATE.** The recruiter mutations that carry the lifecycle gate (POST nudge, POST notes and stage-transition on an applicant) return 403 `LIFECYCLE_GATE` with the message `Applicant is in '<stage>' stage — mutations are blocked` when the applicant's current stage key is `completed` or `withdrawn`.

## Documented But Not Registered

> **These routes appear in doc/registry files but have NO live handler**
>
> GET /api/portal/me/context (does not exist — BFFs must compose it), /api/portal/applications/:appId/j2-dependents, /api/portal/users(/:id), /api/portal/file-proxy, /api/portal/monthly-check-ins/* and /api/portal/sevis-check-ins/* (real routes are /api/portal/checkins/recurring/*), /api/portal/notifications/logs|templates, POST /api/portal/applicant-photos, and /api/portal/reference-submissions/application/:applicationId. If an integration references one of these, it is coded against documentation, not the API.

## Implementation Flags

(1) Dead handler: registerPortalStepProgressRoutes defines a session-auth + ownership-check variant of GET .../step-progress but is never wired; the live handler is the API-key variant with UUID-as-integrity-check only. (2) AI routes: /api/openai/* is RBAC-gated for staff sessions but also accepts API keys holding portal.read (the Applicant Portal keys are the only API-key callers on record) — the designated portal AI path is /api/integrations/applicant-portal/ai/*. The staff-side text routes are `POST /api/openai/enhance-caption` (body `caption`, optional `context`; runs capability `text_enhancement`), `POST /api/openai/review-showcase` (body `captions` or `items`, a non-empty array; runs `text_review`), `POST /api/openai/suggestions` (body `fieldName`, optional `context` and `currentValue`; runs `field_suggestion`) and `POST /api/openai/review-text` (body `text`; runs `text_review`, and returns 503 while the `ai_extraction_enabled` feature flag is off). In the handlers all four require the `dashboard.view` permission (or an API key with scope `portal.read`), return 400 when the required field is missing or empty (an empty value returns a `success: false` body), and return 503 when the capability is not configured; the mutation registry lists them with no permission, but the handler check is the one that applies. (3) Auth split: see the callout above — session-gated vs API-key routes are inconsistent within the prefix; new portal routes should pick one surface deliberately.


## BackOffice Portal Context References

[1]: https://github.com/Alliance-Strategies/BackOfficePrototype/blob/development/package.json "BackOfficePrototype development package manifest"
[2]: https://github.com/Alliance-Strategies/BackOfficePrototype/blob/development/client/src/App.tsx "BackOfficePrototype development client route registry"
[3]: https://github.com/Alliance-Strategies/BackOfficePrototype/blob/development/server/index.ts "BackOfficePrototype development server entry point"
[4]: https://github.com/Alliance-Strategies/BackOfficePrototype/tree/development/server/auth "BackOfficePrototype authentication modules"
