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

# Handle access changes

> Gate delegated writes on access events, reconcile current Company state, and preserve finalized-case knowledge without replaying drafts.

## Outcome

Your integration temporarily gates delegated writes for every access event,
reconciles the current company projection, applies unavailable cleanup only
when current access is unavailable, and resumes future authorized work when
current access is available.

## Prerequisites

* A verified Partner webhook receiver with durable deduplication by event UUID.
* A company-indexed command queue and an access gate that can block work before
  any `/v2/` request.
* Durable storage for company UUID, last observed `case_access`, access-event
  UUIDs and authorization-version provenance, and finalized Case UUIDs.
* A Partner key for state reconciliation.
* Python 3.11+ with `requests` and your integration's `access_control` module.
  Its `AccessControlStore.accept_event` method must be idempotent and durable;
  its state methods must atomically control queue admission and cleanup for one
  company.

## Lifecycle context

API-only onboarding starts provisionally; first setup/login confirms the named
Partner once for the current authorization version. Hosted onboarding confirms
terms and delegation in its flow. A revocation is the final event whose
creation and signature are permitted by the preceding authorized state. That
does **not** make it the last delivery chronologically: Partner deliveries are
unordered and at least once.

An active company admin can reconnect through the client-authorized connection
flow. The company keeps its UUID. `authorization_version` identifies consent
text; it is not a sequence number or ordering clock. Reconnection does not
replay missed events or restore discarded drafts.

## 1. Reconcile every access event against current state

After signature verification, validate all three access-event types. This
sandbox reference flow proves its environment with an authenticated safe read,
durably accepts/deduplicates the event, gates writes, and only then fetches the
authoritative company projection:

```python Python access handler theme={null}
import json
import re
import uuid
from pathlib import Path
from urllib.parse import urlsplit

import requests
from access_control import AccessControlStore

PAYWISE_API_URL = "https://api-sandbox.paywise.de"
PAYWISE_PARTNER_KEY = "pw_sbx_your_partner_key"
VERIFIED_ACCESS_EVENT_PATH = "./verified-access-event.json"

ACCESS_EVENT_TYPES = {
    "company.access.confirmed",
    "company.access.revoked",
    "company.access.restored",
}
UUID_PATTERN = re.compile(
    r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-"
    r"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$"
)
TIMEOUT = (5, 30)




def require_uuid(value, field):
    if not isinstance(value, str) or UUID_PATTERN.fullmatch(value) is None:
        raise ValueError(f"{field} must be a UUID")
    uuid.UUID(value)
    return value


def require_partner_sandbox(session, base_url):
    response = session.get(
        f"{base_url}/partner/v2/info/",
        timeout=TIMEOUT,
    )
    if response.status_code != 200:
        response.raise_for_status()
        raise RuntimeError(f"Expected 200, received {response.status_code}")
    environments = [
        value
        for name, value in response.headers.items()
        if name.lower() == "x-paywise-environment"
    ]
    if environments != ["sandbox"]:
        raise RuntimeError("Refusing reconciliation without exact sandbox proof")


def reconcile_access_event():
    base_url = PAYWISE_API_URL
    base = urlsplit(base_url)
    if (
        base.scheme != "https"
        or not base.hostname
        or base.username is not None
        or base.password is not None
    ):
        raise ValueError("PAYWISE_API_URL must be an HTTPS URL")

    session = requests.Session()
    session.headers.update(
        {"Authorization": f"Bearer {PAYWISE_PARTNER_KEY}"}
    )
    require_partner_sandbox(session, base_url)

    event_path = Path(VERIFIED_ACCESS_EVENT_PATH)
    event_body = event_path.read_bytes()
    try:
        event = json.loads(event_body)
    except (UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ValueError("Verified access event must be valid JSON") from error
    if not isinstance(event, dict) or event.get("type") not in ACCESS_EVENT_TYPES:
        raise ValueError("Unexpected Partner access event type")
    data = event.get("data")
    if not isinstance(data, dict):
        raise ValueError("Access event data must be an object")
    event_id = require_uuid(event.get("id"), "event.id")
    company_id = require_uuid(data.get("company_id"), "event.data.company_id")
    authorization_version = data.get("authorization_version")
    if type(authorization_version) is not int or authorization_version <= 0:
        raise ValueError("authorization_version must be a positive integer")

    store = AccessControlStore()
    store.accept_event(
        event_id=event_id,
        company_id=company_id,
        authorization_version=authorization_version,
        body=event_body,
    )
    store.gate_company(company_id=company_id)

    company_url = f"{base_url}/partner/v2/companies/{company_id}/"
    try:
        response = session.get(
            company_url,
            timeout=TIMEOUT,
        )
    except requests.RequestException:
        store.retain_gate(
            company_id=company_id,
            reason="transport-error",
        )
        raise

    if response.status_code == 404:
        store.apply_current_state(
            event_id=event_id,
            company_id=company_id,
            authorization_version=authorization_version,
            event_body=event_body,
            company_request_url=company_url,
            company_response_body=response.content,
            company_http_status=response.status_code,
            case_access="unavailable",
            case_submission_readiness=None,
            reconciliation="pending",
        )
        store.retain_gate(
            company_id=company_id,
            reason="company-hidden",
            http_status=404,
        )
        return {"case_access": "unavailable", "reconciliation": "pending"}

    if response.status_code != 200:
        store.retain_gate(
            company_id=company_id,
            reason="http-error",
            http_status=response.status_code,
        )
        response.raise_for_status()
        raise RuntimeError(f"Expected 200, received {response.status_code}")

    try:
        current = response.json()
    except ValueError as error:
        store.retain_gate(
            company_id=company_id,
            reason="invalid-company-response",
        )
        raise RuntimeError("Company response was not valid JSON") from error
    if not isinstance(current, dict):
        store.retain_gate(
            company_id=company_id,
            reason="invalid-company-response",
        )
        raise RuntimeError("Company response must be an object")
    if current.get("id") != company_id:
        store.retain_gate(
            company_id=company_id,
            reason="company-identity-mismatch",
        )
        raise RuntimeError("Company response identity mismatch")
    current_access = current.get("case_access")
    if current_access not in {"available", "unavailable"}:
        store.retain_gate(
            company_id=company_id,
            reason="invalid-company-response",
        )
        raise RuntimeError("Company response has no verified access state")
    current_readiness = current.get("case_submission_readiness")

    if current_access == "available":
        store.apply_current_state(
            event_id=event_id,
            company_id=company_id,
            authorization_version=authorization_version,
            event_body=event_body,
            company_request_url=company_url,
            company_response_body=response.content,
            company_http_status=response.status_code,
            case_access="available",
            case_submission_readiness=current_readiness,
            reconciliation="complete",
        )
        store.release_company(company_id=company_id)
        return {"case_access": "available", "reconciliation": "complete"}

    store.apply_current_state(
        event_id=event_id,
        company_id=company_id,
        authorization_version=authorization_version,
        event_body=event_body,
        company_request_url=company_url,
        company_response_body=response.content,
        company_http_status=response.status_code,
        case_access="unavailable",
        case_submission_readiness=current_readiness,
        reconciliation="complete",
    )
    store.retain_gate(
        company_id=company_id,
        reason="current-access-unavailable",
    )
    return {"case_access": "unavailable", "reconciliation": "complete"}


ACCESS_HANDLER_RESULT = reconcile_access_event()
```

