> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paywise.de/llms.txt
> Use this file to discover all available pages before exploring further.

# Messages and client requests

> Exchange reviewed order or mandate messages and answer typed requests to client with scan-gated attachments.

## Outcome

Your message appears in the intended thread, or an open request-to-client has a
schema-valid answer whose usable attachments completed processing.

## Prerequisites

* A Bearer key.
* The exact order or mandate UUID and, for an answer, the request UUID and
  `allowed_answer_types`.
* One stable idempotency key per create or answer command.

## Answer a request during order review

An order can receive a request before acceptance into a mandate. When a
`request_to_client.created` webhook carries `order_id`, use that order UUID
and `request_to_client_id` with the order routes:

| Action | Endpoint |
| - | - |
| Find unanswered questions | `GET /v2/orders/{order_id}/requests-to-client/?answered=false` |
| Read the question and its answer type | `GET /v2/orders/{order_id}/requests-to-client/{id}/` |
| Submit the answer | `POST /v2/orders/{order_id}/requests-to-client/{id}/answer/` |

For a `yes-no` question, send `{"text": "yes", "documents": []}` with your
Bearer credential and an `Idempotency-Key`. The answer response is `200`; a second
answer with a different key returns `409`.

Question attachments expose authenticated download links. Answer documents
can be uploaded inline or through
`/v2/orders/{order_id}/requests-to-client/{request_id}/answer/documents/`;
list, detail, download, and delete routes use the same parent. Documents
remain editable only while the API answer's staff review task is open and
the order remains eligible for review.

The order resumes after every published request is answered. Attachments
continue processing asynchronously and can be downloaded when ready; replace
failed or rejected files while the answer remains editable.
An ordinary order message does not answer a request.

The examples below use mandate requests. Order requests accept the same
answer payloads at the corresponding order routes.

## 1. Create a mandate message

The `201` response is the created `MessageRead` resource, including its `id`.
Use a command-unique title as well so you can find the message if the response
is lost before you store that ID.

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"
  MESSAGE_KEY = "your-message-key"
  MESSAGE_TITLE = "Payment proof 80000000-0000-4000-8000-000000000001"

  message_payload = {"title": MESSAGE_TITLE, "body": "The bank transfer receipt is attached.", "documents": []}
  response = requests.post(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/messages/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Content-Type": "application/json", "Idempotency-Key": MESSAGE_KEY},
      json=message_payload, timeout=(5, 30),
  )
  if response.status_code != 201:
      response.raise_for_status()
      raise RuntimeError(f"Expected 201, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const messagePayload = { title: process.env.MESSAGE_TITLE, body: "The bank transfer receipt is attached.", documents: [] };
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/messages/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.MESSAGE_KEY },
    body: JSON.stringify(messagePayload), signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 201) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class CreateMandateMessageRequest {
    static String jsonString(String value) {
      if (value == null) throw new IllegalArgumentException("MESSAGE_TITLE is required");
      StringBuilder escaped = new StringBuilder("\"");
      for (int index = 0; index < value.length(); index++) {
        char character = value.charAt(index);
        switch (character) {
          case '\"': escaped.append("\\\""); break;
          case '\\': escaped.append("\\\\"); break;
          case '\b': escaped.append("\\b"); break;
          case '\f': escaped.append("\\f"); break;
          case '\n': escaped.append("\\n"); break;
          case '\r': escaped.append("\\r"); break;
          case '\t': escaped.append("\\t"); break;
          default:
            if (character < 0x20) escaped.append(String.format("\\u%04x", (int) character));
            else escaped.append(character);
        }
      }
      return escaped.append('"').toString();
    }

    public static void main(String[] args) throws IOException, InterruptedException {
      String messageTitle = System.getenv("MESSAGE_TITLE");
      String json = "{\"title\":" + jsonString(messageTitle) + ",\"body\":\"The bank transfer receipt is attached.\",\"documents\":[]}";
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/messages/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).header("Content-Type", "application/json")
          .header("Idempotency-Key", System.getenv("MESSAGE_KEY")).POST(HttpRequest.BodyPublishers.ofString(json)).build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 201) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Net.Http.Json;
  using System.Threading;
  using System.Threading.Tasks;

  class CreateMandateMessageRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          var payload = new { title = Environment.GetEnvironmentVariable("MESSAGE_TITLE"), body = "The bank transfer receipt is attached.", documents = Array.Empty<object>() };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/messages/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("MESSAGE_KEY"));
          request.Content = JsonContent.Create(payload);
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 201) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  message_payload="$(jq -cn --arg title "$MESSAGE_TITLE" \
    '{title: $title, body: "The bank transfer receipt is attached.", documents: []}')"
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/messages/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Content-Type: application/json" \
    --header "Idempotency-Key: $MESSAGE_KEY" --output message-create-response.json --write-out '%{http_code}' \
    --data "$message_payload")"
  [ "$http_status" -eq 201 ] || exit 1
  ```
</CodeGroup>

## 2. Exhaust the message search

The `201` body of the create is the message read resource with its `id`; keep
that `id`. This search is the recovery path when the create response was lost
before the `id` was stored — match on the unique `title` you generated:

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/messages/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}"}, params={"limit": 100}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/messages/?limit=100`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class ListMandateMessagesRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/messages/?limit=100"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;

  class ListMandateMessagesRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Get, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/messages/?limit=100");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 200) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/messages/?limit=100" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --output messages-page.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

