# Paracini — Antrags-Transparenz API (Partner Webhook Spec v1)

> **Paracini** is an independent application-transparency layer between
> consumers and licensed lenders, insurers and payment partners. We do
> not issue credit, hold deposits, or take risk. Our job is to keep
> consumers informed about *where their application stands* — from the
> moment they click your CTA until the partner-side decision is final.
>
> This document is the partner-facing contract for the Antrags-Transparenz
> API. It is intentionally bank-agnostic. If you are a lender, neobank,
> BNPL provider, insurer, or affiliate network reading this for due
> diligence: this is everything you need to integrate in under a day.

---

## 1. What the API does

A single signed webhook lets partners push status updates for a
referred application. Paracini stores the update, maps it to neutral
consumer wording (BFSG + §7 KWG compliant), and pushes it into the
consumer's journey timeline.

```
┌────────────┐  CTA click  ┌─────────────┐    HTTPS POST    ┌────────────┐
│  Consumer  │ ──────────► │   Paracini  │ ◄───── signed ───│  Partner   │
│  (DE/EN/TR)│             │  Journey ID │   webhook calls  │ (bank/PSP) │
└────────────┘             └─────────────┘                  └────────────┘
                                  │
                                  ▼
                         Anonymized status log
                         (no PII, no decision claim)
```

* No PII leaves your perimeter. Paracini only receives the opaque
  `journey_id` you received in the redirect query string (e.g.
  `?par_journey=PAR-DE-A8K2QM`).
* No bank decision is ever published verbatim. `approved` /
  `declined` are stored for BI/audit but UI renders neutral copy.
* Webhook is idempotent on `(journey_id, status)`.

---

## 2. Authentication

The endpoint accepts **either** of two auth modes:

| Mode | Use case | Header |
|---|---|---|
| **A. HMAC-SHA256 signature** | Partner-to-Paracini webhook | `X-Paracini-Signature: t=<unix_ts>,v1=<hex>` |
| **B. Admin JWT** | Paracini internal ops tooling | `Authorization: Bearer <token>` |

Partners always use **Mode A**. Mode B is reserved for our own ops
dashboard (`/admin/journey-log`).

### 2.1 Signature construction

```
ts             = unix_seconds_now()
signed_string  = "{ts}.{raw_request_body_bytes}"
signature      = HMAC_SHA256(shared_secret, signed_string)
header_value   = "t=" + ts + ",v1=" + hex(signature)
```

* `raw_request_body_bytes` is the **exact JSON bytes you POST** —
  no re-serialization, no key reordering, no whitespace normalisation.
* `shared_secret` is a 256-bit secret Paracini delivers to your
  technical contact out-of-band (1Password / signed PDF). Rotate
  quarterly or after any incident.
* `hex(signature)` is lowercase hex of the HMAC digest.

### 2.2 Replay protection

* Default tolerance: **300 seconds** (`abs(now - ts) ≤ 300`).
* Requests outside the window return `HTTP 401` without leaking
  state. Resend with a fresh `ts` to recover.
* Paracini also recommends partners maintain idempotency on their
  side: same `(journey_id, status)` retried inside 300s is a no-op
  by design.

### 2.3 Error semantics

| HTTP | `reason` field | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/invalid signature or expired `ts`. Retry with a fresh signature. |
| `200` | `not_found` | `journey_id` was never activated. Treat as terminal. |
| `200` | `invalid_journey_id` | `journey_id` does not match `PAR-(DE|EN|TR)-XXXXX` format. |
| `200` | `invalid_status` | Status not in the allowed set (see §4). |
| `200` | `invalid_payload` | JSON valid but field types/lengths wrong. |
| `200` | `db_unavailable` | Paracini infra issue. Retry with exponential backoff. |
| `200` | `ok: true` | Stored. |

> We deliberately return `200` with `ok: false` for predictable
> partner-side handling — only auth failures break the connection.

---

## 3. Endpoints

### 3.1 `POST /api/journey/update`

The single status-flip endpoint. Idempotent on `(journey_id, status)`.

**Request**