`accept_event` must return success for an already stored event so duplicates
still trigger reconciliation. `gate_company` temporarily prevents queue
admission and dispatch. `apply_current_state` with `unavailable` commits that
state, cancels or quarantines not-yet-sent commands, and removes local
projections of inaccessible Partner drafts. Applying `available` commits the
current state; only the separate successful `release_company` call removes the
temporary gate, without replaying old work. Each state application binds the
accepted event UUID, company UUID, authorization version, exact event bytes,
exact Company request URL, exact response bytes and HTTP status, and the
fetched access/readiness projection in one durable record.

The environment proof uses the authenticated Partner-wide token-info read. It
requires no company selection and fails closed unless one
case-insensitively named response header has the exact lowercase value
`sandbox`. Exceptions from validation, exact identifier/body capture, durable
acceptance, gating, state application, or release remain terminal; the fetch
branches handle expected HTTP outcomes explicitly without releasing the gate
accidentally.

If a delayed revocation arrives after restoration, the fresh company response
is `available`, so the `available` branch preserves or restores traffic. If the
current response is `unavailable`, cleanup applies regardless of which access
event triggered the fetch. Never branch on event arrival order or compare
`authorization_version` values to decide access.

Company detail `404` is a legitimate hidden-resource outcome after final
revocation. It is handled only after durable acceptance and gating: record
unavailable access with reconciliation still pending and keep the gate. A
transport failure, malformed `200`, or other HTTP status also records pending
reconciliation, keeps the gate, and exits for retry. Only a verified `200`
whose exact `id` matches and whose current `case_access` is `available` can
apply available state and release the gate. Never interpret hidden access as
deletion of the company's legal records.

## 2. Interpret the current company projection

The response below shows a connected company whose Case Management API access is
unavailable, for example because paywise suspended Case Management API use. A disconnected
company is hidden with `404`. Readiness can remain green while access is
unavailable, so the reconciliation worker takes the unavailable branch; a
schema-valid response with `case_access: available` would take the available
branch instead.

## Representative response

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

