Skip to content

SDI Standard

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Admissions systems and school management systems do not talk to each other. When a school admits an applicant, a staff member re-keys that applicant’s name, contact details, programme, intake, and uploaded documents into the school’s SMS by hand. This is slow, error-prone, and does not scale.

SDI (Student Data Interoperability) is an open standard for moving an admitted applicant from an admissions system into an SMS as a student record, over a small, predictable, RESTful HTTP interface secured with OAuth 2.0. It is designed so that:

  • A Provider (the SMS) declares the fields it expects through a machine-readable schema, so the admissions system can present its administrators a precise field-mapping experience instead of guesswork.
  • An administrator authorizes the connection once — ideally without ever handling an API key — and the link is then maintained automatically.
  • Documents (transcripts, identity documents, photographs, issued letters) transfer securely by reference, never by embedding bytes in a payload.
  • The interface versions cleanly and is extensible to capabilities beyond admissions over time.

SDI defines only the wire contract a Provider exposes. How an admissions system decides when to send, which of its internal fields map to a Provider’s schema, or how it queues and retries work, is the Consumer’s concern and is out of scope.

A Provider is SDI-conformant when it implements, at minimum:

  1. the Discovery document (§5),
  2. at least one OAuth 2.0 grant defined in §6,
  3. the enrollment-sync capability’s schema and ingest endpoints (§8), and
  4. document resolution as a caller (§10) if any record it accepts can carry documents — the Provider fetches document bytes from the Consumer.

Conformance levels and the certification programme are defined in §15.

SDI is not a database, a central data store, a message bus, or a file-transfer network. A Provider keeps its own data store and exposes only the endpoints defined here. SDI does not require the Consumer and Provider to share any infrastructure.


These are two distinct lifecycle stages owned by two different systems, and SDI sits on the boundary between them:

  • Admissions is the funnel before a person is a student: application, document collection, review, decision (offer / waitlist / reject), and acceptance. The person is a candidate / applicant and is not yet a member of the school. This stage is owned by the admissions system.
  • Enrollment is the state in which the person is a registered, active student: they hold a student record and are associated with a class, section, term, or year. This stage is owned by the SMS.

SDI is the handoff at the boundary. When an applicant is admitted (and/or accepts/enrolls) in the admissions system, SDI transfers that person into the SMS so the SMS can create or update the student record. The decision of which status triggers the handoff (e.g. “admitted” vs. “enrolled”) is a Consumer configuration concern and is not encoded in the protocol.

Role Definition
Provider The SMS. It hosts the SDI endpoints, acts as an OAuth 2.0 authorization server and resource server, declares the fields it expects, and ingests records.
Consumer The admissions system. It is the OAuth 2.0 client, discovers the Provider, maps its source data to the Provider’s declared schema, and pushes records.
Administrator A human at the school who authorizes and manages a connection between a Consumer and a Provider.
  • Capability — a named, independently versioned unit of functionality a Provider may implement (e.g. enrollment-sync). See §7.
  • Connection — an authorized, ongoing relationship between one Consumer and one Provider, backed by OAuth 2.0 tokens.
  • Discovery document — the machine-readable metadata document a Provider publishes at a well-known URL (§5).
  • Record — one applicant/student represented as a JSON object conforming to the Provider’s declared schema, optionally carrying document descriptors.
  • Envelope — the protocol-level wrapper around a batch of records submitted to an ingest endpoint.
  • Steward — the body that governs SDI: it publishes and versions this specification, runs the conformance and certification programme, holds the certification mark, signs the software statements that anchor registration trust (§6.1.2), and maintains the standard’s registries (e.g. the identifier-scheme registry, §9.3.1).

A Consumer integrates with a Provider in four phases: discover, register/authorize, map, and sync.

