Outcome
The intended person keeps one exact membership UUID through create, correction, resend, cancellation or revocation, and resource-idempotent re-add. Email is a field, never a resource selector. Only pending memberships can change role, be resent, or be cancelled. Active memberships can be revoked only when paywise enables that capability for the Partner. Re-add uses a new logical command key but reuses a cancelled or revoked membership UUID. Every add returnspending_setup with an invite_expires_at, whether or not
the address already belongs to a paywise account. The response echoes the
email, first_name, and last_name you submitted until the person accepts
the invite; it never reveals whether an account existed or what its profile
holds. A person whose account this add created sets a password; every account
that existed before — with or without a password, signing in through a social
or single sign-on provider, or deactivated — accepts by signing in, so no
invitee can set the password of an account someone else provisioned. Only
that acceptance makes the membership active and starts paywise notifications
for that company — a Partner cannot activate a membership on a person’s
behalf, and completing a password setup activates only the membership of the
company that sent it. A re-added cancelled or revoked membership is
pending_setup again and needs a fresh acceptance.
Resend and cancel are bodyless idempotent commands: any request body, even
{} or [], is 400 unexpected_body, and a refused command does not claim
its idempotency key. A PATCH timeout is not blindly replayed; reconcile the
exact membership URL first.
The add and resend responses carry setup_url once: the absolute link to
the paywise invite page for that pending membership, for embedding in your own
onboarding screen or mail. It is absent from reads and from idempotent
replays, and null when no invite is open. Persist it immediately if you need
it, and treat it as a secret — opening it is how the person activates the
membership. Two Partner events close the loop without polling:
company.user.activated when the membership becomes active, and
company.user.invite_expired when the 10-day invite lapses unaccepted (the
membership stays pending_setup; resend to issue a new link).
If the person lost the link and their account was created through the Partner
API and never used — active, never signed in, no password, no social login —
they can also use Passwort vergessen in the paywise portal. The reset link
goes only to their own address, never to you; after setting a first password
they sign in and accept the invitation. A setup link stops working once its
account has been signed in to, linked to a social login, or deactivated;
resend the invitation to give a person who can sign in a sign-in link.
1. Reusable sandbox proof
Run this authenticated probe immediately before every Partner write below, including each bounded retry attempt. Require the environment header to equal the exact stringsandbox.
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}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "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 environment = response.headers.get("X-Paywise-Environment");
if (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 MembershipRequest {
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 MembershipRequest {
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 environmentValues)
|| environmentValues.Count() != 1
|| !string.Equals(environmentValues.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
:
2. Add and retain the returned UUID
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_CREATE_COMMAND_ID = "your-membership-create-command-id"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
membership_payload = {"email": "[email protected]", "first_name": "Devon", "last_name": "Example", "role": "developer"}
response = requests.post(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/",
headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}", "Content-Type": "application/json", "Idempotency-Key": MEMBERSHIP_CREATE_COMMAND_ID},
json=membership_payload, timeout=(5, 30),
)
if response.status_code != 201:
response.raise_for_status()
raise RuntimeError(f"Expected 201, received {response.status_code}")
const membershipPayload = { email: "[email protected]", first_name: "Devon", last_name: "Example", role: "developer" };
const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/companies/${process.env.PAYWISE_COMPANY_ID}/users/`, {
method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.MEMBERSHIP_CREATE_COMMAND_ID },
body: JSON.stringify(membershipPayload), 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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String json = "{\"email\":\"[email protected]\",\"first_name\":\"Devon\",\"last_name\":\"Example\",\"role\":\"developer\"}";
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).header("Content-Type", "application/json")
.header("Idempotency-Key", System.getenv("MEMBERSHIP_CREATE_COMMAND_ID")).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 MembershipRequest {
static async Task Main() {
var payload = new { email = "[email protected]", first_name = "Devon", last_name = "Example", role = "developer" };
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/partner/v2/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/");
request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("MEMBERSHIP_CREATE_COMMAND_ID"));
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());
}
}
status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
--request POST "$PAYWISE_API_URL/partner/v2/companies/$PAYWISE_COMPANY_ID/users/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --header "Content-Type: application/json" \
--header "Idempotency-Key: $MEMBERSHIP_CREATE_COMMAND_ID" --output membership-created.json --write-out '%{http_code}' \
--data '{"email":"[email protected]","first_name":"Devon","last_name":"Example","role":"developer"}')"
[ "$status" != "201" ] && exit 1
3. Prove the sandbox before correction
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}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "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 environment = response.headers.get("X-Paywise-Environment");
if (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 MembershipRequest {
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 MembershipRequest {
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 environmentValues)
|| environmentValues.Count() != 1
|| !string.Equals(environmentValues.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
:
4. Correct the exact pending membership
PATCH is resource-targeted and does not use an idempotency key. Its body isMembershipRoleRequest, and role is required — there is no other editable
field on a membership. After an ambiguous outcome, retrieve this exact URL and
compare its current role.
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_ID = "21000000-0000-4000-8000-000000000001"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
response = requests.patch(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/{MEMBERSHIP_ID}/",
headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}", "Content-Type": "application/json"},
json={"role": "readonly"}, 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/companies/${process.env.PAYWISE_COMPANY_ID}/users/${process.env.MEMBERSHIP_ID}/`, {
method: "PATCH", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ role: "readonly" }), 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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/" + System.getenv("MEMBERSHIP_ID") + "/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("{\"role\":\"readonly\"}")).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.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
class MembershipRequest {
static async Task Main() {
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Patch, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/partner/v2/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/{Environment.GetEnvironmentVariable("MEMBERSHIP_ID")}/");
request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
request.Content = JsonContent.Create(new { role = "readonly" });
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 PATCH "$PAYWISE_API_URL/partner/v2/companies/$PAYWISE_COMPANY_ID/users/$MEMBERSHIP_ID/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --header "Content-Type: application/json" \
--output membership-corrected.json --write-out '%{http_code}' --data '{"role":"readonly"}')"
[ "$status" != "200" ] && exit 1
5. Prove the sandbox before resend
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}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "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 environment = response.headers.get("X-Paywise-Environment");
if (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 MembershipRequest {
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 MembershipRequest {
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 environmentValues)
|| environmentValues.Count() != 1
|| !string.Equals(environmentValues.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. Replace and resend the setup token
This bodyless command invalidates earlier setup tokens. A fresh200 resend
response returns the replacement setup_url once; an idempotent replay omits
it. Transfer the URL directly to a restricted secret sink and never log it.
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_ID = "21000000-0000-4000-8000-000000000001"
MEMBERSHIP_RESEND_COMMAND_ID = "your-membership-resend-command-id"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
response = requests.post(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/{MEMBERSHIP_ID}/resend/",
headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}", "Idempotency-Key": MEMBERSHIP_RESEND_COMMAND_ID},
timeout=(5, 30),
)
if response.status_code != 200:
response.raise_for_status()
raise RuntimeError(f"Expected 200, received {response.status_code}")
membership = response.json()
setup_url = membership.get("setup_url")
if not isinstance(setup_url, str) or not setup_url:
raise RuntimeError("Fresh resend response did not include setup_url")
# Transfer setup_url directly to your restricted onboarding flow; never log it.
const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/companies/${process.env.PAYWISE_COMPANY_ID}/users/${process.env.MEMBERSHIP_ID}/resend/`, {
method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Idempotency-Key": process.env.MEMBERSHIP_RESEND_COMMAND_ID }, signal: AbortSignal.timeout(30000),
});
if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const membership = await response.json();
if (typeof membership.setup_url !== "string" || membership.setup_url.length === 0) throw new Error("Fresh resend response did not include setup_url");
const setupUrl = membership.setup_url; // Transfer to a restricted sink; never log it.
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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/" + System.getenv("MEMBERSHIP_ID") + "/resend/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).header("Idempotency-Key", System.getenv("MEMBERSHIP_RESEND_COMMAND_ID"))
.POST(HttpRequest.BodyPublishers.noBody()).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) throw new IOException(response.body());
String membershipWithSetupUrl = response.body();
if (!membershipWithSetupUrl.contains("\"setup_url\"")) throw new IOException("Fresh resend response did not include setup_url");
// Parse setup_url with your JSON library and transfer it to a restricted sink; never log it.
}
}
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
class MembershipRequest {
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/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/{Environment.GetEnvironmentVariable("MEMBERSHIP_ID")}/resend/");
request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("MEMBERSHIP_RESEND_COMMAND_ID"));
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());
var responseBody = await response.Content.ReadAsStringAsync();
using var membership = JsonDocument.Parse(responseBody);
if (!membership.RootElement.TryGetProperty("setup_url", out var setupUrlElement)
|| setupUrlElement.ValueKind != JsonValueKind.String
|| string.IsNullOrEmpty(setupUrlElement.GetString()))
throw new HttpRequestException("Fresh resend response did not include setup_url");
var setupUrl = setupUrlElement.GetString(); // Transfer to a restricted sink; never log it.
}
}
umask 077
status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
--request POST "$PAYWISE_API_URL/partner/v2/companies/$PAYWISE_COMPANY_ID/users/$MEMBERSHIP_ID/resend/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --header "Idempotency-Key: $MEMBERSHIP_RESEND_COMMAND_ID" \
--output membership-resent.json --write-out '%{http_code}')"
[ "$status" != "200" ] && exit 1
membership-resent.json. Move its setup_url into your restricted onboarding
flow immediately, then delete the response file.
7. Prove the sandbox before cancellation
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}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "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 environment = response.headers.get("X-Paywise-Environment");
if (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 MembershipRequest {
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 MembershipRequest {
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 environmentValues)
|| environmentValues.Count() != 1
|| !string.Equals(environmentValues.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. Cancel the same pending membership
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_CANCEL_COMMAND_ID = "your-membership-cancel-command-id"
MEMBERSHIP_ID = "21000000-0000-4000-8000-000000000001"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
response = requests.post(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/{MEMBERSHIP_ID}/cancel/",
headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}", "Idempotency-Key": MEMBERSHIP_CANCEL_COMMAND_ID},
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/companies/${process.env.PAYWISE_COMPANY_ID}/users/${process.env.MEMBERSHIP_ID}/cancel/`, {
method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Idempotency-Key": process.env.MEMBERSHIP_CANCEL_COMMAND_ID }, 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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/" + System.getenv("MEMBERSHIP_ID") + "/cancel/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).header("Idempotency-Key", System.getenv("MEMBERSHIP_CANCEL_COMMAND_ID"))
.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 MembershipRequest {
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/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/{Environment.GetEnvironmentVariable("MEMBERSHIP_ID")}/cancel/");
request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("MEMBERSHIP_CANCEL_COMMAND_ID"));
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 POST "$PAYWISE_API_URL/partner/v2/companies/$PAYWISE_COMPANY_ID/users/$MEMBERSHIP_ID/cancel/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --header "Idempotency-Key: $MEMBERSHIP_CANCEL_COMMAND_ID" \
--output membership-cancelled.json --write-out '%{http_code}')"
[ "$status" != "200" ] && exit 1
Revoke an active membership
Revocation is deliberately separate from pending cancellation. First callGET /partner/v2/info/ and check that capabilities includes
"USER_ACCESS_REVOCATION". Then send a fresh idempotency key and a non-empty audit
reason of at most 500 characters to
POST /partner/v2/companies/{company_id}/users/{membership_id}/revoke/:
{"reason":"Customer removed this employee from the account"}
status: "revoked" and retains revoked_at,
revoked_by_token_id, and revocation_reason. The command clears only that
user’s personal grants for this company and repairs their default-company
selection. It does not affect their other memberships, the company, or
company-owned API credentials.
The API returns 409 last_company_admin_required for the final active admin
and 409 membership_not_active for pending, cancelled, or already revoked
memberships. Pending access must use /cancel/. On a timeout or transient
5xx, retry the exact body with the same key; a replay does not append a
second audit record or webhook.
Re-add the same email through the collection with a new create key to
reactivate the existing UUID. Current revocation fields are cleared, while
paywise retains the append-only revocation audit.
9. Prove the sandbox before re-add
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}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "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 environment = response.headers.get("X-Paywise-Environment");
if (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 MembershipRequest {
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 MembershipRequest {
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 environmentValues)
|| environmentValues.Count() != 1
|| !string.Equals(environmentValues.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. Re-add resource-idempotently
Use the same membership fields and a new key. The response must carry the original membership UUID; a second identity is a failure, not a recovery.import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_READD_COMMAND_ID = "your-membership-readd-command-id"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
membership_payload = {"email": "[email protected]", "first_name": "Devon", "last_name": "Example", "role": "developer"}
response = requests.post(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/",
headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}", "Content-Type": "application/json", "Idempotency-Key": MEMBERSHIP_READD_COMMAND_ID},
json=membership_payload, timeout=(5, 30),
)
if response.status_code != 201:
response.raise_for_status()
raise RuntimeError(f"Expected 201, received {response.status_code}")
const membershipPayload = { email: "[email protected]", first_name: "Devon", last_name: "Example", role: "developer" };
const response = await fetch(`${process.env.PAYWISE_API_URL.replace(/\/$/, "")}/partner/v2/companies/${process.env.PAYWISE_COMPANY_ID}/users/`, {
method: "POST", headers: { Authorization: `Bearer ${process.env.PAYWISE_PARTNER_KEY}`, "Content-Type": "application/json", "Idempotency-Key": process.env.MEMBERSHIP_READD_COMMAND_ID },
body: JSON.stringify(membershipPayload), 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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String json = "{\"email\":\"[email protected]\",\"first_name\":\"Devon\",\"last_name\":\"Example\",\"role\":\"developer\"}";
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("PAYWISE_PARTNER_KEY")).header("Content-Type", "application/json")
.header("Idempotency-Key", System.getenv("MEMBERSHIP_READD_COMMAND_ID")).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 MembershipRequest {
static async Task Main() {
var payload = new { email = "[email protected]", first_name = "Devon", last_name = "Example", role = "developer" };
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Post, $"{Environment.GetEnvironmentVariable("PAYWISE_API_URL")}/partner/v2/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/");
request.Headers.Authorization = new("Bearer", Environment.GetEnvironmentVariable("PAYWISE_PARTNER_KEY"));
request.Headers.Add("Idempotency-Key", Environment.GetEnvironmentVariable("MEMBERSHIP_READD_COMMAND_ID"));
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());
}
}
status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
--request POST "$PAYWISE_API_URL/partner/v2/companies/$PAYWISE_COMPANY_ID/users/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --header "Content-Type: application/json" \
--header "Idempotency-Key: $MEMBERSHIP_READD_COMMAND_ID" --output membership-readded.json --write-out '%{http_code}' \
--data '{"email":"[email protected]","first_name":"Devon","last_name":"Example","role":"developer"}')"
[ "$status" != "201" ] && exit 1
11. Verify the exact resource
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_ID = "21000000-0000-4000-8000-000000000001"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
response = requests.get(
f"{PAYWISE_API_URL}/partner/v2/companies/{PAYWISE_COMPANY_ID}/users/{MEMBERSHIP_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/companies/${process.env.PAYWISE_COMPANY_ID}/users/${process.env.MEMBERSHIP_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 MembershipRequest {
public static void main(String[] args) throws IOException, InterruptedException {
String url = System.getenv("PAYWISE_API_URL") + "/partner/v2/companies/" + System.getenv("PAYWISE_COMPANY_ID") + "/users/" + System.getenv("MEMBERSHIP_ID") + "/";
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).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 MembershipRequest {
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/companies/{Environment.GetEnvironmentVariable("PAYWISE_COMPANY_ID")}/users/{Environment.GetEnvironmentVariable("MEMBERSHIP_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/companies/$PAYWISE_COMPANY_ID/users/$MEMBERSHIP_ID/" \
--header "Authorization: Bearer $PAYWISE_PARTNER_KEY" --output membership-current.json --write-out '%{http_code}')"
[ "$status" != "200" ] && exit 1
Runnable membership workflow
Python workflow
import time
import uuid
import requests
PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
MEMBERSHIP_CANCEL_COMMAND_ID = "your-membership-cancel-command-id"
MEMBERSHIP_CREATE_COMMAND_ID = "your-membership-create-command-id"
MEMBERSHIP_READD_COMMAND_ID = "your-membership-readd-command-id"
MEMBERSHIP_RESEND_COMMAND_ID = "your-membership-resend-command-id"
PAYWISE_COMPANY_ID = "20000000-0000-4000-8000-000000000001"
base_url = PAYWISE_API_URL
if not base_url.startswith("https://"):
raise ValueError("PAYWISE_API_URL must be HTTPS")
company_id = str(uuid.UUID(PAYWISE_COMPANY_ID))
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"})
def prove_sandbox():
response = session.get(f"{base_url}/partner/v2/info/", timeout=(5, 30))
if response.status_code != 200:
response.raise_for_status()
raise RuntimeError(f"Expected 200, received {response.status_code}")
environment = next((value for name, value in response.headers.items() if name.lower() == "x-paywise-environment"), None)
if environment != "sandbox":
raise RuntimeError("Refusing Partner write outside the sandbox")
def command(path, expected_status, key, payload=None):
headers = {"Idempotency-Key": key}
if payload is not None:
headers["Content-Type"] = "application/json"
for attempt in range(3):
prove_sandbox()
try:
response = session.post(f"{base_url}{path}", headers=headers, json=payload, timeout=(5, 30))
except requests.Timeout:
if attempt == 2:
raise
time.sleep(attempt + 1)
continue
if response.status_code != expected_status:
response.raise_for_status()
raise RuntimeError(f"Expected {expected_status}, received {response.status_code}")
return response.json()
raise RuntimeError("Command retry budget exhausted")
payload = {"email": "[email protected]", "first_name": "Devon", "last_name": "Example", "role": "developer"}
collection = f"/partner/v2/companies/{company_id}/users/"
created = command(collection, 201, MEMBERSHIP_CREATE_COMMAND_ID, payload)
membership_id = str(uuid.UUID(created["id"]))
detail = f"{collection}{membership_id}/"
def read_membership():
response = session.get(f"{base_url}{detail}", timeout=(5, 30))
if response.status_code != 200:
response.raise_for_status()
raise RuntimeError(f"Expected 200, received {response.status_code}")
value = response.json()
if value.get("id") != membership_id:
raise RuntimeError("Current membership identity mismatch")
return value
role_patch = {"role": "readonly"}
prove_sandbox()
try:
corrected_response = session.patch(f"{base_url}{detail}", headers={"Content-Type": "application/json"}, json=role_patch, timeout=(5, 30))
except requests.Timeout:
corrected_membership = None
for reconciliation_attempt in range(3):
candidate = read_membership()
if candidate.get("role") == role_patch["role"]:
corrected_membership = candidate
break
if reconciliation_attempt < 2:
time.sleep(reconciliation_attempt + 1)
if corrected_membership is None:
raise RuntimeError("Membership PATCH outcome remains ambiguous; do not retry")
else:
if corrected_response.status_code != 200:
corrected_response.raise_for_status()
raise RuntimeError(f"Expected 200, received {corrected_response.status_code}")
corrected_membership = corrected_response.json()
if corrected_membership.get("id") != membership_id or corrected_membership.get("role") != role_patch["role"]:
raise RuntimeError("Corrected membership identity or role mismatch")
resent = command(f"{detail}resend/", 200, MEMBERSHIP_RESEND_COMMAND_ID)
cancelled = command(f"{detail}cancel/", 200, MEMBERSHIP_CANCEL_COMMAND_ID)
if resent.get("id") != membership_id or cancelled.get("id") != membership_id:
raise RuntimeError("Membership command identity mismatch")
readded = command(collection, 201, MEMBERSHIP_READD_COMMAND_ID, payload)
if readded.get("id") != membership_id:
raise RuntimeError("Re-add created a replacement membership identity")
current_membership = read_membership()
WORKFLOW_RESULT = {"membership_id": membership_id, "status": current_membership.get("status")}
Representative response
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "11000000-0000-4000-8000-000000000001",
"email": "[email protected]",
"first_name": "Devon",
"last_name": "Example",
"role": "developer",
"status": "pending_setup",
"invite_expires_at": "2026-09-06T10:00:00Z",
"revoked_at": null,
"revoked_by_token_id": null,
"revocation_reason": "",
"sandbox_origin": "sandbox_native",
"created_at": "2026-08-27T10:00:00Z",
"updated_at": "2026-08-27T10:00:00Z"
}
Failure and recovery
- On POST timeout or transient
5xx, reuse that logical command’s original key and body within a bounded retry budget. - On PATCH timeout, retrieve the exact membership UUID. Do not blindly repeat a stale correction or create a second email variant.
400 email_not_invitable: the address belongs to paywise staff and cannot become a company member; use a different address.email,first_name, andlast_nameare limited to 128 characters.409 company_offboarded: the company has left paywise. Every membership command — add, role change, resend, cancel, and revoke — is refused before the body is read; reads stay available. There is nothing to retry.404on every membership command after you released the company, or once the company switched to another Partner: the company is outside your access and membership administration is closed. See Release a company.409 last_company_admin_requiredrequires another current admin; it is not bypassed by role churn. Active memberships cannot use pending-only actions (409 active_membership_immutable); cancelled or revoked memberships answer409 membership_not_pendingto role change, resend, and cancel. Pending, cancelled, and already revoked memberships cannot use the revoke action.- A resend
429honorsRetry-Afterbut retains the same resend key. A replayed add or resend returns the membership withoutsetup_url, and so does a repeated add with a new key for an email and role that already exist; if you lost the link, resend once more with a new command key. - Adds are budgeted per Partner: 60 membership adds per hour, counting each
inline user on a company create, and 30 company creates per hour. The
budget is a mail budget: a unit is consumed only when an invitation is
actually sent — a replayed command, a refused one, and a repeated add that
transparently reuses the existing membership (no new invite) cost nothing.
Both buckets are
visible in
GET /partner/v2/usage/asmail:membership-createandmail:company-create. A429 throttledon an add or company create refuses the whole request; wait at leastRetry-Afterseconds and retry with the same command key (see Public limits).
Related reference
- GET
/partner/v2/info/ - POST
/partner/v2/companies/{company_id}/users/ - GET
/partner/v2/companies/{company_id}/users/{membership_id}/ - PATCH
/partner/v2/companies/{company_id}/users/{membership_id}/ - POST
/partner/v2/companies/{company_id}/users/{membership_id}/resend/ - POST
/partner/v2/companies/{company_id}/users/{membership_id}/cancel/ - POST
/partner/v2/companies/{company_id}/users/{membership_id}/revoke/