Follow every exact `next` URL. Match the command-unique title, mandate parent,
and API author across the complete search, require exactly one stable server
`id`, and use that ID for every later mutation.

## Representative response

```http theme={null}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": "70000000-0000-4000-8000-000000000001",
      "href": "https://api.paywise.de/v2/mandates/50000000-0000-4000-8000-000000000001/messages/70000000-0000-4000-8000-000000000001/",
      "parent": {
        "type": "mandate",
        "id": "50000000-0000-4000-8000-000000000001"
      },
      "title": "Payment proof 80000000-0000-4000-8000-000000000001",
      "body": "The bank transfer receipt is attached.",
      "sender": "client",
      "author": {"type": "api"},
      "state": "under_review",
      "editable": true,
      "response_to": null,
      "documents": [],
      "created_at": "2026-08-27T12:00:00Z",
      "updated_at": "2026-08-27T12:00:00Z"
    }
  ]
}
```

This complete response matches `PaginatedMessageReadList`.

## 3. Correct the exact open message

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"
  MESSAGE_ID = "<MESSAGE_ID>"

  response = requests.patch(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/messages/{MESSAGE_ID}/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Content-Type": "application/json"},
      json={"body": "The bank transfer receipt will follow separately."}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/messages/${process.env.MESSAGE_ID}/`, {
    method: "PATCH", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ body: "The bank transfer receipt will follow separately." }), signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class UpdateMandateMessageRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/messages/" + System.getenv("MESSAGE_ID") + "/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).header("Content-Type", "application/json")
          .method("PATCH", HttpRequest.BodyPublishers.ofString("{\"body\":\"The bank transfer receipt will follow separately.\"}")).build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Net.Http.Json;
  using System.Threading;
  using System.Threading.Tasks;

  class UpdateMandateMessageRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Patch, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/messages/{Environment.GetEnvironmentVariable("MESSAGE_ID")}/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Content = JsonContent.Create(new { body = "The bank transfer receipt will follow separately." });
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 200) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request PATCH "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/messages/$MESSAGE_ID/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Content-Type: application/json" \
    --output patched-message.json --write-out '%{http_code}' \
    --data '{"body":"The bank transfer receipt will follow separately."}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

Mutation is allowed only while the server says `editable: true`; a closed staff
review task returns `409` and must not be bypassed.

## 4. Read the exact request to client

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"
  REQUEST_ID = "<REQUEST_ID>"

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/requests-to-client/{REQUEST_ID}/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/requests-to-client/${process.env.REQUEST_ID}/`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class GetClientRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/requests-to-client/" + System.getenv("REQUEST_ID") + "/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;

  class GetClientRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Get, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/requests-to-client/{Environment.GetEnvironmentVariable("REQUEST_ID")}/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 200) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/requests-to-client/$REQUEST_ID/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --output request.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

