Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions proxies/live/apiproxy/targets/target.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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' %]
</Flows>
[% include './partials/Partial.Target.PostFlow.xml' %]
<FaultRules>
Expand Down
1 change: 1 addition & 0 deletions proxies/sandbox/apiproxy/targets/sandbox.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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' %]
</Flows>
[% include './partials/Partial.Target.PostFlow.xml' %]
<FaultRules>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Flow name="GetMessageResponsesEndpoint">
<Description>Handle get message responses</Description>
<Request>
<Step>
<Name>ExtractVariables.MessageResponses.Get.Request</Name>
</Step>
<Step>
<Name>AssignMessage.MessageResponses.Get.Request</Name>
</Step>
{% if ENVIRONMENT_TYPE != 'sandbox' %}
<Step>
<Name>AssignMessage.AuthenticationDetails</Name>
</Step>
{% endif %}
</Request>
<Response>
<Step>
<Name>AssignMessage.MessageResponses.Get.Response</Name>
</Step>
</Response>
<Condition>
(proxy.pathsuffix MatchesPath "/v1/message-responses/{messageId}") and (request.verb = "GET")
</Condition>
</Flow>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Sets the backend request path to /api/response/{messageId} and forwards correlation ID.
-->
<AssignMessage async="false" continueOnError="false" enabled="true" name="AssignMessage.MessageResponses.Get.Request">
<DisplayName>AssignMessage.MessageResponses.Get.Request</DisplayName>
<Properties/>
<AssignTo createNew="false" transport="http" type="request"/>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
<AssignVariable>
<Name>target.copy.pathsuffix</Name>
<Value>false</Value>
</AssignVariable>
<AssignVariable>
<Name>requestpath</Name>
<Template>/api/response/{data.messageId}</Template>
</AssignVariable>
<Set>
<Headers>
<Header name="X-Correlation-Id">{backendCorrelationId}</Header>
</Headers>
<Verb>GET</Verb>
</Set>
</AssignMessage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Passthrough response policy — no body transformation required for this endpoint.
-->
<AssignMessage async="false" continueOnError="false" enabled="true" name="AssignMessage.MessageResponses.Get.Response">
<DisplayName>AssignMessage.MessageResponses.Get.Response</DisplayName>
<Properties/>
<AssignTo createNew="false" transport="http" type="response"/>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
</AssignMessage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!--
Extracts the messageId from the URI path into data.* flow variables.
-->
<ExtractVariables async="false" continueOnError="false" enabled="true" name="ExtractVariables.MessageResponses.Get.Request">
<VariablePrefix>data</VariablePrefix>
<Source>request</Source>
<URIPath>
<Pattern ignoreCase="true">/v1/message-responses/{messageId}</Pattern>
</URIPath>
<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables>
</ExtractVariables>
4 changes: 4 additions & 0 deletions proxies/shared/resources/jsc/Routing.CheckValid.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const validPaths = [
{
match: /^\/channels\/nhsapp\/accounts$/,
methods: ['get']
},
{
match: /^\/v1\/message-responses\/.*$/,
methods: ['get']
}
];

Expand Down
111 changes: 111 additions & 0 deletions sandbox/__test__/message_responses.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 TOO_MANY_RESPONSES_MESSAGE_ID = '00000000-0000-4000-8000-000000000422';

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);
});

it('returns a 400 when messageId is not a UUID', (done) => {
request(server)
.get('/api/v1/message-responses/not-a-valid-uuid')
.expect(400, {
error: 'Invalid messageId format'
})
.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, {
error: 'No responses found for the specified messageId'
})
.expect('Content-Type', /json/, done);
});

it('returns a 500 when too many responses are returned', (done) => {
request(server)
.get(`/api/v1/message-responses/${TOO_MANY_RESPONSES_MESSAGE_ID}`)
.expect(422, {
error: 'response_too_large',
message: 'There are too many responses to return.'
})
.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, {
message: 'Too many requests.'
})
.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)) throw new Error('response must be an array');
const first = body[0];
if (!first.responseId) throw new Error('missing responseId');
if (first.messageId !== VALID_MESSAGE_ID) throw new Error('incorrect messageId');
if (!first.messageReference) throw new Error('missing messageReference');
if (!first.code) throw new Error('missing code');
if (!first.channel) throw new Error('missing channel');
if (!first.channelStatus) throw new Error('missing channelStatus');
if (!first.cascadeType) throw new Error('missing cascadeType');
if (!first.authoredAt) throw new Error('missing authoredAt');
if (!first.timestamp) throw new Error('missing timestamp');
})
.end(done);
});
});
1 change: 1 addition & 0 deletions sandbox/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions sandbox/handlers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
78 changes: 78 additions & 0 deletions sandbox/handlers/message_responses.js
Comment thread
rhyscoxnhs marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { sendError } from './utils.js'

const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

const notFoundMessageId = '00000000-0000-4000-8000-000000000404';
const tooManyResponsesMessageId = '00000000-0000-4000-8000-000000000422';

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'] && req.headers['content-type'] !== 'application/json') {
sendError(res, 415, 'Unsupported media type.');
next();
return;
}

if (req.headers.prefer === 'code=429') {
sendError(res, 429, 'Too many requests.');
next();
return;
}

const { messageId } = req.params;

if (!uuidRegex.test(messageId)) {
res.status(400).json({ error: 'Invalid messageId format' });
next();
return;
}

if (messageId === notFoundMessageId) {
res.status(404).json({ error: 'No responses found for the specified messageId' });
next();
return;
}

if (messageId === tooManyResponsesMessageId) {
res.status(422).json({
error: 'response_too_large',
message: 'There are too many responses to return.'
});
next();
return;
}

res.type('json').status(200).json(getDefaultResponse(messageId));
}

function getDefaultResponse(messageId) {
return [
{
responseId: '22222222-2222-4222-8222-222222222222',
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'
},
{
responseId: '33333333-3333-4333-8333-333333333333',
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'
}
];
}
11 changes: 11 additions & 0 deletions specification/communications-manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions specification/documentation/APIDescription.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
42 changes: 42 additions & 0 deletions specification/documentation/GetMessageResponses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## Overview

Use this endpoint to retrieve recipient responses associated with a specific message.
Comment thread
rhyscoxnhs marked this conversation as resolved.

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 an array of response items.

Each item includes:

* `responseId` - the unique identifier for this response
* `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`
* too many responses - `00000000-0000-4000-8000-000000000422`

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/json'
```
Loading
Loading