Webhooks
Outbound webhook events from LostChurn — event types, payload format, retry policy, and signature verification.
LostChurn sends webhook events to your application when key recovery and campaign actions occur. Use webhooks to keep your systems in sync with recovery status, update your UI, or trigger custom workflows.
Webhook Endpoints
Configure webhook endpoints in the LostChurn dashboard under Settings > Webhooks. Each endpoint specifies:
- URL: The HTTPS endpoint on your server that receives events
- Events: Which event types to send (subscribe to all or select specific types)
- Signing secret: Used to verify payload authenticity
You can create multiple endpoints to route different event types to different services.
Event Types
Recovery Events
| Event | Description | Trigger |
|---|---|---|
recovery.started | A new recovery process has begun | Failed payment received and classified |
recovery.retry_attempted | A silent retry was submitted | Retry executed via PSP |
recovery.succeeded | Payment was successfully recovered | Retry or payment method update succeeded |
recovery.failed | Recovery exhausted all attempts | All retries failed, dunning complete |
recovery.escalated | Recovery moved from silent to active phase | Silent retries exhausted, dunning triggered |
Campaign Events
| Event | Description | Trigger |
|---|---|---|
campaign.sent | A campaign message was sent | Email, SMS, push, or WhatsApp delivered to PSP |
campaign.opened | A campaign email was opened | Recipient opened the email |
campaign.clicked | A campaign link was clicked | Recipient clicked a link in the message |
campaign.bounced | A campaign message bounced | Email address invalid or mailbox full |
campaign.unsubscribed | Recipient unsubscribed from dunning | Customer opted out |
Customer Events
| Event | Description | Trigger |
|---|---|---|
customer.payment_method_updated | Customer updated their payment method | Card updated via dunning link or widget |
customer.subscription_canceled | Customer canceled via cancel flow | Cancel flow widget completed |
customer.retained | Customer accepted a retention offer | Cancel flow offer accepted |
Payment Events
| Event | Description | Trigger |
|---|---|---|
payment.failed | A new payment failure was recorded | Webhook received from PSP |
payment.recovered | A failed payment was recovered | Successful retry or manual payment |
payment.terminal | A payment was marked terminal | Terminal decline code or manual action |
Payload Format
All webhook payloads follow a consistent structure:
{
"id": "evt_abc123def456",
"type": "recovery.succeeded",
"created_at": "2025-03-08T14:30:00Z",
"data": {
"recovery_id": "rec_789xyz",
"payment_id": "pay_abc123",
"customer_id": "cus_def456",
"amount": 4999,
"currency": "usd",
"decline_code": "insufficient_funds",
"decline_category": "soft_retry",
"retry_count": 2,
"recovered_at": "2025-03-08T14:30:00Z",
"psp": "stripe"
}
}Common Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID |
type | string | Event type (e.g., recovery.succeeded) |
created_at | string | ISO 8601 timestamp in UTC |
data | object | Event-specific payload (varies by type) |
Recovery Event Data
| Field | Type | Description |
|---|---|---|
recovery_id | string | LostChurn recovery ID |
payment_id | string | Original failed payment ID |
customer_id | string | Customer ID from your PSP |
amount | integer | Payment amount in smallest currency unit |
currency | string | Three-letter ISO currency code |
decline_code | string | Raw decline code from PSP |
decline_category | string | LostChurn category: soft_retry, hard_customer, terminal, unknown |
retry_count | integer | Number of retry attempts made |
psp | string | Payment processor name |
Campaign Event Data
| Field | Type | Description |
|---|---|---|
campaign_id | string | Campaign ID |
campaign_name | string | Campaign display name |
step_index | integer | Step number in the campaign sequence |
channel | string | Delivery channel: email, sms, push, whatsapp |
customer_id | string | Customer ID |
customer_email | string | Customer email (for email events) |
message_id | string | Unique message ID |
Signature Verification
Every webhook request includes a signature header that you should verify to ensure the payload was sent by LostChurn and has not been tampered with.
Headers
| Header | Description |
|---|---|
X-LostChurn-Signature | HMAC-SHA256 signature of the request body |
X-LostChurn-Timestamp | Unix timestamp of when the event was sent |
X-LostChurn-Event-Id | Unique event ID (same as id in the payload) |
Verification Process
- Read the
X-LostChurn-TimestampandX-LostChurn-Signatureheaders. - Concatenate the timestamp and the raw request body with a
.separator:{timestamp}.{body}. - Compute an HMAC-SHA256 hash using your webhook signing secret.
- Compare the computed hash to the signature header using a constant-time comparison.
- Verify the timestamp is within 5 minutes of your server's current time.
Node.js Verification Example
import crypto from "node:crypto";
function verifyLostChurnWebhook(req, signingSecret) {
const signature = req.headers["x-lostchurn-signature"];
const timestamp = req.headers["x-lostchurn-timestamp"];
const body = req.rawBody; // raw request body as string
// Check timestamp tolerance (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error("Webhook timestamp outside tolerance window");
}
// Compute expected signature
const payload = `${timestamp}.${body}`;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(payload)
.digest("hex");
// Constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
if (!isValid) {
throw new Error("Invalid webhook signature");
}
return JSON.parse(body);
}Python Verification Example
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"]
# Check timestamp tolerance (5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError("Webhook timestamp outside tolerance window")
# Compute expected signature
payload = f"{timestamp}.{body}".encode("utf-8")
expected = hmac.new(
signing_secret.encode("utf-8"),
payload,
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid webhook signature")
return json.loads(body)Retry Policy
If your endpoint returns a non-2xx status code or does not respond within 30 seconds, LostChurn retries the delivery:
| Attempt | Delay After Previous |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 10 minutes |
| 3rd retry | 1 hour |
After 3 failed retries (4 total attempts), the event is marked as failed. Failed events can be viewed and manually retried from Settings > Webhooks > Failed Events in the dashboard.
Your endpoint should return a 200 status code as quickly as possible. Process the webhook payload asynchronously to avoid timeouts. If your endpoint consistently fails, LostChurn will disable the endpoint and notify you via email.
Inbound Webhook Deduplication
When LostChurn receives webhooks from your payment processor, duplicate events are automatically rejected at the edge. The deduplication layer uses a 24-hour sliding window keyed on the event ID from the payment processor (e.g., Stripe's evt_ ID). If the same event arrives twice — whether from a processor retry, a network hiccup, or a misconfigured endpoint — the second delivery is acknowledged with a 200 response but silently discarded.
This means your recovery pipeline never processes the same payment failure twice, even if your processor delivers the event multiple times.
Webhook Payload Storage
Raw inbound webhook payloads are stored encrypted in Cloudflare R2 (AES-256-GCM) for up to 548 days. This retention window covers the full Visa and Mastercard chargeback dispute period.
Stored payloads serve two purposes:
- Dispute evidence — If a customer disputes a recovered charge, the original failure event and all subsequent recovery actions are available as evidence.
- Audit trail — For compliance and debugging, the exact payload received from the payment processor is preserved in its original form.
Payloads are retrievable via LostChurn's internal API for the full 548-day retention window. After expiration, payloads are permanently deleted. Customer PII within payloads is subject to the same GDPR erasure policies as all other stored data.
Best Practices
| Practice | Details |
|---|---|
| Verify signatures | Always verify the HMAC signature before processing a webhook |
| Use raw body | Verify the signature against the raw request body, not a parsed/re-serialized version |
| Handle duplicates | Use the event id to deduplicate. LostChurn may send the same event more than once |
| Respond quickly | Return 200 immediately and process asynchronously |
| Monitor failures | Set up alerts for webhook delivery failures |
Testing Webhooks
You can send test webhook events from the dashboard:
- Go to Settings > Webhooks and select an endpoint.
- Click Send Test Event.
- Select the event type.
- Click Send. The test event is delivered to your endpoint with a
test: truefield in the payload.
For local development, use a tunneling service like ngrok to expose your local server:
ngrok http 8080Then configure the ngrok URL as a webhook endpoint in LostChurn.
Next Steps
- Authentication -- webhook signing secrets and key management
- Payments -- query and retry payments via the API
- Webhook Verification (Security) -- inbound webhook verification from PSPs