Skip to main content

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:

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

The steps below use the Customer API for examples. See 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.


Step 1 — Create

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

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.

Tags

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

suite-run-{uuid}

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


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.

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:

FieldRequiredDescription
toYesTarget lifecycle state (see valid values below)
amountOnly for Closed:PaidPayment amount in the case currency

Valid target states and webhooks fired (Customer API):

ValueWebhooks fired to Creditor subscriptions
Activecase.updated
Closed:Paidpayment.created, case.updated, case.closed — requires amount
Closed:NoPaymentcase.updated, case.closed

Response:

{
"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

:::note 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. :::


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 for a wait_for_event helper). An empty array on the first read is not a failure. :::

Inspect delivery history

Query all webhook events dispatched for a case:

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

Query parameters:

ParameterDescription
caseIdRequired. Filter to events for this case.
sinceOptional. ISO 8601 timestamp — return events created at or after this time.

Response (array, newest first):

[
{
"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) rather than reading it once.

Replay a specific event

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

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

Response:

{
"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.


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 events.

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:

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

Local Development

To receive webhook deliveries on your local machine, see 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.


Complete CI Script Example

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

#!/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 ==="
StepEndpointWhat to check
CreatePOST /casesid in response
DrivePOST /test/cases/{id}/advanceadvanced: true in response
AssertGET /webhooks/events?caseId=...case.closed with deliveredAt non-null
ResetDELETE /test/cases?tag=...204 No Content

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:

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:

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:

{
"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), rather than reading it once immediately after the fire returns.

Valid event types differ by API: CP uses case.assigned, case.updated, case.closed, payment.created, chat.created; 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.

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

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. For signature verification, retry behavior, and test vs live subscriptions, see the Webhooks overview. For troubleshooting failed deliveries, see Webhook Troubleshooting. For creating test cases, see Test Cases.