Metadata-Version: 2.5
Name: credda
Version: 0.1.0
Summary: Official Python SDK for the Credda Reliability Score API
Project-URL: Homepage, https://credda.io
Project-URL: Documentation, https://api.credda.io/docs
Project-URL: Changelog, https://api.credda.io/changelog
Author: Credda
License-Expression: MIT
License-File: LICENSE
Keywords: credda,reliability-score,reputation,trust,verifiable-credentials
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# `credda` — official Python SDK

Python client for the **Credda Reliability Score API**. Credda turns the activity that
already happens on your platform into a **portable, verifiable trust score your users
own**. You report outcome events; Credda scores them with a deterministic, bias-free
formula; your users (and anyone they share with) can verify the result offline.

This is a faithful port of the TypeScript SDK (`@credda/js`) — same endpoints, same
request/response shapes, `snake_case` method names.

> The score is a pure function of the append-only event ledger. **No human and no AI
> ever sets, nudges, or edits it.**

## Install

```bash
pip install credda
```

Requires Python 3.9+. The only dependency is
[`httpx`](https://www.python-httpx.org/): no compiled extensions, no wheels to
match to your platform, nothing to build.

## Reporting a bug

The source for this SDK is not public yet, so there is no issue tracker to
point you at. Email **martin@credda.io** and say what you called, what came
back, and what you expected. Please do not send an API key or a share token in
that message; a `requestId` from the failing response is enough for us to find
it.

## Quickstart

### 1. Get a platform API key

Ask your Credda contact to provision a **platform** and issue an API key
(`crd_live_…`). A platform has a **trust tier** that determines how much weight its
reported events carry. Keep the key server-side — it authenticates every write.

```python
import os
from credda import CreddaClient

credda = CreddaClient(api_key=os.environ["CREDDA_API_KEY"])
```

### 2. Report your users' activity

Every meaningful outcome on your platform is an *event*: a completed transaction, a
fulfilled order, a delivered project, a resolved dispute. Report them as they happen.

**Verification matters.** Set `isVerified: True` when *your platform attests the outcome
actually happened on your system* — that is what builds trust. An unverifiable
self-claim earns none (a user with only unverified activity stays "Provisional").

```python
credda.report_event(
    {
        "userId": "your-stable-user-id",   # your id for the user; Credda maps it internally
        "eventType": "CONTRACT_FULFILLED",
        "isVerified": True,                 # your platform attests this happened
        "completedAt": "2026-07-20T12:00:00.000Z",
        "transactionValue": 4200,           # optional — higher-value outcomes weigh more
        "metadata": {"orderId": "98432"},   # optional — free-form, echoed back
    },
    idempotency_key="order-98432-completed",  # retry-safe, exactly-once ingestion
)
```

Backfilling or streaming? Send up to **100 events in one call**. It is *partial
success* — one bad or duplicate item never fails the rest — and each item takes its own
`idempotencyKey`:

```python
result = credda.report_events([
    {"userId": "u_1", "eventType": "CONTRACT_FULFILLED", "isVerified": True, "idempotencyKey": "order-1"},
    {"userId": "u_2", "eventType": "TRANSACTION_COMPLETED", "isVerified": True, "idempotencyKey": "order-2"},
])
print(result["created"], result["duplicate"], result["failed"])
```

### 3. Read scores

```python
score = credda.get_score("u_1")
print(score["finalScore"], score["scoreBand"])

# Up to 100 users in one call; unknown ids come back as {"userId": ..., "error": "not_found"}
batch = credda.get_scores(["u_1", "u_2", "u_3"])
```

`scoreBand` is a plain-language label (`Distinguished` to `Provisional` to `At Risk`), never a
percentile or ranking.

More views on the same deterministic score:

```python
credda.get_score_explain("u_1")      # plain-language factor breakdown
credda.get_score_components("u_1")   # six independently 0-100-scored components
credda.get_score_delta("u_1")        # why the score last moved (top driver)
credda.get_platforms("u_1")          # platforms contributing to the score
credda.get_risk("u_1")               # advisory anti-gaming signals (never affects the score)
credda.get_usage(days=30)            # your own API consumption vs. tier quota
```

#### ⚠️ Unmeasured is not zero

The single most important rule when reading anything score-shaped. Credda
distinguishes **"we measured it and it was bad"** from **"there was nothing to
measure"**, and the API says which on the wire: an unmeasured rate is `null`
(Python `None`), never `0`. A record with no history has not completed 0% of its
commitments; it has not been measured, and rendering `0` turns an absence into
a failing record.

Every affected value travels with a **discriminator**. Branch on it:

```python
comps = credda.get_score_components("u_1")

# The whole-payload state first: is there anything to measure at all?
ds = comps.get("dataSufficiency")
if ds and ds["insufficientData"]:
    print(ds["note"])           # safe to show verbatim

for c in comps["components"]:
    if not c["available"]:
        print(f"{c['label']:<16} not measured")     # NEVER "0"
        continue
    print(f"{c['label']:<16} {c['score']:.0f}")
```

Reading `score` without the branch gives you the two classic failures:
`TypeError: '<' not supported between instances of 'NoneType' and 'int'`, or the
string `"None"` interpolated into a message, or, worst of all, an `or 0` that
silently renders a zero.

The discriminator for each surface:

| Read | Branch on | Values it guards |
|---|---|---|
| `get_score_components` | `component["available"]`, `dataSufficiency["insufficientData"]` | `score` |
| `get_score_explain` | `factor["available"]`, `dataSufficiency["insufficientData"]` | `value`, `contribution` |
| `get_score_explain()["reasonCodes"]` | `insufficientData`, `dataState` | `adverseActionReasons`, `supportingFactors`, `finalScore` |
| `get_trust_summary` | `evidence["insufficientData"]` | `completionRate`, `onTimeRate` |
| `get_reliability_report` | `metrics["insufficientData"]`, `metrics["dataState"]` | `completionRate`, `onTimeRate`, `consistency`, `disputeRate` |
| `project_score` / `analyze_document` | `timeliness["projectionIsUpperBound"]` | `projected` (it is a best case, not a point estimate) |

Three things follow, each with a test in `tests/test_unmeasured_is_not_zero.py`:

- **A measured zero is still real and must still be shown.** `available: True`
  with `score: 0` is a record that genuinely completed nothing. Hiding real bad
  news is a worse failure than the substitution this rule exists to prevent.
- **An absent measurement is never an adverse reason.** When
  `reasonCodes["insufficientData"]` is true, `adverseActionReasons` and
  `supportingFactors` are **empty by construction**, and the one code present is
  `informational`. Never draw a Regulation B statement of specific reasons from
  it. `dataState` says whether the record has no outcomes at all
  (`no_recorded_outcomes`) or outcomes whose score has not been computed yet
  (`score_not_yet_computed`).
- **An older API is safe.** These fields are additive; against a server that
  predates them the key is simply absent. Use `c.get("available") is not False`
  so an absent flag reads as *unknown*, not as a positive "unavailable".

The annotations for `score`, `value`, `completionRate` and friends stay
non-`Optional` on purpose: widening them is a breaking change for type-checked
consumers of the published package and is held for a version decision. The
discriminators are what make correct code possible today, and the docstrings on
each `TypedDict` in `credda.types` say exactly which field guards which value.

### 4. Paginate history and the timeline

Both accept `limit` + `cursor` and return `nextCursor` (`None` once exhausted):

```python
page = credda.get_score_history("u_1", limit=50)
while True:
    for snapshot in page["data"]:
        ...
    if not page.get("nextCursor"):
        break
    page = credda.get_score_history("u_1", limit=50, cursor=page["nextCursor"])

# …or let the SDK follow the cursor for you:
for item in credda.iter_timeline("u_1", limit=50):
    ...
```

### 5. Project a score (what-if)

Read-only and forward-looking — it never writes a snapshot or touches the ledger:

```python
projection = credda.project_score("u_1", [
    {"eventType": "CONTRACT_FULFILLED", "isVerified": True, "stakeLevel": "HIGH"},
])
print(projection["current"]["finalScore"], "→", projection["projected"]["finalScore"])
```

### 6. Get notified when a score changes (webhooks)

Register an HTTPS endpoint to receive HMAC-signed `score.updated` /
`score.band_changed` / `dispute.resolved` events instead of polling. The signing secret
is returned **once** — store it.

```python
created = credda.create_webhook({
    "url": "https://your-app.example/credda/webhook",
    "events": ["score.updated", "score.band_changed"],
    "description": "prod",
})
secret = created["secret"]  # whsec_… — shown ONCE

credda.list_webhooks()
credda.test_webhook(created["webhook"]["id"])          # synthetic signed delivery
credda.update_webhook(created["webhook"]["id"], {"isActive": False})
credda.get_webhook_deliveries(created["webhook"]["id"], limit=20)
credda.delete_webhook(created["webhook"]["id"])
```

#### Verifying deliveries

Credda signs each delivery with `X-Credda-Signature: sha256=<hex>` — an HMAC-SHA256 over
`f"{X-Credda-Timestamp}.{raw_body}"`. **Verify on the raw body, before parsing JSON**, or
the bytes won't match.

```python
from credda import construct_webhook_event, WebhookVerificationError

# Flask example
@app.post("/credda/webhook")
def credda_webhook():
    try:
        event = construct_webhook_event(
            request.get_data(as_text=True),   # RAW body
            request.headers.get("X-Credda-Signature"),
            WEBHOOK_SECRET,
            request.headers.get("X-Credda-Timestamp"),
        )
    except WebhookVerificationError:
        return "", 400

    if event["type"] == "dispute.resolved":
        ...  # event["data"]["disputeId"]
    else:
        ...  # event["data"]["score"], event["data"]["band"]
    return "", 204
```

Prefer branching over exceptions? `verify_webhook_signature(...)` returns a result
object instead:

```python
from credda import verify_webhook_signature

result = verify_webhook_signature(raw_body, signature_header, secret, timestamp_header)
if not result.valid:
    log.warning("rejected webhook: %s", result.reason)
```

Deliveries older (or newer) than `tolerance_seconds` (default **300**) are rejected as
possible replays; pass `tolerance_seconds=0` to disable that check. You can also pass the
whole header mapping instead of the two values: `verify_webhook_signature(body, None,
secret, headers=request.headers)`.

### 7. Let users verify and carry their trust

Mint a share token for a user to get an embeddable trust badge and a public verify link.
The public reads need **no API key** — safe from a browser:

```python
minted = credda.mint_share_token("u_1")
print(minted["verifyUrl"], minted["embedSnippet"])

public = CreddaClient()                       # no key
trust = public.resolve_token(minted["token"])  # PII-free public payload
bundle = public.get_trust_export(minted["token"])  # portable, self-verifying export

credda.revoke_share_token("u_1")  # invalidates every embed using it
```

Issuer discovery documents are public too:

```python
public.get_did_document()    # /.well-known/did.json
public.get_trust_registry()  # /.well-known/credda-trust-registry.json
```

### 8. Resolve a dispute

```python
credda.resolve_dispute("dsp_1", "FOR_USER")  # or "AGAINST_USER"
```

### 9. Earn `isVerified` with a counterparty confirmation

`report_event` lets you *assert* `isVerified`. A confirmation request is the strong
form: you propose the outcome, hand the one-time token to the named counterparty over
your own channel, and the event is written — verified — only when that distinct party
confirms. Creating the request writes no event and touches no score.

```python
created = credda.create_confirmation_request({
    "userId": "user_123",
    "eventType": "CONTRACT_FULFILLED",
    "counterpartyRef": "client@example.com",
    "counterpartyName": "Acme Studio",
    "description": "Landing page rebuild, delivered 2026-07-20",
    "expiresInDays": 14,
}, idempotency_key="job-9182:confirm")

# Shown ONCE — deliver it yourself; only the counterparty should hold it.
token = created["confirmationToken"]
send_your_own_email(created["confirmation"]["counterpartyRef"], created["previewUrl"])

# The counterparty side (no API key — the token is the capability):
credda.preview_confirmation(created["confirmation"]["id"], token)
result = credda.respond_to_confirmation(created["confirmation"]["id"], token, "confirm")
result["eventId"]  # the verified event that was written
```

### 10. Watch a line with a threshold policy

A policy is declarative "notify me when a subject crosses THIS line" config that
delivers a `policy.threshold_crossed` webhook. It never reads into, blocks, or changes
a score.

```python
credda.create_policy({
    "name": "flag drops below 40",
    "appliesToAll": True,
    "metric": "score",
    "direction": "down",
    "threshold": 40,
})
```

### 11. Benchmark a subject against its cohort

```python
credda.get_benchmarks()  # public: dimensions + the k-anonymity floor

me = credda.get_user_benchmark("user_123", dimension="tenureBand")
if me["available"]:
    print(me["percentile"], me["comparison"])
else:
    print("suppressed:", me["reason"])  # insufficient_data | no_score
```

A cohort below the k-anonymity floor comes back `available: False` with no numbers.
A percentile is a distribution fact, never a verdict.

### 12. Query your book of subjects

```python
page = credda.list_users(
    score_min=40, band="Established", subject_type="PERSON",
    has_verified_events=True, sort="score", order="desc", limit=100,
)
for subject in page["data"]:
    print(subject["externalId"], subject["finalScore"], subject["verifiedEventCount"])
```

The filter surface is a closed, validated set — there is deliberately no free-form
query DSL, because the closed set is what lets the server guarantee tenant scoping
for every combination of filters. CSV export (`?format=csv`) is a raw-HTTP use case.

A subject whose score has not been computed yet reports `finalScore: None` and
`scoreBand: None` — never a placeholder number. Pass `has_score=False` to list
exactly those, or `has_score=True` to exclude them.

To size a segment before pulling it, `get_book_summary` takes the same filters and
answers with counts instead of rows:

```python
summary = credda.get_book_summary(subject_type="ORGANIZATION", has_verified_events=True)
print(summary["matched"], summary["central"]["median"])  # median is None if nothing is scored
```

## Errors

Any non-2xx response raises `CreddaError`, carrying the HTTP status, the API's own
error message, and the request path:

```python
from credda import CreddaError

try:
    credda.get_score("unknown-user")
except CreddaError as err:
    print(err.status, err.message, err.path)
```

## API surface

| Area | Methods |
| --- | --- |
| Public (no key) | `resolve_token`, `get_trust_export`, `get_delivery_receipts`, `get_public_professional_record`, `get_public_reliability_report`, `get_public_career_export`, `get_did_document`, `get_trust_registry`, `get_credential_issuer_metadata` |
| Public catalogs | `get_plans`, `get_webhook_events`, `get_error_catalog`, `get_enums`, `get_reason_codes`, `get_outcome_templates`, `get_changelog`, `get_benchmarks`, `get_open_badge_achievements`, `get_open_badge_achievement` |
| Platform read | `get_score`, `get_scores`, `list_users`, `get_book_summary`, `get_score_explain`, `get_score_delta`, `get_score_components`, `get_score_history`, `get_timeline`, `get_platforms`, `get_risk`, `get_usage`, `get_usage_meters`, `get_quota`, `get_activity`, `export_events`, `get_trust_summary`, `get_dispatch_reliability` |
| Platform analytics | `get_event_analytics`, `get_score_analytics` |
| Benchmarks | `get_benchmark_distribution`, `get_user_benchmark` |
| Verified Earnings | `get_earnings`, `get_earnings_summary`, `mint_earnings_credential` |
| Verified Profile | `get_verified_profile`, `record_qualification` |
| Professional Record | `get_professional_record`, `mint_professional_record_credential` |
| Reliability report | `get_reliability_report`, `get_public_reliability_report` |
| Career export | `get_career_export`, `get_public_career_export` |
| Projection | `project_score` |
| Writes | `report_event`, `report_events`, `mint_share_token`, `revoke_share_token`, `resolve_dispute`, `create_credential_offer` |
| Agents | `register_agent`, `get_agent` |
| Confirmation requests | `create_confirmation_request`, `create_confirmation_batch`, `list_confirmations`, `get_confirmation`, `cancel_confirmation`, `preview_confirmation`, `respond_to_confirmation` |
| Activation campaigns | `create_activation_campaign`, `get_activation_campaign` |
| Reference requests | `create_reference_request`, `list_references`, `get_reference`, `cancel_reference`, `preview_reference`, `respond_to_reference` |
| Threshold policies | `create_policy`, `list_policies`, `get_policy`, `update_policy`, `delete_policy` |
| Score monitors | `create_monitor`, `list_monitors`, `get_monitor`, `update_monitor`, `delete_monitor` |
| Bulk screenings | `create_screening`, `list_screenings`, `get_screening`, `get_screening_results` |
| Ingest & imports | `ingest`, `create_mapping`, `list_mappings`, `get_mapping`, `delete_mapping`, `create_import`, `list_imports`, `get_import`, `get_import_errors` |
| Webhook management | `create_webhook`, `list_webhooks`, `update_webhook`, `delete_webhook`, `test_webhook`, `get_webhook_deliveries`, `replay_webhook_delivery`, `get_recent_webhook_events` |
| Webhook verification | `verify_webhook_signature`, `construct_webhook_event` |
| Convenience | `iter_score_history`, `iter_timeline` |

Every authenticated method takes an optional trailing `api_key` argument that overrides
the constructor's key for that call.

### Deliberately not ported from `@credda/js`

Every **network** method in the TypeScript SDK exists here under the same name in
snake_case — that consistency is the point, and a parity test asserts each one's
method, path, query and body match what `@credda/js` sends.

The one deliberate omission is **offline credential verification** —
`verifyTrustCredential`, `verifyVerifiableCredential`, `isCredentialRevoked`,
`verifyTrustExport` — which the TypeScript SDK does with WebCrypto EdDSA. Porting it
would mean adding a cryptography dependency, and this package is **httpx-only** on
purpose: one dependency, no build step, no wheels to match to your platform. It is an
omission with a workaround, not a missing capability. Every method that returns a
credential (`get_trust_export`, `mint_earnings_credential`,
`mint_professional_record_credential`, …) hands back the signed VC-JWT verbatim, so
verify it with a JOSE library (e.g. `pyjwt[crypto]` or `python-jose`) against the JWKS
in `get_did_document()`, and check revocation against the `statusList` URL on the
payload. Webhook signature verification **is** included (`verify_webhook_signature` —
HMAC-SHA256 from `hashlib`/`hmac`, no dependency needed) and is wire-compatible with
the TypeScript and Go SDKs.

## Notes on shapes

Responses are returned as plain dicts with the API's own camelCase keys — nothing is
renamed or dropped, so a newly added server-side field is always visible. `TypedDict`
definitions for the main shapes live in `credda.types` for editor/`mypy` support.

Two arguments differ from the wire name because they collide with Python keywords:
`get_score_history(from_=...)` maps to the `from` query parameter, and the `score/delta`
response's `from` key must be read as `payload["from"]`.

## Development

```bash
pip install -e ".[dev]"
python -m pytest
```

Tests use `httpx.MockTransport` — no network access, no API key needed.

## Full API reference

`GET https://api.credda.io/openapi.json` · interactive docs at
<https://api.credda.io/docs>.
