LostChurn Docs
API Reference

Webhook Events Reference

Complete catalog of LostChurn webhook event types with payload schemas, field descriptions, and example JSON payloads.

This page is a comprehensive reference for every webhook event LostChurn emits. For setup instructions, retry policy, and endpoint management, see the Webhooks setup guide.

All events share the same envelope structure. The data object is event-specific and described for each event below.

Payload Envelope

Every webhook delivery wraps the event-specific payload in a common envelope:

{
  "id": "evt_abc123def456",
  "type": "recovery.succeeded",
  "created_at": "2026-03-08T14:30:00Z",
  "data": { ... }
}
FieldTypeDescription
idstringUnique event ID — use this for idempotent processing
typestringEvent type string (e.g., recovery.succeeded)
created_atstringISO 8601 UTC timestamp of when the event was generated
dataobjectEvent-specific payload; schema documented per event below

HMAC Signature Verification

Every delivery includes three headers for authenticity verification:

HeaderDescription
X-LostChurn-SignatureHMAC-SHA256 hex digest of {timestamp}.{raw_body}
X-LostChurn-TimestampUnix timestamp of the delivery attempt
X-LostChurn-Event-IdUnique event ID (mirrors id in the payload)

Verification steps:

  1. Read X-LostChurn-Timestamp and X-LostChurn-Signature from the request headers.
  2. Build the signed string: {timestamp}.{raw_request_body} (use the raw bytes, not a parsed object).
  3. Compute HMAC-SHA256 with your endpoint's signing secret.
  4. Compare using a constant-time function — reject if they differ.
  5. Reject the request if the timestamp is more than 5 minutes old (prevents replay attacks).
Node.js / TypeScript
import crypto from "crypto";

export function verifyLostChurnWebhook(
  headers: Record<string, string>,
  rawBody: string,
  signingSecret: string
): boolean {
  const signature = headers["x-lostchurn-signature"];
  const timestamp = headers["x-lostchurn-timestamp"];

  if (!signature || !timestamp) return false;

  // Replay protection: 5-minute window
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error("Webhook timestamp outside tolerance window");
  }

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex")
  );
}
Python
import hmac
import hashlib
import time
import json