flowchart LR
    subgraph Provider["Provider (SMS)"]
      WK[".well-known/<br/>sdi-configuration"]
      AS["OAuth 2.0<br/>authorization server"]
      SCHEMA["schema_endpoint"]
      INGEST["ingest_endpoint"]
      PULL["document puller"]
      STORE[("Student records")]
    end
    subgraph Consumer["Consumer (Admissions system)"]
      DISC["Discovery client"]
      AUTH["OAuth client +<br/>token store"]
      MAP["Field-mapping UI"]
      SYNC["Sync engine"]
      RESOLVE["document resolve<br/>(resolveUrl)"]
      FILES[("Object storage")]
    end

    DISC -->|GET| WK
    AUTH -->|register / authorize / token| AS
    MAP -->|GET expected fields| SCHEMA
    SYNC -->|POST records| INGEST
    INGEST --> STORE
    PULL -.->|signed-JWT resolve doc id| RESOLVE
    RESOLVE -->|short-lived signed URL| PULL
    RESOLVE --> FILES

The connection lifecycle:

sequenceDiagram
    autonumber
    participant A as Administrator
    participant C as Consumer
    participant P as Provider

    A->>C: Enter Provider base URL, click 'Connect'
    C->>P: GET /.well-known/sdi-configuration
    P-->>C: Discovery document (endpoints, grants, capabilities)
    alt Authorization Code + PKCE + Dynamic Registration
        C->>P: POST registration_endpoint (self-register client)
        P-->>C: client_id (+ secret)
        C->>A: Redirect to authorization_endpoint
        A->>P: Authenticate and consent
        P-->>C: Authorization code (redirect)
        C->>P: POST token_endpoint (code + PKCE verifier)
        P-->>C: access_token + refresh_token
    else Client Credentials (pasted client_id / secret)
        A->>C: Paste client_id + client_secret
        C->>P: POST token_endpoint (grant_type=client_credentials)
        P-->>C: access_token
    end
    C-->>A: Connection established

  • Transport. All endpoints MUST be served over HTTPS (TLS 1.2 or later). Plaintext HTTP MUST NOT be used for any SDI endpoint.
  • Style. Endpoints are RESTful: resource-oriented URLs, standard HTTP methods, and standard status codes. Requests and responses use application/json unless otherwise stated.
  • Methods. Reads use GET. Record submission and document resolution use POST. A Provider MAY additionally support PUT/PATCH on its own resources but MUST NOT require them for conformance.
  • Status codes. A Provider MUST use conventional semantics: 200/201 success, 202 accepted for asynchronous processing, 400 malformed request, 401 missing/invalid token, 403 insufficient scope, 404 unknown resource, 409 idempotency conflict, 410 expired resource, 422 semantic validation failure, 429 rate limited, 5xx server error.
  • Errors. Error responses MUST use the application/problem+json media type (RFC 9457) with at least type, title, and status, and SHOULD include detail and, where applicable, an errors array of field-level problems.
  • Idempotency. Any state-changing request (record ingest, document resolution that mints a URL) MUST honour an Idempotency-Key request header. A Provider MUST return the original result for a repeated key within its retention window (§12).
  • Rate limiting. A Provider MAY rate-limit and SHOULD signal limits with 429 and a Retry-After header. Consumers MUST honour Retry-After.
  • Pagination. Any list endpoint a Provider exposes MUST be cursor-paginated and MUST NOT rely on offset paging.
  • Time and identifiers. Timestamps MUST be RFC 3339 / ISO 8601 UTC. Identifiers are opaque UTF-8 strings; a Consumer MUST NOT parse meaning from a Provider’s identifiers and vice versa.

4.1 Localization of Provider-supplied text

Section titled “4.1 Localization of Provider-supplied text”

Some responses carry human-readable text the Consumer displays to administrators — the title and description of schema fields (§8.1), enumerated option labels, and the title/detail of problem+json errors. Because the Consumer cannot pre-translate text the Provider defines, a Provider that supports more than one language MUST localize this text through standard HTTP content negotiation:

  • A Provider MUST advertise the languages it can render in languages_supported and name a default_language in its discovery document (§5.1). Language tags are BCP-47 and SHOULD use the language-REGION form (e.g. en-GB, fr-FR, sw-KE).
  • On any response carrying such text, a Provider MUST honour the request’s Accept-Language header, MUST set Content-Language to the language it actually returned, and MUST set Vary: Accept-Language. When it cannot satisfy the request it MUST fall back to default_language and report that fallback via Content-Language, so the Consumer can detect a mismatch.
  • Machine-readable identifiers are never localized. Property names, enumerated values, contract versions, scopes, and the type/code of an error are locale-invariant; only the human-facing title, description, and detail change with language. This lets a Consumer key its own translations off the stable code and disregard the Provider’s wording where it maintains its own.

