> ## 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.

# Consume Case webhooks

> Create a company subscription, store its one-time secret, verify raw deliveries, and recover from duplicates, reordering, and throttling.

For event types and payload keys, see [Webhook events](/api-docs/case-management-api/concepts/webhook-events).
The [shared webhook guide](/api-docs/essentials/webhooks) covers delivery behavior
and troubleshooting.

## Outcome

Your HTTPS handler verifies exact raw bytes before parsing, durably deduplicates
events, acknowledges after persistence, and refetches authoritative state.

## Prerequisites

* A public HTTPS endpoint without redirects or private/reserved resolution.
* A Bearer key.
* Secure secret storage, a replay window, and a durable event-ID receipt table.
* One stable idempotency key per create, test, redelivery, or rotation command.

## 1. Create and capture the one-time secret

The destination `https://hooks.example.test/paywise` is a placeholder like the
API key: replace it — here and in the runnable workflow below — with your own
publicly reachable HTTPS endpoint before running the sample. The API resolves
the hostname at creation time and rejects unreachable or private destinations
with `400 validation_error`, so the placeholder itself never creates a
subscription.

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  CREATE_WEBHOOK_KEY = "your-create-webhook-key"

  webhook_payload = {"url": "https://hooks.example.test/paywise", "enabled": True, "events": ["order.accepted", "mandate.created", "mandate.balance_updated"], "description": "Production case events", "max_consecutive_failures": 50}
  response = requests.post(
      f"{PAYWISE_API_URL}/v2/webhooks/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Content-Type": "application/json", "Idempotency-Key": CREATE_WEBHOOK_KEY},
      json=webhook_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 webhookPayload = { url: "https://hooks.example.test/paywise", enabled: true, events: ["order.accepted", "mandate.created", "mandate.balance_updated"], description: "Production case events", max_consecutive_failures: 50 };
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/webhooks/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.CREATE_WEBHOOK_KEY },
    body: JSON.stringify(webhookPayload), 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 CreateWebhookRequest {
    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/webhooks/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).header("Content-Type", "application/json")
          .header("Idempotency-Key", System.getenv("CREATE_WEBHOOK_KEY")).POST(HttpRequest.BodyPublishers.ofString("{\"url\":\"https://hooks.example.test/paywise\",\"enabled\":true,\"events\":[\"order.accepted\",\"mandate.created\",\"mandate.balance_updated\"],\"description\":\"Production case events\",\"max_consecutive_failures\":50}")).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 CreateWebhookRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          var payload = new { url = "https://hooks.example.test/paywise", enabled = true, events = new[] { "order.accepted", "mandate.created", "mandate.balance_updated" }, description = "Production case events", max_consecutive_failures = 50 };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/webhooks/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("CREATE_WEBHOOK_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}
  umask 077
  secret_response="$(mktemp "${TMPDIR:-/tmp}/paywise-webhook-create.XXXXXX")"
  trap 'rm -f "$secret_response"' EXIT HUP INT TERM
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/v2/webhooks/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Content-Type: application/json" \
    --header "Idempotency-Key: $CREATE_WEBHOOK_KEY" \
    --output "$secret_response" --write-out '%{http_code}' \
    --data '{"url":"https://hooks.example.test/paywise","enabled":true,"events":["order.accepted","mandate.created","mandate.balance_updated"],"description":"Production case events","max_consecutive_failures":50}')"
  [ "$http_status" -eq 201 ] || exit 1
  # Import secret_key from "$secret_response" directly into your secret manager.
  # Never print the file; the EXIT trap removes it on success or failure.
  ```
</CodeGroup>

`CaseWebhookWithSecret.secret_key` appears exactly once. Import it directly
from the restricted response into a secret manager and securely remove the
response; never print or log it. Ordinary read/update responses never expose
the secret.

## 2. Exhaust the secret-free subscription read

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/webhooks/",
      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/webhooks/?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 ListWebhooksRequest {
    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/webhooks/?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 ListWebhooksRequest
  {
      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/webhooks/?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/webhooks/?limit=100" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --output webhooks.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

## Representative response

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

{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": "b0000000-0000-4000-8000-000000000001",
      "contract_version": "v2",
      "url": "https://hooks.example.test/paywise",
      "enabled": true,
      "events": ["order.accepted", "mandate.created", "mandate.balance_updated"],
      "description": "Production case events",
      "max_consecutive_failures": 50,
      "consecutive_failures": 0,
      "auto_disabled": false,
      "last_failure_at": null,
      "created_at": "2026-08-27T13:00:00Z",
      "updated_at": "2026-08-27T13:00:00Z"
    }
  ]
}
```

This complete `PaginatedWebhookList` response correctly contains no secret.

## 3. Request a test delivery

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  TEST_WEBHOOK_KEY = "your-test-webhook-key"
  WEBHOOK_ID = "90000000-0000-4000-8000-000000000001"

  response = requests.post(
      f"{PAYWISE_API_URL}/v2/webhooks/{WEBHOOK_ID}/test/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Content-Type": "application/json", "Idempotency-Key": TEST_WEBHOOK_KEY},
      json={"event": "order.accepted"}, timeout=(5, 30),
  )
  if response.status_code != 202:
      response.raise_for_status()
      raise RuntimeError(f"Expected 202, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/webhooks/${process.env.WEBHOOK_ID}/test/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.TEST_WEBHOOK_KEY },
    body: JSON.stringify({ event: "order.accepted" }), signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 202) 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 TestWebhookRequest {
    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/webhooks/" + System.getenv("WEBHOOK_ID") + "/test/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY")).header("Content-Type", "application/json")
          .header("Idempotency-Key", System.getenv("TEST_WEBHOOK_KEY")).POST(HttpRequest.BodyPublishers.ofString("{\"event\":\"order.accepted\"}")).build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 202) 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 TestWebhookRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/webhooks/{Environment.GetEnvironmentVariable("WEBHOOK_ID")}/test/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("TEST_WEBHOOK_KEY"));
          request.Content = JsonContent.Create(new { @event = "order.accepted" });
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 202) 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/webhooks/$WEBHOOK_ID/test/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Content-Type: application/json" \
    --header "Idempotency-Key: $TEST_WEBHOOK_KEY" --output test-delivery.json --write-out '%{http_code}' \
    --data '{"event":"order.accepted"}')"
  [ "$http_status" -eq 202 ] || exit 1
  ```
</CodeGroup>

Capture the exact returned `delivery_id` and `event_id`. A fresh request ID is
not a delivery correlation key.

## 4. Read the stable delivery detail

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

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

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/webhook-deliveries/{DELIVERY_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/webhook-deliveries/${process.env.DELIVERY_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 GetWebhookDeliveryRequest {
    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/webhook-deliveries/" + System.getenv("DELIVERY_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 GetWebhookDeliveryRequest
  {
      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/webhook-deliveries/{Environment.GetEnvironmentVariable("DELIVERY_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/webhook-deliveries/$DELIVERY_ID/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --output delivery.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

## 5. Redeliver only after exact ownership validation

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  DELIVERY_ID = "91000000-0000-4000-8000-000000000001"
  REDELIVER_KEY = "your-redeliver-key"

  response = requests.post(
      f"{PAYWISE_API_URL}/v2/webhook-deliveries/{DELIVERY_ID}/redeliver/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Idempotency-Key": REDELIVER_KEY}, timeout=(5, 30),
  )
  if response.status_code != 202:
      response.raise_for_status()
      raise RuntimeError(f"Expected 202, received {response.status_code}")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/v2/webhook-deliveries/${process.env.DELIVERY_ID}/redeliver/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Idempotency-Key": process.env.REDELIVER_KEY }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 202) 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 RedeliverWebhookRequest {
    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/webhook-deliveries/" + System.getenv("DELIVERY_ID") + "/redeliver/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY"))
          .header("Idempotency-Key", System.getenv("REDELIVER_KEY")).POST(HttpRequest.BodyPublishers.noBody()).build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 202) throw new IOException(response.body());
    }
  }
  ```

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

  class RedeliverWebhookRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/webhook-deliveries/{Environment.GetEnvironmentVariable("DELIVERY_ID")}/redeliver/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("REDELIVER_KEY"));
          using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
          using var response = await client.SendAsync(request, cancellation.Token);
          if ((int)response.StatusCode != 202) 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/webhook-deliveries/$DELIVERY_ID/redeliver/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Idempotency-Key: $REDELIVER_KEY" \
    --output redelivery.json --write-out '%{http_code}')"
  [ "$http_status" -eq 202 ] || exit 1
  ```
</CodeGroup>

The management workflow below fetches the stable delivery detail and fails
closed unless both its exact `id` and exact `webhook` equal the expected
values. The redelivery POST occurs only after that check.

Redeliver only a settled delivery — `status` `success` or `failed`. While a
delivery is `pending`, `retrying`, or `delivering`, the dispatcher still owns
it and the command is `409 delivery_in_progress`; wait for it to settle and
read the outcome before deciding. The command takes no request body
(`400 unexpected_body` otherwise), and its `expensive:webhook-delivery`
budget unit is charged only when the `202` is returned.

## 6. Rotate and retain overlap secrets

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_API_KEY = "pw_sbx_your_api_key"
  ROTATE_WEBHOOK_KEY = "your-rotate-webhook-key"
  WEBHOOK_ID = "90000000-0000-4000-8000-000000000001"

  response = requests.post(
      f"{PAYWISE_API_URL}/v2/webhooks/{WEBHOOK_ID}/rotate-secret/",
      headers={"Authorization": f"Bearer {PAYWISE_API_KEY}", "Idempotency-Key": ROTATE_WEBHOOK_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/webhooks/${process.env.WEBHOOK_ID}/rotate-secret/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_API_KEY}`, "Idempotency-Key": process.env.ROTATE_WEBHOOK_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 RotateWebhookSecretRequest {
    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/webhooks/" + System.getenv("WEBHOOK_ID") + "/rotate-secret/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_API_KEY"))
          .header("Idempotency-Key", System.getenv("ROTATE_WEBHOOK_KEY")).POST(HttpRequest.BodyPublishers.noBody()).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 RotateWebhookSecretRequest
  {
      static async Task Main()
      {
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/v2/webhooks/{Environment.GetEnvironmentVariable("WEBHOOK_ID")}/rotate-secret/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_API_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("ROTATE_WEBHOOK_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}
  umask 077
  secret_response="$(mktemp "${TMPDIR:-/tmp}/paywise-webhook-rotate.XXXXXX")"
  trap 'rm -f "$secret_response"' EXIT HUP INT TERM
  http_status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/v2/webhooks/$WEBHOOK_ID/rotate-secret/" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" --header "Idempotency-Key: $ROTATE_WEBHOOK_KEY" \
    --output "$secret_response" --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  # Import secret_key from "$secret_response" directly into your secret manager.
  # Never print the file; the EXIT trap removes it on success or failure.
  ```
</CodeGroup>

Capture the new `secret_key` once. Try the current secret first and retain the
prior secret for the documented 24-hour overlap. Every delivery for the
`contract_version=v2` subscription used in this workflow, including retries
created before rotation, carries newest and previous signatures during that
window. Remove the prior secret after the overlap.

## Exact raw-body verification and durable receipt

Read `webhook-id`, `webhook-timestamp`, and `webhook-signature` before parsing.
The signed bytes are exactly
`<event_id>.<unix_timestamp>.<exact raw JSON>`. Split the space-separated
`v1,<base64>` signatures, strictly Base64-decode and validate every entry,
then accept any raw HMAC-SHA256 digest matching a held secret in constant
time. Enforce the replay window, then parse JSON and require payload `id` to
equal the header ID. Compatibility aliases are not additional signatures.

Persist the event ID, raw/minimized receipt, and one pending outbox job in the
same durable transaction before returning `2xx`. A duplicate receives success
only after the prior receipt and job are confirmed. A separate recoverable
worker claims pending or interrupted jobs, performs an idempotent domain
effect, and marks the job complete. It refetches authoritative state by stable
identity; delivery retries are not a job queue.

For acceptance, reconcile the stable claim UUID you stored when the draft was
created. The submitted order ID is informational after review: paywise may
accept the claim under a different order without changing the claim UUID.
Match membership in `claim_ids`, then read the claim directly and adopt its
current `order_id` and `mandate_id`:

```text Acceptance reconciliation theme={null}
if event["type"] == "order.accepted":
    affected = set(event["data"]["claim_ids"])
    if stored_claim_id in affected:
        claim = checked_get(f"/v2/claims/{stored_claim_id}/")
        require(claim["status"] == "accepted")
        require(claim["mandate_id"] == event["data"]["mandate_id"])
        current_order_id = claim["order_id"]
```

Clients do not reconstruct A→B merge chains; they do not follow `merged_into` for claim commands.
Every claim-specific read or command continues at its
top-level `/v2/claims/{claim_id}/…` URL.

## Runnable Python workflow

```python Python workflow theme={null}
import base64
import binascii
import hashlib
import hmac
import json
import sqlite3
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"
WEBHOOK_CURRENT_SECRET = "your-webhook-current-secret"
WEBHOOK_ID_HEADER = "your-webhook-id-header"
WEBHOOK_NOW = "1788163200"
WEBHOOK_PHASE = "management"
WEBHOOK_PREVIOUS_SECRET = "your-webhook-previous-secret"
WEBHOOK_RAW_BODY = "<WEBHOOK_RAW_BODY>"
WEBHOOK_RECEIPT_DB = "./webhook-receipts.sqlite3"
WEBHOOK_SIGNATURE_HEADER = "your-webhook-signature-header"
WEBHOOK_TIMESTAMP_HEADER = "1788163200"
WEBHOOK_WORKER_CRASH_ONCE = "false"

phase = WEBHOOK_PHASE
if phase not in {"management", "handler", "worker"}:
    raise ValueError("WEBHOOK_PHASE must be management, handler, or worker")

def run_management():
    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}"}

    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/webhooks/",
        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")

    webhook_payload = {
        "url": "https://hooks.example.test/paywise",
        "enabled": True,
        "events": ["order.accepted", "mandate.created", "mandate.balance_updated"],
        "description": "Production case events",
        "max_consecutive_failures": 50,
    }
    create_key = str(uuid.uuid4())
    created_response = post_command(
        f"{base_url}/v2/webhooks/",
        webhook_payload,
        201,
        create_key,
    )
    created = created_response.json()
    webhook_id = created["id"]
    previous_secret = created.pop("secret_key")
    if not webhook_id or not previous_secret:
        raise RuntimeError("Create response omitted the one-time identity or secret")

    next_url = f"{base_url}/v2/webhooks/"
    params = {"limit": 100}
    webhooks_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
        read_response = requests.get(
            next_url, headers=headers, params=params, timeout=(5, 30)
        )
        if read_response.status_code != 200:
            read_response.raise_for_status()
            raise RuntimeError(f"Expected 200, received {read_response.status_code}")
        page = read_response.json()
        for candidate in page["results"]:
            if candidate.get("id") == webhook_id:
                webhooks_by_id[candidate["id"]] = candidate
        next_url = page.get("next")
        params = None
    if set(webhooks_by_id) != {webhook_id}:
        raise RuntimeError("Created webhook was not found by exact ID")
    read_model = webhooks_by_id[webhook_id]
    if "secret_key" in read_model:
        raise RuntimeError("Webhook read identity or secret-free contract failed")

    test_key = str(uuid.uuid4())
    test_response = post_command(
        f"{base_url}/v2/webhooks/{webhook_id}/test/",
        {"event": "order.accepted"},
        202,
        test_key,
    )
    test_command = test_response.json()
    delivery_id = test_command["delivery_id"]
    event_id = test_command["event_id"]

    detail_response = requests.get(
        f"{base_url}/v2/webhook-deliveries/{delivery_id}/",
        headers=headers,
        timeout=(5, 30),
    )
    if detail_response.status_code != 200:
        detail_response.raise_for_status()
        raise RuntimeError(f"Expected 200, received {detail_response.status_code}")
    delivery = detail_response.json()
    if delivery.get("id") != delivery_id or delivery.get("webhook") != webhook_id:
        raise RuntimeError("Delivery ownership mismatch")

    redelivery_key = str(uuid.uuid4())
    redelivery_response = post_command(
        f"{base_url}/v2/webhook-deliveries/{delivery_id}/redeliver/",
        None,
        202,
        redelivery_key,
    )
    redelivery = redelivery_response.json()
    if redelivery.get("event_id") != event_id or not redelivery.get("delivery_id"):
        raise RuntimeError("Redelivery did not preserve the event identity")

    rotate_key = str(uuid.uuid4())
    rotate_response = post_command(
        f"{base_url}/v2/webhooks/{webhook_id}/rotate-secret/",
        None,
        200,
        rotate_key,
    )
    rotated = rotate_response.json()
    current_secret = rotated["secret_key"]
    if rotated.get("id") != webhook_id or not current_secret:
        raise RuntimeError("Rotation response identity or one-time secret failed")
    # Import both one-time values into your secret manager here; never log them.
    return {
        "phase": "management",
        "webhook_id": webhook_id,
        "delivery_id": delivery_id,
        "redelivery_id": redelivery["delivery_id"],
        "event_id": event_id,
    }

def verify_raw_delivery(raw_body, event_id, timestamp_text, signature_header, secrets, now):
    try:
        provided_digests = []
        versions = []
        for signature in signature_header.split(" "):
            version, encoded_digest = signature.split(",", 1)
            provided_digest = base64.b64decode(encoded_digest, validate=True)
            versions.append(version)
            provided_digests.append(provided_digest)
        timestamp = int(timestamp_text)
    except (ValueError, binascii.Error) as error:
        raise ValueError("Malformed webhook signature headers") from error
    if any(version != "v1" for version in versions) or any(
        len(digest) != hashlib.sha256().digest_size for digest in provided_digests
    ):
        raise ValueError("Unsupported webhook signature")
    if abs(now - timestamp) > 300:
        raise ValueError("Webhook timestamp is outside the replay window")
    message = event_id.encode() + b"." + timestamp_text.encode() + b"." + raw_body
    accepted_secret = None
    for label, secret in secrets:
        expected_digest = hmac.new(secret.encode(), message, hashlib.sha256).digest()
        for provided_digest in provided_digests:
            if hmac.compare_digest(expected_digest, provided_digest):
                accepted_secret = label
                break
        if accepted_secret is not None:
            break
    if accepted_secret is None:
        raise ValueError("Invalid webhook signature")
    payload = json.loads(raw_body)
    if payload.get("id") != event_id:
        raise ValueError("Webhook header and payload IDs differ")
    return payload, accepted_secret

def initialize_receipt_store(database_path):
    database = sqlite3.connect(database_path)
    try:
        with database:
            database.execute(
                "CREATE TABLE IF NOT EXISTS receipts "
                "(event_id TEXT PRIMARY KEY, raw_body BLOB NOT NULL)"
            )
            database.execute(
                "CREATE TABLE IF NOT EXISTS outbox_jobs "
                "(event_id TEXT PRIMARY KEY, status TEXT NOT NULL, attempts INTEGER NOT NULL)"
            )
            database.execute(
                "CREATE TABLE IF NOT EXISTS processed_events "
                "(event_id TEXT PRIMARY KEY)"
            )
    finally:
        database.close()

def webhook_handler(database_path, event_payload, exact_body, receipt_phases):
    database = sqlite3.connect(database_path)
    try:
        with database:
            inserted = database.execute(
                "INSERT OR IGNORE INTO receipts(event_id, raw_body) VALUES (?, ?)",
                (event_payload["id"], exact_body),
            ).rowcount == 1
            if inserted:
                database.execute(
                    "INSERT INTO outbox_jobs(event_id, status, attempts) "
                    "VALUES (?, 'pending', 0)",
                    (event_payload["id"],),
                )
            else:
                prior = database.execute(
                    "SELECT raw_body FROM receipts WHERE event_id = ?",
                    (event_payload["id"],),
                ).fetchone()
                job = database.execute(
                    "SELECT event_id FROM outbox_jobs WHERE event_id = ?",
                    (event_payload["id"],),
                ).fetchone()
                if prior is None or bytes(prior[0]) != exact_body or job is None:
                    raise RuntimeError("Duplicate receipt or outbox identity mismatch")
    finally:
        database.close()
    if inserted:
        receipt_phases.append("persisted")
    receipt_phases.append("ack")
    return 204

def run_one_pending_job(database_path, receipt_phases, processed_event_ids, worker_state):
    database = sqlite3.connect(database_path)
    try:
        job = database.execute(
            "SELECT event_id, attempts FROM outbox_jobs "
            "WHERE status IN ('pending', 'processing') ORDER BY event_id LIMIT 1"
        ).fetchone()
        if job is None:
            return False
        job_event_id, attempts = job
        with database:
            database.execute(
                "UPDATE outbox_jobs SET status = 'processing', attempts = attempts + 1 "
                "WHERE event_id = ?",
                (job_event_id,),
            )
        # This table stands in for an idempotent domain side effect. A real
        # worker refetches authoritative state using the stable event identity.
        with database:
            database.execute(
                "INSERT OR IGNORE INTO processed_events(event_id) VALUES (?)",
                (job_event_id,),
            )
        if (
            WEBHOOK_WORKER_CRASH_ONCE == "true"
            and not worker_state["crashed"]
        ):
            worker_state["crashed"] = True
            raise RuntimeError("Simulated worker crash after idempotent side effect")
        with database:
            database.execute(
                "UPDATE outbox_jobs SET status = 'completed' WHERE event_id = ?",
                (job_event_id,),
            )
        processed_event_ids.append(job_event_id)
        receipt_phases.append("processed")
        return True
    finally:
        database.close()

def receipt_store_state(database_path):
    database = sqlite3.connect(database_path)
    try:
        return {
            "stored_event_count": database.execute(
                "SELECT COUNT(*) FROM receipts"
            ).fetchone()[0],
            "job_count": database.execute(
                "SELECT COUNT(*) FROM outbox_jobs"
            ).fetchone()[0],
            "pending_job_count": database.execute(
                "SELECT COUNT(*) FROM outbox_jobs WHERE status != 'completed'"
            ).fetchone()[0],
            "completed_job_count": database.execute(
                "SELECT COUNT(*) FROM outbox_jobs WHERE status = 'completed'"
            ).fetchone()[0],
            "processed_event_count": database.execute(
                "SELECT COUNT(*) FROM processed_events"
            ).fetchone()[0],
            "worker_attempts": database.execute(
                "SELECT COALESCE(MAX(attempts), 0) FROM outbox_jobs"
            ).fetchone()[0],
        }
    finally:
        database.close()

if phase == "management":
    WORKFLOW_RESULT = run_management()
else:
    database_path = WEBHOOK_RECEIPT_DB
    initialize_receipt_store(database_path)
    receipt_phases = []
    processed_event_ids = []
    handler_ack_status = None
    accepted_secret = None
    if phase == "handler":
        raw_body = WEBHOOK_RAW_BODY.encode()
        payload, accepted_secret = verify_raw_delivery(
            raw_body,
            WEBHOOK_ID_HEADER,
            WEBHOOK_TIMESTAMP_HEADER,
            WEBHOOK_SIGNATURE_HEADER,
            [
                ("current", WEBHOOK_CURRENT_SECRET),
                ("previous", WEBHOOK_PREVIOUS_SECRET),
            ],
            int(WEBHOOK_NOW),
        )
        handler_ack_status = webhook_handler(
            database_path, payload, raw_body, receipt_phases
        )
        if handler_ack_status != 204:
            raise RuntimeError("Handler must acknowledge only after durable commit")
    else:
        # An exception leaves durable processing state for a later fresh worker.
        run_one_pending_job(
            database_path,
            receipt_phases,
            processed_event_ids,
            {"crashed": False},
        )
    state = receipt_store_state(database_path)
    if phase == "handler" and (
        state["stored_event_count"] != 1 or state["job_count"] != 1
    ):
        raise RuntimeError("Durable receipt and outbox invariant failed")
    if state["processed_event_count"] > state["job_count"]:
        raise RuntimeError("Idempotent effect invariant failed")
    WORKFLOW_RESULT = {
        "phase": phase,
        "accepted_secret": accepted_secret,
        "receipt_phases": receipt_phases,
        "handler_ack_status": handler_ack_status,
        **state,
    }
```

Run subscription setup, test delivery, ownership-checked redelivery, and secret
rotation only with `WEBHOOK_PHASE=management`. Deploy the HTTP receiver with
`WEBHOOK_PHASE=handler` and the recoverable job runner with
`WEBHOOK_PHASE=worker`, both pointing at the same durable database. Handler and
worker invocations make no paywise management API calls and need no API URL or
API token. Supply the captured values to the receiver as
`WEBHOOK_CURRENT_SECRET` and `WEBHOOK_PREVIOUS_SECRET`; the worker needs neither.
Each invocation is independent: a worker crash is recovered by a later fresh
worker execution, not an in-memory retry. There is deliberately no combined or
default phase; an omitted or unknown phase fails closed.

The subscription search uses a five-page example budget. Keep a finite
production budget plus cycle and exact HTTPS-origin validation before every
opaque `next` request so the Bearer token is never sent elsewhere.

## Failure and recovery

* Invalid signature, mismatched ID, or stale timestamp: reject before domain
  parsing/processing and record only safe diagnostics.
* Duplicate ID: acknowledge only after confirming the durable prior receipt.
* Out-of-order event: persist, acknowledge, and refetch current state.
* Management `429`: test and redelivery share a company bucket; wait at least
  `Retry-After`, then retry the same command and idempotency key.
* `409 delivery_in_progress`: the delivery is still `pending`, `retrying`,
  or `delivering`. Do not retry blindly; poll the delivery until it settles.
* `409 webhook_limit_reached`: the company already holds 20 endpoints
  (enabled or disabled). The cap is enforced atomically, so parallel creates
  cannot slip past it; delete an unused endpoint first.
* `400 unexpected_body` on `rotate-secret` or `redeliver`: send the command
  without a body.
* `403 use_partner_webhooks`: a Partner key with `X-On-Behalf-Of-Company`
  cannot create, change, or redeliver company endpoints; this answer comes
  before any `If-Match` (`412`) or query-parameter (`400`) check. Use the
  Partner-owned endpoints instead.
* Management timeout, `500`, or `503`: retry the same POST and key.

## Verify

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

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

  response = requests.get(
      f"{PAYWISE_API_URL}/v2/webhook-deliveries/?webhook={WEBHOOK_ID}&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/webhook-deliveries/?webhook=${process.env.WEBHOOK_ID}&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 ListDeliveriesRequest {
    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/webhook-deliveries/?webhook=" + System.getenv("WEBHOOK_ID") + "&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 ListDeliveriesRequest
  {
      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/webhook-deliveries/?webhook={Environment.GetEnvironmentVariable("WEBHOOK_ID")}&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/webhook-deliveries/?webhook=$WEBHOOK_ID&limit=100" \
    --header "Authorization: Bearer $PAYWISE_API_KEY" \
    --output deliveries.json --write-out '%{http_code}')"
  [ "$http_status" -eq 200 ] || exit 1
  ```
</CodeGroup>

Require the row whose `event_id` equals the test command's `event_id` to
report `status` `success` and a `2xx` `response_status_code`, and confirm
your receiver stored exactly one receipt for that event UUID — a second
receipt means deduplication did not hold.

## Related reference

* [POST `/v2/webhooks/`](/api-docs/case-management-api/reference/webhooks/create-webhook)
* [GET `/v2/webhooks/`](/api-docs/case-management-api/reference/webhooks/list-webhooks)
* [POST `/v2/webhooks/{id}/test/`](/api-docs/case-management-api/reference/webhooks/test-webhook)
* [POST `/v2/webhooks/{id}/rotate-secret/`](/api-docs/case-management-api/reference/webhooks/rotate-webhook-secret)
* [GET `/v2/webhook-deliveries/`](/api-docs/case-management-api/reference/webhook-deliveries/list-webhook-deliveries)
* [GET `/v2/webhook-deliveries/{id}/`](/api-docs/case-management-api/reference/webhook-deliveries/get-webhook-delivery)
* [POST `/v2/webhook-deliveries/{id}/redeliver/`](/api-docs/case-management-api/reference/webhook-deliveries/redeliver-webhook-delivery)


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