# setla (Python)

Python client for the Setla API. A buyer connection pays for bounded work from a seller connection, and the payment is released or refunded after a deterministic check.

- Python 3.10+, standard library only. Uses `urllib.request`, `json`, `hmac` and `uuid`.
- Responses are plain `dict`s, documented with `TypedDict`s in `setla.types`. Unknown fields are kept.
- Sends an `Idempotency-Key` on every mutation and reuses it across automatic retries.
- Retries network errors, 429 and 5xx with exponential backoff and jitter.
- Raises typed exceptions.
- Includes a pagination generator, a polling helper and webhook verification.
- Ed25519 receipt verification needs the optional `cryptography` package, because the standard library has no Ed25519 implementation.

```sh
# From an authorized checkout of the Setla source repository:
pip install ./sdks/python
pip install "./sdks/python[receipts]"  # Ed25519 receipt verification
```

The SDK source is included in the Setla repository. It is not currently published as a public npm or PyPI package. Contact support@setla.ai for source access if needed.

## Quick start

Check `GET /v1/capabilities` for the deployment’s `payment_mode`; only `live` moves real money. The examples use `https://api.setla.ai`. Use a separate sandbox deployment for simulation. First read capabilities, complete payment setup and hosted seller verification, and obtain human approval for buyer funding limits and seller allowlisting. Self-registration works only when `registration=self_service`; otherwise join an existing workspace through its connection invitation. Use each business’s actual country instead of the sample `US`. These examples do not grant spending authority or complete hosted setup automatically.

### Seller connection

```python
from setla import Setla

BASE_URL = "https://api.setla.ai"

# 1. Register once. The token is shown exactly once: store it in your secret manager now.
seller, registration = Setla.register(BASE_URL, "Sorter Bot", "Sorter Co")
save_secret("SETLA_TOKEN", registration["token"]["plain_text_token"])
print("Give buyers this id:", registration["agent"]["id"])  # agt_...

# 2. Become payable (the simulated provider is ready at once; Stripe returns hosted links).
setup = seller.payment_setup.start("seller", "US")
for action in setup["required_actions"]:
    print("Complete provider onboarding:", action.get("url"))

# 3. Optional: an invite link lets buyers allow-list you.
invite = seller.invites.create(max_uses=5, note="for ACME")
print("Send to buyer (shown once):", invite["url"])

# 4. Work loop.
for job in seller.jobs.list_all(role="seller"):
    if job["state"] != "open" or job["payment_state"] not in ("funded", "authorized"):
        continue
    seller.jobs.claim(job["id"])
    numbers = job["contract"]["input"]["numbers"]
    result = seller.jobs.deliver(job["id"], {"numbers": sorted(numbers)})
    print(job["id"], result["state"], result["check_status"])  # reviewing passed
```

### Buyer connection

```python
import os
from setla import Setla

buyer = Setla("https://api.setla.ai", token=os.environ["SETLA_TOKEN"])
buyer.payment_setup.start("buyer", "US")

# Allow-list the seller, either via its invite...
print(buyer.invites.preview(invite_url)["inviter"])
buyer.invites.accept(invite_url)
# ...or directly, together with spending limits. Outside the sandbox this is a human
# decision: the registration token never carries limits:raise, so the call returns 403
# until a person claims the business and raises the limits in the console. In the
# sandbox the connection already has can_fund within capabilities["sandbox"]["self_service_spending"].
buyer.agents.update(can_fund=True, max_job_cents=2_000, daily_limit_cents=10_000, allowed_seller_ids=["agt_01j9z..."])

# Persist the key with your order so a restarted process replays instead of paying twice.
job = buyer.jobs.create(
    "agt_01j9z...", 500, "json-sort-v1", {"numbers": [42, -7, 3]},
    terms={"claim_minutes": 15, "delivery_minutes": 60, "max_attempts": 2},  # optional
    idempotency_key=f"order-{order_id}",
)
print(job["id"])  # job_01j9z... (opaque public id; store it as a string)

reviewed = buyer.wait_for_job_state(job["id"], ["reviewing", "expired", "cancelled"], timeout=600)
if reviewed["state"] == "reviewing" and reviewed["check_status"] == "passed":
    buyer.verify_job_receipt(reviewed)   # needs setla[receipts]
    buyer.jobs.accept(job["id"])
elif reviewed["state"] == "reviewing":
    buyer.jobs.dispute(job["id"], "Artifact failed verification", evidence=["See check_evidence."])
```

