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

# Manage memberships

> Add, correct, resend, cancel, revoke, and safely re-add company-scoped memberships without exposing global-user state.

## 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 returns `pending_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 string `sandbox`.

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  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")
  ```

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

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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");
    }
  }
  ```

  ```csharp C# theme={null}
  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");
      }
  }
  ```

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

## 2. Add and retain the returned UUID

<CodeGroup>
  ```python Python theme={null}
  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": "developer@example.test", "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}")
  ```

  ```javascript JavaScript theme={null}
  const membershipPayload = { email: "developer@example.test", 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()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class MembershipRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      String json = "{\"email\":\"developer@example.test\",\"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());
    }
  }
  ```

  ```csharp C# theme={null}
  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 = "developer@example.test", 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());
      }
  }
  ```

  ```bash cURL theme={null}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/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":"developer@example.test","first_name":"Devon","last_name":"Example","role":"developer"}')"
  [ "$status" != "201" ] && exit 1
  ```
</CodeGroup>

## 3. Prove the sandbox before correction

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  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")
  ```

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

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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");
    }
  }
  ```

  ```csharp C# theme={null}
  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");
      }
  }
  ```

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

## 4. Correct the exact pending membership

PATCH is resource-targeted and does not use an idempotency key. Its body is
`MembershipRoleRequest`, 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.

<CodeGroup>
  ```python Python theme={null}
  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}")
  ```

  ```javascript JavaScript theme={null}
  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()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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());
    }
  }
  ```

  ```csharp C# theme={null}
  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());
      }
  }
  ```

  ```bash cURL theme={null}
  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
  ```
</CodeGroup>

## 5. Prove the sandbox before resend

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  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")
  ```

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

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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");
    }
  }
  ```

  ```csharp C# theme={null}
  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");
      }
  }
  ```

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

## 6. Replace and resend the setup token

This bodyless command invalidates earlier setup tokens. A fresh `200` 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.

<CodeGroup>
  ```python Python theme={null}
  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.
  ```

  ```javascript JavaScript theme={null}
  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.
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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.
    }
  }
  ```

  ```csharp C# theme={null}
  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.
      }
  }
  ```

  ```bash cURL theme={null}
  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
  ```
</CodeGroup>

The cURL example keeps the one-time response in permission-restricted
`membership-resent.json`. Move its `setup_url` into your restricted onboarding
flow immediately, then delete the response file.

## 7. Prove the sandbox before cancellation

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  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")
  ```

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

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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");
    }
  }
  ```

  ```csharp C# theme={null}
  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");
      }
  }
  ```

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

## 8. Cancel the same pending membership

<CodeGroup>
  ```python Python theme={null}
  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}")
  ```

  ```javascript JavaScript theme={null}
  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()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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());
    }
  }
  ```

  ```csharp C# theme={null}
  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());
      }
  }
  ```

  ```bash cURL theme={null}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/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
  ```
</CodeGroup>

## Revoke an active membership

Revocation is deliberately separate from pending cancellation. First call
`GET /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/`:

```json theme={null}
{"reason":"Customer removed this employee from the account"}
```

A successful response has `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

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

  PAYWISE_API_URL = "https://api-sandbox.paywise.de"
  PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
  response = requests.get(
      f"{PAYWISE_API_URL}/partner/v2/info/",
      headers={"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}, timeout=(5, 30),
  )
  if response.status_code != 200:
      response.raise_for_status()
      raise RuntimeError(f"Expected 200, received {response.status_code}")
  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")
  ```

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

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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");
    }
  }
  ```

  ```csharp C# theme={null}
  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");
      }
  }
  ```

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

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

<CodeGroup>
  ```python Python theme={null}
  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": "developer@example.test", "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}")
  ```

  ```javascript JavaScript theme={null}
  const membershipPayload = { email: "developer@example.test", 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()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class MembershipRequest {
    public static void main(String[] args) throws IOException, InterruptedException {
      String json = "{\"email\":\"developer@example.test\",\"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());
    }
  }
  ```

  ```csharp C# theme={null}
  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 = "developer@example.test", 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());
      }
  }
  ```

  ```bash cURL theme={null}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request POST "$PAYWISE_API_URL/partner/v2/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":"developer@example.test","first_name":"Devon","last_name":"Example","role":"developer"}')"
  [ "$status" != "201" ] && exit 1
  ```
</CodeGroup>

## 11. Verify the exact resource

<CodeGroup>
  ```python Python theme={null}
  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}")
  ```

  ```javascript JavaScript theme={null}
  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()}`);
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.time.Duration;
  class 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());
    }
  }
  ```

  ```csharp C# theme={null}
  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());
      }
  }
  ```

  ```bash cURL theme={null}
  status="$(curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 30 \
    --request GET "$PAYWISE_API_URL/partner/v2/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
  ```
</CodeGroup>

## Runnable membership workflow

```python Python workflow theme={null}
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": "developer@example.test", "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 theme={null}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "11000000-0000-4000-8000-000000000001",
  "email": "developer@example.test",
  "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`,
  and `last_name` are 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.
* `404` on 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](/api-docs/partner-api/workflows/release-a-company).
* `409 last_company_admin_required` requires 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
  answer `409 membership_not_pending` to role change, resend, and cancel.
  Pending, cancelled, and already revoked memberships cannot use the revoke
  action.
* A resend `429` honors `Retry-After` but retains the same resend key. A
  replayed add or resend returns the membership without `setup_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/` as `mail:membership-create` and
  `mail:company-create`. A `429 throttled` on an add or company create
  refuses the whole request; wait at least `Retry-After` seconds and retry
  with the same command key (see [Public limits](/api-docs/essentials/limits)).

## Related reference

* [GET `/partner/v2/info/`](/api-docs/partner-api/reference/meta/get-partner-token-info)
* [POST `/partner/v2/companies/{company_id}/users/`](/api-docs/partner-api/reference/company-users/add-a-company-membership)
* [GET `/partner/v2/companies/{company_id}/users/{membership_id}/`](/api-docs/partner-api/reference/company-users/get-a-company-membership)
* [PATCH `/partner/v2/companies/{company_id}/users/{membership_id}/`](/api-docs/partner-api/reference/company-users/change-a-pending-membership-role)
* [POST `/partner/v2/companies/{company_id}/users/{membership_id}/resend/`](/api-docs/partner-api/reference/company-users/replace-and-resend-a-pending-setup-token)
* [POST `/partner/v2/companies/{company_id}/users/{membership_id}/cancel/`](/api-docs/partner-api/reference/company-users/cancel-a-pending-company-membership)
* [POST `/partner/v2/companies/{company_id}/users/{membership_id}/revoke/`](/api-docs/partner-api/reference/company-users/revoke-an-active-company-membership)


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