Document control
- Title
- Payload Encryption — The NPSC envelope
- Version
- 1.0
- Status
- Issued
- Date of issue
- 12 October 2026
- Owner
- NPS Central Engineering
- Classification
- Confidential — for registered partners
1About this document
This document is for engineers whose partner account is set up to encrypt API payloads. It describes the NPSC envelope, a JSON wrapper that carries an encrypted request or response body, and the steps to seal a request and open a response.
The envelope needs only standard cryptography: HMAC-SHA256, SHA-256 and AES-256-GCM, all available in the Java and Node.js standard libraries. Apart from the body being sealed, nothing about the API changes: the endpoints, tokens and JSON bodies are those described in the Authentication document for your method, Account Information and Modifications.
1.1Notation
In field tables, the presence column shows M for mandatory, O for optional, and C for conditional. The concatenation symbol means byte concatenation. Hex is lowercase. Base64 is standard base64 with padding, not the URL-safe variant. All text is UTF-8.
2Before you begin
| Item | Notes |
|---|---|
| Encryption key | A 256-bit key, delivered once, out of band, as base64url text. Decode it to its 32 bytes before use. Store it in a secrets manager or HSM. |
| Key ID | Issued with the key and sent in every envelope as keyId. Use it exactly as issued. |
| Access token | Your partner token or a subscriber session token. Each message's key is bound to the token the call carries. |
| Clock | Keep your servers on NTP. An envelope more than 5 minutes from NPS Central's clock is refused. |
UAT and Production issue separate keys. A key from one environment is refused in the other.
3How it works
Every message, in either direction, is encrypted under its own key. The sender picks a fresh random number, derives the message key from the issued key, that random number and the call's access token, and encrypts the JSON body with AES-256-GCM. The additional authenticated data (AAD) names the direction, HTTP method, path, key ID, random number and timestamp, so a body sealed for one call cannot be replayed to another endpoint or session, or presented as a response.
4The envelope
Requests and responses use the same structure, with the content type application/vnd.npsc.envelope+json.
{
"version": "NPSC1",
"keyId": "test-key-0001",
"randomNo": "00112233445566778899aabbccddeeff",
"timestamp": 1760261400,
"encData": "oKGio6SlpqeoqaqrW6Zcn0RWG8LiZ5MNtPHvfg7HaiEQDE2Ubp2J9D2PUh3tsQFBXxIJadzXHAwQPRiS7A=="
}Fields
versionstringRequiredAlways NPSC1.
keyIdstringRequiredThe key ID of the key the message was sealed under.
randomNostringRequired16 random bytes as 32 lowercase hex characters, from a secure random generator. New for every message; a value already used is refused.
timestampintegerRequiredWhen the message was sealed, in epoch seconds. Within 300 seconds of NPS Central's clock.
encDatastringRequiredbase64 of IV (12 bytes) + ciphertext + tag (16 bytes).
5Sealing a request
You need the issued key and its key ID, the bearer token you are about to send, the HTTP method, the request path and the JSON body.
- Derive the message key. K = HKDF-SHA256(ikm = issued key, salt = hex-decode(randomNo), info = "NPSC1" + 0x00 + SHA-256(token), length = 32). The token is the bearer token exactly as sent after Bearer in this call's Authorization header.
- Build the AAD. AAD = "NPSC1|REQ|" METHOD "|" path "|" keyId "|" randomNo "|" timestamp. METHOD is upper case. The path starts at /api and has no query string. The timestamp is a decimal number.
- Encrypt. Generate a random 12-byte IV and encrypt the UTF-8 JSON body with AES-256-GCM under K, the IV and the AAD, with a 16-byte tag. Then encData = base64(IV + ciphertext + tag).
- Send, with the headers below.
Request headers
AuthorizationheaderRequiredBearer <token>, the same token used in step 1.
Content-TypeheaderConditionalapplication/vnd.npsc.envelope+json, on a call with a body.
AcceptheaderRequiredapplication/vnd.npsc.envelope+json, on every call, with or without a body.
Idempotency-KeyheaderConditionalWherever the endpoint requires one.
A call without a body, such as a GET, has nothing to seal; send it with the Accept header and no Content-Type. The largest envelope accepted is 10 MB.
6Opening a response
- Read the HTTP status. It is not sealed.
- Check the content type. If it is application/vnd.npsc.envelope+json, continue. If it is application/json or application/problem+json, the body is plain and is read as usual.
- Pick the key named by the envelope's keyId, and check that timestamp is within a few minutes of your clock.
- Derive K as in step 1 of section 5, from the response's randomNo and the token your request carried.
- Build the AAD with RES in place of REQ, your request's method and path, and the response's keyId, randomNo and timestamp.
- Decrypt. The first 12 bytes of the base64-decoded encData are the IV and the last 16 the tag. If the tag does not verify, discard the response and treat the outcome of the call as unknown.
The plaintext is the JSON response documented for the endpoint; an error opens to the usual problem document.
7Errors
| HTTP | code | Cause | What to do |
|---|---|---|---|
| 400 | ENCRYPTION_REQUIRED | A plain JSON body was sent where your account requires encryption | Seal the body. |
| 400 | ENVELOPE_INVALID | A missing or malformed field, a reused randomNo, a stale timestamp, a keyId that is not live, a body that does not decrypt, or a decrypted body that is not JSON | Check the inputs, then send a newly sealed request. |
| 503 | ENVELOPE_UNAVAILABLE | NPS Central could not seal the response | Retry. For a change, use the same Idempotency-Key. |
Seal every retry afresh, with a new randomNo, IV and timestamp. Idempotency is judged on the decrypted body, so a retry with the same Idempotency-Key and the same JSON receives the original response.
8Test vectors
Check your implementation against these values before calling NPS Central. They use a fixed random number, IV and timestamp so that the output can be reproduced; in your integration all three are new for every message.
| Input | Value |
|---|---|
| Issued key (hex) | 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f |
| Access token | psub.example-token |
| Method, path | POST, /api/v1/contributions |
| keyId | test-key-0001 |
| randomNo | 00112233445566778899aabbccddeeff |
| timestamp | 1760261400 |
| IV (hex) | a0a1a2a3a4a5a6a7a8a9aaab |
| Body | {"clientReferenceId":"pop-ref-1"} |
| K (hex) | 17e1482ba0ef3637cefd455256cbd12dffe8799f12248678db840005040b5c5e |
| encData | oKGio6SlpqeoqaqrW6Zcn0RWG8LiZ5MNtPHvfg7HaiEQDE2Ubp2J9D2PUh3tsQFBXxIJadzXHAwQPRiS7A== |
| Input | Value |
|---|---|
| keyId | test-key-0001 |
| randomNo | ffeeddccbbaa99887766554433221100 |
| timestamp | 1760261401 |
| IV (hex) | b0b1b2b3b4b5b6b7b8b9babb |
| K (hex) | 57ee69a4c6bd54c347d90a65fd7fc988f84d488a44ed479043889bf911526de3 |
| encData | sLGys7S1tre4ubq7G0n8kLf8+L0h2Qt/LvLcdsiH0br6yq2SbvsIzxtt0Q== |
| Body | {"result":"ok"} (illustrative) |
Sealing the request inputs must give the request encData exactly, and opening the response encData must give exactly {"result":"ok"}.