def verify_lostchurn_webhook(headers, body, signing_secret):
    signature = headers["X-LostChurn-Signature"]
    timestamp = headers["X-LostChurn-Timestamp"]

    now = int(time.time())
    if abs(now - int(timestamp)) > 300:
        raise ValueError("Webhook timestamp outside tolerance window")

    payload = f"{timestamp}.{body}".encode("utf-8")
    expected = hmac.new(
        signing_secret.encode("utf-8"),
        payload,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise ValueError("Invalid webhook signature")

    return json.loads(body)

Payment Events

payment.failed

A new payment failure was recorded in LostChurn, typically triggered by an inbound PSP webhook.

{
  "id": "evt_pf_01hx4y",
  "type": "payment.failed",
  "created_at": "2026-03-08T10:00:00Z",
  "data": {
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "amount": 4999,
    "currency": "usd",
    "psp": "stripe",
    "psp_payment_id": "ch_3OkZXY2eZvKYlo2C1234567",
    "decline_code": "insufficient_funds",
    "decline_category": "soft_retry",
    "failed_at": "2026-03-08T09:59:50Z"
  }
}
FieldTypeDescription
payment_idstringLostChurn payment identifier
customer_idstringLostChurn customer identifier
merchant_idstringMerchant the payment belongs to
amountintegerAmount in smallest currency unit (e.g., cents)
currencystringISO 4217 currency code (lowercase)
pspstringPayment service provider slug (e.g., stripe, adyen)
psp_payment_idstringOriginal payment ID from the PSP
decline_codestringNormalized decline code
decline_categorystringDecline category: soft_retry, hard, fraud, etc.
failed_atstringISO 8601 UTC timestamp from the PSP

payment.recovered

A previously failed payment was successfully recovered via a retry or out-of-band payment.

{
  "id": "evt_pr_02hy5z",
  "type": "payment.recovered",
  "created_at": "2026-03-09T11:15:00Z",
  "data": {
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "amount": 4999,
    "currency": "usd",
    "psp": "stripe",
    "psp_payment_id": "ch_3OkZXY2eZvKYlo2C1234567",
    "recovered_at": "2026-03-09T11:14:55Z",
    "retry_count": 2,
    "recovery_method": "silent_retry"
  }
}
FieldTypeDescription
retry_countintegerNumber of retry attempts before success
recovered_atstringISO 8601 UTC timestamp of the successful charge
recovery_methodstringsilent_retry, dunning, payment_method_update, or manual

payment.terminal

All recovery attempts exhausted; the payment is marked terminal and will not be retried.

{
  "id": "evt_pt_03iz6a",
  "type": "payment.terminal",
  "created_at": "2026-03-12T08:00:00Z",
  "data": {
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "amount": 4999,
    "currency": "usd",
    "psp": "stripe",
    "decline_code": "do_not_honor",
    "decline_category": "hard",
    "terminal_reason": "hard_decline",
    "terminal_at": "2026-03-12T07:59:30Z"
  }
}
FieldTypeDescription
terminal_reasonstringhard_decline, max_retries_reached, fraud_flagged, or manual
terminal_atstringISO 8601 UTC timestamp when terminal status was set

Recovery Events

recovery.started

A new recovery process has been created for a failed payment.

{
  "id": "evt_rs_04ja7b",
  "type": "recovery.started",
  "created_at": "2026-03-08T10:01:00Z",
  "data": {
    "recovery_id": "rec_001aaa",
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "decline_category": "soft_retry",
    "phase": "silent",
    "scheduled_retries": 3,
    "started_at": "2026-03-08T10:01:00Z"
  }
}
FieldTypeDescription
recovery_idstringLostChurn recovery process identifier
phasestringInitial phase: silent or active
scheduled_retriesintegerNumber of silent retries scheduled
started_atstringISO 8601 UTC timestamp

recovery.retry_attempted

A silent retry was submitted to the PSP.

{
  "id": "evt_rra_05kb8c",
  "type": "recovery.retry_attempted",
  "created_at": "2026-03-09T06:00:00Z",
  "data": {
    "recovery_id": "rec_001aaa",
    "retry_attempt_id": "rta_111bbb",
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "attempt_number": 1,
    "psp": "stripe",
    "status": "pending",
    "attempted_at": "2026-03-09T06:00:00Z"
  }
}
FieldTypeDescription
retry_attempt_idstringUnique ID for this specific retry attempt
attempt_numberintegerSequential attempt index (1-based)
statusstringpending, succeeded, or failed

recovery.succeeded

The recovery process ended successfully — a retry or payment method update charged the customer.

{
  "id": "evt_rsc_06lc9d",
  "type": "recovery.succeeded",
  "created_at": "2026-03-09T11:15:00Z",
  "data": {
    "recovery_id": "rec_001aaa",
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "amount": 4999,
    "currency": "usd",
    "decline_code": "insufficient_funds",
    "decline_category": "soft_retry",
    "retry_count": 2,
    "recovered_at": "2026-03-09T11:14:55Z",
    "psp": "stripe"
  }
}

recovery.failed

All recovery attempts have been exhausted without success. The payment is now terminal.

{
  "id": "evt_rf_07md0e",
  "type": "recovery.failed",
  "created_at": "2026-03-12T08:00:00Z",
  "data": {
    "recovery_id": "rec_001aaa",
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "retry_count": 3,
    "final_decline_code": "do_not_honor",
    "final_decline_category": "hard",
    "failed_at": "2026-03-12T07:59:30Z"
  }
}

recovery.escalated

Silent retries were exhausted and the recovery has moved into the active dunning phase (email/SMS campaigns).

{
  "id": "evt_re_08ne1f",
  "type": "recovery.escalated",
  "created_at": "2026-03-11T06:00:00Z",
  "data": {
    "recovery_id": "rec_001aaa",
    "payment_id": "pay_abc123",
    "customer_id": "cus_def456",
    "previous_phase": "silent",
    "new_phase": "active",
    "silent_retries_attempted": 3,
    "escalated_at": "2026-03-11T06:00:00Z"
  }
}

Campaign Events

campaign.enrolled

A customer was enrolled into a dunning campaign.

{
  "id": "evt_ce_09of2g",
  "type": "campaign.enrolled",
  "created_at": "2026-03-11T06:05:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "campaign_name": "3-Touch Soft Decline Recovery",
    "customer_id": "cus_def456",
    "payment_id": "pay_abc123",
    "recovery_id": "rec_001aaa",
    "enrolled_at": "2026-03-11T06:05:00Z"
  }
}

campaign.sent

A campaign message (email, SMS, push, or WhatsApp) was submitted to the delivery provider.

{
  "id": "evt_cs_10pg3h",
  "type": "campaign.sent",
  "created_at": "2026-03-11T08:00:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "campaign_name": "3-Touch Soft Decline Recovery",
    "step_index": 0,
    "channel": "email",
    "customer_id": "cus_def456",
    "customer_email": "jane@example.com",
    "message_id": "msg_xyz789",
    "sent_at": "2026-03-11T08:00:00Z"
  }
}
FieldTypeDescription
campaign_idstringCampaign identifier
campaign_namestringCampaign display name
step_indexintegerZero-based step index in the campaign sequence
channelstringemail, sms, push, or whatsapp
customer_idstringLostChurn customer identifier
customer_emailstringRecipient email address (email channel only)
message_idstringUnique message delivery identifier

campaign.opened

