> ## 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 Partner webhooks

> Create a Partner-owned subscription, capture its one-time secret, verify raw deliveries, and recover from duplicates and access changes.

## Outcome

Your HTTPS handler verifies Partner lifecycle signatures before parsing,
durably deduplicates at-least-once events, temporarily gates delegated writes
for access events, and refetches authoritative company state independently of
delivery order.

## Prerequisites

* A public HTTPS endpoint that neither redirects nor resolves to a private or
  reserved address.
* A Partner key.
* Secure secret storage, constant-time HMAC comparison, a replay window, and a
  durable table keyed by event UUID.
* Stable idempotency keys for create, test, rotate, or manual-redelivery
  commands.

## Lifecycle context

Partner subscriptions receive company lifecycle events and, with a `companies`
selector, Case events for connected companies. This example uses
`companies: "*"` to include current and future connections. Readiness emits
only when its boolean changes. Confirmation emits once per authorization
version. Revocation is the final event whose creation
and signature are authorized by the previous state, not necessarily the last
delivery chronologically. Restoration resumes future events without replay.
Delivery is at least once and unordered.

## 1. Install the sandbox guard

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"},
      timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  environments = [value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"]
  if environments != ["sandbox"]:
      raise RuntimeError("Refusing Partner write outside the sandbox")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/info/`, {
    method: "GET",
    headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}` },
    signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  const environments = response.headers.get("X-Paywise-Environment");
  if (environments !== "sandbox") throw new Error("Refusing Partner write outside the sandbox");
  ```

  ```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 PartnerWebhookSandboxProof {
    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") + "/partner/v2/info/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
      String environment = response.headers().firstValue("X-Paywise-Environment").orElse("");
      if (!"sandbox".equals(environment)) throw new IOException("Refusing Partner write outside the sandbox");
    }
  }
  ```

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

  class PartnerWebhookSandboxProof
  {
      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")}/partner/v2/info/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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());
          if (!response.Headers.TryGetValues("X-Paywise-Environment", out var values)
              || values.Count() != 1
              || !string.Equals(values.Single(), "sandbox", StringComparison.Ordinal))
              throw new HttpRequestException("Refusing Partner write outside the sandbox");
      }
  }
  ```

  ```bash cURL theme={null}
  proof="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/info/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --dump-header - --output /dev/null --write-out '\n%{http_code}')"
  status="$(printf '%s\n' "$proof" | tail -n 1)"
  [ "$status" != "200" ] && exit 1
  environment="$(printf '%s\n' "$proof" | awk '
    {
      separator = index($0, ":")
      if (!separator || tolower(substr($0, 1, separator - 1)) != "x-paywise-environment") next
      count++
      value = substr($0, separator + 1)
      sub(/\r$/, "", value)
      sub(/^[ \t]*/, "", value)
      sub(/[ \t]*$/, "", value)
    }
    END { if (count != 1) exit 1; print value }
  ')" || exit 1
  [ "$environment" != "sandbox" ] && exit 1
  :
  ```
</CodeGroup>

The function makes an authenticated safe read and fails closed if the exact
case-insensitive environment header is absent or not `sandbox`.

