Webhooks
Cashela notifies your server with a signed POST when a transaction changes status. Pay-In and Pay-Out use the same headers and the same signature scheme; the body is specific to each product.
| Product | Where the URL comes from | Body and events |
|---|---|---|
| Pay-In | notification_url of each deposit | Pay-In webhooks |
| Pay-Out | callback_url of each transaction | Create a transaction |
Headers
| Header | Description |
|---|---|
X-Cashela-Event | The event, for example partner.payin.transaction.updated or partner.payout.transaction.updated. The body does not carry it. |
X-Cashela-Timestamp | Unix time (seconds) of the signature. |
X-Cashela-Nonce | Unique value per delivery (UUID). |
X-Cashela-Signature | HMAC-SHA256 of {timestamp}.{nonce}.{body} with your api_secret, lower-case hex. |
X-Cashela-Trace-Id | Correlation id; quote it to support. |
Verify the signature
- Read the three signature headers; reject the request if one is missing.
- Compute HMAC-SHA256 over
{timestamp}.{nonce}.{raw body}with theapi_secretof the key that created the transaction while it is active (or your account’s most recent active key if it was removed). Use the raw body exactly as received. - Compare with
X-Cashela-Signaturein constant time. - Reject timestamps more than 300 seconds old 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)); }}Test your implementation with the signature test vectors.
Delivery
- Answer any
2xxquickly. Any other status, a timeout or a connection error is a failed delivery. Pay-In retries failed deliveries; for Pay-Out, do not rely on automatic retries: read the transaction or ask for a re-send. - The same notification can arrive more than once: make your handler idempotent.
- Notifications are never sent unsigned.
- Lost one? Ask for it again:
POST /api/v1/pay-in/transactions/{reference}/callback(Pay-In) orPOST /api/v1/pay-out/transactions/{reference}/callback(Pay-Out), or read the transaction. - The return of your customer to your site is never a payment confirmation; only the notification or a read of the transaction is.