A Provider that renders only one language MAY ignore Accept-Language, but MUST still declare that language as default_language and set Content-Language on its responses.


A Provider MUST publish a discovery document as JSON at:

GET https://{provider-base}/.well-known/sdi-configuration

The document is the single entry point: from a Provider’s base URL a Consumer learns every endpoint, supported grant, scope, capability, and version. It MUST be retrievable without authentication and SHOULD be cacheable (Cache-Control/ETag).

Field Type Required Description
sdi_version string yes The SDI protocol version this document describes (e.g. "1.0").
protocol_versions string[] yes All SDI protocol major.minor versions the Provider supports.
issuer string (URL) yes The Provider’s stable issuer identifier.
authorization_endpoint string (URL) conditional Required if Authorization Code grant is supported.
token_endpoint string (URL) yes OAuth 2.0 token endpoint.
registration_endpoint string (URL) conditional Required if Dynamic Client Registration is supported.
jwks_uri string (URL) conditional Required if the Provider issues or verifies signed JWTs.
grant_types_supported string[] yes Subset of authorization_code, client_credentials, refresh_token.
code_challenge_methods_supported string[] conditional Required if Authorization Code is supported; MUST include "S256".
scopes_supported string[] yes All scopes the Provider recognises (§6.4).
capabilities object[] yes The capabilities the Provider implements (§5.2).
languages_supported string[] no BCP-47 language-REGION tags the Provider can render human-readable text in (§4.1). Omit or single-entry when not localized.
default_language string yes BCP-47 tag of the language returned when a request’s Accept-Language cannot be satisfied (§4.1).

A Provider MUST ignore unknown top-level fields and MUST NOT fail if a Consumer sends headers or query parameters it does not recognise.

Each entry in capabilities describes one capability and the endpoints that serve it:

Field Type Required Description
name string yes Capability identifier, e.g. "enrollment-sync".
contract_versions string[] yes The capability’s data-contract versions the Provider supports (semver).
schema_endpoint string (URL) yes Returns the JSON Schema of fields the Provider expects (§8.1).
ingest_endpoint string (URL) yes Accepts record batches (§8.2).
scopes string[] yes The scopes required to call this capability’s endpoints.

A Consumer MUST treat a capability it does not recognise as inert (ignore it), and MUST NOT assume any endpoint exists unless the discovery document advertises it.


SDI uses OAuth 2.0 (RFC 6749). The Provider is the authorization server and the resource server. The Consumer is the client. A Provider MUST support at least one of the two grants below and MUST advertise its choice in grant_types_supported. Supporting both is RECOMMENDED.

6.1 Authorization Code + PKCE (interactive)

Section titled “6.1 Authorization Code + PKCE (interactive)”

This is the RECOMMENDED primary grant. An administrator authorizes the connection through a browser redirect; no API key is ever handled by the Consumer.

  • The Consumer MUST use PKCE (RFC 7636) with the S256 code-challenge method.
  • The Provider’s authorization_endpoint MUST authenticate the administrator and obtain explicit consent for the requested scopes.
  • On success the Provider redirects back with an authorization code, which the Consumer exchanges at the token_endpoint for an access_token and a refresh_token.

To avoid an administrator hand-copying client credentials between two consoles, a Provider SHOULD support Dynamic Client Registration (RFC 7591) at registration_endpoint. The Consumer self-registers (submitting its redirect URIs and requested scopes) and receives a client_id (and, for confidential clients, a client_secret). When registration is not supported, an administrator provisions a client manually in the SMS and uses the Client Credentials grant (§6.2).