## 5. Answer only the still-open typed request

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  ANSWER_KEY = "your-answer-key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"
  REQUEST_ID = "<REQUEST_ID>"

  answer_payload = {"text": "yes", "booking_date": "2026-08-26", "documents": []}
  response = requests.post(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/requests-to-client/{REQUEST_ID}/answer/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Content-Type": "application/json", "Idempotency-Key": ANSWER_KEY},
      json=answer_payload, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const answerPayload = { text: "yes", booking_date: "2026-08-26", documents: [] };
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/requests-to-client/${process.env.REQUEST_ID}/answer/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.ANSWER_KEY },
    body: JSON.stringify(answerPayload), signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class AnswerClientRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/requests-to-client/" + System.getenv("REQUEST_ID") + "/answer/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).header("Content-Type", "application/json")
          .header("Idempotency-Key", System.getenv("ANSWER_KEY")).POST(HttpRequest.BodyPublishers.ofString("{\"text\":\"yes\",\"booking_date\":\"2026-08-26\",\"documents\":[]}")) .build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Net.Http.Json;
  using System.Threading;
  using System.Threading.Tasks;

  class AnswerClientRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          var payload = new { text = "yes", booking_date = "2026-08-26", documents = Array.Empty<object>() };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/requests-to-client/{Environment.GetEnvironmentVariable("REQUEST_ID")}/answer/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("ANSWER_KEY"));
          request.Content = JsonContent.Create(payload);
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 200) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/requests-to-client/$REQUEST_ID/answer/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Content-Type: application/json" \
    --header "Idempotency-Key: $ANSWER_KEY" --output answered-request.json --write-out '%{http_code}' \
    --data '{"text":"yes","booking_date":"2026-08-26","documents":[]}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

This body is valid only for `yes-with-date-no-freetext-on-no`. Other modes
restrict `text`, `booking_date`, and `additional_comment` exactly as described
by `allowed_answer_types`; never answer an already answered request.

## Runnable Python workflow