## Client options

```python
Setla(
    base_url,                 # origin; a trailing /v1 is tolerated
    token=None,               # connection access key
    max_retries=3,            # automatic retries after the first attempt
    timeout=30.0,             # seconds, per attempt
    retry_base_delay=0.5,     # backoff 0.5, 1, 2 s (max 8) with equal jitter
    max_retry_after=60.0,     # longer Retry-After values raise RateLimitError instead of sleeping
    proxies=None,             # None = environment proxies; {} = never use a proxy
    default_headers=None,
)
```

Every mutating method accepts `idempotency_key=`. For endpoints the SDK doesn't wrap yet, use `client.request(method, path, body=..., query=...)`.

## Idempotency and retries

- Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`. A random UUID v4 is used unless you pass `idempotency_key=`. Automatic retries reuse the same key.
- These are retried: connection errors, timeouts, `429` (honouring `Retry-After`) and `5xx`. Any other `4xx` is raised immediately.
- Redirects are never followed, so the bearer token can't leak to another host and request bodies are never dropped.
- Registration and token issuance return their secret once. If the server receives a replayed key, it answers `409`, and the error message explains this.

## Errors

All exceptions derive from `SetlaError`. HTTP failures derive from `APIError` and provide `status`, `message`, `errors` (dict of field to messages), `body`, `headers`, `idempotency_key` and `attempts`.

`ValidationError` (422), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `PayloadTooLargeError` (413), `RateLimitError` (429, `retry_after`), `InternalServerError` (5xx), `BadRequestError`. Also: `APIConnectionError` and `APITimeoutError`, `WebhookVerificationError`, `ReceiptVerificationError`, `WaitTimeoutError` (`last_job`).

```python
from setla import ValidationError

try:
    buyer.jobs.create("agt_x", 50, "json-sort-v1", {"numbers": [1]})
except ValidationError as exc:
    print(exc.status, exc.message, exc.errors)  # 422 ... {'amount_cents': ['...']}
```

## Resources

Jobs and connections are identified by opaque string ids (`job_...`, `agt_...`); pass them back exactly as returned. Path segments are URL-encoded by the client.

`agents` manages the connection this access key belongs to. It keeps its name for compatibility with the API's `agent` fields and `agent:*` abilities; Setla does not create or host agents.

| Attribute | Methods |
| --- | --- |
| `capabilities` | `get()`, `openapi()`, `receipt_public_keys(refresh=False)` |
| `agents` | `register(name, business_name)`, `get()`, `update(**fields)`, `stop()` |
| `tokens` | `issue(name, abilities, expires_in_days=None)`, `revoke(token_id)` |
| `payment_setup` | `start(role, country)`, `get()`, `payment_action(job_id)` |
| `webhooks` | `get()`, `configure(url)`, `disable()`, `rotate_secret()`, `verify(payload, header, secret)` |
| `jobs` | `create(...)`, `get`, `list(role=, per_page=, page=)`, `list_all(role=)`, `claim`, `deliver(job_id, artifact)`, `accept`, `dispute(job_id, reason, evidence=None)`, `cancel`, `wait_for_state` |
| `invites` | `create(expires_in_hours=, max_uses=, note=)`, `list()`, `revoke(id)`, `preview(code_or_url)`, `accept(code_or_url)` |
| `sandbox` | `advance_clock(job_id, seconds)`. Only works when `simulation_available` is true. |
| client | `wait_for_job_state(job_id, states, timeout=60, interval=2)`, `verify_job_receipt(job_or_evidence)`, `request(...)`, `token` (settable) |

`wait_for_job_state` matches both `state` and `payment_state`, for example `"funded"` or `"released"`.

## Webhooks

```python
from setla import verify_webhook, WebhookVerificationError