Dynamic registration MUST NOT be open and unauthenticated. Registering a client grants no data access on its own — data access requires authorization (administrator consent for Authorization Code, or Provider-granted scopes for Client Credentials) — but open registration still enables client-table abuse and, more seriously, client impersonation (a hostile client registering under a trusted name to phish an administrator’s consent). A Provider’s registration_endpoint MUST therefore require at least one of:

  • a software statement (RFC 7591 §2.3) — a JWT signed by the SDI steward asserting the Consumer’s verified identity, which the Provider MUST validate against the steward’s published keys; and/or
  • an initial access token (RFC 7591 §3) the Provider issues out-of-band, typically minted inside an authenticated administrator action in the SMS (“connect an admissions system”).

The Provider MUST present the verified client identity on the consent screen, SHOULD rate-limit registration, and SHOULD expire clients that never complete an authorization.

6.2 Client Credentials (machine-to-machine)

Section titled “6.2 Client Credentials (machine-to-machine)”

This grant supports headless, server-to-server connections. An administrator provisions a client in the SMS and pastes the client_id and client_secret into the Consumer. The Consumer requests tokens directly with grant_type=client_credentials. Because a secret is handled directly, a Consumer MUST store it encrypted at rest (§13) and a Provider SHOULD scope and rate-limit such clients tightly.

  • Access tokens MUST be short-lived (a expires_in of one hour or less is RECOMMENDED).
  • When a refresh_token is issued, the Provider SHOULD apply refresh-token rotation and MUST treat reuse of a rotated (consumed) refresh token as a compromise: it MUST revoke the token family and require re-authorization.
  • On a 401, a Consumer refreshes and retries. If refresh fails with invalid_grant (refresh token expired or revoked), the connection enters a needs-reauthorization state: the Consumer MUST pause syncs and prompt an administrator to re-consent (Authorization Code) or supply new credentials (Client Credentials). Client Credentials connections simply request a new token and have no refresh token.
sequenceDiagram
    autonumber
    participant C as Consumer
    participant P as Provider

    C->>P: POST ingest_endpoint (access_token)
    P-->>C: 401 invalid_token (expired)
    alt has refresh_token
        C->>P: POST token_endpoint (grant_type=refresh_token, rotated)
        alt refresh ok
            P-->>C: new access_token (+ new refresh_token)
            C->>P: retry POST ingest_endpoint
            P-->>C: 200 OK
        else refresh rejected
            P-->>C: 400 invalid_grant
            C->>C: mark connection needs-reauthorization, pause syncs
            C->>C: notify administrator to re-consent
        end
    else client_credentials
        C->>P: POST token_endpoint (grant_type=client_credentials)
        P-->>C: new access_token
        C->>P: retry POST ingest_endpoint
    end

A Provider MUST define least-privilege scopes per capability and advertise them in scopes_supported and on each capability descriptor. The following scope identifiers are RESERVED by this standard:

Scope Grants
sdi.enrollment.write Submit records to the enrollment-sync ingest endpoint.
sdi.enrollment.read Read enrollment schema and prior submission results.

A Provider MUST NOT grant a token broader scope than the administrator consented to. Document resolution is authenticated by the Provider’s signed assertion rather than a scoped token (§10).


SDI is organized around capabilities. A capability is a named, independently versioned unit of functionality with its own schema, ingest, and (optionally) document endpoints, advertised in the discovery document (§5.2). This keeps the core — discovery, OAuth, transport conventions, document handling, versioning — generic, so new capabilities can be added without changing the protocol core.

flowchart TB
    subgraph Core["SDI core (generic)"]
      D["Discovery"]
      O["OAuth 2.0"]
      T["Transport + errors + idempotency"]
      DOC["Document handling"]
      V["Versioning"]
    end
    subgraph Caps["Capabilities"]
      E["enrollment-sync (v1)"]
      R["applicant-update (future)"]
      G["document-issuance (future)"]
      ATT["cohort-backfill (future)"]
    end
    Core --> E
    Core --> R
    Core --> G
    Core --> ATT

This version of the standard defines exactly one capability: enrollment-sync (§8). Future capabilities are out of scope here (§14).


enrollment-sync transfers an admitted/accepting applicant into the SMS as a student record. It defines two Provider endpoints: a field-schema endpoint and an ingest endpoint. Any documents a record references are resolved by the Provider calling back to the Consumer (§10).

GET {schema_endpoint}
Authorization: Bearer {token with sdi.enrollment.read}
Accept-Language: sw-KE, en;q=0.8

