Webhooks
Webhooks provide real-time notifications about events in your Cleavr Connect integration. They're the most scalable way to stay updated on recovery progress, payments, and user activities.
Scalability Note: Webhooks are more efficient than polling. They reduce API calls and provide instant updates.
Setup
1. Configure Webhook Endpoint
Register your webhook endpoint during platform setup:
const webhook = await cleavr.webhooks.create({ url: 'https://yourapp.com/webhooks/cleavr', events: [ 'receivable.*', // All receivable events 'user.onboarding.completed', 'payment.received' ], description: 'Production webhook endpoint'});
// Response{ "id": "wh_a1b2c3d4e5f6", "url": "https://yourapp.com/webhooks/cleavr", "secret": "whsec_xxxxxxxxxxxxx", // Save this! "events": ["receivable.*", "user.onboarding.completed", "payment.received"], "created_at": "2025-08-28T10:00:00Z"}2. Verify Webhook Signatures
Always verify webhook signatures to ensure requests come from Cleavr:
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex');
return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) );}
app.post('/webhooks/cleavr', express.raw({type: 'application/json'}), (req, res) => { const signature = req.headers['cleavr-signature'];
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); }
const event = JSON.parse(req.body);
// Process event processWebhookEvent(event);
// Always respond quickly res.status(200).send('OK');});Event Types
receivable.created
Fired when a new receivable is submitted.
{ "id": "evt_a1b2c3d4e5f6", "type": "receivable.created", "created_at": "2025-08-28T10:00:00Z", "data": { "receivable_id": "rec_xyz123", "user_id": "usr_clv_abc456", "amount": 5000.00, "currency": "EUR", "status": "pending", "debtor": { "name": "Debtor Corp", "has_email": true, "has_phone": true } }}receivable.sent_to_recovery
Recovery process has started.
{ "type": "receivable.sent_to_recovery", "data": { "receivable_id": "rec_xyz123", "status": "in_process", "recovery_started_at": "2025-08-28T10:30:00Z", "assigned_agent": "agent_123", "commission_rate": 15.90, "recovery_page_url": "https://recovery.cleavr.fr/ABCD1234EFGH" }}receivable.contact.attempted
Recovery action taken (email/phone).
{ "type": "receivable.contact.attempted", "data": { "receivable_id": "rec_xyz123", "contact_type": "email", // or "phone", "sms", "letter" "contact_number": 3, // Third attempt "contact_level": 0, // Primary contact "next_action": "phone_call", "next_action_date": "2025-08-30T09:00:00Z" }}receivable.payment.received
Payment received from debtor.
{ "type": "receivable.payment.received", "data": { "receivable_id": "rec_xyz123", "payment_amount": 5000.00, "payment_type": "full", // or "partial" "remaining_amount": 0, "commission_amount": 795.00, "net_amount": 4205.00, "payment_date": "2025-09-02T14:30:00Z", "payment_intent_id": "pi_xxxxx" }}receivable.payment.partial
Partial payment requiring platform decision.
{ "type": "receivable.payment.partial", "data": { "receivable_id": "rec_xyz123", "original_amount": 5000.00, "paid_amount": 3000.00, "remaining_amount": 2000.00, "action_required": "platform_decision", "decision_deadline": "2025-09-04T23:59:59Z" }}receivable.disputed
Debtor disputes the debt.
{ "type": "receivable.disputed", "data": { "receivable_id": "rec_xyz123", "dispute_reason": "service_not_delivered", "dispute_message": "We never received the products", "evidence_requested": [ "delivery_confirmation", "signed_contract" ], "evidence_deadline": "2025-09-07T23:59:59Z", "recovery_paused": true }}receivable.evidence.submitted
Evidence provided for dispute.
{ "type": "receivable.evidence.submitted", "data": { "receivable_id": "rec_xyz123", "evidence_count": 2, "evidence_types": ["delivery_confirmation", "contract"], "recovery_resumed": true }}receivable.recovered
Successfully recovered (terminal state).
{ "type": "receivable.recovered", "data": { "receivable_id": "rec_xyz123", "total_recovered": 5000.00, "commission_charged": 795.00, "net_received": 4205.00, "recovery_duration_days": 15, "payout_scheduled": "2025-09-04T00:00:00Z" }}receivable.cancelled
Recovery cancelled (terminal state).
{ "type": "receivable.cancelled", "data": { "receivable_id": "rec_xyz123", "cancellation_reason": "paid_directly", "cancelled_by": "platform", // or "user", "cleavr" "cancelled_at": "2025-09-02T10:00:00Z" }}Webhook Payload Structure
All webhook events follow this structure:
{ "id": "evt_unique_id", "type": "event.type.name", "api_version": "2025-08-28", "created_at": "2025-08-28T10:00:00Z", "data": { // Event-specific data }, "metadata": { "platform_id": "plat_live_xxx", "environment": "production" }}Handling Webhooks
Best Practices
class WebhookHandler { async handleEvent(event) { // 1. Acknowledge receipt immediately this.acknowledge();
// 2. Verify this is a new event (idempotency) if (await this.isDuplicate(event.id)) { return; }
// 3. Store event for replay capability await this.storeEvent(event);
// 4. Process based on event type switch (event.type) { case 'receivable.payment.received': await this.handlePaymentReceived(event.data); break;
case 'receivable.disputed': await this.handleDispute(event.data); break;
case 'receivable.payment.partial': await this.handlePartialPayment(event.data); break;
// ... other events }
// 5. Mark as processed await this.markProcessed(event.id); }
async handlePaymentReceived(data) { // Update your database await db.invoices.update({ where: { external_id: data.receivable_id }, data: { status: 'paid', paid_amount: data.payment_amount, paid_at: data.payment_date } });
// Notify your user await emailService.send({ to: user.email, subject: 'Payment Received!', template: 'payment_received', data: data }); }
async handlePartialPayment(data) { // Decide whether to continue recovery const decision = await this.evaluatePartialPayment(data);
// Send decision back to Cleavr await cleavr.receivables.handlePartialPayment(data.receivable_id, { action: decision.continue ? 'continue_recovery' : 'mark_complete', notes: decision.reason }); }}Retry Logic
Cleavr implements exponential backoff for failed webhook deliveries:
- Immediate retry
- After 1 minute
- After 5 minutes
- After 30 minutes
- After 2 hours
- After 6 hours
- After 24 hours
After 7 failed attempts, the webhook is marked as failed.
Manual Retry
Retrieve missed events:
// List recent eventsconst events = await cleavr.webhooks.listEvents({ since: '2025-08-28T00:00:00Z', type: 'receivable.payment.received', limit: 100});
// Replay specific eventawait cleavr.webhooks.replayEvent('evt_xyz123');Testing Webhooks
Using Test Events
Trigger test events in development:
// Send test webhookawait cleavr.webhooks.sendTest({ type: 'receivable.payment.received', endpoint_id: 'wh_xxx'});Local Development
Use tools like ngrok for local testing:
# Expose local serverngrok http 3000
# Register ngrok URL as webhook endpoint# https://abc123.ngrok.io/webhooks/cleavrWebhook CLI
Test webhooks via CLI:
# Listen to eventscleavr webhooks listen --forward-to localhost:3000/webhooks/cleavr
# Trigger test eventcleavr webhooks trigger receivable.payment.received \ --receivable rec_test_123 \ --amount 1000Monitoring
Webhook Health
Check webhook delivery status:
const health = await cleavr.webhooks.getHealth('wh_xxx');
// Response{ "endpoint_id": "wh_xxx", "url": "https://yourapp.com/webhooks/cleavr", "status": "healthy", "success_rate": 99.8, "average_response_time_ms": 145, "last_error": null, "failed_attempts_24h": 2, "successful_attempts_24h": 1847}Event Logs
Query webhook event history:
const logs = await cleavr.webhooks.getLogs({ endpoint_id: 'wh_xxx', status: 'failed', // or 'success' since: '2024-01-15T00:00:00Z'});Security
IP Whitelisting
Cleavr webhooks originate from these IPs:
Production:- 34.89.123.45- 34.89.123.46- 34.89.123.47
Test:- 35.90.234.56Request Headers
All webhook requests include:
POST /webhooks/cleavr HTTP/1.1Content-Type: application/jsonCleavr-Signature: sha256=xxxxxxxxxxxxxCleavr-Event-Id: evt_a1b2c3d4e5f6Cleavr-Timestamp: 1234567890User-Agent: Cleavr-Webhooks/1.0Webhook Secret Rotation
Rotate webhook secrets periodically:
// Generate new secretconst newWebhook = await cleavr.webhooks.rotateSecret('wh_xxx');
// Update your configurationprocess.env.WEBHOOK_SECRET = newWebhook.secret;
// Old secret remains valid for 24 hoursCommon Patterns
State Machine Updates
// Track receivable state changesconst stateMachine = { pending: ['in_process', 'cancelled'], in_process: ['payment_received', 'disputed', 'cancelled'], payment_received: ['recovered'], disputed: ['in_process', 'cancelled'], recovered: [], // Terminal state cancelled: [] // Terminal state};
function handleStateChange(event) { const currentState = getReceivableState(event.data.receivable_id); const newState = event.data.status;
if (!stateMachine[currentState].includes(newState)) { logWarning(`Invalid state transition: ${currentState} -> ${newState}`); }
updateReceivableState(event.data.receivable_id, newState);}Aggregating Events
// Build recovery timelineclass RecoveryTimeline { constructor(receivableId) { this.receivableId = receivableId; this.events = []; }
addEvent(webhookEvent) { this.events.push({ timestamp: webhookEvent.created_at, type: webhookEvent.type, data: webhookEvent.data });
this.events.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); }
getSummary() { return { total_contacts: this.events.filter(e => e.type === 'receivable.contact.attempted').length, days_to_recovery: this.calculateDaysToRecovery(), dispute_occurred: this.events.some(e => e.type === 'receivable.disputed') }; }}Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Missing events | Endpoint returned error | Check webhook health, replay events |
| Duplicate events | Network retry | Implement idempotency with event.id |
| Out of order events | Async processing | Use event.created_at for ordering |
| Signature verification fails | Wrong secret | Verify using correct webhook secret |
| Slow webhook processing | Synchronous processing | Process async, respond immediately |
Debug Mode
Enable detailed webhook logging:
await cleavr.webhooks.update('wh_xxx', { debug_mode: true, // Logs full request/response debug_email: 'platform@yourcompany.com'});