Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to the ValidPay Python SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0] - 2026-06-19

### Added

- **Platform delegation — `on_behalf_of`** (Fork B). Platforms that seal on
behalf of the businesses they serve can declare which business each seal is
for: pass `on_behalf_of={"ref": ..., "name": ...}` to `create_intent`,
`create_file_intent`, `create_selective_intent`, or per-item in
`create_intent_batch`. `ref` is your own id for the business (the dedupe key —
same `ref` rolls up); `name` is who the verifier sees. The verifier sees that
business as the issuer, attributed *through* your platform, at the `delegated`
trust rung. ValidPay stays blind to the document contents — identity only.
- `verify_intent` now surfaces `verification_level` (`none` < `delegated` <
`domain` < `business`) and `delegated_by` (`{"platform", "platform_level"}` or
`None`) on `VerifyIntentResult`.

> Requires the ValidPay API with platform-delegation support (live 2026-06-19).

## [1.4.0] - 2026-06-17

### Added
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,34 @@ print(verified.payload)
response, so it verifies both; `verify_split_key_intent()` also still
works.

### Platform delegation — `on_behalf_of`

If you integrate as a **platform** and seal on behalf of the businesses you
serve, name the business on each seal. The verifier sees that business as the
issuer ("who"), attributed *through* your platform ("through whom"), at the
`delegated` trust rung. Those businesses never touch ValidPay — no account, no
login — and ValidPay stays blind to the document contents.

```python
result = client.create_intent(
document_type="lease",
payload={"unit": "4B", "term": "12mo"},
on_behalf_of={
"ref": "landlord_8675309", # YOUR id for this business (dedupe key)
"name": "Smith Properties LLC", # who the verifier sees
},
)

verified = client.verify_intent(result.retrieval_id, result.key)
verified.issuer # "Smith Properties LLC"
verified.verification_level # "delegated"
verified.delegated_by # {"platform": "Your Platform", "platform_level": "domain"}
```

Same `ref` ⇒ same tracked business (its documents and verification counts roll
up). A sub-issuer surfaces as `delegated` only once **your** platform account is
domain-verified; until then its documents show as unverified.

### Selective disclosure (Patent E)

Each field is encrypted with its own per-field key. A disclosure policy maps
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "validpay"
version = "1.4.0"
version = "1.5.0"
description = "Official ValidPay Python SDK — client-side AES-256-GCM encryption + ValidPay API client"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
40 changes: 40 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,46 @@ def test_create_intent_encrypts_locally_and_never_sends_key():
assert decrypted == payload


def test_create_intent_sends_on_behalf_of_for_delegation():
session = _FakeSession([
_FakeResponse(201, {"retrieval_id": "vp_d", "status": "active"}),
])
client = ValidPayClient(
api_key="test_key", base_url="https://api.example.test", session=session
)
client.create_intent(
document_type="lease",
payload={"a": 1},
on_behalf_of={"ref": "cust_42", "name": "Smith Properties"},
)
sent_body = json.loads(session.calls[0]["data"])
assert sent_body["on_behalf_of"] == {"ref": "cust_42", "name": "Smith Properties"}


def test_verify_surfaces_verification_level_and_delegated_by():
key = generate_key()
blob = encrypt(json.dumps({"a": 1}), key)
session = _FakeSession([
_FakeResponse(200, {
"intent_id": "vp_d",
"encrypted_payload": blob,
"issuer": "Smith Properties",
"issuer_verified": False,
"registered_at": "2026-04-29T12:00:00.000Z",
"status": "active",
"document_type": "lease",
"verification_level": "delegated",
"delegated_by": {"platform": "Acme Platform", "platform_level": "domain"},
}),
])
client = ValidPayClient(
api_key="test_key", base_url="https://api.example.test", session=session
)
result = client.verify_intent("vp_d", key)
assert result.verification_level == "delegated"
assert result.delegated_by == {"platform": "Acme Platform", "platform_level": "domain"}


def test_create_intent_split_key_false_is_legacy_full_key():
"""split_key=False preserves the 1.0.x behavior: no fragment fields,
and the returned key decrypts the payload directly."""
Expand Down
2 changes: 1 addition & 1 deletion validpay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@
"embed_qr",
]

