Source: https://docs.debitura.com/developer-concepts/webhooks/ci-testing

# CI Testing with Webhooks

Debitura provides test primitives that let you run a deterministic, isolated CI loop. Test cases live in production infrastructure but are fully isolated from live data and routing.

The complete 4-step CI loop is available on both the **Customer API** and the **Collection Partner API**:

```text
CREATE  →  DRIVE  →  ASSERT  →  RESET
```

1.  **CREATE** — create a test case with `isTest: true` and a run-scoped `tag`
2.  **DRIVE** — `POST /test/cases/{id}/advance` to any lifecycle state
3.  **ASSERT** — poll `GET /webhooks/events?caseId={id}` to verify what fired (recording is async)
4.  **RESET** — `DELETE /test/cases?tag={tag}` to hard-delete all run data

The steps below use the **Customer API** for examples. See [Collection Partner API Notes](https://docs.debitura.com/developer-concepts/webhooks/ci-testing.md#collection-partner-api-notes) for differences in endpoint paths and available event types. Both APIs also support `POST /test/webhooks/fire` to emit any event type without advancing the case lifecycle.

* * *

<a id="step-1--create"></a>

## Step 1 — Create

Create a test case with `isTest: true` and a `tag` to scope cleanup. This is the Customer API endpoint.

```http
POST https://customer-api.debitura.com/cases
Content-Type: application/json
XApiKey: your_api_key

{
  "isTest": true,
  "tag": "suite-run-a3f9c12e",
  "currencyCode": "USD",
  "amountToRecover": 1250.00,
  "date": "2026-01-15",
  "debtor": {
    "type": "Company",
    "name": "CI Test Debtor Ltd",
    "address": "1 Test Street",
    "city": "London",
    "countryAlpha2": "GB"
  }
}
```

Capture the `id` from the response — you'll use it in every subsequent step.

<a id="tags"></a>

### Tags

The `tag` field marks the case for scoped cleanup. Use a unique tag per CI pipeline run — a UUID suffix works well:

```text
suite-run-{uuid}
```

Parallel pipelines with different tags never interfere with each other's cleanup.

* * *

<a id="step-2--drive"></a>

## Step 2 — Drive

Advance the case to a target lifecycle state in a single call. Debitura calls the real internal services so webhooks and timeline entries fire exactly as they would in production.

```http
POST https://customer-api.debitura.com/test/cases/{id}/advance
Content-Type: application/json
XApiKey: your_api_key

{
  "to": "Closed:Paid",
  "amount": 1250.00
}
```

**Request fields:**

| Field | Required | Description |
| --- | --- | --- |
| `to` | Yes | Target lifecycle state (see valid values below) |
| `amount` | Only for `Closed:Paid` | Payment amount in the case currency |

**Valid target states and webhooks fired (Customer API):**

| Value | Webhooks fired to Creditor subscriptions |
| --- | --- |
| `Active` | `case.updated` |
| `Closed:Paid` | `payment.created`, `case.updated`, `case.closed` — requires `amount` |
| `Closed:NoPayment` | `case.updated`, `case.closed` |

**Response:**

```json
{
  "advanced": true,
  "state": "Closed:Paid"
}
```

**Guards:**

-   Returns `400` if the case's `Classification` is not `Test`
-   Returns `400` if the case is already closed with a different close code
-   Returns `400` if `amount` is missing when `to` is `Closed:Paid`
-   **Idempotent**: if the case is already at the requested state, returns `200` with `"advanced": false`

Advance-state caveat

Advance calls real internal services sequentially — intermediate events do fire. However, the sequence is _compressed_ into a single synchronous call rather than unfolding over real time. Advance tests that your **handler mechanics** work correctly (parsing, state detection, idempotency). It does not reproduce exact real-time timing. Drive the case step-by-step with separate advance calls if you need to verify timing-sensitive behavior.

* * *

<a id="step-3--assert"></a>

## Step 3 — Assert

:::caution Event recording is asynchronous — poll, don't read once Both `POST /test/cases/{id}/advance` and `POST /test/webhooks/fire` only **enqueue** events into the delivery pipeline. A `WebhookEvent` row appears in `GET /webhooks/events` only after the worker drains the queue and attempts delivery — typically within a few seconds, but not instantly. This applies to real dispatches too; the pipeline is enqueue-only.

A single immediate `GET /webhooks/events` right after `advance`/`fire` will often return `[]` — this is the normal async window, **not** a missing event. Always **poll with a short backoff** until the expected event appears or a timeout elapses (see the [CI script](https://docs.debitura.com/developer-concepts/webhooks/ci-testing.md#complete-ci-script-example) for a `wait_for_event` helper). An empty array on the first read is not a failure. :::

<a id="inspect-delivery-history"></a>

### Inspect delivery history

Query all webhook events dispatched for a case:

```http
GET https://customer-api.debitura.com/webhooks/events?caseId={id}&since=2026-01-15T00:00:00Z
XApiKey: your_api_key
```

**Query parameters:**

| Parameter | Description |
| --- | --- |
| `caseId` | Required. Filter to events for this case. |
| `since` | Optional. ISO 8601 timestamp — return events created at or after this time. |

**Response (array, newest first):**

```json
[
  {
    "id": "9c5a3d7b-ef84-4201-c678-990011223344",
    "subscriptionId": "b2e4f8a0-dc61-4f92-b345-778899001122",
    "caseId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "eventType": "case.closed",
    "payload": "{...}",
    "createdAt": "2026-01-15T10:34:22Z",
    "deliveredAt": "2026-01-15T10:34:23Z",
    "attemptCount": 1,
    "lastResponseCode": 200,
    "lastError": null
  },
  {
    "id": "d1f6b2c8-a037-4512-d789-001122334455",
    "subscriptionId": "b2e4f8a0-dc61-4f92-b345-778899001122",
    "caseId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "eventType": "payment.created",
    "payload": "{...}",
    "createdAt": "2026-01-15T10:34:21Z",
    "deliveredAt": "2026-01-15T10:34:22Z",
    "attemptCount": 1,
    "lastResponseCode": 200,
    "lastError": null
  }
]
```

Check `eventType` for which events fired and `deliveredAt` (non-null = successfully delivered) for delivery status. Remember that rows appear only once the worker has attempted delivery, so query this endpoint in a poll loop (see [CI script](https://docs.debitura.com/developer-concepts/webhooks/ci-testing.md#complete-ci-script-example)) rather than reading it once.

<a id="replay-a-specific-event"></a>

### Replay a specific event

Re-enqueue the exact original payload to the subscription it was delivered to:

```http
POST https://customer-api.debitura.com/webhooks/events/{eventId}/replay
XApiKey: your_api_key
```

**Response:**

```json
{
  "eventId": "9c5a3d7b-ef84-4201-c678-990011223344",
  "subscriptionId": "b2e4f8a0-dc61-4f92-b345-778899001122",
  "eventType": "case.closed"
}
```

Replayed deliveries include the header `X-Debitura-Replay: true` so your endpoint can distinguish a replay from a live event and implement idempotency accordingly.

* * *

<a id="step-4--reset"></a>

## Step 4 — Reset

Delete all test cases tagged with the run identifier. This is a hard delete — it cascades to payments, chats, files, timeline entries, and webhook delivery records. Entries in the webhook event log (`GET /webhooks/events`) for the deleted cases are kept.

```http
DELETE https://customer-api.debitura.com/test/cases?tag=suite-run-a3f9c12e
XApiKey: your_api_key
```

Returns `204 No Content`. Idempotent — returns `204` even if no cases match the tag. Returns `400` if any matched case has `Classification == Production`.

Delete a single case:

```http
DELETE https://customer-api.debitura.com/test/cases/{id}
XApiKey: your_api_key
```

* * *

<a id="local-development"></a>

## Local Development

To receive webhook deliveries on your local machine, see [Local Development](https://docs.debitura.com/developer-concepts/webhooks.md#local-development) for setup instructions with ngrok and cloudflared.

Set `isTestMode: true` on the subscription so it receives only test case events and never picks up production traffic.

* * *

<a id="complete-ci-script-example"></a>

## Complete CI Script Example

The following shell script runs a full CI loop against the Customer API.

```bash
#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://customer-api.debitura.com"
API_KEY="your_api_key_here"
RUN_TAG="suite-run-$(uuidgen | tr '[:upper:]' '[:lower:]')"

echo "=== CI run: $RUN_TAG ==="

# ---------------------------------------------------------------
# Helper: poll GET /webhooks/events until a delivered event of the
# given type appears, or a timeout elapses.
#
# Event recording is ASYNCHRONOUS: advance/fire only enqueue the event;
# the worker writes the WebhookEvent row a few seconds later. A single
# immediate GET will frequently return [] — that is the normal async
# window, not a failure. Poll with backoff instead of reading once.
# ---------------------------------------------------------------
wait_for_event() {
  local case_id="$1" event_type="$2" since="$3"
  local timeout="${4:-45}" elapsed=0 delay=1

  while [ "$elapsed" -lt "$timeout" ]; do
    local events count
    # Tolerate transient HTTP errors during polling — treat them as "not yet"
    # and retry rather than aborting the run under `set -e`.
    events=$(curl -sf "$BASE_URL/webhooks/events?caseId=$case_id&since=$since" \
      -H "XApiKey: $API_KEY" || echo '[]')
    # deliveredAt non-null means the worker attempted and succeeded delivery
    count=$(echo "$events" | jq "[.[] | select(.eventType == \"$event_type\" and .deliveredAt != null)] | length" 2>/dev/null || echo 0)
    if [ "$count" -ge 1 ]; then
      echo "$count"
      return 0
    fi
    sleep "$delay"
    elapsed=$((elapsed + delay))
    [ "$delay" -lt 5 ] && delay=$((delay + 1))  # linear backoff, capped at 5s
  done
  return 1
}

# ---------------------------------------------------------------
# 1. CREATE — test case
# ---------------------------------------------------------------
echo "[1/4] Creating test case..."
CREATE_RESPONSE=$(curl -sf -X POST "$BASE_URL/cases" \
  -H "Content-Type: application/json" \
  -H "XApiKey: $API_KEY" \
  -d "{
    \"isTest\": true,
    \"tag\": \"$RUN_TAG\",
    \"currencyCode\": \"USD\",
    \"amountToRecover\": 1250.00,
    \"date\": \"$(date -u +%Y-%m-%d)\",
    \"debtor\": {
      \"type\": \"Company\",
      \"name\": \"CI Test Debtor Ltd\",
      \"address\": \"1 Test Street\",
      \"city\": \"London\",
      \"countryAlpha2\": \"GB\"
    }
  }")

CASE_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id')
echo "  Case ID: $CASE_ID"

# ---------------------------------------------------------------
# 2. DRIVE — advance to Closed:Paid
# ---------------------------------------------------------------
echo "[2/4] Advancing case to Closed:Paid..."
DRIVE_RESPONSE=$(curl -sf -X POST "$BASE_URL/test/cases/$CASE_ID/advance" \
  -H "Content-Type: application/json" \
  -H "XApiKey: $API_KEY" \
  -d "{\"to\": \"Closed:Paid\", \"amount\": 1250.00}")

ADVANCED=$(echo "$DRIVE_RESPONSE" | jq -r '.advanced')
STATE=$(echo "$DRIVE_RESPONSE" | jq -r '.state')
echo "  Advanced: $ADVANCED, state: $STATE"

# ---------------------------------------------------------------
# 3. ASSERT — verify case.closed event was delivered
#
# Recording is asynchronous, so poll with backoff (up to ~45s) rather
# than reading the event log once. The row usually appears within a few
# seconds, but the queue drain can lag toward ~30s under load — the budget
# leaves headroom past that. An empty array on the first read is expected,
# not a failure.
# ---------------------------------------------------------------
echo "[3/4] Asserting webhook delivery..."
SINCE=$(date -u -d "5 minutes ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
  || date -u -v-5M +%Y-%m-%dT%H:%M:%SZ)  # macOS fallback

if CLOSED_COUNT=$(wait_for_event "$CASE_ID" "case.closed" "$SINCE" 45); then
  echo "  PASS: case.closed delivered ($CLOSED_COUNT event(s))"
else
  echo "  FAIL: no delivered case.closed event within 45s"
  exit 1
fi

# ---------------------------------------------------------------
# 4. RESET — hard-delete all cases for this run
# ---------------------------------------------------------------
echo "[4/4] Cleaning up tag $RUN_TAG..."
curl -sf -X DELETE \
  "$BASE_URL/test/cases?tag=$RUN_TAG" \
  -H "XApiKey: $API_KEY" \
  -o /dev/null

echo "  Cleanup complete"
echo "=== CI run complete ==="
```

| Step | Endpoint | What to check |
| --- | --- | --- |
| Create | `POST /cases` | `id` in response |
| Drive | `POST /test/cases/{id}/advance` | `advanced: true` in response |
| Assert | `GET /webhooks/events?caseId=...` | `case.closed` with `deliveredAt` non-null |
| Reset | `DELETE /test/cases?tag=...` | `204 No Content` |

* * *

<a id="collection-partner-api-notes"></a>

## Collection Partner API Notes

The Collection Partner API runs the same 4-step CI loop with two differences:

**Case creation uses `POST /managed-cases`**, not `POST /cases`:

```http
POST https://collectionpartner-api.debitura.com/managed-cases
Content-Type: application/json
XApiKey: your_api_key

{
  "isTest": true,
  "tag": "suite-run-a3f9c12e",
  ...
}
```

**Advance fires different event types.** For `→ Closed:Paid`, CP subscriptions receive `payment.created`, `case.updated`, and `case.closed`. For `→ Active`, they receive `case.updated`.

Steps 3 (ASSERT) and 4 (RESET) work identically to the Customer API — use the same `GET /webhooks/events` and `DELETE /test/cases?tag=` endpoints.

**fire — available on both APIs.** Fire any event type against a test case without advancing the lifecycle. Use it to test your handler for an event type without driving the case to that state:

```http
POST https://collectionpartner-api.debitura.com/test/webhooks/fire
Content-Type: application/json
XApiKey: your_api_key

{
  "event": "case.closed",
  "caseId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
```

**Response:**

```json
{
  "eventType": "case.closed",
  "caseId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "enqueuedCount": 1
}
```

`enqueuedCount` confirms the event was queued for delivery — it does **not** mean a `WebhookEvent` row already exists. As with `advance`, recording is asynchronous: poll `GET /webhooks/events` until the event appears (see the [async caveat](https://docs.debitura.com/developer-concepts/webhooks/ci-testing.md#step-3--assert)), rather than reading it once immediately after the fire returns.

Valid event types differ by API: CP uses `case.assigned`, `case.declined`, `case.updated`, `case.closed`, `payment.created`, `payment.deleted`, `chat.created` and the eight `managed_case.*` events; Customer API uses `case.created` instead of `case.assigned`. `payment.created` returns `422` if no payments exist; `chat.created` returns `422` if no chats exist; `payment.deleted` returns `422` if no payment has been deleted on the case.

<a id="managing-role-events"></a>

### Managing-role events

If you manage cases (`POST /managed-cases`), the same loop covers the `managed_case.*` events. Create a test case with `isTest: true`, a test-mode subscription that selects the exact event names, then fire, assert and replay:

```bash
# 1. Subscribe a test-mode endpoint to the managing event
curl -sf -X POST "$CP/webhooks" -H "XApiKey: $API_KEY" -H "Content-Type: application/json" \
  -d '{"Url":"https://ci.example.com/hooks","Events":["managed_case.updated"],"IsTestMode":true}'

# 2. Fire it against the test case you manage
curl -sf -X POST "$CP/test/webhooks/fire" -H "XApiKey: $API_KEY" -H "Content-Type: application/json" \
  -d "{\"event\":\"managed_case.updated\",\"caseId\":\"$CASE_ID\"}"
# -> {"eventType":"managed_case.updated","caseId":"...","enqueuedCount":1}

# 3. Poll history (asynchronous) and take the event ID
curl -sf "$CP/webhooks/events?caseId=$CASE_ID" -H "XApiKey: $API_KEY" \
  | jq '.[] | select(.eventType=="managed_case.updated") | {id, deliveredAt, payload}'

# 4. Replay it; the receiver sees the same envelope id plus X-Debitura-Replay: true
curl -sf -X POST "$CP/webhooks/events/$EVENT_ID/replay" -H "XApiKey: $API_KEY"
```

What to assert:

-   The delivered envelope has `event: "managed_case.updated"`, `data.classification: "test"`, and only the documented fields — no message text and no comments.
-   A fire without a selecting test subscription returns `200` with `enqueuedCount: 0`; a fire for a payment, chat or collector fixture the case does not have returns `422`; a case you do not manage returns `404`.
-   The replayed delivery has the same envelope `id` as the original. Your handler must apply it once.

Field definitions, timing and the full status table are in [Managing-role events](https://docs.debitura.com/collection-partners/integration-guides/webhooks.md#managing-role-events).

**replay-last-event — CP-only convenience wrapper.** Replay the most recent event for a case without looking up the event ID:

```http
POST https://collectionpartner-api.debitura.com/test/cases/{id}/replay-last-event
XApiKey: your_api_key
```

For a full comparison of what each API offers, see [Webhook & Test-Mode Strategy](https://docs.debitura.com/developer-concepts/webhooks/strategy.md). For signature verification, retry behavior, and test vs live subscriptions, see the [Webhooks overview](https://docs.debitura.com/developer-concepts/webhooks.md). For troubleshooting failed deliveries, see [Webhook Troubleshooting](https://docs.debitura.com/developer-concepts/webhooks/troubleshooting.md). For creating test cases, see [Test Cases](https://docs.debitura.com/developer-concepts/test-cases.md).
