---
title: "Integration Contract"
slug: integration-contract
section: Portals
status: stable
description: "The technical contract for how portals connect to, authenticate with, and consume data from Globus"
last-updated: 2026-10-07
---

# Integration Contract

The technical contract for how portals connect to, authenticate with, and consume data from Globus

> **Why This Page Exists**
>
> This page defines the **service-to-Hub integration contract** for portal backends and integration services. It does not define the browser-session implementation for every portal. The current Host Portal uses Auth0/JWT and the current PPPToo client uses a Keycloak-backed Bearer token held in `sessionStorage` (no cookies); those end-user boundaries are documented in their portal pages. [1] [2]

> **What each endpoint is allowed to return**
>
> The endpoint tables below list the surface area each portal can call. The data contract — which applicant fields and which application fields each partner type is permitted to see, with redaction rules and the underlying schema split — is documented canonically in Applicant Data vs Application Data (including the Partner Visibility Matrix and the Globus boundary audit).

## Design Principles

## Authentication

For **service-to-Hub** integration, scoped API keys establish the calling portal or integration service and limit its allowed Hub surface. Browser user sessions are a separate concern: the Hub also documents portal-session and magic-link token mechanisms for particular applicant routes, while the individual portal applications maintain their own end-user identity flows. [3]

> **Credential boundary**
>
> A browser must never receive a service API key. Do not, however, infer that every valid Hub request is an API-key request or that every Bearer token identifies sponsor staff. Trace the route’s registered authorization mechanism in the Hub API reference before implementation. [3]

### API Key Scoping

| Portal | Scope | Access Pattern | Secret Name |
| --- | --- | --- | --- |
| Partner Portal | portal.partner_proxy | Partner endpoints only — scoped to partner org | GLOBUS_API_KEY |
| Host Portal | portal.host_proxy | Host endpoints only — scoped to host org | GLOBUS_API_KEY |
| Applicant Portal | Route-specific: commonly `integrations.portal`, portal session, or magic-link authorization | Applicant endpoints use the registered portal/integration route mechanism and applicant context; there is no single universal browser-held key | Server-side secret where an API-key route is used |

### Host Proxy Auth Pattern

The Host Proxy Auth pattern enables a centralized portal to act on behalf of multiple host organizations using a single API key. This is the recommended pattern for Host Portal integrations:

# Host Proxy Auth example

X-API-Key: # scope: portal.host_proxy

X-Host-Org-Id: # target host organization

X-Host-User-Email: user@host.com # user attribution for audit

Content-Type: application/json

> **Bearer-token clarification**
>
> Service API keys are server-side secrets and must never be exposed to the browser client. The Hub also registers portal-session and magic-link routes that can use a Bearer-style portal credential, so Bearer must not be treated as a sponsor-staff-only mechanism. Use the authorization registered for the exact route. [3]

### Required Headers for Scoped Service Calls

The following headers apply when the specific Hub route uses a scoped portal or integration API key. The exact header set is route-dependent; portal-session and magic-link routes use their own registered mechanism. [3]

| Header | When applicable | Purpose |
| --- | --- | --- |
| `X-API-Key` | Scoped service-to-Hub routes | Identifies the calling portal/integration service and contributes its allowed scope. |
| `Content-Type: application/json` | JSON request bodies | Declares the request body format. |
| `X-Partner-User-Email` | Partner-context bootstrap or attribution routes | Identifies the acting partner user when the route contract requires it. |
| `X-Partner-Org-Id` | Partner-proxy routes | Resolves the target partner organization after the service has a valid context. |
| `X-Host-User-Email` | Host-proxy bootstrap or attribution routes | Identifies the acting host user when required by the route contract. |
| `X-Host-Org-Id` | Host-proxy routes | Resolves the target host organization for the proxy contract. |
| `X-Applicant-User-Id` | Applicant-scoped integration routes | Provides applicant context where the Hub’s route contract requires it. |

The current Host Portal uses Auth0/JWT for its browser and v2 facade boundary, while the Participant Portal’s protected client routes use their own centralized session flow. Neither browser implementation should be documented as directly holding the service API key. [1] [2]

## Context and Bootstrap Behavior

There is **no universal current `/api/portal/me/context` endpoint** that every portal can call. The Hub’s verified portal API reference explicitly identifies that path as unregistered and notes that a BFF may compose an equivalent context from registered applicant, application, program, and configuration routes. [3]

A portal may still need a context-resolution sequence, but it must be designed against the actual service contract:

| Step | Required practice |
| --- | --- |
| 1. Establish user session | Use the portal’s own identity/session flow; do not expose a Hub service key to the browser. |
| 2. Select the owning route | Choose a registered Hub portal, integration, partner, or host route for the needed resource. [3] |
| 3. Supply the route’s registered credential and context | Use API-key/header, portal-session, magic-link, or staff authorization only as required by that exact Hub route. [3] |
| 4. Compose client context deliberately | A BFF can aggregate the permitted source calls into a UI context; it must not invent authorization or policy outcomes. |
| 5. Respect Hub decisions | Render scopes, read-only state, agreement status, and rejection envelopes supplied by the authoritative route rather than substituting portal-side eligibility or compliance logic. |

This change retains the architecture principle that context should drive permitted UI actions, while removing an endpoint and response schema that are not registered as a universal current Hub contract. [3]

## Endpoint Conventions

The Hub exposes distinct portal, integration, and partner/host surfaces with route-level authorization. A scoped partner or host proxy credential cannot be treated as interchangeable with another portal’s credential, and a browser session must not be substituted for a service credential without a route contract that permits it. [3]

This page intentionally does not retain empty endpoint tables. Use the source-of-truth references below for actual paths and identities:

| Consumer or subject | Current endpoint reference |
| --- | --- |
| Applicant/participant integration | [Globus Portal API Reference](/docs/globus-portal-api) and [Participant Portal Endpoint Reference](/docs/applicant-portal-endpoints) |
| Host Portal user-facing service | [Host Portal Endpoint Reference](/docs/host-portal-endpoints) |
| Host governance and lifecycle behavior | [Host Portal Governance](/docs/host-governance) |
| Shared cross-portal ownership | [Portal Overview](/docs/portal-boundaries) |

### Host Suspend / Terminate Cascade