```python Python workflow theme={null}
import uuid
from urllib.parse import urlsplit
import requests

PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_API_KEY = "pw_sbx_your_api_key"
MANDATE_ID = "50000000-0000-4000-8000-000000000001"
REQUEST_ID = "<REQUEST_ID>"

base_url = PAYWISE_API_URL
base_parts = urlsplit(base_url)
if (
    base_parts.scheme != "https"
    or not base_parts.hostname
    or base_parts.username is not None
    or base_parts.password is not None
):
    raise ValueError("PAYWISE_API_URL must be an HTTPS sandbox URL")
api_origin = (base_parts.hostname.lower(), base_parts.port or 443)
page_budget = 5
headers = {"Authorization": f"Bearer {PAYWISE_API_KEY}"}
mandate_id = MANDATE_ID

def post_command(url, payload, expected_status, idempotency_key):
    command_headers = {**headers, "Idempotency-Key": idempotency_key}
    for attempt in range(2):
        try:
            response = requests.post(
                url,
                headers=command_headers,
                json=payload,
                timeout=(5, 30),
            )
        except requests.Timeout:
            if attempt == 1:
                raise RuntimeError("Ambiguous command outcome after bounded replay")
            continue
        if response.status_code in {500, 503} and attempt == 0:
            continue
        if response.status_code != expected_status:
            response.raise_for_status()
            raise RuntimeError(
                f"Expected {expected_status}, received {response.status_code}"
            )
        return response
    raise RuntimeError("Command recovery exhausted")

probe = requests.get(
    f"{base_url}/v2/mandates/{mandate_id}/messages/",
    headers=headers, params={"limit": 1}, timeout=(5, 30),
)
if probe.status_code != 200:
    probe.raise_for_status()
    raise RuntimeError(f"Expected 200, received {probe.status_code}")
if probe.headers.get("X-Paywise-Environment", "").lower() != "sandbox":
    raise RuntimeError("Refusing writes without authenticated sandbox proof")

message_title = f"Payment proof {uuid.uuid4()}"
message_payload = {
    "title": message_title,
    "body": "The bank transfer receipt is attached.",
    "documents": [],
}
message_key = str(uuid.uuid4())
created = post_command(
    f"{base_url}/v2/mandates/{mandate_id}/messages/",
    message_payload,
    201,
    message_key,
)

next_url = f"{base_url}/v2/mandates/{mandate_id}/messages/"
params = {"limit": 100}
matches_by_id = {}
seen_urls = set()
pages_read = 0
while next_url:
    next_parts = urlsplit(next_url)
    next_origin = (
        next_parts.hostname.lower() if next_parts.hostname else "",
        next_parts.port or (443 if next_parts.scheme == "https" else None),
    )
    if (
        next_parts.scheme != "https"
        or next_origin != api_origin
        or next_parts.username is not None
        or next_parts.password is not None
    ):
        raise RuntimeError("Refusing a pagination URL outside the API HTTPS origin")
    if next_url in seen_urls:
        raise RuntimeError("Pagination cycle detected")
    if pages_read >= page_budget:
        raise RuntimeError("Pagination page budget exhausted")
    seen_urls.add(next_url)
    pages_read += 1
    page_response = requests.get(
        next_url, headers=headers, params=params, timeout=(5, 30)
    )
    if page_response.status_code != 200:
        page_response.raise_for_status()
        raise RuntimeError(f"Expected 200, received {page_response.status_code}")
    page = page_response.json()
    for candidate in page["results"]:
        if (
            candidate.get("title") == message_title
            and candidate.get("parent") == {"type": "mandate", "id": mandate_id}
            and candidate.get("author") == {"type": "api"}
        ):
            matches_by_id[candidate["id"]] = candidate
    next_url = page.get("next")
    params = None
if len(matches_by_id) != 1:
    raise RuntimeError(f"Expected one server message ID, found {sorted(matches_by_id)}")
message_id, message = next(iter(matches_by_id.items()))
if not message.get("editable"):
    raise RuntimeError("Message review window is closed")

patched = requests.patch(
    f"{base_url}/v2/mandates/{mandate_id}/messages/{message_id}/",
    headers=headers,
    json={"body": "The bank transfer receipt will follow separately."},
    timeout=(5, 30),
)
if patched.status_code != 200:
    patched.raise_for_status()
    raise RuntimeError(f"Expected 200, received {patched.status_code}")
if patched.json().get("id") != message_id:
    raise RuntimeError("Patched message ID changed")

request_id = REQUEST_ID
request_response = requests.get(
    f"{base_url}/v2/mandates/{mandate_id}/requests-to-client/{request_id}/",
    headers=headers, timeout=(5, 30),
)
if request_response.status_code != 200:
    request_response.raise_for_status()
    raise RuntimeError(f"Expected 200, received {request_response.status_code}")
client_request = request_response.json()
if client_request.get("id") != request_id:
    raise RuntimeError("Request response did not preserve the exact ID")
if client_request.get("answered"):
    raise RuntimeError("Request is already answered")
if client_request.get("allowed_answer_types") != "yes-with-date-no-freetext-on-no":
    raise RuntimeError("This example answer is forbidden for the request type")

answer_payload = {"text": "yes", "booking_date": "2026-08-26", "documents": []}
answer_key = str(uuid.uuid4())
answered = post_command(
    f"{base_url}/v2/mandates/{mandate_id}/requests-to-client/{request_id}/answer/",
    answer_payload,
    200,
    answer_key,
)
answer = answered.json()
if answer.get("id") != request_id or not answer.get("answered"):
    raise RuntimeError("Answer response did not preserve the request lifecycle")

WORKFLOW_RESULT = {
    "message_id": message_id,
    "request_id": answer["id"],
    "answered": answer["answered"],
}
```

The search has a documented five-page example budget. Production code should
configure a finite budget sized for its account while retaining cycle and
same-origin HTTPS checks before every opaque `next` request.

## Failure and recovery