```http
POST /api/journey/update HTTP/1.1
Host: api.paracini.com
Content-Type: application/json
X-Paracini-Signature: t=1735862400,v1=4e9c0bb1c5f0a2e6b07a3d6f1c1b4d9e8a2f1c3b4d5e6f70819a2b3c4d5e6f70

{
  "journey_id": "PAR-DE-A8K2QM",
  "status": "under_review",
  "note": "manual underwriting started",
  "source": "partner_underwriting"
}
```

**Response (success)**

```json
{
  "ok": true,
  "journey_id": "PAR-DE-A8K2QM",
  "status": "under_review"
}
```

**Response (auth failure)**

```http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"detail": "unauthorized"}
```

**Response (post-auth validation failure)**

```json
{"ok": false, "reason": "not_found"}
```

### 3.2 `GET /api/journey/status?journey_id=…`

Public, anonymous lookup. Returns the current row. Partners typically
don't need this — it's primarily for the consumer SPA. Listed here
for completeness.

```json
{
  "ok": true,
  "journey": {
    "journey_id": "PAR-DE-A8K2QM",
    "status": "under_review",
    "updated_at": "2026-02-24T11:30:00Z",
    "events": [
      {"status": "partner_redirected", "at": "2026-02-24T10:50:11Z"},
      {"status": "received",           "at": "2026-02-24T10:50:48Z"},
      {"status": "under_review",       "at": "2026-02-24T11:30:00Z"}
    ]
  }
}
```

---

## 4. Status vocabulary

Statuses split into two layers. **Always send the raw lender lifecycle
status** — Paracini handles the consumer-facing translation.

### 4.1 Stage statuses (Paracini funnel)

Set by Paracini itself when the consumer interacts with the SPA.
Partners normally never send these:

| Status | When it fires |
|---|---|
| `partner_redirected` | Consumer clicked the partner CTA. |
| `eligibility_pending` | Paracini AI is computing the readiness score. |
| `offers_gathering` | Multiple partner iframes loading. |
| `awaiting_responses` | Partner-side acknowledgement is pending. |
| `result_available` | Terminal — partner returned any decision. |
| `expired` | Journey timed out without a partner response. |
| `cancelled` | Consumer cancelled. |

### 4.2 Lender lifecycle (partner → Paracini)

The states partners push to Paracini. Stored verbatim for BI / audit;
UI renders the neutral wording in the rightmost column.

| Status | Meaning (partner side) | Consumer-facing wording |
|---|---|---|
| `received` | Application landed in partner inbox. | "Antrag bei Partner eingegangen" |
| `docs_pending` | Borrower must upload docs. | "Unterlagen werden benötigt" |
| `under_review` | Automated / manual risk check. | "Antrag wird geprüft" |
| `approved` | Partner says yes. | "Antrags-Ergebnis verfügbar" (no decision verbatim) |
| `declined` | Partner says no. | "Antrags-Ergebnis verfügbar" (no decision verbatim) |
| `payout_sent` | Funds disbursed. | "Auszahlung erfolgt" |

> Why we never echo `approved` / `declined` to consumers:
> §7 KWG and BFSG transparency rules + Paracini's brand promise
> ("we never claim a decision we didn't make"). Partners receive
> full granularity via the admin BI layer.

### 4.3 Recommended lifecycle order

```
received → docs_pending? → under_review → (approved | declined)
                                              │
                                              └─► payout_sent (if approved)
```

`docs_pending` is optional. `payout_sent` is the terminal happy-path.

---

## 5. Example signature generation

### 5.1 Node.js (built-in `crypto`)

```js
const crypto = require("crypto");

function signWebhook(secret, bodyString) {
  const ts = Math.floor(Date.now() / 1000);
  const payload = `${ts}.${bodyString}`;
  const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
  return `t=${ts},v1=${sig}`;
}

const body = JSON.stringify({
  journey_id: "PAR-DE-A8K2QM",
  status: "under_review",
  note: "manual underwriting",
});

const header = signWebhook(process.env.PARACINI_WEBHOOK_SECRET, body);

await fetch("https://api.paracini.com/api/journey/update", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Paracini-Signature": header },
  body,
});
```

### 5.2 Python (stdlib `hmac` + `requests`)