A campaign email was opened by the recipient.

{
  "id": "evt_co_11qh4i",
  "type": "campaign.opened",
  "created_at": "2026-03-11T09:30:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "step_index": 0,
    "channel": "email",
    "customer_id": "cus_def456",
    "message_id": "msg_xyz789",
    "opened_at": "2026-03-11T09:30:00Z"
  }
}

campaign.clicked

A recipient clicked a link inside a campaign message.

{
  "id": "evt_ck_12ri5j",
  "type": "campaign.clicked",
  "created_at": "2026-03-11T09:32:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "step_index": 0,
    "channel": "email",
    "customer_id": "cus_def456",
    "message_id": "msg_xyz789",
    "link_url": "https://pay.example.com/update?token=abc",
    "clicked_at": "2026-03-11T09:32:00Z"
  }
}

campaign.bounced

A campaign message was rejected or could not be delivered.

{
  "id": "evt_cb_13sj6k",
  "type": "campaign.bounced",
  "created_at": "2026-03-11T08:01:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "step_index": 0,
    "channel": "email",
    "customer_id": "cus_def456",
    "customer_email": "jane@example.com",
    "message_id": "msg_xyz789",
    "bounce_type": "hard",
    "bounce_reason": "invalid_address",
    "bounced_at": "2026-03-11T08:01:00Z"
  }
}
FieldTypeDescription
bounce_typestringhard (permanent) or soft (temporary)
bounce_reasonstringinvalid_address, mailbox_full, domain_not_found, etc.

campaign.unsubscribed

A recipient unsubscribed from dunning communications.

{
  "id": "evt_cu_14tk7l",
  "type": "campaign.unsubscribed",
  "created_at": "2026-03-11T10:00:00Z",
  "data": {
    "campaign_id": "cmp_aaa111",
    "customer_id": "cus_def456",
    "customer_email": "jane@example.com",
    "message_id": "msg_xyz789",
    "unsubscribed_at": "2026-03-11T10:00:00Z"
  }
}

Customer Events

customer.payment_method_updated

A customer updated their payment method via a dunning link or the payment update widget.

{
  "id": "evt_cpu_15ul8m",
  "type": "customer.payment_method_updated",
  "created_at": "2026-03-11T12:00:00Z",
  "data": {
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "payment_id": "pay_abc123",
    "recovery_id": "rec_001aaa",
    "new_card_last4": "4242",
    "new_card_brand": "visa",
    "updated_at": "2026-03-11T12:00:00Z"
  }
}

customer.subscription_canceled

A customer completed the cancel flow and confirmed their cancellation.

{
  "id": "evt_csc_16vm9n",
  "type": "customer.subscription_canceled",
  "created_at": "2026-03-10T15:30:00Z",
  "data": {
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "cancel_session_id": "cs_zzz999",
    "cancellation_reason": "too_expensive",
    "canceled_at": "2026-03-10T15:30:00Z"
  }
}
FieldTypeDescription
cancel_session_idstringCancel flow session identifier
cancellation_reasonstringCustomer-selected reason code

customer.retained

A customer accepted a retention offer during the cancel flow and did not cancel.

{
  "id": "evt_cr_17wn0o",
  "type": "customer.retained",
  "created_at": "2026-03-10T15:32:00Z",
  "data": {
    "customer_id": "cus_def456",
    "merchant_id": "mer_789xyz",
    "cancel_session_id": "cs_zzz999",
    "offer_type": "discount",
    "offer_value": "20_percent_off_3_months",
    "retained_at": "2026-03-10T15:32:00Z"
  }
}
FieldTypeDescription
offer_typestringdiscount, pause, downgrade, or free_month
offer_valuestringHuman-readable description of the offer applied

Quick Reference

EventCategoryTrigger
payment.failedPaymentPSP webhook received; decline recorded
payment.recoveredPaymentSuccessful retry or out-of-band payment
payment.terminalPaymentAll recovery exhausted; no further retries
recovery.startedRecoveryFailed payment classified; recovery opened
recovery.retry_attemptedRecoverySilent retry submitted to PSP
recovery.succeededRecoveryRecovery process ended successfully
recovery.failedRecoveryAll retries exhausted; payment terminal
recovery.escalatedRecoverySilent phase ended; active dunning started
campaign.enrolledCampaignCustomer added to dunning campaign
campaign.sentCampaignMessage submitted to delivery provider
campaign.openedCampaignEmail opened by recipient
campaign.clickedCampaignLink in message clicked by recipient
campaign.bouncedCampaignMessage delivery failed
campaign.unsubscribedCampaignCustomer opted out of dunning
customer.payment_method_updatedCustomerCard updated via dunning link or widget
customer.subscription_canceledCustomerCancel flow completed with cancellation
customer.retainedCustomerCancel flow completed with offer accepted

On this page