DCI Protocol Details
Contents
DCI Protocol Details#
For: developers
The wire-level details of the DCI protocol as implemented in OpenSPP — message envelope, HTTP Signature format, endpoint paths, query types, and data schemas.
Prerequisites#
Familiarity with OAuth 2.0 and Bearer token authentication
Basic understanding of HTTP Message Signatures (draft-cavage / RFC 9421)
Familiarity with Pydantic-style JSON schemas
Message envelope#
Every DCI message uses a three-part envelope — a signature header string, a message header object, and a message body object. The schema is defined in spp_dci/schemas/envelope.py.
{
"signature": "namespace=\"dci\", kidId=\"openspp|key1|ed25519\", algorithm=\"ed25519\", created=\"1705315800\", expires=\"1705316100\", headers=\"(created) (expires) digest\", signature=\"<base64>\"",
"header": {
"version": "1.0.0",
"message_id": "uuid",
"message_ts": "2026-04-22T10:30:00Z",
"action": "search",
"sender_id": "external.mis.gov",
"sender_uri": "https://external.mis.gov/dci/callback",
"receiver_id": "openspp",
"total_count": 1,
"is_msg_encrypted": false
},
"message": {
"transaction_id": "uuid",
"search_request": [...]
}
}
Header fields#
Field |
Type |
Notes |
|---|---|---|
|
string |
DCI version, default |
|
string |
UUID for this message |
|
datetime |
ISO-8601 timestamp |
|
string |
|
|
string |
Sender's DCI identifier |
|
string or null |
Callback URL — required for async operations |
|
string |
Receiver's DCI identifier |
|
integer |
Number of items in the message body (default 0) |
|
bool |
Envelope-level encryption flag (default false) |
Callback responses additionally include status (rcvd, pdng, succ, rjct), status_reason_code, status_reason_message, and completed_count. See the DCICallbackHeader model.
HTTP Signature#
The signature field is a key=value parameter string following the draft-cavage HTTP Message Signatures style. OpenSPP's implementation is in spp_dci/services/signing.py.
Signature format#
namespace="dci",
kidId="{sender_id}|{key_id}|{algorithm}",
algorithm="{algorithm}",
created="{unix_ts}",
expires="{unix_ts + 300}",
headers="(created) (expires) digest",
signature="{base64_signature}"
namespaceis always"dci"kidIdencodes the sender, key identifier, and algorithm, separated by|algorithmised25519(recommended) orrs256expiresiscreated + 300— the signature is valid for 5 minutesheaderslists which virtual headers are signed, always(created) (expires) digestin the current implementationsignatureis the base64-encoded cryptographic signature
Note: the signature field in the envelope stores the raw parameter string starting with namespace="dci", .... There is no Signature: HTTP-header prefix — that prefix only applies if you transmit the signature as an HTTP header rather than inside the envelope.
Signing string#
The receiver verifies the signature against a signing string built from three lines:
(created): 1705315800
(expires): 1705316100
digest: aGVsbG8gd29ybGQ=
Each line is
{label}: {value}with a literal space after the colonLines are joined by
\nThe
digestvalue is a base64-encoded SHA-256 of the canonical JSON serialization of{header, message}
Digest computation#
The digest is computed over the canonical JSON of an object containing the header and message (not the envelope as a whole — the signature is computed before it's added):
content = {"header": header, "message": message}
canonical_json = json.dumps(content, sort_keys=True, separators=(",", ":"))
digest = base64.b64encode(hashlib.sha256(canonical_json.encode()).digest()).decode()
Both sort_keys=True and the compact separators=(",", ":") are essential — any whitespace difference between sender and receiver produces different digests and verification fails.
Clock skew#
The server allows up to 60 seconds of clock skew between created/expires and its own clock. Systems with drift beyond that will see err.signature.expired or err.signature.not_yet_valid style failures.
Authentication#
The DCI server requires two parallel authentication mechanisms on every protected request:
1. Bearer token (allowlist)#
Authorization: Bearer <token>
The token is validated against the dci.api_tokens Odoo system parameter — a comma-separated list of accepted tokens. This is a pre-shared-secret scheme, not OAuth 2.0. The DCI server does not issue tokens dynamically; administrators rotate tokens by editing the system parameter.
Two development-mode bypass flags exist:
System parameter |
Default |
Effect |
|---|---|---|
|
|
If |
|
|
If |
Both must be false in production.
2. HTTP Signature (sender identity + message integrity)#
Beyond bearer validation, every request body must contain a valid DCI envelope whose signature can be verified against the sender's registered public key. The server:
Parses the envelope and extracts
sender_idfrom the headerLooks up that sender in
spp.dci.sender.registryUses the registered
public_key(or fetches the sender's JWKS viajwks_url)Verifies the signature against the signing string built from
(created),(expires), and the computed digest
A request that passes bearer validation but fails signature verification returns 401 Unauthorized with a status reason code like err.signature.invalid or err.signature.expired.
Base URL and endpoint paths#
The DCI server mounts under FastAPI root path /dci_api/v1 (configured in spp_dci_server/data/fastapi_endpoint_data.xml). Full URLs have the form:
https://<host>/dci_api/v1/<path>
Endpoints#
Method |
Path |
Purpose |
|---|---|---|
POST |
|
Synchronous search — returns results immediately |
POST |
|
Asynchronous search — returns |
POST |
|
Subscribe to registry events |
POST |
|
Cancel subscriptions by code |
POST |
|
Poll transaction status for an async operation |
POST |
|
Send a receipt for a prior operation (e.g., after processing a notification) |
GET |
|
Public signing keys (no auth) |
All POST endpoints require Bearer + HTTP Signature auth. The GET JWKS endpoint is public.
Query types#
Search requests carry a search_criteria.query_type that selects how the query payload is interpreted. The schema is defined in spp_dci/schemas/search.py and the enum values are in spp_dci/schemas/constants.py.
idtype-value#
Simple identifier lookup:
{
"query_type": "idtype-value",
"query": {
"type": "UIN",
"value": "12345678"
}
}
Valid identifier types are defined by IdentifierType enum: UIN, BRN, MRN, DRN. Deployments may use their own values if both sides agree.
expression#
Conditional query with AND/OR composition:
{
"query_type": "expression",
"query": {
"seq": [
{"attribute": "birth_date", "operator": ">=", "value": "1990-01-01"},
{"attribute": "sex", "operator": "=", "value": "female"}
]
}
}
seq— list of conditions combined with ANDor_— list of expressions combined with ORConditions can nest (an
or_inside aseq, etc.)
Supported operators: =, >, <, >=, <=, in, contains.
predicate#
CEL-style predicate expression:
{
"query_type": "predicate",
"query": "person.age >= 18 && person.has_disability == true"
}
Registry type values#
The reg_type field in search/subscribe criteria uses namespaced URIs, defined in spp_dci/schemas/constants.py::RegistryType:
Enum name |
Wire value |
|---|---|
|
|
|
|
|
|
|
|
|
|
When building requests, use the .value of the enum (the namespaced URI), not the enum name.
Event type values#
reg_event_type is defined by RegistryEventType:
REGISTRATION, UPDATE, DELETE, BIRTH, DEATH, MARRIAGE, DIVORCE, ENROLLMENT, DISENROLLMENT, BENEFIT_DISBURSEMENT.
Status values#
Responses carry a status field in the envelope header (for callbacks) or in each search response item. Values are in RequestStatus:
Value |
Meaning |
|---|---|
|
Request received and accepted for processing |
|
Processing in progress |
|
Completed successfully |
|
Rejected — see status reason |
Status reason codes#
When a request or item is rejected, the response includes a dotted-lowercase reason code. These come from enums in spp_dci/schemas/constants.py.
Message-level errors (err.*)#
Typically issued by auth middleware before the router runs:
Code |
Meaning |
|---|---|
|
Authorization header absent |
|
Authorization header not |
|
Envelope has no signature |
|
Signature verification failed |
|
Signature |
Search-level errors (rjct.*)#
Issued by the search service when it rejects a request:
Code |
Meaning |
|---|---|
|
Reference ID malformed or missing |
|
Criteria failed validation |
|
Filter predicate malformed |
|
Sort spec malformed |
|
Pagination fields out of range |
|
|
|
Too many matches — narrow the query |
|
Message ID replayed |
|
Unknown or unsupported |
|
More than the allowed number of items in the envelope |
Similar rjct.* enums exist for SubscribeStatusReasonCode and UnsubscribeStatusReasonCode.
Data schemas#
Person#
The Person schema (spp_dci/schemas/person.py) carries identity, demographic, contact, and (optionally) disability fields:
Field |
Type |
Notes |
|---|---|---|
|
list of |
One or more external IDs |
|
|
Given, surname, second, maiden, prefix, suffix |
|
|
|
|
date |
|
|
date |
|
|
list of |
|
|
list of string |
|
|
list of string |
|
|
datetime |
|
|
datetime |
Identifier has identifier_type (e.g., UIN) and identifier_value.
Group#
Group schema (spp_dci/schemas/group.py):
Field |
Type |
Notes |
|---|---|---|
|
list of |
|
|
|
|
|
list of |
|
|
float |
|
|
|
|
|
int |
|
|
list of |
|
|
list of |
Key-value extensions |
|
datetime |
|
|
datetime |
Disability info#
Disability data (DisabilityInfo) uses the Washington Group short-set model:
disability_limitation_type—VISION,HEARING,MOBILITY,COGNITION,SELF_CARE,COMMUNICATIONfunctional_severity— integer 1-4 where 1=no difficulty, 4=cannot do at all
Common types#
Type |
Fields |
|---|---|
|
|
|
|
|
|
|
|
Field mappings (DCI ↔ res.partner)#
When mapping DCI Person objects to/from OpenSPP's res.partner:
DCI field |
res.partner field |
Notes |
|---|---|---|
|
|
Lookup via |
|
|
Direct mapping |
|
|
Direct |
|
|
Direct |
|
(not mapped) |
|
|
|
Vocabulary lookup |
|
|
Direct |
|
partner address fields |
Pick primary; see |
|
|
One-to-many |
|
|
First email only |
The reg_ids field is a One2many from res.partner to spp.registry.id (spp_registry/models/registrant.py).
JWKS endpoint#
GET /dci_api/v1/.well-known/jwks.json publishes the public keys of every active spp.dci.signing.key record. Example response:
{
"keys": [
{
"kty": "OKP",
"kid": "openspp|key1|ed25519",
"use": "sig",
"alg": "EdDSA",
"crv": "Ed25519",
"x": "<base64url>"
}
]
}
The kid format is {sender_id}|{key_id}|{algorithm}. External registries that verify OpenSPP's signatures fetch this endpoint to discover the current set of keys.
Common mistakes#
Wrong digest format. The signing string uses digest: <base64> — no SHA-256= prefix, no other algorithm labels. Getting this wrong silently breaks verification.
Whitespace in JSON before hashing. The digest must be computed over json.dumps(..., sort_keys=True, separators=(",", ":")). Any pretty-printing or default whitespace changes the bytes and breaks the digest match.
Signature expires in 5 minutes. Don't cache signed envelopes; sign fresh on every request. If your client builds and serializes slowly, your requests can age out before they land.
Bearer token and HTTP Signature are both required. Passing the bearer token alone returns 401. Both mechanisms must succeed.
Using the wrong registry type string. Use "ns:org:RegistryType:Social" on the wire, not "SOCIAL_REGISTRY". The enum name differs from the value.
Confusing err.* and rjct.* codes. err.* codes come from the auth middleware (before the router executes). rjct.* codes come from the search/subscribe business logic. The two namespaces don't overlap.
Not swapping sender_id and receiver_id on responses. A response's sender_id is the server's ID; receiver_id is the original caller. Copying the request header verbatim is wrong.
Missing message_id uniqueness. Every message needs a fresh UUID for message_id. Replaying an ID triggers rjct.message_id.duplicate.
See also#
DCI Overview — DCI architecture and interaction patterns
OpenSPP as DCI Server — exposing OpenSPP as a DCI server
OpenSPP as DCI Client — querying external DCI registries from OpenSPP
DCI API Standards — upstream DCI specifications
HTTP Message Signatures — RFC 9421
openspp.org