Webhooks & Event Handling
Webhooks & Event Handling
Cashela sends real-time webhook notifications to your server when transaction events occur. This guide covers webhook delivery, signature verification, event types, and best practices for reliable event processing.
Overview
Webhooks allow your application to receive automatic notifications about transaction events, eliminating the need for polling. When an event occurs (e.g., payment succeeded), Cashela sends an HTTP POST request to your configured endpoint with the event details.
Key Features:
- Real-time delivery within seconds of event occurrence
- Cryptographic signature verification for security
- Automatic retry with exponential backoff
- Idempotent event processing support
- Comprehensive event types for full transaction lifecycle
Webhook Configuration
Setting Up Your Endpoint
- Create an HTTPS endpoint on your server that accepts POST requests
- Configure the URL in your Cashela Dashboard under Settings → Webhooks
- Copy your webhook secret for signature verification
- Test the endpoint using the Dashboard’s test webhook feature
Endpoint Requirements:
- Must use HTTPS (TLS 1.2 or higher)
- Must respond within 10 seconds
- Must return HTTP 2xx status code
- Must be publicly accessible (no authentication on webhook endpoint itself)
Webhook Delivery
Delivery Details
- Protocol: HTTPS
POST - Content-Type:
application/json - Destination: Your configured
notification_url - Authentication: Signed using HMAC-SHA256 with your webhook secret
- Signature Header:
X-Cashela-Signature: t=<unix_timestamp>,v1=<hex_digest> - Timeout: 10 seconds
- Retry Policy: Exponential backoff for up to 72 hours
Retry Behavior
If your endpoint doesn’t return a 2xx response, Cashela retries with exponential backoff:
| Attempt | Delay After Previous |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |
| 8+ | Every 24 hours (up to 72 hours total) |
Best Practice: Return
200 OKimmediately after receiving and queuing the event. Process the event asynchronously in a background worker to avoid timeouts.
Event Types
PayIn Transaction Events
| Event | Description | When Sent |
|---|---|---|
pay-in.created | Transaction created, awaiting payment | Immediately after deposit creation |
pay-in.pending | Payment initiated, processing | When payer begins payment flow |
pay-in.processing | Payment received, being verified | Payment provider confirms receipt |
pay-in.succeeded | Payment completed successfully | Funds confirmed and credited |
pay-in.failed | Payment failed or declined | Payment attempt unsuccessful |
pay-in.cancelled | Transaction cancelled or expired | Timeout or explicit cancellation |
Refund Events
| Event | Description | When Sent |
|---|---|---|
refund.created | Refund initiated | Refund request accepted |
refund.processing | Refund in progress | Funds being returned |
refund.succeeded | Refund completed | Funds returned to payer |
refund.failed | Refund failed | Refund could not be processed |
Dispute Events
| Event | Description | When Sent |
|---|---|---|
dispute.created | Chargeback or dispute opened | Dispute received from payment provider |
dispute.updated | Dispute status changed | New evidence or status update |
dispute.closed | Dispute resolved | Final decision reached |
Event Payload Structure
All webhook events follow a consistent structure:
{ "id": "evt_01HJ3KBCD8E9F0G1H2I3J4K5L6", "object": "event", "type": "pay-in.succeeded", "api_version": "2025-08-01", "created_at": "2025-02-02T10:15:00Z", "data": { "object": { "id": "payin_01HJ3K9X7M8N9P0Q1R2S3T4U5V", "object": "payin", "reference": "CSH-20250202-00001234", "external_identifier": "ORDER-2025-00123", "amount": 1000.00, "currency": "MXN", "status": "succeeded", "payment_method": { "type": "CREDIT_CARD", "code": "VI", "name": "Visa" }, "customer": { "first_name": "Alexander", "last_name": "Sanchez", "email": "alex.sanchez@example.com" }, "created_at": "2025-02-02T10:10:00Z", "updated_at": "2025-02-02T10:15:00Z" }, "previous_attributes": { "status": "processing" } }, "request_id": "req_01HJ3KBCD8E9F0G1H2I3J4K5L6", "livemode": true}Event Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique event identifier. Use for idempotency. |
object | string | Always "event" |
type | string | Event type (e.g., pay-in.succeeded) |
api_version | string | API version that generated this event |
created_at | string | ISO 8601 timestamp of event creation |
data.object | object | The full object that triggered the event |
data.previous_attributes | object | Fields that changed (for update events) |
request_id | string | Related API request ID, if applicable |
livemode | boolean | true for production, false for sandbox |
Signature Verification
Always verify webhook signatures before processing events. This prevents attackers from sending fake events to your endpoint.
Signature Format
Cashela signs each webhook with HMAC-SHA256 using your webhook secret:
X-Cashela-Signature: t=1706878500,v1=5d2b9e8c4f7a1b3e6d8c9f2a5b8e1d4c7f9a2b5e8c1d4f7a9b2e5c8d1f4a7b9eComponents:
t= Unix timestamp when the signature was generatedv1= HMAC-SHA256 hex digest
Verification Process
- Extract
t(timestamp) andv1(signature) from the header - Verify the timestamp is within tolerance (default: 5 minutes)
- Construct the signed payload:
${t}.${raw_request_body} - Compute HMAC-SHA256 using your webhook secret
- Compare signatures using constant-time comparison
Node.js Implementation
import crypto from 'crypto';import { Request, Response, NextFunction } from 'express';
interface VerifyOptions { webhookSecret: string; toleranceSeconds?: number;}
function verifyWebhookSignature( rawBody: Buffer, signatureHeader: string, options: VerifyOptions): boolean { const { webhookSecret, toleranceSeconds = 300 } = options;
// Parse signature header const parts: Record<string, string> = {}; signatureHeader.split(',').forEach(part => { const [key, value] = part.trim().split('='); parts[key] = value; });
const timestamp = parseInt(parts.t, 10); const signature = parts.v1;
if (!timestamp || !signature) { throw new Error('Invalid signature header format'); }
// Check timestamp tolerance (prevent replay attacks) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > toleranceSeconds) { throw new Error('Webhook timestamp outside tolerance window'); }
// Compute expected signature const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`; const expectedSignature = crypto .createHmac('sha256', webhookSecret) .update(signedPayload) .digest('hex');
// Constant-time comparison return crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signature) );}
// Express middleware examplefunction webhookMiddleware(webhookSecret: string) { return (req: Request, res: Response, next: NextFunction) => { const signature = req.headers['x-cashela-signature'] as string;
if (!signature) { return res.status(400).json({ error: 'Missing signature header' }); }
try { const isValid = verifyWebhookSignature( req.body, // Must be raw Buffer signature, { webhookSecret } );
if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); }
next(); } catch (error) { return res.status(400).json({ error: error.message }); } };}Python Implementation
import hmacimport hashlibimport timefrom typing import Optional
def verify_webhook_signature( raw_body: bytes, signature_header: str, webhook_secret: str, tolerance_seconds: int = 300) -> bool: """ Verify Cashela webhook signature.
Args: raw_body: Raw request body as bytes signature_header: X-Cashela-Signature header value webhook_secret: Your webhook secret from dashboard tolerance_seconds: Maximum age of webhook (default 5 minutes)
Returns: True if signature is valid
Raises: ValueError: If signature format is invalid or timestamp out of range """ # Parse header parts = {} for item in signature_header.split(','): key, _, value = item.strip().partition('=') parts[key] = value
timestamp_str = parts.get('t') signature = parts.get('v1')
if not timestamp_str or not signature: raise ValueError('Invalid signature header format')
timestamp = int(timestamp_str)
# Verify timestamp now = int(time.time()) if abs(now - timestamp) > tolerance_seconds: raise ValueError(f'Webhook timestamp too old: {now - timestamp} seconds')
# Compute expected signature signed_payload = f"{timestamp}.".encode() + raw_body expected = hmac.new( webhook_secret.encode(), signed_payload, hashlib.sha256 ).hexdigest()
# Constant-time comparison return hmac.compare_digest(expected, signature)
# Flask examplefrom flask import Flask, request, jsonify
app = Flask(__name__)WEBHOOK_SECRET = 'your_webhook_secret'
@app.route('/webhooks/cashela', methods=['POST'])def handle_webhook(): signature = request.headers.get('X-Cashela-Signature')
if not signature: return jsonify({'error': 'Missing signature'}), 400
try: is_valid = verify_webhook_signature( request.get_data(), # Raw body signature, WEBHOOK_SECRET )
if not is_valid: return jsonify({'error': 'Invalid signature'}), 401
except ValueError as e: return jsonify({'error': str(e)}), 400
# Process event event = request.json handle_event(event)
return '', 200PHP Implementation
<?php
function verifyWebhookSignature( string $rawBody, string $signatureHeader, string $webhookSecret, int $toleranceSeconds = 300): bool { // Parse header $parts = []; foreach (explode(',', $signatureHeader) as $item) { [$key, $value] = explode('=', trim($item), 2); $parts[$key] = $value; }
$timestamp = (int) ($parts['t'] ?? 0); $signature = $parts['v1'] ?? '';
if (!$timestamp || !$signature) { throw new InvalidArgumentException('Invalid signature header format'); }
// Verify timestamp $now = time(); if (abs($now - $timestamp) > $toleranceSeconds) { throw new InvalidArgumentException('Webhook timestamp outside tolerance'); }
// Compute expected signature $signedPayload = "{$timestamp}.{$rawBody}"; $expected = hash_hmac('sha256', $signedPayload, $webhookSecret);
// Constant-time comparison return hash_equals($expected, $signature);}
// Usage$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_X_CASHELA_SIGNATURE'] ?? '';
try { $isValid = verifyWebhookSignature($rawBody, $signature, WEBHOOK_SECRET);
if (!$isValid) { http_response_code(401); exit('Invalid signature'); }
$event = json_decode($rawBody, true); handleEvent($event);
http_response_code(200);
} catch (InvalidArgumentException $e) { http_response_code(400); exit($e->getMessage());}Critical: Always use the raw request body for signature verification. JSON parsing and re-serialization will change the byte sequence and cause verification to fail.
Best Practices
1. Respond Quickly
Return 200 OK immediately after receiving the event. Process asynchronously:
app.post('/webhooks/cashela', async (req, res) => { // Verify signature first if (!verifySignature(req)) { return res.status(401).send('Invalid signature'); }
// Store event for processing (database, queue, etc.) await eventQueue.add(req.body);
// Respond immediately res.status(200).send('OK');
// Don't process here - do it in a background worker});2. Implement Idempotent Handlers
Events may be delivered multiple times. Use the event id to prevent duplicate processing:
def handle_webhook(event): event_id = event['id']
# Check if already processed if redis.exists(f'processed_event:{event_id}'): logger.info(f'Duplicate event {event_id}, skipping') return
# Process the event process_event(event)
# Mark as processed (with TTL) redis.setex(f'processed_event:{event_id}', 86400 * 7, '1') # 7 days3. Handle Event Order
Events may arrive out of order. Use timestamps and object state:
async function handlePaymentEvent(event) { const payment = event.data.object; const eventTime = new Date(event.created_at);
// Get current stored state const stored = await db.payments.findById(payment.id);
if (stored && new Date(stored.updated_at) > eventTime) { // We have a more recent update, skip this event console.log('Skipping older event'); return; }
// Update with new state await db.payments.upsert({ id: payment.id, status: payment.status, updated_at: eventTime });}4. Log Everything
Comprehensive logging helps debugging:
import loggingimport json
logger = logging.getLogger('webhooks')
def log_webhook(request, event, result): logger.info('Webhook received', extra={ 'event_id': event.get('id'), 'event_type': event.get('type'), 'request_id': event.get('request_id'), 'signature_valid': result.signature_valid, 'processing_time_ms': result.duration_ms, 'response_status': result.status_code })5. Set Up Monitoring
Monitor webhook health:
// Track metricsconst metrics = { webhooksReceived: new Counter('webhooks_received_total'), webhookProcessingTime: new Histogram('webhook_processing_seconds'), webhookErrors: new Counter('webhook_errors_total')};
app.post('/webhooks/cashela', async (req, res) => { const start = Date.now(); metrics.webhooksReceived.inc({ type: req.body.type });
try { await processWebhook(req.body); metrics.webhookProcessingTime.observe((Date.now() - start) / 1000); res.status(200).send(); } catch (error) { metrics.webhookErrors.inc({ type: req.body.type, error: error.code }); throw error; }});Error Handling Model (Synchronous API)
All Cashela REST APIs return errors with a canonical envelope.
{ "error": { "code": "invalid_request", "message": "The field 'amount' must be greater than 0.", "status": 422, "request_id": "req_8fJcTz123", "docs": "https://docs.cashela.com/errors#invalid_request" }}HTTP status mapping
- 400
INVALID_INPUT– malformed JSON or missing fields - 401
UNAUTHORIZED– bad credentials - 403
FORBIDDEN– IP not allow‑listed / permission denied - 404
NOT_FOUND– resource or endpoint not found - 409
IDEMPOTENCY_CONFLICT– same key, different body - 422
VALIDATION_ERROR– business rule violation - 429
RATE_LIMITED– too many requests - 5xx
INTERNAL_ERROR– transient; retry with backoff
Rate limit headers
Cashela includes standard headers on rate‑limited responses:
X-RateLimit-Limit: 1000X-RateLimit-Remaining: 997X-RateLimit-Reset: 1724062800Retries & Idempotency
- POST requests must include
Idempotency-Key: <uuid-v4>. - The same key will return the original response for duplicates.
- If the same key is used with a different payload, the API returns 409 IDEMPOTENCY_CONFLICT.
- Client retries: exponential backoff (e.g., 1s, 2s, 4s, max 5 attempts) on 5xx and network errors only.
Testing Webhooks in Sandbox
- Use the sandbox environment:
https://sandbox.api.cashela.com/api/v1/ - Configure a test endpoint (e.g., an ngrok/Cloudflare Tunnel URL) that accepts HTTPS.
- Log the raw body and all headers for debugging.
Troubleshooting
- Signature mismatch: Ensure you used the raw body and correct shared secret; check no whitespace or JSON reformatting occurred before hashing.
- No webhooks received: Verify DNS/SSL of your endpoint and that it returns 2xx within 10s.
- Duplicate events: Your handler must be idempotent; use event
id/request_idfor de‑dupe. - 400/422 from your endpoint: Return 2xx after enqueueing; do validation after acknowledging.
Related Endpoints
- Create a Deposit:
POST /api/v1/pay-in/deposit-creation - Get Exchange Rates:
POST /api/v1/pay-in/deposit-creation/exchange-rates - Available Payment Methods:
POST /api/v1/pay-in/deposit-creation/available-payment-methods