```python
import hmac, hashlib, json, os, time
import requests

def sign_webhook(secret: str, body_bytes: bytes) -> str:
    ts = int(time.time())
    msg = f"{ts}.".encode() + body_bytes
    sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return f"t={ts},v1={sig}"

body = json.dumps({
    "journey_id": "PAR-DE-A8K2QM",
    "status": "under_review",
    "note": "manual underwriting",
}).encode()

headers = {
    "Content-Type": "application/json",
    "X-Paracini-Signature": sign_webhook(os.environ["PARACINI_WEBHOOK_SECRET"], body),
}
r = requests.post("https://api.paracini.com/api/journey/update", data=body, headers=headers, timeout=10)
print(r.status_code, r.json())
```

### 5.3 cURL (manual)

```bash
SECRET="…"   # delivered out-of-band
TS=$(date +%s)
BODY='{"journey_id":"PAR-DE-A8K2QM","status":"received","source":"partner_demo"}'
SIG=$(python3 -c "
import hmac, hashlib, sys
s=sys.argv[1]; t=sys.argv[2]; b=sys.argv[3]
print(hmac.new(s.encode(), (t+'.').encode()+b.encode(), hashlib.sha256).hexdigest())
" "$SECRET" "$TS" "$BODY")

curl -s -X POST https://api.paracini.com/api/journey/update \
  -H "Content-Type: application/json" \
  -H "X-Paracini-Signature: t=$TS,v1=$SIG" \
  -d "$BODY"
```

---

## 6. Operational expectations

| Property | Target |
|---|---|
| TLS | TLS 1.2+ required (Vercel edge enforced). |
| Endpoint availability | ≥ 99.9% monthly. |
| p95 latency | < 300ms from EU edge. |
| Retry policy | Partner-side recommended: exp. backoff 1s/5s/30s/2m/10m. |
| Replay window | 300s default (configurable per partner). |
| Secret rotation | Quarterly minimum; immediate on incident. |
| Idempotency | Same `(journey_id, status)` inside 300s is a no-op. |

---

## 7. Security model (one-page summary)

1. **Tamper protection** — HMAC signs the exact raw request body.
   Any byte changed invalidates the signature.
2. **Replay protection** — 300s timestamp window; out-of-window
   requests are rejected without revealing journey state.
3. **No PII** — Webhook payload only carries an opaque `journey_id`
   + status string. Borrower name, IBAN, score never traverse this
   channel.
4. **Idempotency** — Status flips are stored append-only in the
   `events[]` array on each row. Replays are safe.
5. **No decision publication** — `approved` / `declined` are never
   echoed verbatim to consumers; BFSG-safe neutral copy is used.
6. **Audit trail** — Every flip records the `actor` (`admin:<email>`
   or `partner_webhook`) so dispute resolution can trace any change.

---

## 8. Onboarding checklist (for new partners)

1. Sign a 1-page Data-Processing Addendum (we provide template).
2. Receive `PARACINI_WEBHOOK_SECRET` from your TAM via 1Password.
3. Implement signature using §5 example for your stack.
4. Sandbox: hit `https://staging.paracini.com/api/journey/update`
   with `journey_id="PAR-DE-SAND01"` until you see HTTP 200.
5. Production cut-over: flip your env var to the production
   secret; first 50 events are mirrored to a dedicated channel for
   visual review.
6. Done. Average integration time: 4–6 engineer-hours.

---

## 9. Versioning & deprecation policy

* This is **v1**. Any backwards-incompatible change ships under a
  new path (`/api/journey/v2/update`) with ≥ 90 days of overlap.
* New optional fields can be added to the request body at any time
  without notice — partners ignore unknown fields.
* New lifecycle statuses ship under a feature flag; partners opt-in
  per status via their TAM.

---

## 10. Contact

* **Tech**: `partner-integrations@paracini.com`
* **Security incidents**: `security@paracini.com` (PGP fingerprint
  on request)
* **Status page**: `https://status.paracini.com`

---

*Paracini · Antrags-Transparenz API · v1 · Berlin / Frankfurt · DSGVO-konform · §26 BDSG · §7 KWG aligned*