__version__ = "1.4.0"
__version__ = "1.5.0"
18 changes: 18 additions & 0 deletions validpay/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def create_intent(
valid_from: Optional[str] = None,
valid_until: Optional[str] = None,
split_key: bool = True,
on_behalf_of: Optional[Dict[str, str]] = None,
) -> CreateIntentResult:
"""Encrypt ``payload`` locally and register it with the ValidPay API.

Expand Down Expand Up @@ -157,6 +158,8 @@ def create_intent(
body["valid_from"] = valid_from
if valid_until is not None:
body["valid_until"] = valid_until
if on_behalf_of is not None:
body["on_behalf_of"] = on_behalf_of

data = self._request(
"POST",
Expand Down Expand Up @@ -185,6 +188,7 @@ def create_file_intent(
valid_from: Optional[str] = None,
valid_until: Optional[str] = None,
split_key: bool = True,
on_behalf_of: Optional[Dict[str, str]] = None,
) -> CreateIntentResult:
"""Seal a full document file (PDF, image, DOCX, …) end-to-end.

Expand Down Expand Up @@ -252,6 +256,8 @@ def create_file_intent(
body["valid_from"] = valid_from
if valid_until is not None:
body["valid_until"] = valid_until
if on_behalf_of is not None:
body["on_behalf_of"] = on_behalf_of

data = self._request("POST", "/v1/intent", body=body, auth=True)

Expand Down Expand Up @@ -329,6 +335,9 @@ def create_intent_batch(
req_item["valid_from"] = valid_from
if valid_until is not None:
req_item["valid_until"] = valid_until
on_behalf_of = item.get("on_behalf_of")
if on_behalf_of is not None:
req_item["on_behalf_of"] = on_behalf_of
request_items.append(req_item)

data = self._request(
Expand Down Expand Up @@ -478,6 +487,8 @@ def verify_intent(
registered_at=data.get("registered_at", ""),
status=data.get("status", ""),
integrity_verified=integrity_verified,
verification_level=data.get("verification_level"),
delegated_by=data.get("delegated_by"),
valid_from=valid_from_str,
valid_until=valid_until_str,
time_lock_status=time_lock_status,
Expand Down Expand Up @@ -589,6 +600,8 @@ def verify_split_key_intent(
registered_at=data.get("registered_at", ""),
status=data.get("status", ""),
integrity_verified=integrity_verified,
verification_level=data.get("verification_level"),
delegated_by=data.get("delegated_by"),
valid_from=valid_from_str,
valid_until=valid_until_str,
time_lock_status=time_lock_status,
Expand All @@ -603,6 +616,7 @@ def create_selective_intent(
split_key: bool = False,
valid_from: Optional[str] = None,
valid_until: Optional[str] = None,
on_behalf_of: Optional[Dict[str, str]] = None,
) -> CreateIntentResult:
"""Create an intent with per-field encryption and a disclosure policy (Patent E).

Expand Down Expand Up @@ -677,6 +691,8 @@ def create_selective_intent(
body["valid_from"] = valid_from
if valid_until is not None:
body["valid_until"] = valid_until
if on_behalf_of is not None:
body["on_behalf_of"] = on_behalf_of

data = self._request("POST", "/v1/intent", body=body, auth=True)

Expand Down Expand Up @@ -817,6 +833,8 @@ def verify_selective_intent(
registered_at=data.get("registered_at", ""),
status=data.get("status", ""),
integrity_verified=integrity_verified,
verification_level=data.get("verification_level"),
delegated_by=data.get("delegated_by"),
valid_from=valid_from_str,
valid_until=valid_until_str,
time_lock_status=time_lock_status,
Expand Down
6 changes: 6 additions & 0 deletions validpay/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,9 @@ class VerifyIntentResult:
valid_from: Optional[str] = None
valid_until: Optional[str] = None
time_lock_status: Optional[str] = None
# Platform delegation (Fork B). ``verification_level`` is the issuer's graded
# trust rung: "none" < "delegated" < "domain" < "business". ``delegated_by``
# is set when this was sealed on behalf of, via a platform — a dict
# ``{"platform": str, "platform_level": "domain"|"business"}`` — else None.
verification_level: Optional[str] = None
delegated_by: Optional[dict] = None
Loading