# Webhook Integration Guide

This guide covers everything you need to integrate with the Quantum Webhook API, from creating subscriptions to verifying webhook signatures and handling application lifecycle events.

## Table of Contents

- [Overview](#overview)
- [Key Concepts](#key-concepts)
- [API Authentication](#api-authentication)
- [Webhook Delivery Authentication](#webhook-delivery-authentication)
- [Creating a Subscription](#creating-a-subscription)
- [Managing Subscriptions](#managing-subscriptions)
- [Event Types](#event-types)
- [Filtering Events](#filtering-events)
- [Application Lifecycle](#application-lifecycle)
- [Receiving Webhooks](#receiving-webhooks)
- [Signature Verification](#signature-verification)
- [Retry Mechanism](#retry-mechanism)
- [Events](#events)
- [Best Practices](#best-practices)

---

## Overview

The Quantum Webhook API allows you to receive real-time notifications about application events. Instead of polling for updates, you register a webhook endpoint and we'll send HTTP POST requests to your server whenever relevant events occur.

**Base URL:** `https://qa.quantumlends.com/api/v3/webhooks`

---

## Key Concepts

Understanding these core entities will help you work effectively with the webhook system.

> **Note:** All resource identifiers (events, subscriptions, deliveries, applications) use UUID format (e.g., `f47ac10b-58cc-4372-a567-0e02b2c3d479`).

### Events

An **Event** represents something that happened in the Quantum system (e.g., an application was created, an offer was accepted). When an event occurs, the system generates deliveries to all subscriptions that are listening for that event type.

- Each event has a unique UUID (e.g., `f47ac10b-58cc-4372-a567-0e02b2c3d479`)
- Source events are immutable records of what happened
- One event can trigger multiple deliveries (one per matching subscription)

### Event Deliveries

An **Event Delivery** represents an attempt to deliver an event to a specific subscription endpoint. Each delivery tracks:

- The HTTP response from your endpoint
- Success/failure status
- Retry attempts if the initial delivery failed
- Timestamps for each attempt

### Subscriptions

A **Subscription** defines where and how you want to receive webhooks:

- The HTTPS endpoint URL to receive webhooks
- Which event types to listen for
- Optional per-event [filters](#filtering-events) to narrow which events are delivered
- Retry configuration (0-6 retries)
- Status (active, paused, or disabled)

### Relationship Diagram

```
Event (what happened)
    │
    ├──► Event Delivery → Subscription A (your-server.com/webhooks)
    │
    └──► Event Delivery → Subscription B (backup-server.com/webhooks)
```

---

## API Authentication

All API requests require Bearer token authentication. Include your API key in the `Authorization` header:

```bash
# Set your API key and base URL
API_KEY="your_api_key_here"
BASE_URL="https://qa.quantumlends.com/api/v3/webhooks"

# All requests should include these headers
curl -H "Content-Type: application/json" \
     -H "Authorization: Bearer $API_KEY" \
     "$BASE_URL/subscriptions"
```

> **Note:** This section is about authenticating **your** calls to the Quantum webhooks API. For how Quantum authenticates to **your** endpoint when delivering webhooks, see [Webhook Delivery Authentication](#webhook-delivery-authentication).

---

## Webhook Delivery Authentication

When Quantum delivers a webhook to your endpoint, it authenticates itself in one of two ways. Every subscription is configured with one of these modes:

### HMAC Signature (default)

Quantum signs the payload with the subscription's `whsec_*` secret and sends the result in the `x-quantum-signature` header. Your endpoint verifies the signature (see [Signature Verification](#signature-verification)). No configuration is needed — this is the default.

### OAuth2 Client Credentials (optional)

Quantum additionally fetches a Bearer token from your OAuth2 token endpoint and sends it in `Authorization: Bearer <token>`. HMAC signatures are **still** included, so your existing signature verification continues to work unchanged.

Use OAuth2 when your infrastructure or security policies require Bearer token authentication on inbound webhooks. For most partners, HMAC alone is sufficient.

#### Token Flow

![Diagram showing the OAuth2 client credentials token acquisition and webhook delivery flow between Quantum and the partner](/images/webhook-oauth2-token-flow.jpg)

{% details summary="Text version of the flow diagram" %}

```
Quantum                              Partner
   │                                    │
   │  POST <token_url>                  │
   │  Authorization: Basic <credential> │
   │  grant_type=client_credentials     ├──► 200 { access_token, expires_in }
   │                                    │
   │  POST /your/webhook/endpoint       │
   │  Authorization: Bearer <token>     │
   │  x-quantum-signature: t=...,v1=... ├──► 2xx
```

{% /details %}

Before each delivery, Quantum requests a Bearer token from your OAuth2 token endpoint using HTTP Basic authentication and the `client_credentials` grant. Successfully acquired tokens are cached server-side until shortly before they expire.

#### Requirements for Your Token Endpoint

- **HTTPS only** — plaintext token endpoints are rejected
- Accepts `POST` with:
  - `Authorization: Basic <credential>` header
  - `Content-Type: application/x-www-form-urlencoded`
  - body `grant_type=client_credentials`
- Returns JSON `{ "access_token": "...", "expires_in": <integer seconds> }`
- Tokens with missing, non-integer, or very short `expires_in` are used for the current delivery but not cached

#### Credential Format

The `credential` field you send to Quantum is the standard HTTP Basic credential: base64(`client_id:client_secret`). Encode it yourself before submitting. Do not include the `Basic ` prefix.

```bash
# Example: encoding a client_id and client_secret
echo -n "my_client_id:my_client_secret" | base64
# dXNlci1jbGllbnQ6c2VjcmV0LXZhbHVl
```

#### Security

- Your `credential` is stored encrypted at rest and is **never** returned by any API response.
- Cached Bearer tokens are invalidated automatically whenever you change the auth config via `PATCH`.
- If Quantum cannot acquire a token at delivery time (your token endpoint is down, returns an error, times out, etc.), it counts as a failed delivery attempt and the normal [retry policy](#retry-mechanism) applies.

See [Creating a Subscription with OAuth2 Bearer Token Auth](#creating-a-subscription-with-oauth2-bearer-token-auth) and [Updating Auth Configuration](#updating-auth-configuration) for how to enable, update, and revert this setting.

---

## Creating a Subscription

To start receiving webhooks, create a subscription by specifying your endpoint URL and the events you want to receive.

### Endpoint

```
POST /subscriptions
```

### Request

```bash
curl -X POST "$BASE_URL/subscriptions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": [
      "application.created",
      "application.status.updated",
      "application.offer.created",
      "application.contract.signed",
      "application.funding.started"
    ],
    "num_retries": 3
  }'
```

> **Note:** `num_retries` is optional (0-6, defaults to 3).

### Response

```json
{
    "id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": [
        "application.created",
        "application.status.updated",
        "application.offer.created",
        "application.contract.signed",
        "application.funding.started"
    ],
    "status": "active",
    "num_retries": 3,
    "secret": "whsec_xYz789AbCdEf...",
    "auth": null,
    "filter": {},
    "created": "2025-01-08T10:30:00Z"
}
```

> **Note:** `"filter": {}` means no filters are applied and all subscribed events are delivered. See [Filtering Events](#filtering-events) to narrow deliveries.

> **IMPORTANT:** The `secret` is only returned once during creation. Store it securely immediately - you will need it to verify webhook signatures. If you lose it, you'll need to refresh it (see [Refreshing Secrets](#refreshing-secrets)).

> **Note:** `"auth": null` means the subscription uses the default HMAC signature authentication. See [Webhook Delivery Authentication](#webhook-delivery-authentication).

### Requirements

- **URL must use HTTPS** - We only deliver webhooks to secure endpoints
- **Events array** - Must contain at least one valid event type
- **Retries** - Value between 0 and 6 (default: 3)
- **Filter** (optional) - Omit (or send `{}`) to deliver all subscribed events. See [Filtering Events](#filtering-events) to narrow deliveries per event type.
- **Auth** (optional) - Omit to use HMAC signature authentication (default). See [Creating a Subscription with OAuth2 Bearer Token Auth](#creating-a-subscription-with-oauth2-bearer-token-auth) to configure OAuth2 Bearer tokens.

### Creating a Subscription with OAuth2 Bearer Token Auth

To configure OAuth2 client credentials auth at creation time, include an `auth` block:

```bash
curl -X POST "$BASE_URL/subscriptions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": [
      "application.created",
      "application.status.updated"
    ],
    "num_retries": 3,
    "auth": {
      "type": "oauth2_client_credentials",
      "token_url": "https://auth.your-server.com/oauth2/token",
      "credential": "dGVzdF9jbGllbnQ6dGVzdF9zZWNyZXQ="
    }
  }'
```

**`auth` fields:**

- `type` — one of `hmac_signature` (default) or `oauth2_client_credentials`
- `token_url` — your OAuth2 token endpoint URL (HTTPS). Required for `oauth2_client_credentials`.
- `credential` — base64-encoded `client_id:client_secret` Basic credential. Required for `oauth2_client_credentials`.

**Response:**

```json
{
    "id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": ["application.created", "application.status.updated"],
    "status": "active",
    "num_retries": 3,
    "secret": "whsec_xYz789AbCdEf...",
    "auth": {
        "type": "oauth2_client_credentials",
        "token_url": "https://auth.your-server.com/oauth2/token"
    },
    "filter": {},
    "created": "2025-01-08T10:30:00Z"
}
```

> **IMPORTANT:** The `credential` you submitted is stored encrypted and **never** returned in any API response. If you lose your credential, you must issue a new one from your identity provider and update the subscription via `PATCH`.

See [Webhook Delivery Authentication](#webhook-delivery-authentication) for the full delivery flow and requirements for your token endpoint.

---

## Managing Subscriptions

### List Subscriptions

```bash
# Get all subscriptions with filters
curl -X GET "$BASE_URL/subscriptions?page=1&size=25&status=active&sort_by=created&sort_dir=desc" \
  -H "Authorization: Bearer $API_KEY"

# Returns: { results: [...], current_page, page_size, total_pages, total_items }
```

### Update a Subscription

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Update URL, events, retries, status, or filter
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "url": "https://your-server.com/webhooks/v2/quantumlends",
    "events": [
      "application.created",
      "application.status.updated",
      "application.adverse_action.sent"
    ],
    "num_retries": 5,
    "status": "active",
    "filter": {
      "application.status.updated": { "status": "Declined" }
    }
  }'
```

> **Note:** `filter` follows replace semantics — see [Updating Filters](#updating-filters). Omit it to leave the current filters unchanged.

### Pause/Resume a Subscription

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Pause - temporarily stop receiving webhooks
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"status": "paused"}'

# Resume - start receiving webhooks again
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"status": "active"}'
```

### Updating Auth Configuration

Use `PATCH` to enable, change, or disable OAuth2 Bearer token auth for an existing subscription. The field follows three-way PATCH semantics:

- **Omit `auth`** — the current auth configuration is left unchanged
- **Send an `auth` object** — sets or replaces the auth configuration
- **Send `"auth": null`** — reverts the subscription to HMAC-only

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Add or update OAuth2 Bearer token auth
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "auth": {
      "type": "oauth2_client_credentials",
      "token_url": "https://auth.your-server.com/oauth2/token",
      "credential": "dGVzdF9jbGllbnQ6dGVzdF9zZWNyZXQ="
    }
  }'

# Revert to HMAC-only (remove OAuth2 auth)
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"auth": null}'
```

> **Note:** Any change to the `auth` configuration automatically invalidates the cached Bearer token, so new credentials or endpoint URLs take effect on the next delivery.

### Refreshing Secrets

If you lose your webhook secret or need to rotate it for security reasons:

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

curl -X POST "$BASE_URL/subscriptions/$SUBSCRIPTION_ID/refresh-secret" \
  -H "Authorization: Bearer $API_KEY"

# Response:
# {
#   "id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
#   "secret": "whsec_newSecretHere...",
#   "previous_secret_valid_until": "2025-01-08T11:30:00Z"
# }
```

> **Grace Period:** After refreshing, both the old and new secrets are valid until `previous_secret_valid_until`. This allows you to update your server without missing webhooks.

---

## Event Types

Subscribe to any combination of these events:

| Event Type | Description |
|------------|-------------|
| `application.created` | A new application has been submitted |
| `application.updated` | Application data has been modified |
| `application.status.updated` | Application status has changed |
| `application.offer.created` | A loan offer has been generated |
| `application.offer.selected` | Customer has selected/accepted an offer |
| `application.offer.declined` | Customer has declined an offer |
| `application.contract.sent` | Contract documents have been sent |
| `application.contract.signed` | Contract has been signed |
| `application.funding.started` | Funding process has begun |
| `application.tasks.updated` | Application tasks have been updated |
| `application.document.status.updated` | Document status has changed |
| `application.document.uploaded` | A document has been uploaded |
| `application.additional_owner.profile.completed` | Additional owner profile completed |
| `application.adverse_action.sent` | Adverse action notice sent (decline) |
| `prefill_application.created` | A prefilled application has been created |
| `prefill_application.token_expired` | Applicant attempted to use an expired prefill link |

---

## Filtering Events

By default, a subscription delivers **every** event of each subscribed type. Filters let you narrow deliveries based on the event's data — so you only receive the events you actually care about, without adding filtering logic to your own endpoint.

A filter is an optional `filter` object on the subscription, keyed by event type. Each event type maps to a set of field conditions that an event must satisfy to be delivered.

{% callout type="info" %}
**Initial release:** the only supported filter is `status` on the `application.status.updated` event. Conditions on any other field or event type are rejected. Event types you don't list in `filter` are always delivered unfiltered. The `filter` field shape is forward-compatible, so additional fields and event types can be added later without an API change.
{% /callout %}

### Example: Only Declined Status Updates

This subscription receives `application.status.updated` only when the application's new status is `Declined`. All `application.created` events are still delivered, because that event type is not present in the filter.

```bash
curl -X POST "$BASE_URL/subscriptions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": [
      "application.created",
      "application.status.updated"
    ],
    "filter": {
      "application.status.updated": { "status": "Declined" }
    }
  }'
```

The `filter` is echoed back in the response:

```json
{
    "id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
    "url": "https://your-server.com/webhooks/quantumlends",
    "events": ["application.created", "application.status.updated"],
    "status": "active",
    "num_retries": 3,
    "secret": "whsec_xYz789AbCdEf...",
    "auth": null,
    "filter": {
        "application.status.updated": { "status": "Declined" }
    },
    "created": "2025-01-08T10:30:00Z"
}
```

### Condition Semantics

- **Scalar value = equals** — `{ "status": "Declined" }` matches only when the status equals `Declined`.
- **List value = in (OR)** — `{ "status": ["Declined", "Withdrawn"] }` matches when the status is **any** of the listed values.
- **Multiple fields = AND** — when an event type maps to more than one field, an event must satisfy **all** of them.
- **Case-insensitive** — values are compared case-insensitively (`"declined"` and `"Declined"` behave identically). Examples use the Title Case form from the [Application Statuses](#application-statuses) table.
- **Absent event types pass through** — any subscribed event type not listed in `filter` is delivered unfiltered.
- **Empty filter delivers everything** — omitting `filter` or sending `{}` delivers all subscribed events.

> **Note:** With only `status` on `application.status.updated` supported in this release, the list (OR) and multi-field (AND) forms describe how filters will behave as more fields become available.

### Delivery Behavior

When an event does not match a subscription's filter, it is **silently skipped** for that subscription — no delivery is attempted and nothing appears in that subscription's [delivery history](#checking-event-delivery-status).

The underlying event is **still recorded** and remains queryable via [List Events](#events). Filtering affects *delivery*, not whether the event occurred.

### Validation

Invalid filters are rejected at create/update time with a `422` response. A filter is invalid when it contains:

- a key for an event type that is **not** in the subscription's `events` array
- a key for an event type that does **not support filtering** (anything other than `application.status.updated` in this release)
- an **unknown field** name (anything other than `status` for `application.status.updated`)
- a **non-string** condition value (values must be a string or a list of strings)

### Updating Filters

`filter` uses replace semantics on `PATCH`:

- **Omit `filter`** — the current filters are left unchanged
- **Send a `filter` object** — replaces the entire stored filter map
- **Send `"filter": {}`** — clears all filters (delivers all subscribed events again)

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Replace filters
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "filter": {
      "application.status.updated": { "status": "Declined" }
    }
  }'

# Clear all filters
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"filter": {}}'
```

> **Note:** If you change `events` and `filter` in the same request, the new filter is validated against the new event list. Removing an event type from `events` while still filtering on it returns a `422`.

---

## Application Lifecycle

### Happy Path (Approved Application)

```
application.created
        │
        ▼
application.status.updated → "In Processing"
        │
        ▼
application.tasks.updated  ◄── New task: upload bank statements
        │
        ▼
application.document.uploaded  ◄── Customer uploads bank statements
        │
        ▼
application.document.status.updated  ◄── UW team approves document
        │
        ▼
application.tasks.updated  ◄── Task completed
        │
        ▼
application.status.updated → "Under Credit Review"
        │
        ▼
application.status.updated → "Approved"
        │
        ▼
application.offer.created
        │
        ▼
application.status.updated → "Awaiting Offer Acceptance"
        │
        ▼
application.offer.selected  ◄── Customer accepts the offer
        │
        ▼
application.status.updated → "Preparing Loan Documents"
        │
        ▼
application.status.updated → "Pending Closing Information from Customer"
        │
        ▼
application.tasks.updated  ◄── New tasks: upload additional documents
        │
        ▼
application.document.uploaded  ◄── Customer uploads documents (may repeat)
        │
        ▼
application.document.status.updated  ◄── Documents accepted
        │
        ▼
application.tasks.updated  ◄── Tasks completed
        │
        ▼
application.contract.sent
        │
        ▼
application.status.updated → "Awaiting Document Execution"
        │
        ▼
application.contract.signed
        │
        ▼
application.status.updated → "Awaiting Funding"
        │
        ▼
application.funding.started
        │
        ▼
application.status.updated → "Booked"  ◄── Loan funded, process complete
```

### Offer Declined by Customer Path

```
application.created
        │
        ▼
application.status.updated → "In Processing"
        │
        ▼
application.tasks.updated  ◄── New task: upload bank statements
        │
        ▼
application.document.uploaded  ◄── Customer uploads bank statements
        │
        ▼
application.document.status.updated  ◄── UW team approves document
        │
        ▼
application.tasks.updated  ◄── Task completed
        │
        ▼
application.status.updated → "Under Credit Review"
        │
        ▼
application.status.updated → "Approved"
        │
        ▼
application.offer.created
        │
        ▼
application.status.updated → "Awaiting Offer Acceptance"
        │
        ▼
application.offer.declined  ◄── Customer declines the offer
        │
        ▼
application.status.updated → "Offer Declined"
```

### Application Declined by Lender Path

```
application.created
        │
        ▼
application.status.updated → "In Processing"
        │
        ▼
application.tasks.updated  ◄── New task: upload bank statements
        │
        ▼
application.document.uploaded  ◄── Customer uploads bank statements
        │
        ▼
application.document.status.updated  ◄── UW team approves document
        │
        ▼
application.tasks.updated  ◄── Task completed
        │
        ▼
application.status.updated → "Under Credit Review"
        │
        ▼
application.status.updated → "Declined"
        │
        ▼
application.adverse_action.sent  ◄── Adverse action notice sent (daily 9 pm ET batch)
```

### Application Statuses

| Status | Description |
|--------|-------------|
| `submitted` | Initial submission |
| `In Processing` | Being processed |
| `Under Credit Review` | Credit evaluation in progress |
| `Approved` | Approved for an offer |
| `Awaiting Offer Acceptance` | Waiting for customer to accept offer |
| `Preparing Loan Documents` | Documents being prepared |
| `Pending Closing Information from Customer` | Awaiting customer additional info |
| `Awaiting Document Execution` | Waiting for document signatures |
| `Awaiting Funding` | Ready for funding |
| `Booked` | Loan funded and complete |
| `Declined` | Application declined |
| `Offer Declined` | Customer declined the offer |
| `Withdrawn` | Application withdrawn |

---

## Receiving Webhooks

When an event occurs, we send an HTTP POST request to your subscription URL.

### Webhook Payload Structure

```json
{
    "id": "705bee34-9578-4bf9-b654-d036294f8efc",
    "type": "application.offer.created",
    "occurred_at": "2026-01-12T20:36:24.217Z",
    "resource": {
        "id": "88c911f7-1a59-4860-b786-825c9b45bc1b",
        "type": "application"
    },
    "links": {
        "event_url": "https://qa.quantumlends.com/api/v3/webhooks/event-delivery-history/705bee34-9578-4bf9-b654-d036294f8efc",
        "resource_url": "https://qa.quantumlends.com/api/v3/applications/88c911f7-1a59-4860-b786-825c9b45bc1b/offers"
    }
}
```

| Field | Description |
|-------|-------------|
| `id` | Unique UUID for this event |
| `type` | The event type (e.g., `application.offer.created`) |
| `occurred_at` | ISO 8601 timestamp when the event occurred |
| `resource.id` | UUID of the affected resource (e.g., application ID) |
| `resource.type` | Type of resource (e.g., `application`) |
| `links.event_url` | URL to fetch event delivery details |
| `links.resource_url` | URL to fetch the affected resource |

### Headers Sent with Webhooks

HMAC signature headers are present on **every** delivery. The `Authorization` header is additionally present when the subscription is configured with OAuth2 client credentials auth (see [Webhook Delivery Authentication](#webhook-delivery-authentication)).

| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `x-quantum-event-id` | Unique UUID of the event |
| `x-quantum-event-type` | The event type (e.g., `application.offer.created`) |
| `x-quantum-signature` | Signature for verification (see below) |
| `Authorization` | `Bearer <token>` — only when OAuth2 client credentials auth is configured |

---

## Signature Verification

**Always verify webhook signatures** to ensure requests are genuinely from Quantum and haven't been tampered with.

### Signature Format

The `x-quantum-signature` header contains:

```
t=1768250184, v1=ec18e1eb555bc335c447c7057467c215732edf68ee0c332c6db0bd44848b1918
```

- `t` - Unix timestamp when the webhook was sent
- `v1` - HMAC-SHA256 signature

### Verification Implementation (FastAPI)

```python
import hashlib
import hmac
import time

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

WEBHOOK_SECRET = "whsec_your_secret_here"
MAX_TIMESTAMP_AGE_SECONDS = 300  # 5 minutes


def verify_webhook_signature(
    secret: str,
    signature_header: str,
    payload: str,
    max_timestamp_age: int = MAX_TIMESTAMP_AGE_SECONDS,
) -> bool:
    """
    Verify HMAC-SHA256 signature from a webhook request.

    Args:
        secret: Your webhook secret (e.g., whsec_abc123...)
        signature_header: Value of x-quantum-signature header
        payload: Raw request body as a string (not parsed JSON)
        max_timestamp_age: Maximum age in seconds (default: 300)

    Returns:
        True if signature is valid, False otherwise
    """
    try:
        # 1. Parse signature header
        parts = {}
        for part in signature_header.split(","):
            key, value = part.strip().split("=", 1)
            parts[key.strip()] = value.strip()

        timestamp = int(parts["t"])
        received_signature = parts["v1"]

        # 2. Validate timestamp to prevent replay attacks
        current_time = int(time.time())
        timestamp_age = current_time - timestamp

        if timestamp_age > max_timestamp_age:
            return False  # Too old
        if timestamp_age < -max_timestamp_age:
            return False  # Too far in future

        # 3. Compute expected signature
        signing_string = f"{timestamp}.{payload}"
        expected_signature = hmac.new(
            secret.encode("utf-8"),
            signing_string.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()

        # 4. Compare using constant-time comparison
        return hmac.compare_digest(expected_signature, received_signature)

    except (KeyError, ValueError, TypeError):
        return False


@app.post("/webhook")
async def receive_webhook(request: Request):
    """Receive and verify webhook from Quantum."""
    # Get signature header
    signature_header = request.headers.get("x-quantum-signature", "")
    if not signature_header:
        raise HTTPException(status_code=401, detail="Missing signature")

    # Get raw payload as string for signature verification
    payload = (await request.body()).decode("utf-8")

    # Verify signature
    if not verify_webhook_signature(WEBHOOK_SECRET, signature_header, payload):
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Parse the event
    event = await request.json()
    event_type = event.get("type")
    resource_id = event["resource"]["id"]

    # Handle different event types
    if event_type == "application.created":
        print(f"New application: {resource_id}")
    elif event_type == "application.status.updated":
        print(f"Application {resource_id} status changed")
    elif event_type == "application.offer.created":
        print(f"Offer created for application: {resource_id}")
    elif event_type == "application.offer.selected":
        print(f"Offer selected for application: {resource_id}")
    elif event_type == "application.offer.declined":
        print(f"Offer declined for application: {resource_id}")
    elif event_type == "application.adverse_action.sent":
        print(f"Adverse action notice sent for application: {resource_id}")
    elif event_type == "prefill_application.created":
        print(f"Prefill application created: {resource_id}")
    elif event_type == "prefill_application.token_expired":
        print(f"Prefill token expired for application: {resource_id}")
    # ... handle other events

    # Fetch additional details if needed using links.resource_url
    resource_url = event["links"]["resource_url"]

    return {"status": "received"}
```

---

## Retry Mechanism

If your endpoint fails to respond with a `2xx` status code, we'll retry the delivery.

### Retry Behavior

- **Retries:** Configurable from 0 to 6 attempts (default: 3)
- **Backoff:** Exponential backoff between retries
- **Timeout:** Your endpoint should respond within 30 seconds

### Checking Event Delivery Status

```bash
# List event deliveries with filters
curl -X GET "$BASE_URL/event-delivery-history?subscription_id=c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f&success=false&event_type=application.status.updated&event_id=a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b&start_date=2025-01-01&end_date=2025-01-08" \
  -H "Authorization: Bearer $API_KEY"
```

### Get Event Delivery Details with Retry History

```bash
DELIVERY_ID="b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e"

curl -X GET "$BASE_URL/event-delivery-history/$DELIVERY_ID" \
  -H "Authorization: Bearer $API_KEY"

# Includes delivery_attempts array with all delivery attempts
```

### Manual Retry

If automatic retries are exhausted, you can manually retry:

```bash
DELIVERY_ID="b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e"

curl -X POST "$BASE_URL/event-delivery-history/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer $API_KEY"
```

---

## Events

Source events allow you to query the original events that occurred in the system, independent of how they were delivered to your subscriptions.

### List Events

```bash
# List events with filters
curl -X GET "$BASE_URL/events?page=1&size=25&event_type=application.offer.created&resource_type=application&resource_id=88c911f7-1a59-4860-b786-825c9b45bc1b&start_date=2025-01-01&end_date=2025-01-08" \
  -H "Authorization: Bearer $API_KEY"

# Returns: { results: [...], current_page, page_size, total_pages, total_items }
```

### Event Response

```json
{
    "id": "a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b",
    "event_type": "application.offer.created",
    "resource_type": "application",
    "resource_id": "88c911f7-1a59-4860-b786-825c9b45bc1b",
    "received_at": "2025-01-08T10:30:00Z",
    "delivery_count": 2,
    "links": {
        "deliveries_url": "https://qa.quantumlends.com/api/v3/webhooks/event-delivery-history?source_event_id=a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b"
    }
}
```

### Get Event Details

Retrieve detailed information about an event, including all its deliveries:

```bash
EVENT_ID="a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b"

curl -X GET "$BASE_URL/events/$EVENT_ID" \
  -H "Authorization: Bearer $API_KEY"
```

### Event Detail Response

```json
{
    "id": "a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b",
    "event_type": "application.offer.created",
    "resource_type": "application",
    "resource_id": "88c911f7-1a59-4860-b786-825c9b45bc1b",
    "received_at": "2025-01-08T10:30:00Z",
    "delivery_count": 2,
    "deliveries": [
        {
            "id": "b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
            "subscription_id": "c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
            "attempt_number": 1,
            "http_status": 200,
            "response_time_ms": 145,
            "success": true,
            "delivered_at": "2025-01-08T10:30:01Z",
            "error_message": null
        }
    ],
    "links": {
        "deliveries_url": "https://qa.quantumlends.com/api/v3/webhooks/event-delivery-history?source_event_id=a8d4e2f1-3b5c-4a6d-9e8f-1c2d3e4f5a6b"
    }
}
```

### Use Cases for Events

1. **Debugging delivery issues** - Find the event and see all delivery attempts across subscriptions
2. **Audit trail** - Query what events occurred for a specific application
3. **Reconciliation** - Verify that all expected events were generated

---

## Best Practices

### 1. Store Your Secret Securely

```bash
# Use environment variables - NEVER hardcode secrets
export QUANTUMLENDS_WEBHOOK_SECRET="whsec_your_secret_here"

# Or use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.)
```

### 2. Respond Quickly

Return a `200` response immediately, then process the event asynchronously:

```python
from threading import Thread


@app.route("/webhooks/quantumlends", methods=["POST"])
def handle_webhook():
    # Verify signature first
    if not verify_signature():
        return "", 401

    event = request.get_json()

    # Process asynchronously
    Thread(target=process_event, args=(event,)).start()

    # Return immediately
    return "", 200


def process_event(event):
    # Do the actual processing here
    # Database updates, notifications, etc.
    pass
```

### 3. Handle Duplicate Events

Events may occasionally be delivered more than once. Make your handlers idempotent:

```python
def handle_application_created(event):
    event_id = event["id"]

    # Check if we've already processed this event
    if event_already_processed(event_id):
        return

    # Process the event
    create_application_record(event)

    # Mark as processed
    mark_event_processed(event_id)
```

### 4. Subscribe Only to Events You Need

Reduces noise and processing overhead:

```bash
# Good - specific events
curl -X POST "$BASE_URL/subscriptions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "url": "https://your-server.com/webhooks",
    "events": [
      "application.created",
      "application.status.updated",
      "application.funding.started"
    ]
  }'

# Avoid subscribing to all events if you don't need them
```

### 5. Monitor Webhook Health

Regularly check your event delivery success rate:

```bash
# Check for failed deliveries in the last 24 hours
curl -X GET "$BASE_URL/event-delivery-history?success=false&start_date=$(date -u -v-1d +%Y-%m-%d)" \
  -H "Authorization: Bearer $API_KEY"

# If total_items > 0, investigate the failures
```

### 6. Use Pause During Maintenance

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Before maintenance - pause the subscription
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"status": "paused"}'

# Perform maintenance...

# After maintenance - resume the subscription
curl -X PATCH "$BASE_URL/subscriptions/$SUBSCRIPTION_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"status": "active"}'
```

### 7. Rotate Secrets Periodically

```bash
SUBSCRIPTION_ID="c1d2e3f4-5a6b-7c8d-9e0f-1a2b3c4d5e6f"

# Refresh the secret
curl -X POST "$BASE_URL/subscriptions/$SUBSCRIPTION_ID/refresh-secret" \
  -H "Authorization: Bearer $API_KEY"

# Response contains:
# - secret: The new webhook secret
# - previous_secret_valid_until: Grace period expiration
#
# Update your secret store with the new secret immediately
```

---

## Quick Reference

### API Endpoints

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/subscriptions` | Create subscription |
| `GET` | `/subscriptions` | List subscriptions |
| `PATCH` | `/subscriptions/{id}` | Update subscription |
| `POST` | `/subscriptions/{id}/refresh-secret` | Rotate secret |
| `GET` | `/events` | List events |
| `GET` | `/events/{id}` | Get event details |
| `GET` | `/event-delivery-history` | List event deliveries |
| `GET` | `/event-delivery-history/{id}` | Get event delivery details |
| `POST` | `/event-delivery-history/{id}/retry` | Retry failed delivery |

### Response Codes

| Code | Meaning |
|------|---------|
| `200` | Success |
| `201` | Created |
| `400` | Bad request (invalid parameters) |
| `401` | Unauthorized (invalid/missing API key) |
| `404` | Resource not found |
| `422` | Validation error |
| `500` | Server error |
| `502` | Bad gateway — OAuth2 token fetch from your token endpoint failed (manual retry endpoint only) |

---

## Support

If you encounter issues with the webhook integration, please contact your Quantum partner representative or reach out to our technical support team.