## 2. Create a Partner-owned subscription

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

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

  webhook_payload = {
      "url": "https://hooks.example.test/paywise-partner",
      "enabled": True,
      "events": ["*"],
      "companies": "*",
      "description": "Partner company and case updates",
  }
  response = requests.post(
      f"{PAYWISE_API_URL}/partner/v2/webhooks/",
      headers={
          "Authorization": f"Bearer {PAYWISE_PARTNER_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-partner", enabled: true, events: ["*"], companies: "*", description: "Partner company and case updates" };
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/webhooks/`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_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 CreatePartnerWebhook {
    public static void main(String[] args) throws IOException, InterruptedException {
      String json = "{\"url\":\"https://hooks.example.test/paywise-partner\",\"enabled\":true,\"events\":[\"*\"],\"companies\":\"*\",\"description\":\"Partner company and case updates\"}";
      HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
      HttpRequest request = HttpRequest.newBuilder().uri(URI.create(System.getenv("PAYWISE_API_URL") + "/partner/v2/webhooks/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY"))
          .header("Content-Type", "application/json").header("Idempotency-Key", System.getenv("CREATE_WEBHOOK_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 CreatePartnerWebhook
  {
      static async Task Main()
      {
          var payload = new { url = "https://hooks.example.test/paywise-partner", enabled = true, events = new[] { "*" }, companies = "*", description = "Partner company and case updates" };
          using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
          using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/partner/v2/webhooks/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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}/partner-webhook-created.XXXXXX")"
  chmod 600 "$secret_response"
  trap 'rm -f "$secret_response"' EXIT HUP INT TERM
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/webhooks/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --header "Content-Type: application/json" \
    --header "Idempotency-Key: $CREATE_WEBHOOK_KEY" \
    --output "$secret_response" --write-out '%{http_code}' \
    --data @- <<'JSON'
  {"url":"https://hooks.example.test/paywise-partner","enabled":true,"events":["*"],"companies":"*","description":"Partner company and case updates"}
  JSON
  )"
  [ "$status" != "201" ] && exit 1
  ```
</CodeGroup>

The create response uses the documented `PartnerWebhookWithSecret` shape and
contains `secret_key` exactly once. Import `.secret_key` directly from the
restricted response file into a secrets manager, then securely remove the
file. List, retrieve, and update responses never reveal it. See the
[shared one-time-secret rule](/api-docs/essentials/webhooks#signing-secrets).

Never print or log the secret. Omitting `events` subscribes to the Partner
lifecycle catalog that exists at creation.

The machine contract supports `"events":["*"]`, with the wildcard as the only
element. With `companies: "*"`, it includes all supported company and Case
events, including future event types. Replace it with explicit event names
when you need only selected updates; never combine the wildcard with names.
The test command's `event` field remains limited to one concrete event from
the Partner catalog.

## 3. Confirm the secret-free read model

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/webhooks/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_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(/\/$/, "")}/partner/v2/webhooks/?limit=100`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_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 ListPartnerWebhooks {
    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") + "/partner/v2/webhooks/?limit=100"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_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 ListPartnerWebhooks
  {
      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")}/partner/v2/webhooks/?limit=100");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/webhooks/?limit=100" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --output partner-webhooks.json --write-out '%{http_code}')"
  [ "$status" != "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",
    "url": "https://hooks.example.test/paywise-partner",
    "enabled": true,
    "events": ["*"],
    "companies": "*",
    "description": "Partner company and case updates",
    "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 response matches `PaginatedPartnerWebhookList` and correctly
contains no secret.

## 4. Verify exact delivery bytes

Read `webhook-id`, `webhook-timestamp`, and `webhook-signature` before parsing.
Preserve the exact body bytes and build the UTF-8 message:

```text theme={null}
<webhook-id>.<webhook-timestamp>.<raw-body>
```

Compute HMAC-SHA256 using the endpoint secret as UTF-8, Base64-encode the raw
32-byte digest and compare in constant time against the header's space-separated
`v1,<base64>` entries. Strictly decode and validate every entry before
accepting any signature matching a held secret. Confirm the
header ID equals payload `id` and reject timestamps outside your replay window.
`X-Paywise-Signature` and `X-Paywise-Timestamp` are compatibility aliases, not
additional signatures.

In one durable receipt transaction, insert the event UUID and processing
record. Return `2xx` after durable acceptance, including for an already stored
duplicate, then refetch `data.company_url` asynchronously. Never use webhook
retry delivery as the application's job queue.

## 5. Prove the sandbox before requesting a test delivery

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"},
      timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  environments = [value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"]
  if environments != ["sandbox"]:
      raise RuntimeError("Refusing Partner write outside the sandbox")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/info/`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  if (response.headers.get("X-Paywise-Environment") !== "sandbox") throw new Error("Refusing Partner write outside the sandbox");
  ```

  ```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 PartnerWebhookSandboxProof {
    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") + "/partner/v2/info/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
      String environment = response.headers().firstValue("X-Paywise-Environment").orElse("");
      if (!"sandbox".equals(environment)) throw new IOException("Refusing Partner write outside the sandbox");
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Linq;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;
  class PartnerWebhookSandboxProof {
      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")}/partner/v2/info/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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());
          if (!response.Headers.TryGetValues("X-Paywise-Environment", out var values)
              || values.Count() != 1 || !string.Equals(values.Single(), "sandbox", StringComparison.Ordinal))
              throw new HttpRequestException("Refusing Partner write outside the sandbox");
      }
  }
  ```

  ```bash cURL theme={null}
  proof="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/info/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --dump-header - --output /dev/null --write-out '\n%{http_code}')"
  status="$(printf '%s\n' "$proof" | tail -n 1)"
  [ "$status" != "200" ] && exit 1
  environment="$(printf '%s\n' "$proof" | awk '
    {
      separator = index($0, ":")
      if (!separator || tolower(substr($0, 1, separator - 1)) != "x-paywise-environment") next
      count++
      value = substr($0, separator + 1)
      sub(/\r$/, "", value)
      sub(/^[ \t]*/, "", value)
      sub(/[ \t]*$/, "", value)
    }
    END { if (count != 1) exit 1; print value }
  ')" || exit 1
  [ "$environment" != "sandbox" ] && exit 1
  :
  ```
</CodeGroup>

## 6. Test with stable runtime identifiers

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

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

  response = requests.post(
      f"{PAYWISE_API_URL}/partner/v2/webhooks/{WEBHOOK_ID}/test/",
      headers={
          "Authorization": f"Bearer {PAYWISE_PARTNER_KEY}",
          "Content-Type": "application/json",
          "Idempotency-Key": TEST_WEBHOOK_KEY,
      },
      json={"event": "company.created"},
      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(/\/$/, "")}/partner/v2/webhooks/${process.env.WEBHOOK_ID}/test/`, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.TEST_WEBHOOK_KEY },
    body: JSON.stringify({ event: "company.created" }), 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 TestPartnerWebhook {
    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") + "/partner/v2/webhooks/" + System.getenv("WEBHOOK_ID") + "/test/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY"))
          .header("Content-Type", "application/json").header("Idempotency-Key", System.getenv("TEST_WEBHOOK_KEY"))
          .POST(HttpRequest.BodyPublishers.ofString("{\"event\":\"company.created\"}")).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 TestPartnerWebhook {
      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")}/partner/v2/webhooks/{Environment.GetEnvironmentVariable("WEBHOOK_ID")}/test/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("TEST_WEBHOOK_KEY"));
          request.Content = JsonContent.Create(new { @event = "company.created" });
          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}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/webhooks/$WEBHOOK_ID/test/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --header "Content-Type: application/json" \
    --header "Idempotency-Key: $TEST_WEBHOOK_KEY" \
    --output partner-test-delivery-raw.json --write-out '%{http_code}' \
    --data '{"event":"company.created"}')"
  [ "$status" != "202" ] && exit 1
  ```
</CodeGroup>

Capture `delivery_id` and `event_id` from this response. Read that exact
delivery directly; never choose a list position.

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

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

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/webhook-deliveries/{DELIVERY_ID}/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_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(/\/$/, "")}/partner/v2/webhook-deliveries/${process.env.DELIVERY_ID}/`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_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 GetPartnerWebhookDelivery {
    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") + "/partner/v2/webhook-deliveries/" + System.getenv("DELIVERY_ID") + "/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_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 GetPartnerWebhookDelivery {
      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")}/partner/v2/webhook-deliveries/{Environment.GetEnvironmentVariable("DELIVERY_ID")}/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/webhook-deliveries/$DELIVERY_ID/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --output partner-test-delivery.json --write-out '%{http_code}')"
  [ "$status" != "200" ] && exit 1
  ```
</CodeGroup>

The test command returns `202 Accepted` with required `detail`, stable
`delivery_id`, and `event_id` fields. The shared idempotency executor replays
that body for an exact retry while it is retained, so reuse the same key and
body after a timeout. The response's fresh `X-Paywise-Request-Id` is not a
delivery or execution identifier.

Retrieve the exact runtime `delivery_id`; do not choose a delivery list's first
row. Multiple subscriptions may legitimately create several delivery records
for the same event UUID. The authenticated Partner-scoped detail endpoint is
the ownership boundary: require its returned `id` and `event_id` to match the
two identifiers captured from the test command. A delivery detail does not
carry a subscription identifier.

## 7. Prove the sandbox before manual redelivery

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"},
      timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  environments = [value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"]
  if environments != ["sandbox"]:
      raise RuntimeError("Refusing Partner write outside the sandbox")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/info/`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  if (response.headers.get("X-Paywise-Environment") !== "sandbox") throw new Error("Refusing Partner write outside the sandbox");
  ```

  ```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 PartnerWebhookSandboxProof {
    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") + "/partner/v2/info/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
      String environment = response.headers().firstValue("X-Paywise-Environment").orElse("");
      if (!"sandbox".equals(environment)) throw new IOException("Refusing Partner write outside the sandbox");
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Linq;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;
  class PartnerWebhookSandboxProof {
      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")}/partner/v2/info/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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());
          if (!response.Headers.TryGetValues("X-Paywise-Environment", out var values)
              || values.Count() != 1 || !string.Equals(values.Single(), "sandbox", StringComparison.Ordinal))
              throw new HttpRequestException("Refusing Partner write outside the sandbox");
      }
  }
  ```

  ```bash cURL theme={null}
  proof="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/info/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --dump-header - --output /dev/null --write-out '\n%{http_code}')"
  status="$(printf '%s\n' "$proof" | tail -n 1)"
  [ "$status" != "200" ] && exit 1
  environment="$(printf '%s\n' "$proof" | awk '
    {
      separator = index($0, ":")
      if (!separator || tolower(substr($0, 1, separator - 1)) != "x-paywise-environment") next
      count++
      value = substr($0, separator + 1)
      sub(/\r$/, "", value)
      sub(/^[ \t]*/, "", value)
      sub(/[ \t]*$/, "", value)
    }
    END { if (count != 1) exit 1; print value }
  ')" || exit 1
  [ "$environment" != "sandbox" ] && exit 1
  :
  ```
</CodeGroup>

## 8. Redeliver an exact retained delivery

Use a source delivery UUID captured from a command response or durable delivery
record, never a list position. Runtime creates a new delivery UUID while
preserving the original event UUID and payload:

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  DELIVERY_ID = "91000000-0000-4000-8000-000000000001"
  REDELIVER_WEBHOOK_KEY = "your-redeliver-webhook-key"

  response = requests.post(
      f"{PAYWISE_API_URL}/partner/v2/webhook-deliveries/{DELIVERY_ID}/redeliver/",
      headers={
          "Authorization": f"Bearer {PAYWISE_PARTNER_KEY}",
          "Idempotency-Key": REDELIVER_WEBHOOK_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(/\/$/, "")}/partner/v2/webhook-deliveries/${process.env.DELIVERY_ID}/redeliver/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Idempotency-Key": process.env.REDELIVER_WEBHOOK_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 RedeliverPartnerWebhook {
    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") + "/partner/v2/webhook-deliveries/" + System.getenv("DELIVERY_ID") + "/redeliver/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY"))
          .header("Idempotency-Key", System.getenv("REDELIVER_WEBHOOK_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 RedeliverPartnerWebhook {
      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")}/partner/v2/webhook-deliveries/{Environment.GetEnvironmentVariable("DELIVERY_ID")}/redeliver/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
          request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("REDELIVER_WEBHOOK_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}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/webhook-deliveries/$DELIVERY_ID/redeliver/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --header "Idempotency-Key: $REDELIVER_WEBHOOK_KEY" \
    --output partner-redelivery-raw.json --write-out '%{http_code}')"
  [ "$status" != "202" ] && exit 1
  ```
</CodeGroup>

The representative command response is:

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

{
  "detail": "Delivery re-queued.",
  "delivery_id": "b2000000-0000-4000-8000-000000000001",
  "event_id": "b1000000-0000-4000-8000-000000000001"
}
```

This documented response matches `PartnerWebhookCommandResult`. Correlate
retries with its stable new `delivery_id` and preserved `event_id`, and do not
substitute the fresh response request ID. An exact retry uses the same
idempotency key and body.

## 9. Prove the sandbox before rotating the secret

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"

  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"},
      timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  environments = [value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"]
  if environments != ["sandbox"]:
      raise RuntimeError("Refusing Partner write outside the sandbox")
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/info/`, {
    method: "GET", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}` }, signal: AbortSignal.timeout(30000),
  });
  if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  if (response.headers.get("X-Paywise-Environment") !== "sandbox") throw new Error("Refusing Partner write outside the sandbox");
  ```

  ```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 PartnerWebhookSandboxProof {
    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") + "/partner/v2/info/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).GET().build();
      HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
      if (response.statusCode() != 200) throw new IOException(response.body());
      String environment = response.headers().firstValue("X-Paywise-Environment").orElse("");
      if (!"sandbox".equals(environment)) throw new IOException("Refusing Partner write outside the sandbox");
    }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Linq;
  using System.Net.Http;
  using System.Threading;
  using System.Threading.Tasks;
  class PartnerWebhookSandboxProof {
      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")}/partner/v2/info/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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());
          if (!response.Headers.TryGetValues("X-Paywise-Environment", out var values)
              || values.Count() != 1 || !string.Equals(values.Single(), "sandbox", StringComparison.Ordinal))
              throw new HttpRequestException("Refusing Partner write outside the sandbox");
      }
  }
  ```

  ```bash cURL theme={null}
  proof="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/info/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --dump-header - --output /dev/null --write-out '\n%{http_code}')"
  status="$(printf '%s\n' "$proof" | tail -n 1)"
  [ "$status" != "200" ] && exit 1
  environment="$(printf '%s\n' "$proof" | awk '
    {
      separator = index($0, ":")
      if (!separator || tolower(substr($0, 1, separator - 1)) != "x-paywise-environment") next
      count++
      value = substr($0, separator + 1)
      sub(/\r$/, "", value)
      sub(/^[ \t]*/, "", value)
      sub(/[ \t]*$/, "", value)
    }
    END { if (count != 1) exit 1; print value }
  ')" || exit 1
  [ "$environment" != "sandbox" ] && exit 1
  :
  ```
</CodeGroup>

## 10. Rotate with a 24-hour overlap

Rotate is a bodyless command. Capture its documented one-time-secret response:

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

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

  response = requests.post(
      f"{PAYWISE_API_URL}/partner/v2/webhooks/{WEBHOOK_ID}/rotate-secret/",
      headers={
          "Authorization": f"Bearer {PAYWISE_PARTNER_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(/\/$/, "")}/partner/v2/webhooks/${process.env.WEBHOOK_ID}/rotate-secret/`, {
    method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_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 RotatePartnerWebhookSecret {
    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") + "/partner/v2/webhooks/" + System.getenv("WEBHOOK_ID") + "/rotate-secret/"))
          .timeout(Duration.ofSeconds(30)).header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_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 RotatePartnerWebhookSecret {
      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")}/partner/v2/webhooks/{Environment.GetEnvironmentVariable("WEBHOOK_ID")}/rotate-secret/");
          request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_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}/partner-webhook-rotated.XXXXXX")"
  chmod 600 "$secret_response"
  trap 'rm -f "$secret_response"' EXIT HUP INT TERM
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/webhooks/$WEBHOOK_ID/rotate-secret/" \
    --header "Authorization: Bearer $PAYWISE_PARTNER_KEY" \
    --header "Idempotency-Key: $ROTATE_WEBHOOK_KEY" \
    --output "$secret_response" --write-out '%{http_code}')"
  [ "$status" != "200" ] && exit 1
  ```
</CodeGroup>

Store the new `secret_key` and retain the prior secret for the 24-hour overlap.
Every delivery for the v2 subscription in that window, including retries
created before rotation, carries newest and previous signatures. Try the
current secret first. A second
rotation during the overlap returns `409`; do not discard the prior secret
early.

## Complete runnable Python workflow

Run management commands only in the management phase. The HTTP receiver and
worker share the same durable database, but neither needs Partner API
credentials or makes management requests.

```python Python workflow theme={null}
import base64
import binascii
import hashlib
import hmac
import json
import os
import sqlite3
import tempfile
import time
from urllib.parse import urlsplit

import requests

PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
CREATE_WEBHOOK_KEY = "your-create-webhook-key"
REDELIVER_WEBHOOK_KEY = "your-redeliver-webhook-key"
ROTATE_WEBHOOK_KEY = "your-rotate-webhook-key"
TEST_WEBHOOK_KEY = "your-test-webhook-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_SECRET_DIRECTORY = "./webhook-secrets"
WEBHOOK_SIGNATURE_HEADER = "your-webhook-signature-header"
WEBHOOK_TIMESTAMP_HEADER = "1788163200"

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


def write_secret_handoff(secret, prefix):
    directory = WEBHOOK_SECRET_DIRECTORY
    descriptor, path = tempfile.mkstemp(prefix=prefix, dir=directory, text=True)
    try:
        os.fchmod(descriptor, 0o600)
        os.write(descriptor, secret.encode())
    finally:
        os.close(descriptor)
    return path


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 URL")
    api_origin = (base_parts.hostname.lower(), base_parts.port or 443)
    page_budget = 5
    session = requests.Session()
    session.headers.update(
        {"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}
    )

    def require_sandbox():
        proof = session.get(
            f"{base_url}/partner/v2/info/",
            timeout=(5, 30),
        )
        if proof.status_code != 200:
            proof.raise_for_status()
            raise RuntimeError(f"Expected 200, received {proof.status_code}")
        environments = [
            value
            for name, value in proof.headers.items()
            if name.lower() == "x-paywise-environment"
        ]
        if environments != ["sandbox"]:
            raise RuntimeError("Refusing Partner write outside the sandbox")

    def post_command(url, payload, expected_status, idempotency_key):
        headers = {"Idempotency-Key": idempotency_key}
        for attempt in range(2):
            require_sandbox()
            try:
                response = session.post(
                    url,
                    headers=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 == 429:
                if attempt == 1:
                    raise RuntimeError("Command remained throttled after bounded replay")
                retry_after_text = response.headers.get("Retry-After", "")
                if (
                    not retry_after_text.isascii()
                    or not retry_after_text.isdigit()
                ):
                    raise RuntimeError("Retry-After must be integer seconds")
                retry_after = int(retry_after_text)
                if not 1 <= retry_after <= 60:
                    raise RuntimeError("Retry-After must be between 1 and 60 seconds")
                time.sleep(retry_after)
                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")

    webhook_payload = {
        "url": "https://hooks.example.test/paywise-partner",
        "enabled": True,
        "events": ["*"],
        "companies": "*",
        "description": "Partner company and case updates",
    }
    created_response = post_command(
        f"{base_url}/partner/v2/webhooks/",
        webhook_payload,
        201,
        CREATE_WEBHOOK_KEY,
    )
    created = created_response.json()
    webhook_id = created.get("id")
    previous_secret = created.get("secret_key")
    if not webhook_id or not previous_secret:
        raise RuntimeError("Create response omitted the one-time identity or secret")
    write_secret_handoff(previous_secret, "partner-webhook-created-")

    next_url = f"{base_url}/partner/v2/webhooks/"
    params = {"limit": 100}
    seen_urls = set()
    pages_read = 0
    webhooks_by_id = {}
    while next_url:
        parts = urlsplit(next_url)
        next_origin = (
            parts.hostname.lower() if parts.hostname else "",
            parts.port or (443 if parts.scheme == "https" else None),
        )
        if (
            parts.scheme != "https"
            or next_origin != api_origin
            or parts.username is not None
            or parts.password is not None
        ):
            raise RuntimeError("Refusing pagination outside the Partner 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 = session.get(
            next_url,
            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 Partner webhook was not found by exact ID")
    if "secret_key" in webhooks_by_id[webhook_id]:
        raise RuntimeError("Secret appeared in the Partner webhook read model")

    test_response = post_command(
        f"{base_url}/partner/v2/webhooks/{webhook_id}/test/",
        {"event": "company.created"},
        202,
        TEST_WEBHOOK_KEY,
    )
    test_command = test_response.json()
    delivery_id = test_command["delivery_id"]
    event_id = test_command["event_id"]

    detail_response = session.get(
        f"{base_url}/partner/v2/webhook-deliveries/{delivery_id}/",
        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("event_id") != event_id
    ):
        raise RuntimeError("Delivery identity or event correlation mismatch")

    redelivery_response = post_command(
        f"{base_url}/partner/v2/webhook-deliveries/{delivery_id}/redeliver/",
        None,
        202,
        REDELIVER_WEBHOOK_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_response = post_command(
        f"{base_url}/partner/v2/webhooks/{webhook_id}/rotate-secret/",
        None,
        200,
        ROTATE_WEBHOOK_KEY,
    )
    rotated = rotate_response.json()
    current_secret = rotated.get("secret_key")
    if rotated.get("id") != webhook_id or not current_secret:
        raise RuntimeError("Rotation response identity or one-time secret failed")
    write_secret_handoff(current_secret, "partner-webhook-rotated-")

    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 webhook_inbox "
                "(event_id TEXT PRIMARY KEY, raw_body BLOB NOT NULL)"
            )
            database.execute(
                "CREATE TABLE IF NOT EXISTS webhook_outbox "
                "(event_id TEXT PRIMARY KEY, status TEXT NOT NULL, attempts INTEGER NOT NULL)"
            )
            database.execute(
                "CREATE TABLE IF NOT EXISTS webhook_state "
                "(event_id TEXT PRIMARY KEY, state TEXT NOT NULL)"
            )
    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 webhook_inbox(event_id, raw_body) VALUES (?, ?)",
                (event_payload["id"], exact_body),
            ).rowcount == 1
            if inserted:
                database.execute(
                    "INSERT INTO webhook_outbox(event_id, status, attempts) "
                    "VALUES (?, 'pending', 0)",
                    (event_payload["id"],),
                )
            else:
                prior = database.execute(
                    "SELECT raw_body FROM webhook_inbox WHERE event_id = ?",
                    (event_payload["id"],),
                ).fetchone()
                outbox = database.execute(
                    "SELECT event_id FROM webhook_outbox WHERE event_id = ?",
                    (event_payload["id"],),
                ).fetchone()
                if prior is None or bytes(prior[0]) != exact_body or outbox is None:
                    raise RuntimeError("Duplicate inbox 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):
    database = sqlite3.connect(database_path)
    try:
        job = database.execute(
            "SELECT event_id FROM webhook_outbox "
            "WHERE status IN ('pending', 'processing') ORDER BY event_id LIMIT 1"
        ).fetchone()
        if job is None:
            return False
        event_id = job[0]
        with database:
            database.execute(
                "UPDATE webhook_outbox SET status = 'processing', attempts = attempts + 1 "
                "WHERE event_id = ?",
                (event_id,),
            )
            database.execute(
                "INSERT OR IGNORE INTO webhook_state(event_id, state) "
                "VALUES (?, 'processed')",
                (event_id,),
            )
            database.execute(
                "UPDATE webhook_outbox SET status = 'completed' WHERE event_id = ?",
                (event_id,),
            )
        receipt_phases.append("processed")
        return True
    finally:
        database.close()


def receipt_store_state(database_path):
    database = sqlite3.connect(database_path)
    try:
        return {
            "inbox_count": database.execute(
                "SELECT COUNT(*) FROM webhook_inbox"
            ).fetchone()[0],
            "outbox_count": database.execute(
                "SELECT COUNT(*) FROM webhook_outbox"
            ).fetchone()[0],
            "pending_outbox_count": database.execute(
                "SELECT COUNT(*) FROM webhook_outbox WHERE status != 'completed'"
            ).fetchone()[0],
            "state_count": database.execute(
                "SELECT COUNT(*) FROM webhook_state"
            ).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 = []
    accepted_secret = None
    handler_ack_status = 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("Acknowledge only after the durable inbox transaction")
    else:
        run_one_pending_job(database_path, receipt_phases)
    state = receipt_store_state(database_path)
    if phase == "handler" and (
        state["inbox_count"] != 1 or state["outbox_count"] != 1
    ):
        raise RuntimeError("Durable inbox and outbox invariant failed")
    if state["state_count"] > state["outbox_count"]:
        raise RuntimeError("Durable state invariant failed")
    WORKFLOW_RESULT = {
        "phase": phase,
        "accepted_secret": accepted_secret,
        "handler_ack_status": handler_ack_status,
        "receipt_phases": receipt_phases,
        **state,
    }
```

The management phase follows at most five pages and validates the exact HTTPS
origin, credentials, and cycle budget before every opaque `next` request.
Create and rotation secrets go only to mode-`0600` unpredictable handoff
files and never to output. Deploy handler and worker as independent processes
against the same durable database; delivery retries are not the worker queue.

## Failure and recovery

* Invalid signature, mismatched ID, or stale timestamp: reject before parsing
  or processing and record only safe diagnostics.
* Duplicate event: acknowledge after confirming prior durable receipt; apply
  the business effect once, but still reconcile current state.
* Any access event: durably accept and deduplicate, temporarily gate delegated
  writes, and fetch the exact current Company. Apply unavailable cleanup only
  when current `case_access` is unavailable; preserve or restore traffic when
  it is available, even if a delayed revocation triggered reconciliation.
* Out-of-order event: never roll access or readiness backward from arrival
  order, and never use `authorization_version` as an ordering clock.
* Handler network failure, `429`, or `5xx`: paywise retries for at most 48
  hours. Redirects and other `4xx` responses are terminal.
* Management timeout or transient `5xx`: make at most one replay with the same
  POST body and idempotency key. For `429`, accept only an integer
  `Retry-After` from 1 through 60 seconds, wait that exact interval, and make
  the same single bounded replay; fail closed on a missing, malformed, or
  excessive value.
* Missed restoration: restoration does not replay missed events or deleted
  drafts. Run periodic current-state reconciliation so a stale local gate
  cannot strand future traffic.
* Manual redelivery: reuse only an exact retained delivery UUID. Duplicate
  destinations and redeliveries make business-field or first-row selection
  unsafe. Redeliver settled deliveries only: `409 delivery_in_progress` means
  the delivery is still `pending`, `retrying`, or `delivering` — poll it
  until it settles. `404` on a delivery you retained means the company's
  Partner access has ended since; the delivery is no longer yours to replay.
  `rotate-secret` and `redeliver` take no request body (`400
  unexpected_body`).
* Delivery `failed` with `error_message: partner_access_unavailable`: the
  company's Partner access ended between queueing and delivery. Nothing was
  sent and the endpoint's health is unaffected; reconcile the company with
  [Handle access changes](/api-docs/partner-api/workflows/handle-access-changes).
* Missing `invoice.*` or `dunning.*` rows in `GET /partner/v2/events/`:
  verify access to the company and contact paywise support if expected
  events are unavailable.

## Verify

Send a test event, retrieve its exact runtime delivery ID, and confirm your
receipt table stores one row for the event UUID after an exact retry. Manually
redeliver that exact delivery and confirm a new delivery ID carries the same
event ID. During a rotation drill, accept deliveries with both allowed
secrets, then remove the prior secret after 24 hours. Exercise all access-event
types and confirm each one gates writes until current Company state is fetched.

## Related reference

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


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