The Provider MUST return a JSON Schema (Draft 2020-12) describing the fields it expects in a record. This is the contract a Consumer maps its source data onto, and the data a Consumer’s field-mapping experience is built from. The schema:

  • MUST declare each expected field with a stable property name, a type, and whether it is required. Property names are locale-invariant (§4.1).

  • SHOULD annotate fields with a human-readable title and description for display in mapping UIs.

  • MUST localize title/description per the request’s Accept-Language when it supports more than one language, setting Content-Language on the response and falling back to default_language (§4.1).

  • MAY constrain values (enum, format, pattern, maxLength) — a Consumer SHOULD surface these constraints to administrators during mapping.

  • SHOULD, where an enumerated field needs human-readable option labels, use the JSON Schema oneOf + const/title pattern so the stable value (const) stays locale-invariant while the label (title) localizes with the rest of the schema:

    "sex": {
    "type": "string",
    "title": "Sex",
    "oneOf": [
    { "const": "male", "title": "Male" },
    { "const": "female", "title": "Female" }
    ]
    }
  • SHOULD use the SDI canonical field vocabulary (§9.3) for common attributes so that mappings are predictable across Providers, while remaining free to declare Provider-specific fields.

The response MUST be tagged with its contract_version (header SDI-Contract-Version and/or a schema $id that encodes the version). A Provider MUST keep a previously advertised schema version resolvable for the deprecation window (§11).

POST {ingest_endpoint}
Authorization: Bearer {token with sdi.enrollment.write}
Idempotency-Key: {key}
SDI-Contract-Version: {version}
Content-Type: application/json

The body is an envelope (§9.1) carrying one or more records. The Provider validates each record against the advertised schema and creates or updates the corresponding student record. The Provider MUST return a per-record result (§9.2): each record is independently created, updated, or rejected, so a partial batch can succeed. The Provider MUST NOT fail an entire batch because one record is invalid.

Idempotency is keyed on Idempotency-Key and each record’s recordRef (§9.1): re-submitting the same record under the same key MUST NOT create a duplicate.


{
"sdi_version": "1.0",
"capability": "enrollment-sync",
"contract_version": "1.0",
"records": [
{
"recordRef": "consumer-side-stable-id",
"data": { "...": "fields conforming to the Provider's schema (§8.1)" },
"documents": [ "...document descriptors (§10)" ]
}
]
}
  • recordRef (REQUIRED) is a Consumer-stable identifier for the applicant/record. The Provider MUST treat it as the correlation and idempotency key on the record level, and SHOULD return it in results and persist it for future updates.
  • data (REQUIRED) holds the mapped fields and MUST validate against the Provider’s advertised schema for contract_version.
  • documents (OPTIONAL) is an array of document descriptors (§10).

The ingest response MUST return one result per submitted record:

{
"results": [
{
"recordRef": "consumer-side-stable-id",
"outcome": "created",
"externalId": "sms-student-id-123",
"problems": []
},
{
"recordRef": "another-id",
"outcome": "rejected",
"problems": [
{ "pointer": "/data/dateOfBirth", "code": "invalid_format", "detail": "expected YYYY-MM-DD" }
]
}
]
}
  • outcome is one of created, updated, rejected, or skipped (idempotent no-op).
  • externalId (REQUIRED for created/updated) is the Provider’s identifier for the student record, which a Consumer SHOULD store to support later updates.
  • problems uses field pointers (RFC 6901 JSON Pointers into the submitted record) so a Consumer can map failures back to specific fields.

To make mappings predictable across Providers, SDI defines a RECOMMENDED vocabulary of field identifiers for common attributes. A Provider’s schema SHOULD use these identifiers where the concept applies, and MAY add Provider-specific fields freely. The vocabulary is grouped:

  • Identity / profile — givenName, middleName, familyName, fullName, preferredName, email, phone, dateOfBirth, sex, nationality, address, nationalId, localIds (array of { scheme, value } for jurisdiction- or examination-body identifiers).
  • Admission context — applicationRef, decision, programme, level, intakeTerm, intakeYear, campus, decisionDate.
  • Documents — carried in the envelope’s documents array, not as inline data fields (§10).
  • Extensions — a Provider’s schema MAY define an extensions object property for arbitrary Consumer passthrough data that has no canonical field.

