Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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

  1. Create an HTTPS endpoint on your server that accepts POST requests
  2. Configure the URL in your Cashela Dashboard under Settings → Webhooks
  3. Copy your webhook secret for signature verification
  4. 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:

AttemptDelay After Previous
1Immediate
21 minute
35 minutes
430 minutes
52 hours
68 hours
724 hours
8+Every 24 hours (up to 72 hours total)

Best Practice: Return 200 OK immediately after receiving and queuing the event. Process the event asynchronously in a background worker to avoid timeouts.


Event Types

PayIn Transaction Events

EventDescriptionWhen Sent
pay-in.createdTransaction created, awaiting paymentImmediately after deposit creation
pay-in.pendingPayment initiated, processingWhen payer begins payment flow
pay-in.processingPayment received, being verifiedPayment provider confirms receipt
pay-in.succeededPayment completed successfullyFunds confirmed and credited
pay-in.failedPayment failed or declinedPayment attempt unsuccessful
pay-in.cancelledTransaction cancelled or expiredTimeout or explicit cancellation

Refund Events

EventDescriptionWhen Sent
refund.createdRefund initiatedRefund request accepted
refund.processingRefund in progressFunds being returned
refund.succeededRefund completedFunds returned to payer
refund.failedRefund failedRefund could not be processed

Dispute Events

EventDescriptionWhen Sent
dispute.createdChargeback or dispute openedDispute received from payment provider
dispute.updatedDispute status changedNew evidence or status update
dispute.closedDispute resolvedFinal 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

FieldTypeDescription
idstringUnique event identifier. Use for idempotency.
objectstringAlways "event"
typestringEvent type (e.g., pay-in.succeeded)
api_versionstringAPI version that generated this event
created_atstringISO 8601 timestamp of event creation
data.objectobjectThe full object that triggered the event
data.previous_attributesobjectFields that changed (for update events)
request_idstringRelated API request ID, if applicable
livemodebooleantrue 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=5d2b9e8c4f7a1b3e6d8c9f2a5b8e1d4c7f9a2b5e8c1d4f7a9b2e5c8d1f4a7b9e

Components:

  • t = Unix timestamp when the signature was generated
  • v1 = HMAC-SHA256 hex digest

Verification Process

  1. Extract t (timestamp) and v1 (signature) from the header
  2. Verify the timestamp is within tolerance (default: 5 minutes)
  3. Construct the signed payload: ${t}.${raw_request_body}
  4. Compute HMAC-SHA256 using your webhook secret
  5. 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 example
function 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 hmac
import hashlib
import time
from 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 example
from 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 '', 200

PHP 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 days

3. 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 logging
import 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 metrics
const 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: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1724062800

Retries & 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_id for de‑dupe.
  • 400/422 from your endpoint: Return 2xx after enqueueing; do validation after acknowledging.

  • 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