diff --git a/docs/proxies.md b/docs/proxies.md
index 205b41ff7..85e18c918 100644
--- a/docs/proxies.md
+++ b/docs/proxies.md
@@ -421,6 +421,37 @@ flowchart
MRESP --> E
```
+### Get Message Responses
+
+This flow maps get message responses requests to the app-response service and maps the response to the public API format.
+
+Source: [proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml](https://github.com/NHSDigital/communications-manager-api/blob/release/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml)
+
+```mermaid
+flowchart
+ S[Start] --> Q1{Matches get message responses endpoint?}
+ Q1 --> |No| E[End]
+ Q1 --> |Yes| EV[Extract messageId from request
+
+ ExtractVariables.MessageResponses.Get.Request]
+ EV --> V[Validate messageId
+
+ JavaScript.MessageResponses.Get.Validate]
+ V --> Q2{Validation errors found?}
+ Q2 --> |Yes| 400[Raise 400 error
+
+ RaiseFault.4xxGeneric]
+ 400 --> E
+ Q2 --> |No| MR[Create app-response request
+
+ AssignMessage.MessageResponses.Get.Request]
+ MR --> SEND[Send request to app-response service]
+ SEND --> RESP[Convert response
+
+ AssignMessage.MessageResponses.Get.Response]
+ RESP --> E
+```
+
### Target Post Flow
This flow runs on all outgoing responses from the target.
diff --git a/proxies/live/apiproxy/proxies/default.xml b/proxies/live/apiproxy/proxies/default.xml
index 5f4c2dc74..4529c9e8a 100644
--- a/proxies/live/apiproxy/proxies/default.xml
+++ b/proxies/live/apiproxy/proxies/default.xml
@@ -15,6 +15,10 @@
secure
[% include './partials/Partial.Proxy.Routes.xml' %]
+
+ (proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET")
+ app-response-target
+
communications-manager-target
diff --git a/proxies/live/apiproxy/targets/app-response-target.xml b/proxies/live/apiproxy/targets/app-response-target.xml
new file mode 100644
index 000000000..22d756377
--- /dev/null
+++ b/proxies/live/apiproxy/targets/app-response-target.xml
@@ -0,0 +1,26 @@
+
+
+
+ [% include './partials/Partial.Target.PreFlowRequest.xml' %]
+
+
+
+ [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %]
+
+ [% include './partials/Partial.Target.PostFlow.xml' %]
+
+ [% include './partials/Partial.Target.FaultRules.xml' %]
+
+
+
+ true
+
+
+
+
+ {requestpath}
+
+ 29000
+
+
+
diff --git a/proxies/sandbox/apiproxy/targets/sandbox.xml b/proxies/sandbox/apiproxy/targets/sandbox.xml
index 4543da831..6a6b53eb9 100644
--- a/proxies/sandbox/apiproxy/targets/sandbox.xml
+++ b/proxies/sandbox/apiproxy/targets/sandbox.xml
@@ -9,6 +9,7 @@
[% include './partials/Partial.Flows.CreateMessageEndpoint.xml' %]
[% include './partials/Partial.Flows.GetMessageEndpoint.xml' %]
[% include './partials/Partial.Flows.GetNhsAppAccountsEndpoint.xml' %]
+ [% include './partials/Partial.Flows.GetMessageResponsesEndpoint.xml' %]
[% include './partials/Partial.Target.PostFlow.xml' %]
diff --git a/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml
new file mode 100644
index 000000000..42b9ef738
--- /dev/null
+++ b/proxies/shared/partials/Partial.Flows.GetMessageResponsesEndpoint.xml
@@ -0,0 +1,36 @@
+
+ Handle get message responses
+
+
+
+ RaiseFault.404NotFound
+ environment.name = "ref"
+
+
+ ExtractVariables.MessageResponses.Get.Request
+
+
+ JavaScript.MessageResponses.Get.Validate
+
+
+ RaiseFault.4xxGeneric
+ errors != null
+
+
+ AssignMessage.MessageResponses.Get.Request
+
+ {% if ENVIRONMENT_TYPE != 'sandbox' %}
+
+ AssignMessage.AuthenticationDetails
+
+ {% endif %}
+
+
+
+ AssignMessage.MessageResponses.Get.Response
+
+
+
+ (proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET")
+
+
diff --git a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml
new file mode 100644
index 000000000..3fbc72790
--- /dev/null
+++ b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Request.xml
@@ -0,0 +1,24 @@
+
+
+
+ AssignMessage.MessageResponses.Get.Request
+
+
+ true
+
+ target.copy.pathsuffix
+ false
+
+
+ requestpath
+ /api/response/{data.messageId}
+
+
+
+
+
+ GET
+
+
diff --git a/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml
new file mode 100644
index 000000000..36535d230
--- /dev/null
+++ b/proxies/shared/policies/AssignMessage.MessageResponses.Get.Response.xml
@@ -0,0 +1,10 @@
+
+
+
+ AssignMessage.MessageResponses.Get.Response
+
+
+ true
+
diff --git a/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml
new file mode 100644
index 000000000..787aceead
--- /dev/null
+++ b/proxies/shared/policies/ExtractVariables.MessageResponses.Get.Request.xml
@@ -0,0 +1,12 @@
+
+
+
+ data
+ request
+
+ /v1/message-responses/{messageId}
+
+ true
+
diff --git a/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml b/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml
new file mode 100644
index 000000000..878bd2fa5
--- /dev/null
+++ b/proxies/shared/policies/JavaScript.MessageResponses.Get.Validate.xml
@@ -0,0 +1,11 @@
+
+
+
+ JavaScript.MessageResponses.Get.Validate
+
+ jsc://helpers/validationErrors.js
+ jsc://helpers/validationChecks.js
+ jsc://MessageResponses.Get.Validate.js
+
diff --git a/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js
new file mode 100644
index 000000000..1271966a2
--- /dev/null
+++ b/proxies/shared/resources/jsc/MessageResponses.Get.Validate.js
@@ -0,0 +1,13 @@
+/* global context, validateUuidParameter */
+
+const errors = []
+
+validateUuidParameter(errors, context.getVariable("data.messageId"), "messageId");
+
+if (errors.length > 0) {
+ context.setVariable("generic_status_code", errors[0].status);
+ context.setVariable("errors", JSON.stringify(errors));
+} else {
+ context.setVariable("generic_status_code", null);
+ context.setVariable("errors", null);
+}
diff --git a/proxies/shared/resources/jsc/Routing.CheckValid.js b/proxies/shared/resources/jsc/Routing.CheckValid.js
index 1a7087403..e26fabf28 100644
--- a/proxies/shared/resources/jsc/Routing.CheckValid.js
+++ b/proxies/shared/resources/jsc/Routing.CheckValid.js
@@ -26,6 +26,10 @@ const validPaths = [
{
match: /^\/channels\/nhsapp\/accounts$/,
methods: ['get']
+ },
+ {
+ match: /^\/v1\/message-responses\/.*$/,
+ methods: ['get']
}
];
diff --git a/proxies/shared/resources/jsc/helpers/validationChecks.js b/proxies/shared/resources/jsc/helpers/validationChecks.js
index 303085aee..45bbaca75 100644
--- a/proxies/shared/resources/jsc/helpers/validationChecks.js
+++ b/proxies/shared/resources/jsc/helpers/validationChecks.js
@@ -57,6 +57,17 @@ const validateUuid = (errors, fieldValue, fieldPointer) => {
return true
}
+const validateUuidParameter = (errors, fieldValue, parameter) => {
+ if (typeof fieldValue !== "string" || !uuidRegex.test(fieldValue)) {
+ errors.push(invalidParameterError(
+ parameter,
+ "The messageId path parameter is not a valid UUID."
+ ));
+ return false
+ }
+ return true
+}
+
const validateConstantString = (errors, fieldValue, fieldPointer, requiredValue) => {
if (isUndefined(fieldValue)) {
errors.push(missingError(fieldPointer));
diff --git a/proxies/shared/resources/jsc/helpers/validationErrors.js b/proxies/shared/resources/jsc/helpers/validationErrors.js
index 8f5944600..f8610bae2 100644
--- a/proxies/shared/resources/jsc/helpers/validationErrors.js
+++ b/proxies/shared/resources/jsc/helpers/validationErrors.js
@@ -45,6 +45,22 @@ function invalidError(pointer) {
);
}
+function invalidParameterError(parameter, detail) {
+ return {
+ "id": messageId + "." + errors.length,
+ "code": "CM_INVALID_REQUEST",
+ "links": {
+ "about": "https://digital.nhs.uk/developer/api-catalogue/nhs-notify"
+ },
+ "status": "400",
+ "title": "Invalid Request",
+ "detail": detail,
+ "source": {
+ "parameter": parameter
+ }
+ };
+}
+
function duplicateError(pointer) {
return createErrorObject(
"CM_DUPLICATE_VALUE",
diff --git a/sandbox/__test__/message_responses.spec.js b/sandbox/__test__/message_responses.spec.js
new file mode 100644
index 000000000..680696ebb
--- /dev/null
+++ b/sandbox/__test__/message_responses.spec.js
@@ -0,0 +1,119 @@
+import request from "supertest"
+import * as uuid from 'uuid';
+import { setup } from './helpers.js'
+
+const VALID_MESSAGE_ID = '11111111-1111-4111-8111-111111111111';
+const NOT_FOUND_MESSAGE_ID = '00000000-0000-4000-8000-000000000404';
+const UUID_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8];
+
+describe('/api/v1/message-responses/:messageId', () => {
+ let env;
+ let server;
+
+ beforeEach(() => {
+ env = process.env;
+ server = setup();
+ });
+
+ afterEach(() => {
+ process.env = env;
+ server.close();
+ });
+
+ it('returns a X-Correlation-Id when provided', (done) => {
+ const correlationId = uuid.v4();
+ request(server)
+ .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`)
+ .set('X-Correlation-Id', correlationId)
+ .expect(200)
+ .expect('X-Correlation-Id', correlationId, done);
+ });
+
+ it('returns a service ban (403) when the user is banned', (done) => {
+ request(server)
+ .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`)
+ .set({ Authorization: 'banned' })
+ .expect(403, {
+ error: 'Forbidden'
+ })
+ .expect('Content-Type', /json/, done);
+ });
+
+ UUID_VERSIONS.forEach((version) => {
+ it(`returns a 200 for a valid UUID v${version} messageId`, (done) => {
+ const messageId = `11111111-1111-${version}111-8111-111111111111`;
+
+ request(server)
+ .get(`/api/v1/message-responses/${messageId}`)
+ .expect(200)
+ .expect('Content-Type', /json/, done);
+ });
+ });
+
+ it('returns a 404 when no responses are found', (done) => {
+ request(server)
+ .get(`/api/v1/message-responses/${NOT_FOUND_MESSAGE_ID}`)
+ .expect(404, {
+ errors: [
+ {
+ code: 'CM_NOT_FOUND',
+ status: '404',
+ title: 'Resource not found',
+ detail: 'The resource at the requested URI was not found.'
+ }
+ ]
+ })
+ .expect('Content-Type', /json/, done);
+ });
+
+ it('returns a 415 when the content type is not supported', (done) => {
+ request(server)
+ .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`)
+ .set('Content-Type', 'text/plain')
+ .expect(415, {
+ message: 'Unsupported media type.'
+ })
+ .expect('Content-Type', /json/, done);
+ });
+
+ it('returns a 429 when the request is rate limited', (done) => {
+ request(server)
+ .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`)
+ .set('Prefer', 'code=429')
+ .expect(429, {
+ errors: [
+ {
+ code: 'CM_QUOTA',
+ status: '429',
+ title: 'Too many requests',
+ detail: 'You have made too many requests. Re-send the request after the time (in seconds) specified `Retry-After` header.'
+ }
+ ]
+ })
+ .expect('Content-Type', /json/, done);
+ });
+
+ it('returns a 200 with correct response structure for a valid messageId', (done) => {
+ request(server)
+ .get(`/api/v1/message-responses/${VALID_MESSAGE_ID}`)
+ .expect(200)
+ .expect('Content-Type', /json/)
+ .expect((res) => {
+ const { body } = res;
+ if (!Array.isArray(body.data)) throw new Error('response must contain a data array');
+ const first = body.data[0];
+ if (!first.id) throw new Error('missing id');
+ if (first.type !== 'RecipientResponse') throw new Error('incorrect type');
+ const { attributes } = first;
+ if (attributes.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId');
+ if (!attributes.messageReference) throw new Error('missing messageReference');
+ if (!attributes.code) throw new Error('missing code');
+ if (!attributes.channel) throw new Error('missing channel');
+ if (!attributes.channelStatus) throw new Error('missing channelStatus');
+ if (!attributes.cascadeType) throw new Error('missing cascadeType');
+ if (!attributes.authoredAt) throw new Error('missing authoredAt');
+ if (!attributes.timestamp) throw new Error('missing timestamp');
+ })
+ .end(done);
+ });
+});
diff --git a/sandbox/app.js b/sandbox/app.js
index 1fe8b6f9a..c977d2c63 100644
--- a/sandbox/app.js
+++ b/sandbox/app.js
@@ -129,6 +129,7 @@ app.post("/api/v1/send", handlers.batchSend);
app.post("/api/v1/messages", handlers.messages);
app.get("/api/v1/messages/:messageId", handlers.getMessage);
app.get("/api/channels/nhsapp/accounts", handlers.nhsappAccounts);
+app.get("/api/v1/message-responses/:messageId", handlers.messageResponses);
app.get("/_timeout", handlers.triggerTimeout);
app.get("/_invalid_certificate", handlers.backend403);
app.get("/_timeout_408", handlers.backend408);
diff --git a/sandbox/handlers/index.js b/sandbox/handlers/index.js
index 15b14371b..559a843b0 100644
--- a/sandbox/handlers/index.js
+++ b/sandbox/handlers/index.js
@@ -3,6 +3,7 @@ export { batchSend } from "./batch_send.js"
export { messages } from "./messages.js"
export { getMessage } from "./get_message.js"
export { nhsappAccounts } from "./nhsapp_accounts.js"
+export { messageResponses } from "./message_responses.js"
export { triggerTimeout } from "./trigger_timeout.js"
export { backend403 } from "./responses/backend_403.js"
export { backend408 } from "./responses/backend_408.js"
diff --git a/sandbox/handlers/message_responses.js b/sandbox/handlers/message_responses.js
new file mode 100644
index 000000000..006b88aaf
--- /dev/null
+++ b/sandbox/handlers/message_responses.js
@@ -0,0 +1,87 @@
+import { sendError } from './utils.js'
+
+const notFoundMessageId = '00000000-0000-4000-8000-000000000404';
+const supportedContentTypes = ['application/json', 'application/vnd.api+json'];
+
+export async function messageResponses(req, res, next) {
+ if (req.headers.authorization === 'banned') {
+ res.status(403).json({ error: 'Forbidden' });
+ next();
+ return;
+ }
+
+ if (req.headers['content-type'] && !supportedContentTypes.includes(req.headers['content-type'])) {
+ sendError(res, 415, 'Unsupported media type.');
+ next();
+ return;
+ }
+
+ if (req.headers.prefer === 'code=429') {
+ res.status(429).json({
+ errors: [
+ {
+ code: 'CM_QUOTA',
+ status: '429',
+ title: 'Too many requests',
+ detail: 'You have made too many requests. Re-send the request after the time (in seconds) specified `Retry-After` header.'
+ }
+ ]
+ });
+ next();
+ return;
+ }
+
+ const { messageId } = req.params;
+
+ if (messageId === notFoundMessageId) {
+ res.status(404).json({
+ errors: [
+ {
+ code: 'CM_NOT_FOUND',
+ status: '404',
+ title: 'Resource not found',
+ detail: 'The resource at the requested URI was not found.'
+ }
+ ]
+ });
+ next();
+ return;
+ }
+
+ res.type('json').status(200).json(getDefaultResponse(messageId));
+}
+
+function getDefaultResponse(messageId) {
+ return {
+ data: [
+ {
+ type: 'RecipientResponse',
+ id: '22222222-2222-4222-8222-222222222222',
+ attributes: {
+ messageId,
+ messageReference: 'msg-ref-1',
+ channel: 'nhsapp',
+ channelStatus: 'delivered',
+ cascadeType: 'primary',
+ code: 'YES',
+ authoredAt: '2026-01-02T09:00:00.000Z',
+ timestamp: '2026-01-02T09:00:02.345Z'
+ }
+ },
+ {
+ type: 'RecipientResponse',
+ id: '33333333-3333-4333-8333-333333333333',
+ attributes: {
+ messageId,
+ messageReference: 'msg-ref-1',
+ channel: 'nhsapp',
+ channelStatus: 'delivered',
+ cascadeType: 'secondary',
+ code: 'NO',
+ authoredAt: '2026-01-02T09:05:00.000Z',
+ timestamp: '2026-01-02T09:05:01.678Z'
+ }
+ }
+ ]
+ };
+}
diff --git a/specification/callbacks/recipient_response.yaml b/specification/callbacks/recipient_response.yaml
index da8517845..673626d92 100644
--- a/specification/callbacks/recipient_response.yaml
+++ b/specification/callbacks/recipient_response.yaml
@@ -29,7 +29,7 @@ requestBody:
type: array
minItems: 1
items:
- $ref: ../schemas/components/RecipientResponse.yaml
+ $ref: ../schemas/components/RecipientResponseCallback.yaml
responses:
'202':
description: Accepted
diff --git a/specification/communications-manager.yaml b/specification/communications-manager.yaml
index 4ab713263..aa492d0ad 100644
--- a/specification/communications-manager.yaml
+++ b/specification/communications-manager.yaml
@@ -32,6 +32,17 @@ paths:
description: The unique identifier for the message.
get:
$ref: endpoints/get_message.yaml
+ /v1/message-responses/{messageId}:
+ parameters:
+ - schema:
+ type: string
+ format: uuid
+ name: messageId
+ in: path
+ required: true
+ description: The unique identifier for the message.
+ get:
+ $ref: endpoints/get_responses.yaml
/channels/nhsapp/accounts:
get:
$ref: endpoints/get_nhsapp_account_details.yaml
diff --git a/specification/documentation/APIDescription.md b/specification/documentation/APIDescription.md
index c4a24db72..d36639bf4 100644
--- a/specification/documentation/APIDescription.md
+++ b/specification/documentation/APIDescription.md
@@ -255,6 +255,10 @@ In order to present the recipient with answers, include the `answerOptions` fiel
If you subscribe to recipient response callbacks, NHS Notify will send you a callback when a recipient responds to a message (currently only NHS App supports this). See [the recipient response callback](#post-/-client-provided-recipient-response-URI-) for more details.
+### Retrieving responses via the API
+
+Use the [get message responses](#get-/v1/message-responses/-messageId-) endpoint to query responses for a given message. Responses are available for up to 9 months after the message was sent.
+
## Message character limits
Different character limits apply to each of the communication channels as listed below. NHS Notify will validate that any personalisation fields submitted in the send message request do not exceed these limits but it is the client's responsibility to ensure that when personalisation is combined with any templated text, the channel character limit is not exceeded.
diff --git a/specification/documentation/GetMessageResponses.md b/specification/documentation/GetMessageResponses.md
new file mode 100644
index 000000000..ed42cf6f9
--- /dev/null
+++ b/specification/documentation/GetMessageResponses.md
@@ -0,0 +1,66 @@
+## Overview
+
+Use this endpoint to retrieve recipient responses associated with a specific message.
+
+Recipient responses are the keyword answers selected by a recipient for a message sent through the NHS App. For more information, see the [recipient response callback](#post-/-client-provided-recipient-response-URI-).
+
+Responses are available for retrieval for up to 9 months after the message was sent.
+
+### Response structure
+
+A successful response returns a JSON:API document containing an array of recipient response resources, for example:
+
+```json
+{
+ "data": [
+ {
+ "type": "RecipientResponse",
+ "id": "33333333-3333-4333-8333-333333333333",
+ "attributes": {
+ "messageId": "11111111-1111-4111-8111-111111111111",
+ "messageReference": "da0b1495-c7cb-468c-9d81-07dee089d728",
+ "channel": "nhsapp",
+ "channelStatus": "delivered",
+ "cascadeType": "primary",
+ "code": "YES",
+ "authoredAt": "2026-06-15T14:30:00.000Z",
+ "timestamp": "2026-06-15T14:30:05.123Z"
+ }
+ }
+ ]
+}
+```
+
+Each resource has:
+
+* `type` - the resource type, always `RecipientResponse`
+* `id` - the unique identifier for this response
+
+Each resource's `attributes` include:
+
+* `messageId` - the identifier of the message this response relates to
+* `messageReference` - the reference you provided when the message was created
+* `channel` - the channel through which the response was received
+* `channelStatus` - the status of the channel at the time the response was received
+* `channelFailureReasonCode` - the reason code for the channel failure (only present when `channelStatus` is `failed`)
+* `cascadeType` - whether this is a `primary` or `secondary` cascade response
+* `code` - the keyword code selected by the recipient
+* `authoredAt` - the date-time the recipient submitted their response
+* `timestamp` - the date-time the response was recorded by NHS Notify
+
+If no responses exist for the given message, a `404` response is returned.
+
+### Sandbox
+
+When sending this request on sandbox you can use any valid UUID v4 message ID.
+
+To simulate error responses in the sandbox, use the following message IDs:
+
+* not found - `00000000-0000-4000-8000-000000000404`
+
+Here's an example curl command:
+
+```
+curl -X GET 'https://sandbox.api.service.nhs.uk/comms/v1/message-responses/11111111-1111-4111-8111-111111111111' \
+ --header 'Accept: application/vnd.api+json'
+```
diff --git a/specification/endpoints/get_responses.yaml b/specification/endpoints/get_responses.yaml
new file mode 100644
index 000000000..89e757b86
--- /dev/null
+++ b/specification/endpoints/get_responses.yaml
@@ -0,0 +1,32 @@
+summary: Get responses for a message
+description:
+ $ref: ../documentation/GetMessageResponses.md
+operationId: get-message-responses
+parameters:
+ - $ref: ../snippets/AuthorizationParameter.yaml
+ - $ref: ../snippets/CorrelationParameter.yaml
+responses:
+ '200':
+ $ref: ../responses/2xx/200_Responses.yaml
+ '400':
+ $ref: ../responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml
+ '401':
+ $ref: ../responses/4xx/401_AccessDenied.yaml
+ '403':
+ $ref: ../responses/4xx/403_Forbidden.yaml
+ '404':
+ $ref: ../responses/4xx/404_NotFound.yaml
+ '405':
+ $ref: ../responses/4xx/405_NotAllowed.yaml
+ '408':
+ $ref: ../responses/4xx/408_RequestTimeout.yaml
+ '415':
+ $ref: ../responses/4xx/415_UnsupportedMedia.yaml
+ '429':
+ $ref: ../responses/4xx/429_TooManyRequests.yaml
+ '500':
+ $ref: ../responses/5xx/500_InternalServerError.yaml
+ '503':
+ $ref: ../responses/5xx/503_ServiceUnavailable.yaml
+ '504':
+ $ref: ../responses/5xx/504_ServiceTimeout.yaml
diff --git a/specification/responses/2xx/200_Responses.yaml b/specification/responses/2xx/200_Responses.yaml
new file mode 100644
index 000000000..2afbc0f6d
--- /dev/null
+++ b/specification/responses/2xx/200_Responses.yaml
@@ -0,0 +1,10 @@
+description: The responses for the given message have been retrieved successfully.
+headers:
+ $ref: ../../snippets/StandardResponseHeaders.yaml
+content:
+ application/vnd.api+json:
+ schema:
+ $ref: ../../schemas/responses/RecipientResponse.yaml
+ application/json:
+ schema:
+ $ref: ../../schemas/responses/RecipientResponse.yaml
diff --git a/specification/responses/4xx/406_NotAcceptable.yaml b/specification/responses/4xx/406_NotAcceptable.yaml
index d8ad56b3b..34de3ef24 100644
--- a/specification/responses/4xx/406_NotAcceptable.yaml
+++ b/specification/responses/4xx/406_NotAcceptable.yaml
@@ -15,4 +15,4 @@ content:
schema:
$ref: ../../schemas/responses/errors/NotAcceptable.yaml
headers:
- $ref: ../../snippets/StandardResponseHeaders.yaml
\ No newline at end of file
+ $ref: ../../snippets/StandardResponseHeaders.yaml
diff --git a/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml b/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml
new file mode 100644
index 000000000..860563b22
--- /dev/null
+++ b/specification/responses/4xx/message_responses/400_MessageResponsesInvalidRequest.yaml
@@ -0,0 +1,19 @@
+description: |+
+ A validation error has occurred with the request sent.
+
+ The following validation errors can occur:
+
+ | Error code | Title | Description |
+ | -------------------- | --------------- | --------------------------------------------------- |
+ | `CM_INVALID_REQUEST` | Invalid Request | The `messageId` path parameter is not a valid UUID. |
+
+ Within each error is a source object which identifies the request parameter that caused the error.
+content:
+ application/vnd.api+json:
+ schema:
+ $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml
+ application/json:
+ schema:
+ $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml
+headers:
+ $ref: ../../../snippets/StandardResponseHeaders.yaml
diff --git a/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml b/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml
new file mode 100644
index 000000000..0999b8efc
--- /dev/null
+++ b/specification/responses/5xx/message_responses/500_MessageResponsesInternalServerError.yaml
@@ -0,0 +1,20 @@
+description: |+
+ An error has occurred that is stopping your request from being processed.
+
+ The following errors can occur:
+
+ | Error code | Title | Description |
+ | -------------------------- | ------------------------ | ----------------------------------------------------------- |
+ | `CM_TOO_MANY_RESPONSES` | Too many responses | There are too many responses to return. |
+ | `CM_INTERNAL_SERVER_ERROR` | Error processing request | There was an internal error whilst processing this request. |
+
+ The `CM_TOO_MANY_RESPONSES` error occurs when more than 1000 responses exist for the given message ID.
+content:
+ application/vnd.api+json:
+ schema:
+ $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml
+ application/json:
+ schema:
+ $ref: ../../../schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml
+headers:
+ $ref: ../../../snippets/StandardResponseHeaders.yaml
diff --git a/specification/schemas/components/RecipientResponse.yaml b/specification/schemas/components/RecipientResponseCallback.yaml
similarity index 100%
rename from specification/schemas/components/RecipientResponse.yaml
rename to specification/schemas/components/RecipientResponseCallback.yaml
diff --git a/specification/schemas/enums/EnumRecipientResponse.yaml b/specification/schemas/enums/EnumRecipientResponse.yaml
new file mode 100644
index 000000000..1b103f91e
--- /dev/null
+++ b/specification/schemas/enums/EnumRecipientResponse.yaml
@@ -0,0 +1,5 @@
+title: Enum_RecipientResponse
+type: string
+enum:
+ - RecipientResponse
+example: RecipientResponse
diff --git a/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml b/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml
new file mode 100644
index 000000000..4de745cb2
--- /dev/null
+++ b/specification/schemas/enums/ErrorMessageResponsesInternalServerError.yaml
@@ -0,0 +1,6 @@
+title: Enum_Error_MessageResponsesInternalServerError
+type: string
+enum:
+ - CM_TOO_MANY_RESPONSES
+ - CM_INTERNAL_SERVER_ERROR
+example: CM_TOO_MANY_RESPONSES
diff --git a/specification/schemas/responses/RecipientResponse.yaml b/specification/schemas/responses/RecipientResponse.yaml
new file mode 100644
index 000000000..39a5a746d
--- /dev/null
+++ b/specification/schemas/responses/RecipientResponse.yaml
@@ -0,0 +1,81 @@
+title: RecipientResponse
+type: object
+additionalProperties: false
+properties:
+ data:
+ type: array
+ items:
+ type: object
+ additionalProperties: false
+ required:
+ - type
+ - id
+ - attributes
+ properties:
+ type:
+ $ref: ../enums/EnumRecipientResponse.yaml
+ id:
+ type: string
+ format: uuid
+ description: The unique identifier for this response.
+ example: "33333333-3333-4333-8333-333333333333"
+ attributes:
+ type: object
+ additionalProperties: false
+ required:
+ - authoredAt
+ - cascadeType
+ - channel
+ - channelStatus
+ - code
+ - messageId
+ - messageReference
+ - timestamp
+ properties:
+ messageId:
+ type: string
+ description: The unique identifier of the message this response relates to.
+ format: uuid
+ example: "11111111-1111-4111-8111-111111111111"
+ messageReference:
+ type: string
+ description: The reference for the message, as provided when the message was created.
+ example: "da0b1495-c7cb-468c-9d81-07dee089d728"
+ channel:
+ type: string
+ enum:
+ - nhsapp
+ example: nhsapp
+ channelStatus:
+ type: string
+ description: The status of the channel at the time the response was received.
+ enum:
+ - sending
+ - delivered
+ - failed
+ example: delivered
+ channelFailureReasonCode:
+ type: string
+ description: The reason code for the channel failure. Only present when channelStatus is failed.
+ example: "CFR_SUPE_0001"
+ cascadeType:
+ type: string
+ description: Whether this is a primary or secondary cascade response.
+ enum:
+ - primary
+ - secondary
+ example: primary
+ code:
+ type: string
+ description: The keyword code from the recipient's response.
+ example: "YES"
+ authoredAt:
+ type: string
+ description: The date-time the recipient submitted their response.
+ format: date-time
+ example: "2026-06-15T14:30:00.000Z"
+ timestamp:
+ type: string
+ description: The date-time the response was recorded by NHS Notify.
+ format: date-time
+ example: "2026-06-15T14:30:05.123Z"
diff --git a/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml b/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml
new file mode 100644
index 000000000..2dfec4065
--- /dev/null
+++ b/specification/schemas/responses/errors/message_responses/MessageResponsesInternalServerError.yaml
@@ -0,0 +1,35 @@
+type: object
+title: Internal server error
+additionalProperties: false
+properties:
+ errors:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items:
+ type: object
+ additionalProperties: false
+ properties:
+ id:
+ $ref: ../../../types/ErrorIdentifier.yaml
+ code:
+ $ref: ../../../enums/ErrorMessageResponsesInternalServerError.yaml
+ links:
+ $ref: ../../../types/LinksError.yaml
+ status:
+ type: string
+ enum:
+ - '500'
+ example: '500'
+ title:
+ type: string
+ enum:
+ - Too many responses
+ - Error processing request
+ example: Too many responses
+ detail:
+ type: string
+ enum:
+ - 'There are too many responses to return.'
+ - 'There was an internal error whilst processing this request.'
+ example: 'There are too many responses to return.'
diff --git a/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml b/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml
new file mode 100644
index 000000000..bf973d548
--- /dev/null
+++ b/specification/schemas/responses/errors/message_responses/MessageResponsesInvalidRequest.yaml
@@ -0,0 +1,46 @@
+type: object
+title: Invalid request
+additionalProperties: false
+properties:
+ errors:
+ type: array
+ minItems: 1
+ uniqueItems: true
+ items:
+ type: object
+ additionalProperties: false
+ properties:
+ id:
+ $ref: ../../../types/ErrorIdentifier.yaml
+ code:
+ type: string
+ enum:
+ - CM_INVALID_REQUEST
+ example: CM_INVALID_REQUEST
+ title: Enum_Error_InvalidGetMessageResponses_Request
+ links:
+ $ref: ../../../types/LinksError.yaml
+ status:
+ type: string
+ enum:
+ - '400'
+ example: '400'
+ title:
+ type: string
+ enum:
+ - Invalid Request
+ example: Invalid Request
+ detail:
+ type: string
+ enum:
+ - The messageId path parameter is not a valid UUID.
+ example: 'The messageId path parameter is not a valid UUID.'
+ source:
+ type: object
+ additionalProperties: false
+ properties:
+ parameter:
+ type: string
+ enum:
+ - messageId
+ example: messageId
diff --git a/tests/api/message_responses/__init__.py b/tests/api/message_responses/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/api/message_responses/test_200_success.py b/tests/api/message_responses/test_200_success.py
new file mode 100644
index 000000000..f27e1b4e9
--- /dev/null
+++ b/tests/api/message_responses/test_200_success.py
@@ -0,0 +1,32 @@
+import os
+import requests
+import pytest
+from lib import Assertions, Generators
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, VALID_MESSAGE_ID
+from lib.fixtures import * # NOSONAR
+
+# ref has no app-response backend; the endpoint is deliberately disabled there
+pytestmark = pytest.mark.skipif(
+ os.environ.get("API_ENVIRONMENT") == "ref",
+ reason="message-responses endpoint is not available in ref"
+)
+
+
+@pytest.mark.devtest
+def test_200_success(url, bearer_token):
+ headers = Generators.generate_valid_headers(bearer_token.value)
+
+ resp = requests.get(
+ f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{VALID_MESSAGE_ID}",
+ headers=headers
+ )
+
+ assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}"
+ body = resp.json()
+ assert isinstance(body.get("data"), list)
+ assert len(body["data"]) > 0
+
+ first = body["data"][0]
+ assert first["type"] == "RecipientResponse"
+ assert "id" in first
+ assert first["attributes"]["messageId"] == VALID_MESSAGE_ID
diff --git a/tests/api/message_responses/test_400.py b/tests/api/message_responses/test_400.py
new file mode 100644
index 000000000..e997f6643
--- /dev/null
+++ b/tests/api/message_responses/test_400.py
@@ -0,0 +1,34 @@
+import os
+import requests
+import pytest
+from lib import Assertions, Generators
+import lib.constants.constants as constants
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, INVALID_MESSAGE_IDS
+from lib.fixtures import * # NOSONAR
+
+# ref has no app-response backend; the endpoint is deliberately disabled there
+pytestmark = pytest.mark.skipif(
+ os.environ.get("API_ENVIRONMENT") == "ref",
+ reason="message-responses endpoint is not available in ref"
+)
+
+
+@pytest.mark.devtest
+@pytest.mark.parametrize("message_id", INVALID_MESSAGE_IDS)
+def test_400_invalid_message_id(url, bearer_token, message_id):
+ headers = Generators.generate_valid_headers(bearer_token.value)
+
+ resp = requests.get(
+ f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{message_id}",
+ headers=headers
+ )
+
+ Assertions.assert_error_with_optional_correlation_id(
+ resp,
+ 400,
+ Generators.generate_error(
+ constants.ERROR_MESSAGE_RESPONSES_INVALID_MESSAGE_ID,
+ source={"parameter": "messageId"}
+ ),
+ None
+ )
diff --git a/tests/api/message_responses/test_404.py b/tests/api/message_responses/test_404.py
new file mode 100644
index 000000000..d11919e7f
--- /dev/null
+++ b/tests/api/message_responses/test_404.py
@@ -0,0 +1,29 @@
+import os
+import requests
+import pytest
+from lib import Assertions, Generators
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, NOT_FOUND_MESSAGE_ID
+from lib.fixtures import * # NOSONAR
+
+# ref has no app-response backend; the endpoint is deliberately disabled there
+pytestmark = pytest.mark.skipif(
+ os.environ.get("API_ENVIRONMENT") == "ref",
+ reason="message-responses endpoint is not available in ref"
+)
+
+
+@pytest.mark.devtest
+def test_404_not_found(url, bearer_token):
+ headers = Generators.generate_valid_headers(bearer_token.value)
+
+ resp = requests.get(
+ f"{url}{MESSAGE_RESPONSES_ENDPOINT}/{NOT_FOUND_MESSAGE_ID}",
+ headers=headers
+ )
+
+ Assertions.assert_error_with_optional_correlation_id(
+ resp,
+ 404,
+ Generators.generate_not_found_error(),
+ None
+ )
diff --git a/tests/lib/constants/constants.py b/tests/lib/constants/constants.py
index a40b01518..bef22258e 100644
--- a/tests/lib/constants/constants.py
+++ b/tests/lib/constants/constants.py
@@ -1,6 +1,12 @@
INT_URL = "https://int.api.service.nhs.uk/comms"
PROD_URL = "https://api.service.nhs.uk/comms"
-VALID_ENDPOINTS = ["/v1/message-batches", "/v1/messages", "/v1/api/send", "/v1/messages/1234"]
+VALID_ENDPOINTS = [
+ "/v1/message-batches",
+ "/v1/messages",
+ "/v1/api/send",
+ "/v1/messages/1234",
+ "/v1/message-responses/11111111-1111-4111-8111-111111111111",
+]
CORS_METHODS = "GET, PUT, POST, PATCH, DELETE"
CORS_MAX_AGE = "3628800"
CORS_ALLOW_HEADERS = "origin, x-requested-with, accept, " \
@@ -313,6 +319,13 @@ def __init__(self, code, status, title, detail, links={}):
""
)
+ERROR_MESSAGE_RESPONSES_INVALID_MESSAGE_ID = Error(
+ "CM_INVALID_REQUEST",
+ "400",
+ "Invalid Request",
+ "The messageId path parameter is not a valid UUID."
+)
+
INVALID_REQUEST_TITLE = "Invalid Request"
# NHS App Accounts errors
diff --git a/tests/lib/constants/message_responses_paths.py b/tests/lib/constants/message_responses_paths.py
new file mode 100644
index 000000000..f51af526d
--- /dev/null
+++ b/tests/lib/constants/message_responses_paths.py
@@ -0,0 +1,13 @@
+MESSAGE_RESPONSES_ENDPOINT = "/v1/message-responses"
+
+VALID_MESSAGE_ID = "11111111-1111-4111-8111-111111111111"
+NOT_FOUND_MESSAGE_ID = "00000000-0000-4000-8000-000000000404"
+BAD_GATEWAY_MESSAGE_ID = "00000000-0000-4000-8000-000000000502"
+
+INVALID_MESSAGE_IDS = [
+ "not-a-uuid",
+ "12345",
+ "invalid_id"
+]
+
+CORRELATION_IDS = [None, "228aac39-542d-4803-b28e-5de9e100b9f8"]
diff --git a/tests/sandbox/message_responses/__init__.py b/tests/sandbox/message_responses/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/sandbox/message_responses/test_400.py b/tests/sandbox/message_responses/test_400.py
new file mode 100644
index 000000000..422947359
--- /dev/null
+++ b/tests/sandbox/message_responses/test_400.py
@@ -0,0 +1,18 @@
+import requests
+import pytest
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, INVALID_MESSAGE_IDS
+
+
+@pytest.mark.sandboxtest
+@pytest.mark.parametrize("correlation_id", CORRELATION_IDS)
+@pytest.mark.parametrize("message_id", INVALID_MESSAGE_IDS)
+def test_400_invalid_message_id(nhsd_apim_proxy_url, correlation_id, message_id):
+ resp = requests.get(
+ f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{message_id}",
+ headers={
+ "X-Correlation-Id": correlation_id,
+ "Accept": "application/json"
+ }
+ )
+
+ assert resp.status_code == 400, f"Response: {resp.status_code}: {resp.text}"
diff --git a/tests/sandbox/message_responses/test_404.py b/tests/sandbox/message_responses/test_404.py
new file mode 100644
index 000000000..740ca68f6
--- /dev/null
+++ b/tests/sandbox/message_responses/test_404.py
@@ -0,0 +1,23 @@
+import requests
+import pytest
+from lib import Assertions, Generators
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, NOT_FOUND_MESSAGE_ID
+
+
+@pytest.mark.sandboxtest
+@pytest.mark.parametrize("correlation_id", CORRELATION_IDS)
+def test_404_message_not_found(nhsd_apim_proxy_url, correlation_id):
+ resp = requests.get(
+ f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{NOT_FOUND_MESSAGE_ID}",
+ headers={
+ "X-Correlation-Id": correlation_id,
+ "Accept": "application/json"
+ }
+ )
+
+ Assertions.assert_error_with_optional_correlation_id(
+ resp,
+ 404,
+ Generators.generate_not_found_error(),
+ correlation_id
+ )
diff --git a/tests/sandbox/message_responses/test_502.py b/tests/sandbox/message_responses/test_502.py
new file mode 100644
index 000000000..d32a9e801
--- /dev/null
+++ b/tests/sandbox/message_responses/test_502.py
@@ -0,0 +1,23 @@
+import requests
+import pytest
+from lib import Assertions, Generators
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, BAD_GATEWAY_MESSAGE_ID
+
+
+@pytest.mark.sandboxtest
+@pytest.mark.parametrize("correlation_id", CORRELATION_IDS)
+def test_502_bad_gateway(nhsd_apim_proxy_url, correlation_id):
+ resp = requests.get(
+ f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{BAD_GATEWAY_MESSAGE_ID}",
+ headers={
+ "X-Correlation-Id": correlation_id,
+ "Accept": "application/json"
+ }
+ )
+
+ Assertions.assert_error_with_optional_correlation_id(
+ resp,
+ 502,
+ Generators.generate_bad_gateway_error(),
+ correlation_id
+ )
diff --git a/tests/sandbox/message_responses/test_success.py b/tests/sandbox/message_responses/test_success.py
new file mode 100644
index 000000000..fa37e1c25
--- /dev/null
+++ b/tests/sandbox/message_responses/test_success.py
@@ -0,0 +1,37 @@
+import requests
+import pytest
+from lib import Assertions
+from lib.constants.message_responses_paths import MESSAGE_RESPONSES_ENDPOINT, CORRELATION_IDS, VALID_MESSAGE_ID
+
+
+@pytest.mark.sandboxtest
+@pytest.mark.parametrize("correlation_id", CORRELATION_IDS)
+def test_200_success(nhsd_apim_proxy_url, correlation_id):
+ resp = requests.get(
+ f"{nhsd_apim_proxy_url}{MESSAGE_RESPONSES_ENDPOINT}/{VALID_MESSAGE_ID}",
+ headers={
+ "X-Correlation-Id": correlation_id,
+ "Accept": "application/json"
+ }
+ )
+
+ assert resp.status_code == 200, f"Response: {resp.status_code}: {resp.text}"
+ body = resp.json()
+ assert isinstance(body.get("data"), list)
+ assert len(body["data"]) > 0
+
+ first = body["data"][0]
+ assert first["type"] == "RecipientResponse"
+ assert "id" in first
+
+ attributes = first["attributes"]
+ assert attributes["messageId"] == VALID_MESSAGE_ID
+ assert "messageReference" in attributes
+ assert "code" in attributes
+ assert "channel" in attributes
+ assert "channelStatus" in attributes
+ assert "cascadeType" in attributes
+ assert "authoredAt" in attributes
+ assert "timestamp" in attributes
+
+ Assertions.assert_correlation_id(resp.headers.get("X-Correlation-Id"), correlation_id)