The vocabulary is intentionally minimal and locale-neutral, and accommodates jurisdiction-specific identifiers (e.g. national examination references) through localIds rather than fixed fields. Field values are not constrained to any regional code set by this standard.

scheme names which identifier system a value belongs to, so a Provider can interpret and store it correctly. It is a stable, namespaced, lowercase string. The RECOMMENDED convention is country.authority (ISO 3166-1 alpha-2 country code, then the issuing authority), with a small set of country-agnostic identifiers permitted:

scheme Identifier
ng.jamb Nigeria — JAMB registration number
ng.waec Nigeria — WAEC examination number
ke.knec Kenya — KNEC index number
ng.nin Nigeria — National Identification Number
za.id South Africa — national ID number
passport Passport number (country-agnostic)

The standard’s steward maintains a registry of well-known schemes; a Provider MUST pass through and preserve an unrecognised scheme rather than reject it. Example: { "scheme": "ng.jamb", "value": "12345678AB" }.


Admission records frequently reference files — transcripts, identity documents, photographs, issued offer/acceptance letters. SDI transfers documents by reference, never by embedding bytes. A record carries lightweight descriptors; the documents live in the Consumer, and the Provider fetches the bytes on demand by calling back to the Consumer.

During ingest the Consumer is the client; for documents the Provider is the caller and authenticates itself to the Consumer (§10.3). The resolve endpoint is the Consumer’s, conveyed per-document via the descriptor’s resolveUrl.