{
  "id": "10000000-0000-4000-8000-000000000001",
  "customer_number": "5G0123",
  "name": "Example Client GmbH",
  "legal_form": "gmbh",
  "address": {
    "street": "Musterstraße 1",
    "postal_code": "10115",
    "city": "Berlin",
    "country": "DE"
  },
  "phone": "+49301234567",
  "tax_treatment": "input_tax_deductible",
  "vat_number": "DE129273398",
  "default_claim_type": "H05",
  "payout_bank_account": {
    "account_holder": "Example Client GmbH",
    "iban": "DE89370400440532013000",
    "bic": null
  },
  "notification_channels": [{
    "type": "email",
    "value": "operations@example.test",
    "notifications": ["status_updates", "requests_to_client", "statements"]
  }],
  "legal_representatives": [{
    "type": "managing_director",
    "name": "Alex Example"
  }],
  "case_access": "unavailable",
  "case_submission_readiness": {"ready": true, "issues": []},
  "users": [{
    "id": "11000000-0000-4000-8000-000000000001",
    "email": "admin@example.test",
    "first_name": "Alex",
    "last_name": "Example",
    "role": "admin",
    "status": "active",
    "invite_expires_at": null,
    "revoked_at": null,
    "revoked_by_token_id": null,
    "revocation_reason": "",
    "sandbox_origin": "sandbox_native",
    "created_at": "2026-08-01T10:00:00Z",
    "updated_at": "2026-08-27T10:05:00Z"
  }],
  "sandbox_origin": "sandbox_native",
  "created_at": "2026-08-01T10:00:00Z",
  "updated_at": "2026-08-27T10:05:00Z"
}
```

This complete response matches `Company` and demonstrates why readiness cannot
stand in for delegation: the data is ready while Partner access is unavailable.

## 3. Preserve finalized knowledge and discard draft work

paywise deletes Partner-created drafts that become inaccessible. Remove them
from retry queues and mark local draft projections unavailable; do not attempt
to recreate them under a new key. Keep finalized order and mandate UUIDs in the
audit trail. Finalized cases remain available to company and staff channels,
even when the Partner can no longer retrieve them.

Do not infer finalization from a lost `404`. Only a previously stored successful
finalize response or authoritative pre-revocation read establishes that state.

## 4. Repair missed events with scheduled reconciliation

Run the same gate, exact Company GET, and `case_access` branch periodically for
every company your integration already knows, even without a new event. This
repairs a missed restoration so traffic cannot remain stranded behind a stale
local block. It also repairs a delayed or missed revocation from current state.

Restoration resumes future Partner events, but events missed while access was
unavailable are not replayed. Reconcile every still-relevant finalized resource
your Partner can currently access. Treat any new draft or command as a new
business decision with a new idempotency key—never replay the revoked queue.

If rebuilding an all-company projection from
`GET /partner/v2/companies/`, use overlapping `updated_since` scans, follow
every exact `next` URL, reconcile by `(id, updated_at)`, and periodically repeat
a full scan. Mutable offset pagination has no snapshot, so one pass cannot
prove completeness under concurrent changes.

## Failure and recovery

* Duplicate access event: deduplicate by event UUID, then refetch the company;
  do not skip current-state reconciliation.
* Out-of-order delivery: temporarily gate and fetch current state. Do not use
  `authorization_version` to order confirmation, revocation, or restoration.
* Company `404` after revocation: record unavailable/pending reconciliation and
  keep traffic gated. The Company may legitimately be hidden; an inaccessible
  Partner draft may also have been deleted.
* Missing events during revocation: expected. Restoration has no replay; run
  current-state reconciliation. Deliveries that were still queued when access
  ended are closed as `failed` with `error_message:
  partner_access_unavailable` without an HTTP call; they cannot be
  redelivered (`404`).
* Membership commands after a release: `404`, like every other request for a
  company outside your access. An offboarded company answers
  `409 company_offboarded` to every membership command instead.
* Company detail still unavailable after restoration: do not unblock. Verify
  entitlement with the authorized portal/support path.

## Verify

Exercise revocation, restoration, and delayed-revocation delivery with queued
work. Confirm the event is durably accepted before `2xx`, every delivery first
gates writes, unavailable cleanup follows only current `case_access:
unavailable`, and a delayed revocation cannot override current available state.
Confirm finalized IDs remain, drafts are not recreated, and a periodic run
repairs a deliberately missed restoration without replaying commands.

## Related reference

* [GET `/partner/v2/companies/{id}/`](/api-docs/partner-api/reference/companies/get-a-managed-company)
* [GET `/partner/v2/info/`](/api-docs/partner-api/reference/meta/get-partner-token-info)
* [GET `/partner/v2/companies/`](/api-docs/partner-api/reference/companies/list-managed-companies)
* [GET `/partner/v2/webhook-deliveries/`](/api-docs/partner-api/reference/webhook-deliveries/list-webhook-deliveries)
* [GET `/v2/orders/{id}/`](/api-docs/case-management-api/reference/orders/get-order)


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