@app.post("/setla/webhook")          # Flask example
def setla_webhook():
    try:
        event = verify_webhook(
            request.get_data(),       # raw bytes, exactly as received
            request.headers.get("X-Agent-Signature"),
            [current_secret, previous_secret],  # any match is accepted during rotation
            tolerance=300,
        )
    except WebhookVerificationError:
        return "", 400
    if not already_processed(event["id"]):   # at-least-once delivery
        handle(event["type"], event["job_id"])
    return "", 200
```

Signature: `X-Agent-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, f"{t}.{raw_body}")>`. The comparison uses `hmac.compare_digest`, and timestamps more than 300 s from the local clock are rejected. `sign_payload(secret, body)` builds a valid header, which is useful for tests.

## Verification receipts

Every check is signed with Ed25519 over the canonical JSON of its receipt (`setla.canonical_json`): keys sorted recursively by UTF-8 byte order, no whitespace, slashes and unicode left unescaped, empty objects as `[]`, and numbers in ECMAScript `JSON.stringify` form (`1.0` -> `1`, `-0.0` -> `0`, `1e21` -> `1e+21`, `1e-07` -> `1e-7`). Public keys come from `GET /v1/capabilities` under `verification.receipt_public_keys`: the `current` key plus any `previous` keys kept after a rotation. The key is picked by the evidence `key_id`, so receipts signed before a rotation keep verifying.

When a receipt has `hash_alg: "sha256-canonical-json-v1"`, `input_hash` and `artifact_hash` are the SHA-256 of the canonical JSON of `contract.input` and the delivery artifact. You can recompute them with the standard library alone. Older receipts without `hash_alg` come back with `checked=False`.

```python
verified = client.verify_job_receipt(job)          # keys fetched and cached
from setla import verify_receipt, verify_receipt_hashes, recompute_receipt_hashes
verify_receipt(job["check_evidence"], pinned_keys, expected_job_id=job["id"])
verify_receipt_hashes(job)                         # raises ReceiptVerificationError on a mismatch
recompute_receipt_hashes(job, delivery=None)       # ReceiptHashCheck(checked, matches, input_matches, ...)
```

Without `cryptography` installed, both signature calls raise `ReceiptVerificationError` explaining how to install the `receipts` extra. `ed25519_available()` tells you in advance whether verification will work. Hash recomputation doesn't need it. If the server had no usable signing key, evidence carries `signed: False` and an `unsigned_reason`, and `verify_receipt` rejects it. The check result itself still stands.

## Development

```sh
python3 -m unittest discover -v     # local http.server mock, no network
python3 -m compileall -q setla
BASE_URL=http://127.0.0.1:8099 python3 ../e2e/smoke.py   # against a local server
```


Before funding a captured long Stripe job, show the buyer `capabilities.cancellation_policy.summary` and its `terms_url`, and obtain acknowledgment. Only then include `"cancellation_policy_acknowledgment": "actual_processing_cost_v1"` in the job creation request. Missing acknowledgment returns 422 without creating the job, reserving budget, or queuing funding. Never automatically retry with acknowledgment or assume it from a saved card or funding permission. Short authorized jobs, simulation, and prepaid jobs do not require this long-card-job acknowledgment. Bounty postings whose default job windows require upfront Stripe capture also require the buyer's acknowledgment; it is saved at posting and reused when a seller claims. Existing bounties without acknowledgment cannot silently create a captured long job if the funding policy changes.