{
"id": "opaque-document-id",
"type": "transcript",
"resolveUrl": "https://admissions.example/sdi/documents/resolve",
"filename": "transcript.pdf",
"contentType": "application/pdf",
"size": 482113,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
  • id (REQUIRED) is the handle the Provider presents back to resolve the document.
  • resolveUrl (REQUIRED) is the Consumer’s resolution endpoint for this document. The descriptor carries no working file URL and no long-lived token.
  • type (REQUIRED) is a Consumer-declared document class (e.g. transcript, identity, photo, offer_letter); Providers SHOULD preserve it but MUST NOT require a fixed vocabulary.
  • sha256 (REQUIRED) lets the Provider verify integrity after download.
  • contentType, filename, size are REQUIRED metadata.

To obtain bytes, the Provider POSTs the document id to its resolveUrl, authenticating with a signed JWT assertion (§10.3):

POST {resolveUrl}
Authorization: Bearer {provider-signed JWT assertion}
Content-Type: application/json
{ "id": "opaque-document-id" }

The Consumer verifies the assertion, maps it to the connection, and responds with a short-lived, single-purpose signed URL:

{ "url": "https://files.example/...signed...", "expiresAt": "2026-06-29T12:34:56Z", "sha256": "9f86..." }

The Provider then downloads the bytes from url and MUST verify the sha256 before storing them.

sequenceDiagram
    autonumber
    participant P as Provider (SMS)
    participant C as Consumer
    participant S as Object storage

    Note over P: record carried document descriptors with id and resolveUrl
    P->>C: POST resolveUrl with id and signed JWT assertion
    C->>C: verify JWT against Provider jwks_uri from discovery, map to connection
    C->>C: authorize, verify scan-clean and retention
    C-->>P: returns short-lived signed url, expiresAt, sha256
    P->>S: GET the signed url
    S-->>P: bytes
    P->>P: verify sha256, then store

Because the Provider is the caller here, it MUST authenticate itself to the Consumer. The normative mechanism is a signed JWT assertion (the private_key_jwt pattern, RFC 7523):

  • The Provider signs a short-lived JWT (iss = its issuer, aud = the resolveUrl, plus jti, iat, exp) with the key published at its jwks_uri and sends it as a bearer token.
  • The Consumer MUST verify the signature against the Provider’s jwks_uri — which it already knows from discovery for this connection — MUST verify that aud matches its resolve endpoint, and MUST map the verified iss to the originating connection. It MUST reject expired assertions and SHOULD reject replayed jti values.

The aud claim binds the assertion to the resolve endpoint, so it cannot be replayed against any other endpoint. Identity comes from the verified iss; authorization is enforced separately, so a valid assertion only ever yields documents belonging to its own connection (§10.4). This keeps every secret out of the payload and reuses the keys the Provider already maintains as an OAuth authorization server.

  • The signed URL MUST be short-lived and SHOULD be single-use. It MUST NOT be embedded in the ingest payload — it is minted only in response to an authenticated resolution call, so it never sits in stored records or logs.
  • A Consumer MUST NOT expose a document that has not passed malware scanning; it MAY return 409 with a problem document while a scan is pending.
  • After the Consumer’s retention window, resolution MUST return 410 Gone.
  • A document id is bound to the connection that produced it; the Consumer MUST reject a resolution whose verified caller does not match that connection.

SDI versions at two levels:

  • Protocol version (sdi_version, protocol_versions in discovery) — MAJOR.MINOR. A Consumer and Provider negotiate the highest mutually supported protocol version from the discovery document.
  • Capability contract version (contract_versions per capability) — semver per capability schema. The contract version in use is carried on the schema response and on every ingest request via the SDI-Contract-Version header (and MAY also be negotiated via the Accept media type, e.g. application/vnd.sdi.enrollment-sync.v1+json).

Rules:

  • A Provider MUST support every version it advertises and MUST keep a deprecated version resolvable for a deprecation window of at least 180 days after announcing its removal.
  • Within a MAJOR/MAJOR.MINOR line, changes MUST be backward compatible (additive fields, relaxed constraints). Breaking changes MUST increment the major version and be advertised as a new entry, never by mutating an existing one.
  • A Consumer MUST select a version both sides advertise and MUST NOT assume an unadvertised version exists.

SDI is a push interface; reliability is a shared responsibility with a clear division:

  • Idempotency (Provider). A Provider MUST deduplicate on Idempotency-Key + recordRef and return the original result for repeats within a retention window of at least 24 hours (SHOULD be 7 days).
  • Partial success (Provider). A batch MUST report per-record outcomes; valid records are persisted even when others are rejected.
  • Retries (Consumer). A Consumer MUST retry transient failures (429, 5xx, network) with exponential backoff and MUST reuse the same Idempotency-Key across retries of the same batch.
  • Ordering. SDI does not guarantee ordering across batches. A Provider MUST treat a record update for an existing recordRef as authoritative regardless of arrival order relative to unrelated records.
  • Triggering and queueing (when to send, how to queue) are Consumer concerns and out of scope.

  • Transport. TLS for every endpoint (§4). Tokens MUST travel only in the Authorization header, never in URLs.
  • Least privilege. Tokens are scoped per capability (§6.4); a Provider MUST reject calls whose token lacks the required scope with 403.
  • Token hygiene. Short-lived access tokens; refresh-token rotation with reuse detection (§6.3). A Consumer MUST store client secrets and refresh tokens encrypted at rest.
  • Replay protection. State-changing requests are idempotent (§4, §12); a Provider SHOULD reject stale or replayed requests outside the idempotency window.
  • PII minimization. A Consumer SHOULD transmit only fields mapped to the Provider’s schema and MUST NOT rely on the Provider to discard over-shared data.
  • Documents. Signed URLs are short-lived, single-purpose, scan-gated, scope-checked, and connection- isolated (§10.3); integrity is verified via sha256.
  • Auditability. A Provider SHOULD record the authenticated client, scope, and outcome of every ingest and document-resolution call.

New functionality is added as new capabilities (§7), not by changing the core. A future capability declares its own schema and ingest endpoints in the discovery document and is versioned independently.

SDI’s direction is admissions → SMS: the Consumer pushes, the Provider receives. Future capabilities preserve that direction. Examples reserved for future work — not defined here — include applicant-update (pushing post-handoff lifecycle changes — a withdrawal, deferral, or corrected detail — so the SMS record stays current), document-issuance (pushing issued admission documents such as offer or acceptance letters to the SMS), and cohort-backfill (a bulk historical push of an already-admitted cohort). A future export profile (a Consumer emitting to other downstream systems) is likewise an extension point and is out of scope for this version.

Because capabilities are discovered, a Consumer that predates a capability simply ignores it, and a Provider that implements only enrollment-sync remains fully conformant.


A Provider is conformant at the enrollment-sync level when it:

  1. serves a valid discovery document (§5),
  2. implements at least one OAuth 2.0 grant (§6) with the reserved scopes it advertises,
  3. serves the enrollment-sync schema and ingest endpoints with per-record results and idempotency (§8, §9, §12), and
  4. resolves documents as a caller per §10 — fetching bytes from the Consumer with a signed-JWT assertion — if any accepted record may carry documents.

A Provider MUST pass the published conformance test suite for each level it claims.

“SDI Certified” is a stewardship programme: a Provider that passes the conformance suite may be listed in the public SDI Provider directory and use the certification mark under the programme’s terms. The standard itself is open and free to implement; certification and the marks are administered by the standard’s steward. Certification is OPTIONAL for conformance and MUST NOT be a precondition for two systems interoperating.


16. Appendix A — example discovery document

Section titled “16. Appendix A — example discovery document”
{
"sdi_version": "1.0",
"protocol_versions": ["1.0"],
"issuer": "https://sms.example.edu",
"authorization_endpoint": "https://sms.example.edu/oauth/authorize",
"token_endpoint": "https://sms.example.edu/oauth/token",
"registration_endpoint": "https://sms.example.edu/oauth/register",
"jwks_uri": "https://sms.example.edu/oauth/jwks",
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["sdi.enrollment.write", "sdi.enrollment.read"],
"languages_supported": ["en-GB", "fr-FR", "sw-KE"],
"default_language": "en-GB",
"capabilities": [
{
"name": "enrollment-sync",
"contract_versions": ["1.0"],
"schema_endpoint": "https://sms.example.edu/sdi/enrollment-sync/schema",
"ingest_endpoint": "https://sms.example.edu/sdi/enrollment-sync/records",
"scopes": ["sdi.enrollment.write", "sdi.enrollment.read"]
}
]
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sdi.admissio.app/schemas/1.0/envelope.json",
"type": "object",
"required": ["sdi_version", "capability", "contract_version", "records"],
"properties": {
"sdi_version": { "type": "string" },
"capability": { "type": "string" },
"contract_version": { "type": "string" },
"records": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["recordRef", "data"],
"properties": {
"recordRef": { "type": "string", "minLength": 1 },
"data": { "type": "object" },
"documents": {
"type": "array",
"items": { "$ref": "https://sdi.admissio.app/schemas/1.0/document-descriptor.json" }
}
}
}
}
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sdi.admissio.app/schemas/1.0/document-descriptor.json",
"type": "object",
"required": ["id", "type", "resolveUrl", "filename", "contentType", "size", "sha256"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"type": { "type": "string", "minLength": 1 },
"resolveUrl": { "type": "string", "format": "uri" },
"filename": { "type": "string" },
"contentType": { "type": "string" },
"size": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
}

17.3 Example Provider field schema (illustrative, not normative)

Section titled “17.3 Example Provider field schema (illustrative, not normative)”

A Provider’s schema_endpoint returns something like the following — its own declared fields, using the SDI canonical vocabulary where applicable:

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sms.example.edu/sdi/enrollment-sync/schema/1.0",
"type": "object",
"required": ["givenName", "familyName", "email", "intakeYear"],
"properties": {
"givenName": { "type": "string", "title": "First name" },
"familyName": { "type": "string", "title": "Last name" },
"email": { "type": "string", "format": "email", "title": "Email" },
"phone": { "type": "string", "title": "Phone" },
"dateOfBirth": { "type": "string", "format": "date", "title": "Date of birth" },
"sex": {
"type": "string",
"title": "Sex",
"oneOf": [
{ "const": "male", "title": "Male" },
{ "const": "female", "title": "Female" }
]
},
"programme": { "type": "string", "title": "Programme" },
"intakeYear": { "type": "integer", "title": "Intake year" },
"localIds": {
"type": "array",
"items": {
"type": "object",
"required": ["scheme", "value"],
"properties": { "scheme": { "type": "string" }, "value": { "type": "string" } }
}
},
"extensions": { "type": "object" }
}
}