Webhooks
When a deposit reaches a final status, Cashela sends a signed POST to the deposit’s notification_url with the outcome. Verify the signature, update your order, and answer 2xx.
When notifications are sent
Event (X-Cashela-Event header) | When |
|---|---|
partner.payin.transaction.updated | The deposit reaches its final status: COMPLETED, DECLINED or CANCELLED (including an expired deposit). |
partner.payin.transaction.voided | A card deposit was voided. Its status becomes REVERSED. |
partner.payin.transaction.refunded | A card deposit was refunded: REVERSED when refunded in full, COMPLETED after a partial refund. |
partner.payin.dispute.opened, partner.payin.dispute.won, partner.payin.dispute.lost | A card dispute on the deposit was opened or resolved. |
The event travels only in the X-Cashela-Event header, never in the body. Ignore event names you do not know: new ones may be added.
Delivery and retries
- Cashela sends
POSTwithContent-Type: application/jsonto the deposit’snotification_url(HTTPS only). - Answer any
2xxto confirm receipt. Any other status, a timeout or a connection error counts as a failed delivery, and Cashela retries. - Answer quickly and do the heavy work asynchronously.
- The same notification may arrive more than once. Make your handler idempotent: process each
ref+status(+ event) only once. - Notifications are never sent unsigned.
- If you missed one, ask for it again with Re-send a notification, or read the deposit with Get a deposit.
Headers
| Header | Description |
|---|---|
X-Cashela-Event | The event that fired this notification (table above). |
X-Cashela-Timestamp | Unix time (seconds) the signature was computed at. |
X-Cashela-Nonce | Unique value per delivery (UUID). |
X-Cashela-Signature | HMAC-SHA256 of {timestamp}.{nonce}.{body} with your api_secret, lower-case hex (64 characters). |
X-Cashela-Trace-Id | Correlation id of the deposit. Quote it to support. |
Verify the signature
The secret is the api_secret of the API key that created the deposit while that key is still active; if it no longer exists, it is the api_secret of your account’s most recent active key. It is never sent in a notification.
- Read
X-Cashela-Timestamp,X-Cashela-NonceandX-Cashela-Signature. Reject the request if any is missing. - Compute HMAC-SHA256 over the string
{timestamp}.{nonce}.{body}with yourapi_secret, where{body}is the raw request body exactly as received. Parsing and re-serialising the JSON changes the bytes, and the signature will not match. - Compare your result with
X-Cashela-Signatureusing a constant-time comparison. - Reject timestamps more than 300 seconds (5 minutes) away from your clock, and nonces you have already seen.
// Express: mount this route with express.raw({ type: "application/json" })// so req.body is the raw Buffer.const crypto = require("crypto");
function isValidCashelaNotification(req, apiSecret) { const timestamp = req.get("X-Cashela-Timestamp"); const nonce = req.get("X-Cashela-Nonce"); const signature = req.get("X-Cashela-Signature"); if (!timestamp || !nonce || !signature) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto .createHmac("sha256", apiSecret) .update(`${timestamp}.${nonce}.${req.body.toString("utf8")}`) .digest("hex"); return expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}function isValidCashelaNotification(array $headers, string $rawBody, string $apiSecret): bool{ // $rawBody = file_get_contents('php://input'); $timestamp = $headers['X-Cashela-Timestamp'] ?? null; $nonce = $headers['X-Cashela-Nonce'] ?? null; $signature = $headers['X-Cashela-Signature'] ?? null; if ($timestamp === null || $nonce === null || $signature === null) { return false; } if (abs(time() - (int) $timestamp) > 300) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $nonce . '.' . $rawBody, $apiSecret);
return hash_equals($expected, $signature);}import hashlibimport hmacimport time
def is_valid_cashela_notification(headers, raw_body: bytes, api_secret: str) -> bool: # Flask: raw_body = request.get_data(); headers = request.headers timestamp = headers.get("X-Cashela-Timestamp") nonce = headers.get("X-Cashela-Nonce") signature = headers.get("X-Cashela-Signature") if not timestamp or not nonce or not signature: return False if abs(time.time() - int(timestamp)) > 300: return False
message = f"{timestamp}.{nonce}.".encode("utf-8") + raw_body expected = hmac.new(api_secret.encode("utf-8"), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class CashelaSignature {
public static boolean isValid(String timestamp, String nonce, String signature, byte[] rawBody, String apiSecret) throws Exception { if (timestamp == null || nonce == null || signature == null) { return false; } long now = System.currentTimeMillis() / 1000L; if (Math.abs(now - Long.parseLong(timestamp)) > 300) { return false; }
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + "." + nonce + ".").getBytes(StandardCharsets.UTF_8)); byte[] digest = mac.doFinal(rawBody);
StringBuilder expected = new StringBuilder(digest.length * 2); for (byte b : digest) { expected.append(String.format("%02x", b)); } return MessageDigest.isEqual( expected.toString().getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8)); }}These examples check the timestamp; also store each nonce for at least the tolerance window and reject repeats.
Test vectors
Check your implementation against these cases before going live. Download them as JSON: webhook-signatures.json.
| Case | Secret | Timestamp | Expected result |
|---|---|---|---|
valid | whsec_test_0123456789abcdef | 1759838400 | Valid. Signature b2e80494a3da3d33cfb2c38b1c6c334efb46ed098b4f29f24760da6831de0f6c |
tampered-body | same | same | Invalid: the body changed (status is CANCELLED) but the signature is the one of valid. |
reserialized-body | same | same | Invalid: the same JSON pretty-printed. Hash the raw bytes. |
wrong-secret | whsec_test_wrong | same | Invalid. |
stale-timestamp | whsec_test_0123456789abcdef | 100 | Invalid: the signature matches, but the timestamp is outside the 300-second tolerance. |
All cases use the nonce 8f14e45f-ceea-467d-9a0b-000000000001 and, except where noted, this exact body (no spaces, no trailing newline):
{"ref":"PI-TEST-0001","external_identifier":"partner-order-0001","status":"COMPLETED","date":"2025-10-07T12:00:00.000000Z","amount":4750.5,"currency":"CLP","settlement_currency":"USD","net_amount":5,"processing_fee_amount":0.25,"fx_spread_amount":0,"tax_amount":0,"card":null}Notification body
The body has the same keys, in the same order, on every status and event.
{ "ref": "210000000001", "external_identifier": "order-2026-0002", "status": "COMPLETED", "date": "2026-10-20T15:04:05.000000Z", "amount": 5, "currency": "USD", "settlement_currency": "USD", "net_amount": 4.85, "processing_fee_amount": 0.15, "fx_spread_amount": 0, "tax_amount": 0, "card": { "network": "visa", "last4": "4242" }}| Field | Type | Description |
|---|---|---|
ref | string | The deposit’s reference. |
external_identifier | string | Your id, as sent when you created the deposit. |
status | string | The deposit status after the event: COMPLETED, DECLINED, CANCELLED or REVERSED. |
date | string | ISO-8601 date and time of the event. |
amount | number | What your customer was charged, in currency. |
currency | string | Currency of amount. |
settlement_currency | string | Currency of the four amounts below. |
net_amount | number | What is credited to you, in settlement_currency. |
processing_fee_amount | number | Processing fee, in settlement_currency. |
fx_spread_amount | number | Exchange-rate spread, in settlement_currency. 0 when there was no currency conversion. |
tax_amount | number | Taxes, in settlement_currency. |
card | object | null | network (visa, mastercard, amex, discover, or null if unknown) and last4 of the card that paid. null for non-card methods and before the charge. Never a full card number, CVV, expiry or token. |
How to read the amounts:
amount/currencyis what your customer was charged. For card options charged in USD, it is the USD charged (for example5.00 USD), not the local-currency invoice. The invoice stays available asdetails.invoice_*in Get a deposit, anddetails.charged_amount/details.charged_currencymatchamount/currencyhere.- When
currencyandsettlement_currencydiffer (for example a local-currency method credited in USD),net_amount + processing_fee_amount + fx_spread_amount + tax_amountdoes not add up toamount: they are in different currencies. - On
DECLINEDandCANCELLED, the amounts are the quote the deposit was created with, not money that moved. Nothing was charged and nothing is credited. Reconcile money only onCOMPLETEDandREVERSED. - On
REVERSED, the body keeps the original deposit amounts. The detail of the void or refund is inreversals.refundsof Get a deposit.
Re-send a notification
Asks Cashela to send a deposit’s notification again, now, to its notification_url.
/api/v1/pay-in/transactions/{reference}/callback{reference}: the deposit’sreference, yourexternal_identifier, or the depositid.- Only for a deposit in a final status. Otherwise
409(PARTNER_PAYIN_CALLBACK_NOT_FINAL). - Limited to 6 requests per minute.
- The answer is
200even when your endpoint did not accept the delivery: checkdata.callback.
curl -X POST https://sandbox-api.cashela.com/api/v1/pay-in/transactions/210000000001/callback \ -u "$CASHELA_API_KEY:$CASHELA_API_SECRET" \ -H "Accept: application/json"{ "success": true, "data": { "transaction": { "reference": "210000000001", "status": "COMPLETED", "…": "…" }, "callback": { "status": "…", "success": true, "reference": "210000000001", "http_status": 200 } }, "message": "…"}data.callback field | Description |
|---|---|
success | true if your endpoint answered 2xx. |
http_status | What your endpoint answered, when it answered. |
reason | When it failed without an answer, a stable code such as callback_url_not_configured or callback_url_blocked. |
Checklist
- Your
notification_urlis HTTPS, public, and answers2xxwithin a few seconds. - You verify the signature over the raw body, the timestamp and the nonce.
- Your handler is idempotent.
- You read the event from
X-Cashela-Event, not from the body. - You reconcile money only on
COMPLETEDandREVERSED. - You can recover a missed notification with the re-send endpoint or by reading the deposit.