* `400`: re-read `allowed_answer_types`; remove forbidden fields and add any
  required comment or booking date. A message `title` is a single line —
  control characters are `control_characters`, an empty title is `blank`.
  A message `body` is multi-line (TAB, LF, and CR accepted) and is
  sanitized; a body that is empty after sanitization is `400` on `body`
  with code `blank`, on create and on `PATCH`.
* `400 documents` / `not_allowed`: inline answer `documents` are accepted
  only on `fileupload`, `freetext`, `yes-no-freetext-on-no`, and
  `yes-with-date-no-freetext-on-no` requests. Remove them for every other
  type.
* `400 blank` / `invalid` on `text` or `additional_comment`: both fields are
  plain text — markup is stripped and entities decoded before validation.
  Send the words only; a value that is empty after stripping is `blank`,
  markup that does not converge to plain text is `invalid`.
* `400 parse_error` on `/answer/documents/`: that route uses the bounded JSON
  parser like every other write — non-finite numbers and nesting deeper than
  64 levels are rejected before validation.
* `403` on an answer: when the sandbox capability policy refuses the
  command (`sandbox_feature_unavailable`, `sandbox_source_managed`), the
  envelope adds `translation_key` — the key of the localized message that
  matches `detail` — so you can map the refusal without parsing `detail`.
* `409`: refetch the exact message or request. Never overwrite a closed review
  task or completed answer. Creating or editing a message (or its documents)
  on a withdrawn, rejected, or merged order is `409 conflict`; use the
  surviving order named in `merged_into`, or the mandate once accepted.
* `409 order_expired` on an order message: the draft order expired; it takes
  no further messages. `409 conflict` with
  `Messages are disabled for this mandate.`: messaging is switched off for
  that case, for new messages, edits, and message documents alike.
* `404` on creating a mandate message: the mandate is a subcase; write to its
  main case. Existing subcase threads stay readable.
* Timeout, `500`, or `503`: retry a POST with the same idempotency key.
* A failed or rejected answer document is left out of the handoff to paywise;
  replace it while the answer remains editable if the evidence is still
  needed.

## Verify

<CodeGroup>
  ```python Python theme={null}
  import requests

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  MANDATE_ID = "50000000-0000-4000-8000-000000000001"

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/mandates/{MANDATE_ID}/messages/?limit=100",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}"},
      timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/mandates/${process.env.MANDATE_ID}/messages/?limit=100`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;

  class ListMandateMessagesRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/v2/mandates/" + System.getenv("MANDATE_ID") + "/messages/?limit=100"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;

  class ListMandateMessagesRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Get, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/mandates/{Environment.GetEnvironmentVariable("MANDATE_ID")}/messages/?limit=100");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 200) throw new HttpRequestException(await response.Content.ReadAsStringAsync());
      }
  }
  ```

  ```bash cURL theme={null}
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/v2/mandates/$MANDATE_ID/messages/?limit=100" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" \
    --output messages.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

Follow every `next` URL and require exactly one row whose `title` equals
the command-unique title you sent; record its `id`. For an answered request,
read the request again and require `answered_at` to be set.

## Related reference

* [POST `/v2/orders/{order_id}/messages/`](/api-docs/case-management-api/reference/orders/create-order-message)
* [GET `/v2/orders/{order_id}/messages/`](/api-docs/case-management-api/reference/orders/list-order-messages)
* [POST `/v2/mandates/{mandate_id}/messages/`](/api-docs/case-management-api/reference/mandates/create-mandate-message)
* [GET `/v2/mandates/{mandate_id}/messages/`](/api-docs/case-management-api/reference/mandates/list-mandate-messages)
* [PATCH `/v2/mandates/{mandate_id}/messages/{id}/`](/api-docs/case-management-api/reference/mandates/update-mandate-message)
* [GET `/v2/mandates/{mandate_id}/requests-to-client/{id}/`](/api-docs/case-management-api/reference/mandates/get-mandate-request-to-client)
* [POST `/v2/mandates/{mandate_id}/requests-to-client/{id}/answer/`](/api-docs/case-management-api/reference/mandates/answer-mandate-request-to-client)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.