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 acompanies
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
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")
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");
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");
}
}
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");
}
}
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
:
sandbox.
2. Create a Partner-owned subscription
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}")
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()}`);
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());
}
}
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());
}
}
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
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.
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
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}")
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()}`);
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());
}
}
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());
}
}
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
Representative response
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"
}]
}
PaginatedPartnerWebhookList and correctly
contains no secret.
4. Verify exact delivery bytes
Readwebhook-id, webhook-timestamp, and webhook-signature before parsing.
Preserve the exact body bytes and build the UTF-8 message:
<webhook-id>.<webhook-timestamp>.<raw-body>
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
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")
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");
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");
}
}
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");
}
}
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
:
6. Test with stable runtime identifiers
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}")
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()}`);
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());
}
}
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());
}
}
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
delivery_id and event_id from this response. Read that exact
delivery directly; never choose a list position.
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}")
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()}`);
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());
}
}
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());
}
}
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
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
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")
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");
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");
}
}
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");
}
}
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
:
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: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}")
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()}`);
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());
}
}
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());
}
}
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
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"
}
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
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")
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");
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");
}
}
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");
}
}
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
:
10. Rotate with a 24-hour overlap
Rotate is a bodyless command. Capture its documented one-time-secret response: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}")
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()}`);
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());
}
}
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());
}
}
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
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 workflow
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,
}
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_accessis 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_versionas an ordering clock. - Handler network failure,
429, or5xx: paywise retries for at most 48 hours. Redirects and other4xxresponses are terminal. - Management timeout or transient
5xx: make at most one replay with the same POST body and idempotency key. For429, accept only an integerRetry-Afterfrom 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_progressmeans the delivery is stillpending,retrying, ordelivering— poll it until it settles.404on a delivery you retained means the company’s Partner access has ended since; the delivery is no longer yours to replay.rotate-secretandredelivertake no request body (400 unexpected_body). - Delivery
failedwitherror_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. - Missing
invoice.*ordunning.*rows inGET /partner/v2/events/: verify access to the company and contact paywise support if expected events are unavailable.