Wire-level mirror of the cascade behaviour documented on Host Portal Governance &rarr; Suspend / Terminate Cascade Contract . When a host transitions to Suspended or Terminated, four error envelopes become possible on otherwise-valid requests; portals must surface them with stable copy and route the user to the right recovery path (re-fetch host status, contact sponsor, etc.). Reads on every affected resource continue to succeed.

| Status | Error Code | Trigger | Details Payload |
| --- | --- | --- | --- |
| 409 | placement_frozen_host_suspended | Any mutation on a placement whose host is currently Suspended or Terminated (status transition, edit, cancel). Reads continue to succeed. | placementId, hostOrgId, hostStatus |
| 409 | document_locked_host_suspended | POST /api/partner/host/documents/initiate-upload, POST .../finalize-upload (including replacement uploads), POST .../:id/review, PATCH .../:id/expiry, or DELETE .../:id on a host document while the owning host is Suspended or Terminated (or the document is locked). List / GET / download / preview return 200. | documentId, hostOrgId, hostStatus |
| 400 | VALIDATION_ERROR | POST /api/partner-orgs/:id/transition with targetState probation, suspended or terminated and a missing or shorter-than-10-character reason, or (for suspended or terminated) no reasonCategory. The body accepts confirmation_token but it is not enforced; termination_confirmation_required is never returned. | none (only body-schema failures carry the zod issues list) |
| 422 | invalid_lifecycle_transition | POST /api/partner-orgs/:id/transition from terminated to any other state (terminated has no allowed transitions). Rejected regardless of caller or reason — the re-onboarding path runs as a new host org with new IDs. | hostOrgId, from, to |

> **Cascade is a single transaction; audit fans out per effect**
>
> The host lifecycle transition and all of its cascaded effects (placement freeze, document lock, outstanding job-order flag) commit in one transaction — either every effect lands or none does. The audit log, however, fans out: one parent `partner.org.status_change` row plus one cascade row per effect (`placement.frozen`, `host_document.locked`, `job_order.flagged_for_sponsor_review`, `partner_user.deactivated_by_suspension`). Query the parent row via GET /api/audit-logs?entityType=partner_org&entityId=HOST_ORG_ID; cascade rows are written under their own entity types. The host.status_changed webhook event documented in Event Types fires exactly once for the parent transition, not once per cascaded row.

### Host Document Center

Canonical path — legacy alias deprecated

The canonical prefix for every host document endpoint is /api/partner/host/documents*, aligning the resource with the rest of the host portal surface. The legacy mount /api/host/documents* is retained as a dual-mounted alias served by the same handlers (no redirect) that emits Sunset: Sat, 31 Oct 2026 00:00:00 GMT, Deprecation, and Link: <…>; rel="successor-version" headers (RFC 8594). New integrations must code against the canonical path; the legacy alias will be removed after the sunset date once portal traffic on it reaches zero.

The endpoints under /api/partner/host/documents cover host-organization compliance documents — insurance certificates, W-9 / tax documentation, site visit reports, housing inspection reports, signed operating agreements, and any other host-scoped compliance artifacts. Documents are owned by a host org, not by an applicant or placement. SEVIS certificate uploads, applicant documents, and partner documents are out of scope and live behind their own contracts. The categories enforced by the platform mirror the Compliance Document Tracking list: insurance, w9_tax, site_visit_report, housing_inspection, operating_agreement, business_license, workers_comp, camp_accreditation, other. The business_license, workers_comp, and camp_accreditation categories are the canonical submission channel for the camp host verification evidence — the Rules Engine's host.document.* facts key off these exact category values.

Rule evidence, not a verification checklist

Host documents are evidence: the Rules Engine's Facts Resolver reads their presence, review state, expiration date, and policy information as canonical facts. For Camp and Camp SWT hosts, the Business License, Workers Compensation, and Camp Accreditation documents (together with the accepted Terms and Conditions acceptance record) feed the Camp Host Verification Policy on the Rules Engine page, which derives the single verification outcome the camp DS-2019 guard consumes. The endpoints here never compute or expose a verification decision — the requirements rollup below is a document-status view, not the verification outcome.

Document-Compliance Rollup

GET /api/partner/host/documents/requirements returns a read-only compliance rollup for the authenticated host org: a per-requirement list plus a summary. Each requirement carries a derived status — one of approved, pending_review, rejected, resubmission_requested, expiring_soon, expired, or missing. The expiring_soon / expired / missing status and the matching summary counts are computed in one place by the shared getHostDocumentRequirements resolver, so the Host Portal and Globus staff tools never diverge on what counts as overdue.

- Expiry warning window (additive). An otherwise-approved document whose expiration is approaching — but not yet past — derives the expiring_soon status so host admins can re-upload before it lapses. Each requirement also carries a daysUntilExpiry countdown (whole days remaining until the document expires; null when there is no dated document), and the summary adds an expiringSoon count alongside the existing expired and missing counts. Precedence: once a document is past its expiration, expired wins over expiring_soon, and the per-status summary counts stay mutually exclusive (each requirement is counted under exactly one status). This is an additive extension — the previously documented statuses, fields, and summary shape are unchanged.
- Responses use the shared public shape: the internal GCS storage path is stripped, and a derived effectiveReviewState is added so consumers read a single resolved status field instead of re-deriving expiry locally.
- Read scope host.documents.view; roles admin, recruiter, finance, and readonly all qualify. Results are scoped to the authenticated host org — another org's documents never appear.
- The deprecated legacy alias GET /api/host/documents/requirements is served directly by the same handler (no redirect) and emits the RFC 8594 Deprecation, Sunset: Sat, 31 Oct 2026 00:00:00 GMT, and Link (successor-version + sunset) headers. The canonical path returns no deprecation headers.
Signed-URL Upload Flow

The Host Portal never streams files through Globus and never touches GCS directly. The full storage contract behind this flow — GCS bucket layout, orphan reaping, versioning, the proxied-download access model, retention, and encryption — lives on the Document Storage spec. The steps below restate the contract for the host endpoints specifically:

