Metadata-Version: 2.4
Name: enruta
Version: 0.3.0
Summary: Python client for the Enruta API: agents, sellers and platforms. Standard library only.
Author: Enruta, Inc.
License-Expression: Apache-2.0
Project-URL: Homepage, https://enruta.ai
Project-URL: Documentation, https://enruta.ai/docs
Project-URL: Source, https://github.com/enruta-ai/enruta/tree/main/packages/sdk-python
Keywords: enruta,agentic-payments,mandate,evidence-record,ucp
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# enruta (Python)

Python client for the [Enruta](https://enruta.ai) API, version 0.3.0. Standard library only (`urllib`, `json`, `hmac`), Python 3.10 or later. It covers the three sides of a payment: agents (payment requests, clarifications, identity, merchant checks, the money split, recurrences, records and the objects around a payment), sellers (agent orders, adjustments, disputes, PSP connections, hosted storefronts) and platforms (connected accounts, keys, usage, brand, feeds).

```python
import os, uuid
from enruta import Enruta

client = Enruta(os.environ["ENRUTA_AGENT_KEY"])

r = client.payment_requests.create(
    {
        "payee": {"name": "Ream & Rule", "domain": "reamandrule.com"},
        "amount": {"value": "48.00", "currency": "USD"},
        "purpose": "printer paper for the office",
        "intent": {"text": "get printer paper for the office", "slots": {"category": "office_supplies"}},
    },
    idempotency_key=str(uuid.uuid4()),
)

if r["decision"]["result"] == "clarify":
    # Ask the user every question, then send their answers in their own words.
    answers = [{"question_id": q["id"], "answer": input(q["question"] + " ")} for q in r["decision"]["questions"]]
    r = client.clarifications.answer(r["id"], answers)

if r["decision"]["result"] in ("allow", "observe"):
    credential = r["mandate"]["credential"]  # a single-use token, never a card number
```

A request can also come back `handed_off` with `r["handoff"]["rail"]`: the policy allowed it, but the rail issued no credential (it cannot carry the payment, or it failed while issuing: `rail_unavailable`, `rail_error`). Nothing was paid; show the person `r["handoff"]["message"]` and `r["handoff"]["next_step"]`.

An API or data source that charges per request answers HTTP 402 (x402 or the Machine Payments Protocol). Request the payment with `"checkout": {"protocol": "x402", "reference": url}`, then pay the resource with the request's mandate:

```python
res = client.payment_requests.pay_resource(r["id"], url)  # method=, headers=, body= for a POST
if res["paid"]:
    data = res["body"]  # the resource's answer as text; res["receipt"] is the rail's receipt
else:
    print(res["reason"])  # a refusal, not an error: report it and stop
```

## Surface

| Area | Methods |
| --- | --- |
| Payment requests | `payment_requests.create(body, idempotency_key=)`, `.get(id, wait=)`, `.hand_off(id, body)`, `.pay_resource(id, url, method=None, headers=None, body=None)` |
| Clarification | `clarifications.answer(request_id, answers, answered_by=None)` |
| Identity | `agents.identity(agent_id)`, `attestations.nonce(agent_id=)`, `attestations.create({"agent_id", "anchor": {"type", ...}})`, `attestations.list()` |
| Merchant check | `merchants.identity(domain)` |
| Money split | `economics.for_payment(request_id)`, `sellers.orders.economics(order_id)` |
| Recurrences | `recurrences.create`, `.list`, `.get`, `.pause`, `.resume`, `.end`, `.occurrences` |
| After the payment | `reversals.*`, `settlements.get`, `reconciliation.*`, `disputes.*` (with `.deliver(id, target)`), `records.*` (with `.psp_metadata(id, psp)`), `exports.*` |
| Controls | `consents.*` (with `.revoke(id, effective_at=, cascades_scheduled=)`), `agents.pause/resume`, `approval_delegations.*`, `org.settings.get/update`, `org.verification.start/get`, `billing.subscription/plans/usage/invoices`, `policies.*`, `rails.list`, `approvals.get` |
| Sellers | `sellers.orders.observe/list/get`, `sellers.verify`, `sellers.report_adjustment`, `sellers.request_reversal`, `sellers.confirm_fulfillment`, `sellers.disputes`, `sellers.packet`, `sellers.representment_pdf`, `sellers.reconciliation`, `sellers.conformance.run/list`, `sellers.middleware`, `sellers.domains.list/add`, `sellers.psp_connections.create/list/delete/test` |
| Storefronts | `storefronts.create`, `.list`, `.get`, `.update`, `.refresh_catalog`, `.orders` |
| Platforms | `platform.accounts.create/list/get/update/create_key`, `platform.usage(period=)`, `platform.brand.get/update`, `platform.feeds.create/list/delete/events/replay` |
| Sandbox | `sandbox.create_keys()`, `create_sandbox_keys(base_url=)` (no key sent) |

Every method returns the JSON the API sent as plain dictionaries; `enruta.types` has TypedDicts for the main shapes (`PaymentRequest`, `Decision`, `IntentState`, `AgentIdentity`, `PayeeIdentity`, `Economics`, `Recurrence`, `PlatformAccount`, ...). `iterate(client.recurrences.list, status="active")` follows `next_cursor` through every page. `client.request(method, path, query=, body=)` reaches endpoints the typed surface does not cover yet.

## Platforms: acting for a connected account

```python
platform = Enruta(os.environ["ENRUTA_PLATFORM_KEY"])
account = platform.platform.accounts.create({"kind": "agents", "name": "Globex buyer", "external_ref": "cust_9", "agents": [{"name": "Buyer"}]})
buyer = platform.for_account("cust_9", agent=account["agents"][0]["id"])  # Enruta-Account and Enruta-Agent headers
buyer.payment_requests.create({...}, idempotency_key=...)
```

`Enruta(key, account=..., agent=...)` does the same from the start. An unknown or suspended account answers `PermissionDeniedError` with `code == "account_not_connected"`.

## Errors

Every non-2xx answer raises a subclass of `EnrutaError` (`status`, `code`, `message`, `request_id`, `details`, `retry_after`): `InvalidRequestError` (400, 422), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429, with `retry_after`), `APIError` (5xx and anything else), `NetworkError` (no response, `status == 0`), `InvalidResponseError` (a body that is not JSON). A denied payment or reversal is not an error: it is a result with `decision.result == "deny"` and it has a record.

## Webhooks and feeds

Deliveries carry `Enruta-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>`. Verify the raw body before parsing:

```python
from enruta import construct_event, SignatureVerificationError

try:
    event = construct_event(request_body_bytes, request.headers.get("Enruta-Signature"), os.environ["ENRUTA_WEBHOOK_SECRET"])
except SignatureVerificationError:
    return 400
```

`verify_signature(payload, header, secret, tolerance=300)` returns a bool (constant-time comparison; any `v1` matches during a secret rotation).

## Keys and secrets

The client never logs and never puts the key in `repr`. Keys shown once by the API (a connected account's key, a feed's signing secret, sandbox keys) are yours to store; never write them to files that are committed. The API never accepts a card number, and neither should your integration.

## Tests

```
cd packages/sdk-python
python3 -m unittest discover -s tests -t .
```

The tests run against a local `http.server` stub; nothing reaches the network.

Apache-2.0.