- Portal calls POST /api/partner/host/documents/initiate-upload with . Globus validates the request, allocates a pending documentId, and returns .
- Portal PUTs the file directly to uploadUrl using the returned headers (typically Content-Type and x-goog-content-length-range).
- Portal calls POST /api/partner/host/documents/finalize-upload with to finalize. Globus verifies the GCS object exists, persists metadata, sets review state to pending, and emits document.uploaded.
- If finalize is not called within the upload URL TTL, Globus reaps the orphaned object and pending row.
Bidirectional Visibility

Documents flow in both directions and surface against the same host_documents rows:

- Documents uploaded by the Host Portal via the flow above are immediately visible inside Globus staff tools (compliance document tracker and staff review queue).
- Documents uploaded by Globus staff against a host org (e.g., site visit reports, sponsor-generated artifacts) are returned by GET /api/partner/host/documents and visible in the Host Portal's document center, subject to a visibleToHost flag that defaults to true for host-scoped categories and false for internal-only artifacts. Staff can flip the flag.
- Each row carries an uploadedSource of host_portal or globus_staff so the Host Portal UI can label provenance correctly.
- visibleToHost = false documents are filtered out of host-portal responses entirely (not redacted in the row, omitted from the list).
Permissions & Auth

- All endpoints require host API key authentication scoped to X-Host-Org-Id (see Host Proxy Auth Pattern).
- Read endpoints require host.documents.view.
- Upload, replace, and delete endpoints require host.documents.manage (Host Admin role).
- All mutations require X-Host-User-Email for audit attribution. A missing header on a mutation returns the standard 400 validation error envelope.
- Cross-org access (a doc id whose hostOrgId does not match the auth'd org) returns 404, never 403, to avoid leaking existence.
- The host document preview returns an absolute backend URL carrying a short-lived opaque document capability (valid for five minutes), never a GCS URL. Opening that URL requires no forwarded API key or host headers. `original=1` on the preview request issues a separate retained-original link. The returned URL points at `GET /internal/host-document-content?token=...`, a capability-authenticated browser transport rather than an unauthenticated document lookup; invalid, expired, tampered, hidden, removed or rebound documents return 404.
- `GET /api/partner/host/documents/:id/content` streams authenticated inline document bytes with the same host auth, scope, visibility and inheritance as preview; `original=1` requests the unchanged retained artifact. Source-linked system agreement copies are regenerated from the exact accepted evidence, never current terms, and missing or mismatched evidence returns 409 `ACCEPTED_AGREEMENT_EVIDENCE_UNAVAILABLE`.
Validation

- Follows the platform's existing validation conventions (Zod schemas + the standard error envelope). No new error shape is introduced.
- File size cap and accepted MIME types come from the platform's existing GCS upload policy — this contract does not redefine them. Globus enforces; the portal surfaces the standard validation error to the user.
- category outside the allowed enum is a validation error.
- Finalize (POST /api/partner/host/documents/finalize-upload) with a documentId whose GCS object is missing returns a validation error and does not create the row.
Activity Events

Document mutations emit on the standard event outbox so they appear in both the host-portal activity log and Globus staff tools. See Event Types for the canonical payloads.

- document.uploaded — carries portal_source (host when initiated by the Host Portal) and host_org_id in place of application_id for host-scoped uploads.
- document.replaced — payload includes previous_document_id; the prior version stays in storage for audit.
- document.deleted — payload . Soft-delete from the portal; hard-delete is staff-only.
- document.approved / document.rejected — existing events; consumers include the Host Portal for host-scoped docs.
- host.document_expiring_soon (30-day warning) and host.document_expired — fire off the expiresOn field on host_documents.
### Host Activity Feed

*Status: the parameters, row shape and event list below describe the deprecated `GET /api/partner/host/activity` (`activity.ts`, sunset 2026-11-06), not the built `GET /api/partner/host/activity-log`. The built endpoint is an audit-log feed scoped to the host org, with parameters `page`, `pageSize` (default 50, max 200), `sort` (`oldest_first`), `actionType`, `type` and `q`, for host roles admin, recruiter, finance, and readonly (the deprecated route uses the same four roles). No `X-Globus-Delivery-Id` header is emitted; host-event webhook envelopes carry `eventId` in the body.*

GET /api/partner/host/activity-log is a read-only, employer-scoped, reverse-chronological participant lifecycle feed (Task #92) for participants linked to this host (via job order, placement, arrival, or housing assignment). It also surfaces contact-change events emitted from the host-portal contact CRUD routes (Task #111 / Fix #1895) so hosts can audit who edited primary-contact details. Hosts may either subscribe to webhook events or poll this endpoint — the correlationId on each row matches the originating webhook event id so the two streams can be de-duplicated. Any earlier shorter host-activity path is no longer documented and should not be relied on — this canonical endpoint is the only supported reference.

Auth & RBAC

Uses the standard Host Proxy Auth pattern: X-Host-Org-Id identifies the employer; X-Host-User-Email provides user attribution. Visible to roles holding host.placements.view (Host Admin, Hiring Manager, Finance, Read-Only). Returns 403 with the standard error envelope if any participant or job order referenced in a query parameter is not owned by the caller's host org.

Query Parameters

| Param | Type | Notes |
| --- | --- | --- |
| participantId | string? | Filter to a single participant. 403 if not owned by this host. |
| jobOrderId | string? | Filter to events touching a specific job order. 403 if not owned. |
| eventType | string[]? | Repeatable. One of the documented host-consumer event names. Unknown values → 400 INVALID_QUERY. |
| since | ISO-8601? | Inclusive lower bound on occurredAt. |
| until | ISO-8601? | Inclusive upper bound on occurredAt. since > until → 400 INVALID_QUERY. |
| page | integer? | 1-indexed. Default 1. |
| pageSize | integer? | Default 25, max 100. Out-of-range → 400 INVALID_QUERY. |

Response envelope — standard paginated envelope (), not cursor-based. Each row in data has the shape:

"id": "act_01H...", // stable activity row id

"occurredAt": "2026-04-19T14:22:01Z", // ISO-8601

"eventType": "applicant.arrived", // see Event Types section

"participant":

"id": "appl_...",

"displayName": "M. Petrova" // PII redaction matches /api/host/placements

"jobOrder": id / jo_... / title / Lifeguard — Camp Echo, // when applicable

"fromStatus": "placed", // lifecycle phase vocabulary

"toStatus": "arrived", // lifecycle phase vocabulary

"actor":

"type": "system" | "globus_staff" | "host_user" | "partner_user",

"displayName": "Globus Compliance" // never raw user IDs across orgs

"reason": "Position eliminated", // populated for cancellations / terminations

"reasonCode": "host_capacity_reduced", // standard reason code (Task #163 catalogue)

"correlationId": "evt_01H..." // matches webhook X-Globus-Delivery-Id

eventType is restricted to the following set (a subset of events in the Event Types table whose Consumers column includes “Host”):

- applicant.status_changed
- applicant.placement_confirmed
- applicant.arrived
- applicant.terminated
- application.journey_changed — placement-crossing transitions only
- job_order.capacity_changed
- placement.cancelled — added once the Placement Cancel with Reason event ships
fromStatus and toStatus are drawn from the lifecycle phase vocabulary documented on the Applicant Lifecycle page.

Empty & Error Behaviour

Empty state matches the Empty & Not-Yet-Implemented Responses contract: data: [] with a valid pagination object — never null. Errors use the standard envelope () with documented status codes: 400(INVALID_QUERY), 401, 403, 404, 429, 503.

> **Polling vs. Webhooks**
>
> Hosts that subscribe to webhooks should treat /api/partner/host/activity-log as a catch-up / replay surface, not a parallel stream. De-duplicate by correlationId before applying any UI state change.

### Applicant Portal Endpoints

| Method | Path | Description | Identity |
| --- | --- | --- | --- |
| method | path | desc | auth |

_(row above is a template, repeated for each data row)_

### SEVIS Operations Endpoints

Internal Globus endpoints for SEVIS batch processing, certificate management, J-2 dependent tracking, and biographical data sync. These are staff-facing endpoints, not portal-facing. All require an authenticated staff session; the four batch-mutation endpoints additionally require the Emergency SEVIS Access confirmation described in SEVIS & Government &rarr; Governed Overrides & Emergency Access when the caller is a System Admin.

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| POST | /api/sevis/batches/create | Create a draft batch scoped to one program + season. | Staff session |
| POST | /api/sevis/batches/:id/add-items | Add one or more applicants to a draft batch. Re-checks each applicant's live SEVIS readiness at add time. | Staff session |
| PATCH | /api/sevis/batches/:id | Confirm a draft batch (draft &rarr; confirmed). Re-validates readiness and host verification for every item; fails with 403 HOST_VERIFICATION_REQUIRED if any placement's host hasn't passed verification (403 OPERATING_AGREEMENT_REQUIRED takes precedence when an agreement failure is also present). Locks the batch for submission on success. | Staff session |
| POST | /api/sevis/batches/:id/send | Submit a confirmed batch to SEVIS (confirmed &rarr; sent). Known failure mode: returns 500 INTERNAL_ERROR with an opaque traceId and no idempotency signal, which can leave the batch in the submission_uncertain state described in SEVIS & Government &rarr; Batch Lifecycle States . Requires the batch to be in confirmed state; returns 409 BATCH_STATE_CONFLICT otherwise. | Staff session |

_(SEVIS endpoints beyond the four above — certificate management, J-2 dependent tracking, biographical data sync — are not yet catalogued here.)_

### Program Extension & Program Renewal Endpoints

Endpoints for managing Program Extension and Program Renewal requests (program-duration lifecycle events, not reservation-hold extensions). Includes both staff-facing review endpoints and applicant-facing submission endpoints.

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| method | path | desc | auth |

_(row above is a template, repeated for each data row)_

### Placement Transition Endpoints

These endpoints implement the human-driven edges of the Layer 7 — Reservation & Placement State Machine . The Reserve edge is the existing POST /api/portal/reservations listed under Partner Portal Endpoints (Available &rarr; Reserved). Confirm and Cancel are listed below. The system-only Reserved &rarr; Expired edge is driven by the reservation sweeper and is not exposed as a synchronous endpoint — see the state-machine spec.

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| POST | /api/admin/reservations/:id/confirm | Confirm an active reservation hold (active → confirmed). The partner variant is POST /api/admin/partners/reservations/:id/confirm. There is no POST /api/placements/:id/confirm route. | Staff RBAC (permission `reservations.manage`) |
| POST | /api/placements/:id/cancel | Cancel a placement (to cancelled). Body `{ reason }` (required, trimmed, min length 1). Returns 409 PLACEMENT_ALREADY_CANCELLED if already cancelled, a placement_frozen_host_suspended error if the host is Suspended or Terminated, and 400 VALIDATION_ERROR for an empty reason. When the placement was confirmed, releases the capacity slot and partner reservation quota; enqueues a placement.cancelled event. | Staff RBAC (permission `placement.cancel`) |
| POST | /api/admin/placements/:id/cancel | Cancel a placement. Body `{ reason, note? }`: `reason` is an existing cancellation reason code and `note` is optional (maximum 2000 characters). The response carries `status`, `operationId` and `reconciliation` (`completed`, `pending` or `needs_attention`); cancellation alone is not pool eligibility. Terminal placements return 409 and frozen placements remain protected. | Staff RBAC (permission `placements.manage`), application scope |
| GET | /api/admin/placements/:id/cancellation | Read the durable cancellation reconciliation status. Returns `reconciliation` (`not_requested`, `pending`, `completed` or `needs_attention`), `operationId` (null for legacy history), `attempts` (failed automatic attempts) and `message`. Pending is not a pool-ready result; after 10 failures an authorized operator must preview and retry reconciliation. | Staff RBAC (permission `placements.manage`), caller application scope |
| POST | /api/admin/placements/:id/reconcile-cancellation | Preview-first reconciliation of a cancelled placement. Body `preview` is required: `true` reads evidence without changes, `false` explicitly applies a reviewed repair. Apply requires `reason` (1–2000 characters) and `expectedUpdatedAt` (the timestamp returned by preview). Every apply requires a reason and audit; there is no bulk mutation. | Staff RBAC (permission `placements.manage`), application scope |

> **Approved-terms validation runs first on Confirm (Camp hosts)**
>
> *Status: not built. No approved-terms check or `APPROVED_TERMS_MISMATCH` error exists in code, and there is no `POST /api/placements/:id/confirm` route; the text below is a design spec.*
>
> For Camp and Camp SWT hosts, POST /api/placements/:id/confirm validates the submitted seasonal salary and contract start/end dates server-side against the host's effective approved terms revision before any placement side effects. A mismatch returns 422 with stable code APPROVED_TERMS_MISMATCH and per-field submitted-vs-approved details; a Camp host with no effective approved revision is refused fail-closed. This check precedes the transition-matrix evaluation below. Full contract: Host Verification & Approved Terms → Hire-Time Enforcement.

> **422 PLACEMENT_INVALID_TRANSITION (canonical contract)**
>
> *Status: design spec, not built. No `PLACEMENT_INVALID_TRANSITION` error exists in code; cancelling an already-cancelled placement returns 409 `PLACEMENT_ALREADY_CANCELLED`.*
>
> Both Confirm and Cancel return 422 Unprocessable Entity with the stable error code PLACEMENT_INVALID_TRANSITION when the requested (from, to) pair is not in the allowed rows of the matrix. The error envelope echoes details.placementId, details.from, and details.to so portals can re-fetch and reconcile cached state. 422 (invalid transition) is distinct from 409 RESERVATION_POSITION_TAKEN (race on the Reserve edge). Full transition matrix and the atomic-transaction contract live in Layer 7 &rarr; Reservation & Placement State Machine . The recovery procedure is in Error Handling &rarr; Invalid Placement Transition runbook .

### Placement History Endpoints

The canonical contract for the per-placement chronological status history is defined on the Layer 7 — Placement Status History page. The endpoints below are the wire surface for that contract, implemented in `server/routes/placementHistory.ts`. They honour the same RBAC scoping as the placement itself: a placement outside the caller's scope returns 404 (never 403) so existence is not leaked.

**CSV is a query parameter, not a path suffix.** Each history endpoint returns JSON by default and CSV when called with `?format=csv`. There is no `.csv` path variant, and the routes are mounted under `/api/admin` and `/api/partner/host`, not bare `/api/placements`.

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| GET | `/api/admin/placements/:id/history` | Chronological JSON array (oldest-first) of every status transition. Standard success envelope — **not paginated**; the full history is returned in one response. | `isAuthenticatedOrApiKey("placements.read")` + `requirePermission("placements.view")` |
| GET | `/api/admin/placements/:id/history?format=csv` | Same history as CSV. `Content-Type: text/csv`; `Content-Disposition: attachment; filename="placement-<id>-history.csv"`. RFC 4180 quoting, plus a leading apostrophe on any value starting with `=`, `+`, `-`, `@`, tab or CR (spreadsheet-injection guard). | as above |
| GET | `/api/partner/host/placements/:id/history` | Host-portal equivalent, scoped to the caller's partner organisation. Also honours `?format=csv`. A missing partner context yields 404 rather than leaking history. | `isAuthenticatedOrApiKey("placements.read")` + partner auth |
| GET | `/api/admin/placements-history` | Cross-placement admin listing, filterable by `partnerOrgId`, `jobOrderId`, `since`, `until`. Honours `?format=csv`. | staff placements-history middlewares |
| GET | `/api/admin/placements-history/export` | Explicit export alias for the listing above; same handler. | as above |

CSV column order is fixed: `placementId, timestamp, action, fromStatus, toStatus, actor, actorEmail, reason`.

JSON Response Shape

Each row in data:

"id": "...", // audit row id

"placementId": "...",

"action": "...", // audit action

"fromStatus": "pending", // null only on the initial-creation row

"toStatus": "confirmed",

"reason": "...", // nullable

"timestamp": "2026-04-19T14:22:01Z",

"actor": id / displayName / email

CSV Column Order (stable)

placementId,timestamp,action,fromStatus,toStatus,actor,actorEmail,reason

Adding or reordering a column is a breaking change and goes through the API Change Registry . fromStatus is empty (never the literal text "null") on the initial-creation row.

### Notification Management Endpoints

Endpoints for managing notification templates, querying the audit trail, and controlling scheduled notification jobs. All staff-facing with RBAC enforcement.

| Method | Path | Description | Auth |
| --- | --- | --- | --- |
| method | path | desc | auth |

_(row above is a template, repeated for each data row)_

> **Endpoint Discovery**
>
> Portal teams should not discover endpoints by trial and error. This contract is the authoritative reference. If an endpoint is not listed here, it is not available to portals. If you need a new endpoint, it must be defined in Globus first and added to this contract.

## Response Envelopes

Globus uses consistent response envelopes. Portal data-source layers must map these envelopes to whatever shape their frontend framework expects. The mapping should happen in a single translation layer, not scattered across individual API calls.

### Paginated Responses

List endpoints return paginated responses in this shape:

"data": [ ... ],

"pagination":

"page": 1,

"pageSize": 25,

"total": 142,

"totalPages": 6

Portals that use a different list shape internally (e.g., ) must translate in their data-source layer:

// Translation layer — do this ONCE, not per-endpoint

function mapPaginated(response)

return

items: response.data,

total: response.pagination.total

### Single Object Responses

Detail endpoints (e.g., dashboard, context, single applicant) return the object directly or wrapped in an envelope key:

Direct shape

id / ... / name

Envelope shape

stats

The translation layer must handle both shapes. Check for envelope keys and unwrap when present.

### Empty & Not-Yet-Implemented Responses

Some Globus endpoints may return null or empty data when the feature is not yet implemented or the entity has no records. Portals must handle these gracefully:

response
## Error Handling

Globus returns structured error responses. Portals must parse these and present meaningful feedback to users — never expose raw error objects or stack traces.

### Error Response Shape

"error":

"code": "QUOTA_EXCEEDED",

"message": "Intake quota exceeded for this agreement",

"details":

"quota_type": "intake",

"limit": 100,

"current": 100

### Status Code Reference

| Code | Meaning | Portal Action |
| --- | --- | --- |
| 200 | Success | Process response normally |
| 400 | Bad Request | Show validation errors to user. Check the error.details for field-level issues. |
| 401 | Unauthorized | API key is invalid or missing. Check configuration — do not prompt user to login. |
| 403 | Forbidden | User's role or agreement does not permit this action. Show a permission denied message. |
| 404 | Not Found | Resource does not exist. Show not-found state — do not retry. |
| 409 | Conflict | Duplicate or state conflict (e.g., applicant already exists, or a concurrent caller won a job-order position with code RESERVATION_POSITION_TAKEN — see Layer 7 → Reservation Concurrency & Placement Integrity). Show conflict details from error.message and re-fetch capacity before retrying. |
| 422 | Unprocessable | Business rule violation (quota exceeded or agreement expired). Show the enforcement message. |
| 429 | Rate Limited | Back off and retry with exponential delay. Show a temporary loading state. |
| 500 | Server Error | Globus internal error. Show a generic error message. Do not retry immediately. |
| 503 | Service Unavailable | Globus is down. Enter degraded mode (see below). |

> **Pinned workflow version — 422 codes**
>
> `POST /api/integrations/applicant-portal/events` with `eventType` `application.step.completed` (scope `integrations.portal`) is checked against the application's pinned workflow version before any write; an application with no pin skips the check. `WORKFLOW_VERSION_MISMATCH`: the submitted `workflowVersionId` differs from the pinned one (`details.inboundVersionId`, `details.pinnedVersionId`). `STEP_KEY_NOT_IN_PINNED_VERSION`: `data.step_key` is not a step of the pinned version (`details.stepKey`, `details.pinnedVersionId`). `GET /api/integrations/applicant-portal/applications/:applicationId/workflow-snapshot` returns `WORKFLOW_VERSION_NOT_PINNED` (the application has no pin), `WORKFLOW_VERSION_NOT_FOUND` (the pinned version no longer exists) or `WORKFLOW_VERSION_NOT_PUBLISHED` (the pinned version is not published; `details.status`), all with status 422.

> **Reservation 409 — RESERVATION_POSITION_TAKEN (canonical contract)**
>
> POST /api/portal/reservations uses a row-level lock on the target job order to serialize concurrent reservation writes. A request that loses the race receives 409 Conflict with the stable error code RESERVATION_POSITION_TAKEN and an applicant-safe message portals may surface verbatim. Full payload, lock semantics, and the nightly duplicate-placement integrity scanner that backs this contract live in Layer 7 &rarr; Reservation Concurrency & Placement Integrity . The runbook for resolving any flagged duplicate is in Error Handling .

## Degraded Mode Pattern

Every portal must implement a degraded mode for when Globus becomes temporarily unreachable. This is not optional — it is a required resilience pattern. Users should be able to view cached data without losing their session, but must be explicitly prevented from making changes that cannot be persisted.

### Portal States

unlinked account / Contact your organization administrator to be added to Globus.
### UI Requirements for Degraded Mode

Display an amber banner at the top of every page: \"Limited connectivity — data may not be current. Write operations are temporarily unavailable.\" / Disable all submit buttons, upload controls, and mutation triggers / Show a \"last synced\" timestamp so users know how stale the data is / Attempt to reconnect to Globus every 30 seconds in the background / When connectivity is restored, refresh all cached data and remove the banner / Log all failed write attempts so they can be flagged for manual retry if needed
## Webhook & Event Contract

Globus pushes state changes to portals via webhooks. This eliminates the need for portals to poll for updates and ensures near-real-time visibility into applicant lifecycle changes. See n8n Automation Layer for the full event pipeline architecture.

### Dual Event Outbox

Globus uses two separate event outbox tables for different purposes. Understanding this distinction is important for portal integrations:

ops_events (External)

Events destined for n8n webhooks and external portal integrations. These are the events that portals receive and should handle.

Best-effort delivery with retries / Dead-letter queue for failed deliveries / Admin dashboard for monitoring delivery health / Payload enriched with entity names and context
event_outbox (Internal)

Domain events for cross-context consistency within Globus. These events are not delivered to portals — they drive internal reconciliation and side effects.

Supports correlationId for request tracing / Supports causationId for event chain tracking / Used by lifecycle reconciliation engine / Triggers reactive document and SEVIS processing
### Webhook Delivery

Globus sends webhooks as HTTP POST requests to a portal-registered callback URL / Payloads are JSON with a consistent envelope (event type, timestamp, payload) / Delivery is at-least-once — portals must be idempotent (use event_id for deduplication) / Failed deliveries are retried with exponential backoff (1s, 5s, 30s, 5m, 30m) / After 5 failed attempts, the event is logged to a dead-letter queue and the portal is flagged for review
### HMAC Verification

Every webhook includes an HMAC signature that portals must verify before processing:

// Webhook headers

X-Globus-Signature: (hex-encoded HMAC-SHA256 of the raw body, no sha256= prefix)

The event type and timestamp are carried in the JSON body envelope (eventType, timestamp), not in headers; host events also carry eventId in the body.

// Verification

const expected = hmac("sha256", GLOBUS_WEBHOOK_SECRET, raw_body);

if (!timingSafeEqual(signature, expected)) reject(401);

> **Reject Unsigned Webhooks**
>
> Portals must reject any webhook that fails HMAC verification. Never process unverified payloads — this is a critical security boundary. Use timing-safe comparison to prevent timing attacks.

### Event Types

> **Events Backing the Host Activity Feed**
>
> The host-consumer events applicant.status_changed, applicant.placement_confirmed, applicant.arrived, applicant.terminated, application.journey_changed (when the journey crosses a placement boundary), job_order.capacity_changed, and the placement.cancelled event introduced by the Placement Cancel with Reason work are the underlying events that back GET /api/partner/host/activity-log . Hosts can choose between subscribing to webhooks and polling the activity endpoint — rows on the feed carry the originating correlationId so the two streams can be safely combined.

| Event | Description | Key Fields | Consumers |
| --- | --- | --- | --- |
| event | desc | payload | consumers |

_(row above is a template, repeated for each data row)_

## Portal Authentication Architecture

Portal user authentication is separate from the portal-to-Globus API authentication described above. The API key authenticates the portal as a system. User authentication determines which human is using the portal.

### Current State (Transitional)

During development, portal user identity is bootstrapped from an environment variable (GLOBUS_PARTNER_USER_EMAIL). This is a placeholder that must be replaced before production deployment.

# Dev-only bootstrap

X-Partner-User-Email: $GLOBUS_PARTNER_USER_EMAIL

### Target State (Production)

The Partner Portal prototype used Replit Auth as its identity provider (Google, GitHub, Apple, email/password). Replit Auth is not a production IdP for the GCP-hosted platform — do not build new logins against it; the production identity decision follows the platform pattern (Keycloak for applicants, Auth0 for host/staff surfaces). The split of concerns below still stands: the IdP handles authentication (who is this person?), Globus handles authorization (what can they do?). These are separate, non-overlapping concerns:

Replit Auth (Identity)

“Is this a real person?”

Verifies the human, provides email, name, avatar.

Globus (Authorization)

“What can they do?”

Resolves email &rarr; org &rarr; role &rarr; scope &rarr; agreement.

The production authentication flow:

not found
### Local Session Cache

The portal may maintain a local partner_users table as a session cache to store preferences, last login time, and session metadata. This is not an authoritative user database — Globus remains the single source of truth for identity, org membership, and permissions. If the local cache is wiped, the portal rebuilds it from the next Globus bootstrap call.

> **Globus Resolves Identity — Portals Do Not**
>
> Portals never maintain their own user database. Globus resolves email → user → org → role → scope on every bootstrap call. If a user’s role or agreement changes in Globus, the next portal session automatically reflects that change. No sync required.

## Partner Model &rarr; Autonomy Mapping

The three Partner Models map to specific autonomy levels from the Partner Autonomy Framework. This mapping determines which features and endpoints are available to each partner type.

| Partner Model | Autonomy Level | Portal Features | Risk |
| --- | --- | --- | --- |
| Standard | A — Recruitment Feed | Applicant upload (CSV + individual), status tracking, quota dashboard, document center, milestone visibility | |
| Managed | B — Recruitment + Matching | All Standard features + job order browsing, reservation requests, placement nominations, match dashboard | |
| Regional Operator | C/D — Extended Operations | All Managed features + post-arrival dashboards, check-in coordination, issue escalation, regional host management, delegated compliance tasks | |

### Model Capabilities Matrix

How the partner model determines endpoint access:

| Capability | Standard | Managed | Regional Op. |
| --- | --- | --- | --- |
| Submit applicants (individual) | | | |
| Bulk CSV upload | | | |
| Track applicant status | | | |
| View quota utilization | | | |
| Upload partner documents | | | |
| Browse job orders | | | |
| Reserve placements | | | |
| Nominate candidates | | | |
| View post-arrival milestones | | | |
| Coordinate check-ins | | | |
| Report issues / escalate | | | |
| Manage regional hosts | | | |
| Delegated compliance tasks | | | |

> **Model Is Agreement-Driven**
>
> The partner model is set on the Partner Agreement in Globus. Portals do not need to implement model-detection logic — the context endpoint returns the agreement details including the model type. The portal simply enables or disables features based on the model returned.

## Travel Request Integration

Travel requests follow the same pattern as document review: the Applicant Portal pushes the request to Globus, Globus staff reviews and decides, and webhook events communicate the decision back to the portal. The portal never approves or rejects travel requests on its own.

> **Globus Owns Travel Approval**
>
> The Applicant Portal collects the travel request and pushes it to Globus. Globus staff reviews, approves, or rejects. The portal learns the outcome through webhook events. Any existing approve/reject endpoints on the portal side must be removed or locked — only the Globus webhook can change a travel request’s approval status.

### Push Payload

When an applicant submits a travel request in the Applicant Portal, the portal calls POST /api/travel-requests/receive with the following payload. Authentication uses the same portalServiceAuth key the portal already uses for document push.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| applicationId | string | yes | Globus application ID the travel request belongs to. |
| portalTravelRequestId | string | yes | Portal's local ID for this travel request. Echoed back in all webhook events for matching. |
| travelType | string | yes | Type of travel. |
| destinationCity | string | | Destination city. |
| destinationCountry | string | | Destination country. |
| departureDate | string (ISO 8601) | | Planned departure date. |
| returnDate | string (ISO 8601) | | Planned return date. |
| description | string | | Applicant's reason for travel (free text). |
| validationClassification | string | | Portal's pre-classification. Globus may override. |
| holidayPeriods | object[] | | Program holiday periods the travel dates overlap. |
| supportingDocumentIds | string[] | | Array of Globus document IDs (already pushed via document integration) — e.g., approval letters, academic calendars. |

Response

### Review Workflow

Once Globus receives the travel request, it enters the staff review workflow:

> **RBAC**
>
> Travel request review uses a travel.review permission assigned to staff roles that handle on-program compliance. This is separate from document review permissions and can be scoped independently.

### Portal Responsibilities

The Applicant Portal retains ownership of the applicant-facing travel request UX, while Globus owns the approval decision:

| Responsibility | Owner | Details |
| --- | --- | --- |
| Travel request form + validation UX | | Date validation, holiday period warnings, destination input, supporting document upload. |
| Draft / submit / withdraw lifecycle | | Applicant can draft, edit, submit, or withdraw a request before approval. Withdraw after push emits travel_request.withdrawn to Globus. |
| Push to Globus on submit | | POST /api/travel-requests/receive with full payload. Store globus travel_request_id on local record. |
| Date validation logic | | Client-side validation helps applicants understand if dates fall in restricted periods. This is UX guidance — not an approval decision. |
| Approval / rejection decision | | Only Globus staff can approve or reject. Portal must not have its own approve/reject endpoints. |
| Webhook handling | | Handle travel_request.received, travel_request.approved, travel_request.rejected events. Update local status and notify applicant. |
| Holiday period data | | Holiday periods are configured per program/season in Globus. Portal fetches them via the program context to drive client-side date validation. |
| Applicant notification | | Portal notifies the applicant when a travel request is approved, rejected, or received. |

> **Portal Must Not Self-Approve**
>
> If the portal currently has /travel-requests/:id/approve or /travel-requests/:id/reject endpoints, they must be removed or locked down so that only the Globus webhook can change approval status. The portal should never approve or reject travel requests on its own — this violates “Globus decides, portals reflect.”

## API Platform

Globus exposes a structured API platform with machine-readable specifications, stability guarantees, and versioning controls. All portal integrations are governed by this platform layer.

### OpenAPI 3.1 Specification

Globus generates an OpenAPI 3.1 specification from its endpoint schema registry. The spec is the canonical reference for all portal integrations and drives client SDK generation, automated testing, and documentation.

The spec is assembled at request time by `generateOpenApiSpec()` in `server/routes/openapi.ts` from three sources: the declarative `endpointSchemas` registry (`server/routes/endpointSchemas/`), the read-route catalog in `server/routes/apiReference.ts`, and the mutation registry middleware. Operations listed in `schemaOperationRoutes` (imported from `./openapi.schemaRoutes`) are also added, but only when the spec does not already contain that method and path; generation throws an error if such an operation has no canonical endpoint schema. It is **not** generated from route decorators or Zod schemas at build time, and there is no per-major-version spec file.

| Surface | Method + path | What it returns |
| --- | --- | --- |
| Machine-readable spec | `GET /api/docs/openapi.json` | Full OpenAPI 3.1 document (JSON) |
| Staff API reference | `GET /api/docs/api-reference` | RBAC-gated human-readable endpoint catalog |
| Public API reference | `GET /api/public/docs/api-reference` | Unauthenticated subset of the same catalog |
| Stability report | `GET /api/docs/stability-report` | Per-endpoint stability tier rollup |
| Known versions | `GET /api/versions` | Every known API version with its registered changes |

> **There is no YAML spec, no Swagger UI, and no ReDoc surface.**
>
> `GET /api/openapi.json`, `GET /api/openapi.yaml`, `GET /api/docs` (as a Swagger UI page), `GET /api/redoc`, and `GET /api/changes` are **not registered**. Earlier revisions of this page listed them; they were never built. Use the table above.

### Endpoint Stability Tiers

Every endpoint carries a stability tier. Tiers are surfaced in the OpenAPI document via the `x-stability` extension, which is emitted **only when the tier is not `stable`** — an operation with no `x-stability` key is stable by default. The tier vocabulary is closed to three values, described in the spec's `x-stability-tiers` block.

| Tier | Description as emitted in the spec | Portal guidance |
| --- | --- | --- |
| `stable` | Production-ready endpoints with backward compatibility guarantees. | Safe for production portal integrations. |
| `beta` | Endpoints available for integration but may change without notice. | Feature previews only; subscribe to version changes. |
| `internal` | Internal/debug endpoints that may be removed at any time. | Not for portal use. |

There is **no `alpha` tier and no `deprecated` tier.** Deprecation is expressed per-endpoint rather than as a tier: deprecated route aliases emit RFC 8594 `Deprecation` / `Sunset` / `Link` headers and carry the sunset date in their description (see the Task #411 host-document aliases in `server/routes/apiReference.ts`, sunset 2026-10-31).

By default `generateOpenApiSpec()` includes the `stable` and `beta` tiers; `internal` is opt-in.

> **Portal Teams Should Target Stable Endpoints**
>
> Production portal integrations should only depend on stable endpoints. Beta endpoints may be used for feature previews, but portal teams must subscribe to the change registry to receive deprecation notices.

### Version Annotations

Version metadata appears in the OpenAPI document, not on every response. The extensions actually emitted by `generateOpenApiSpec()` are:

| Extension | Level | Emitted when |
| --- | --- | --- |
| `x-stability` | operation | Only when the tier is not `stable` |
| `x-permission` | operation | The route declares an RBAC permission |
| `x-version-changes` | operation | The change registry has entries for that endpoint |
| `x-api-versions` | document root | Always — the known-version list |
| `x-stability-tiers` | document root | Always — the tier vocabulary and descriptions |

There is no `x-api-version`, `x-introduced`, or `x-sunset` operation extension, and no `X-Stability` response header.

### Versioning Middleware

Globus uses date-based API versioning via the **`API-Version`** request header — not `X-API-Version`. `apiVersionMiddleware` (`server/middleware/apiVersion.ts`) resolves it and echoes the resolved value back in an **`API-Version`** response header on every request.

| Request condition | Behaviour |
| --- | --- |
| No `API-Version` header | Resolves to `getLatestVersion()`; response carries that value. |
| Header is not a `YYYY-MM-DD` date | `400` with error code `INVALID_API_VERSION`. |
| Header is a valid date but not a known version | `400` with error code `UNKNOWN_API_VERSION`, and `details.availableVersions` lists the known set. |
| Header is a known version | `req.apiVersion` is pinned to it and echoed back. |

> **Version Pinning Is Recommended**
>
> Portal teams should pin to a specific API version in their configuration and only upgrade after testing against the new version. Omitting the header always resolves to the latest version, which may include additive changes.

### API Change Registry

All API changes are recorded in a structured change registry. Portal teams can query the registry to understand what changed between versions. The AI Connector surface (/api/ai/v1/*, added 2026-05-18) is registered here as well — entries under the action prefix ai_connector. announce additions and deprecations. See AI Connector for the full endpoint catalog and the well-known OpenAPI document URL.

The registry is queried through **`GET /api/versions`** (`server/routes/versions.ts`). There is no `GET /api/changes` endpoint and no `since` / `until` filtering — the route returns every known version with its full change list, and the caller filters client-side.

```
GET /api/versions

# Response: standard success envelope wrapping one entry per known version
[
  {
    "version": "2026-04-01",
    "isLatest": true,
    "isBaseline": false,
    "changes": [ /* change entries registered for this version */ ]
  }
]
```

| Change Type | Description | Breaking? |
| --- | --- | --- |
| added | New endpoint or new optional field added to an existing endpoint | No |
| changed | Existing endpoint behavior modified (response shape, validation rules) | Potentially |
| deprecated | Endpoint marked for removal with a sunset date and replacement | No (until removal) |
| removed | Endpoint removed from the API (only after deprecation period) | Yes |
| fixed | Bug fix that corrects behavior to match documented spec | No |

## Anti-Patterns

Common mistakes that violate the integration contract. Portal teams must avoid these:

> **Contract Is Authoritative**
>
> This integration contract is the single source of truth for portal teams. If a pattern is not documented here, it should not be assumed to work. If a portal team needs something not covered, it must be defined in Globus first and added to this contract before implementation begins. See Portal Functional Boundaries for what each portal owns and does not own.


## References

[1]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/App.tsx "HostPortalPrototype development client route registry"
[2]: https://github.com/Alliance-Strategies/PPPToo/blob/development/client/src/App.tsx "PPPToo client route registry and RequireAuth guard"
[3]: /docs/globus-portal-api "Globus Portal API Reference"
