Webhooks
Receive payment and refund events at a webhook endpoint
Kashier notifies your server about payment and refund events by sending POST requests to a URL you control. Each request carries an Event object with the full transaction details — see Webhook payloads for the event types and every field Kashier sends. Use webhooks for post-payment commerce events such as sending custom email receipts, fulfilling orders, or updating your database. They are also the only way to receive automatic updates for payment methods that confirm asynchronously.
Prerequisites
Before you configure a webhook, make sure you have:
- A publicly accessible server endpoint that can receive unauthenticated POST requests — Kashier calls it directly over the internet, so
localhostor anything behind auth won't work. - Your Payment API Key, found in your dashboard under the Integrations section — you'll use it in Step 4 to verify that incoming requests really came from Kashier.
- A read of Webhook payloads, so you know which event types and fields to expect in the request body.
Step 1: Build your endpoint
Add a new route to your server and make sure it's publicly accessible so Kashier can send unauthenticated POST requests to it. Kashier sends the event data as JSON in the request body: an Event object whose event field names the transaction operation (pay, authorize, capture, refund, partial_refund, void, reject, or reversal) and whose data payload contains the transaction details. See Webhook payloads for the full payload and field notes.
event is not a success signal
Kashier also sends a webhook when an operation fails — same event value, with "status": "FAILURE" in data. Branch on data.status (SUCCESS, FAILURE, or PENDING), never on event alone. status is the transaction status, not the order status.
Step 2: Point Kashier at your endpoint
There are two ways an event reaches your endpoint, and they work together — if both apply to a payment, Kashier delivers to both independently.
Configure a webhook. Webhook management is where you register endpoints, and it's the way to set webhooks up. Each webhook has a name, a URL, optional custom headers, a mode (test or live), and the list of event types it's subscribed to — so a production endpoint can take just pay and refund while a separate staging endpoint listens on test mode. Delivery records, resend, and test-send live in the same module — see Webhook management for which of them are available today.
Already had a webhook URL with Kashier?
You don't need to re-register it. A single stored webhook URL configured before webhook management existed is migrated into the module for you and keeps receiving what it received before — see If you used the old single-URL webhook.
Pass a per-request destination. Payment-creating calls also accept a serverWebhook field — payment sessions, the Direct API card form, and recurring payments each take one. That URL applies to that payment only. Kashier records and delivers to it under the same mode as the payment, without it becoming a configured webhook, and without event filtering — a per-request destination receives every event that payment produces. Use it when the destination genuinely varies per transaction, for example a platform routing each order's notifications to a different vendor. Payouts have no per-request equivalent; transfer events only go to configured webhooks.
Step 3: Respond to events
Respond with 200 as soon as you receive the event. Kashier treats HTTP 200 and HTTP 409 Conflict as acknowledged — return 409 for an event you have already processed. Any other status code counts as unacknowledged, as does any response slower than the 30-second delivery timeout.
Kashier retries an unacknowledged event up to 10 times, backing off 2 minutes → 10 minutes → 30 minutes → 1 hour → 2 hours → 4 hours, then every 4 hours.
Don't send a response body — it's discarded, and only the status code is read. If your handler starts a long-running task, return 200 first and do the work afterwards; otherwise delivery times out and the event is sent again. Make your handler safe to run twice — see Idempotency.
Step 4: Verify the signature
Kashier signs every webhook request with SHA256 HMAC so you can verify it wasn't tampered with in transit.
To generate the signature, sort the elements of the signatureKeys array in the data payload alphabetically. Each element is a key of the data object. Select those keys and their values from the received data object, then build the signature payload:
amount=1&channel=online%20%7C%20e-commerce¤cy=EGP&kashierOrderId=9ad06b17-755b-4e21-9774-aff3e2726ac9&merchantOrderId=1653481557813&method=card&orderReference=TEST-ORD-38855&status=SUCCESS&transactionId=TX-249893963&transactionResponseCode=00Hash the signature payload with your Payment API Key, then compare your result with the x-kashier-signature request header. If both are equal, the data is safe to save and use in your system.
Hint
You must use the Payment API Key that you used to create the Payment Hash. You can find the Payment API Key in your dashboard under the Integrations section.
Ensure that only the values of the keys are URL-encoded, not the entire string. This means each value should be properly encoded before concatenating the key-value pairs to form the signature payload.
How the signature is computed, step by step
Here's the computation above traced through with concrete values, so you can check your own implementation against a known-good result.
1. Start from the received data payload. Say Kashier's request body contains this data object (trimmed to the signature-relevant fields — see Webhook payloads for the full shape):
{
"amount": 1,
"channel": "online | e-commerce",
"currency": "EGP",
"kashierOrderId": "9ad06b17-755b-4e21-9774-aff3e2726ac9",
"merchantOrderId": "1653481557813",
"method": "card",
"orderReference": "TEST-ORD-38855",
"status": "SUCCESS",
"transactionId": "TX-249893963",
"transactionResponseCode": "00",
"signatureKeys": [
"amount",
"channel",
"currency",
"kashierOrderId",
"merchantOrderId",
"method",
"orderReference",
"status",
"transactionId",
"transactionResponseCode"
]
}2. Sort signatureKeys alphabetically. In this example it's already sorted: amount, channel, currency, kashierOrderId, merchantOrderId, method, orderReference, status, transactionId, transactionResponseCode.
3. Look up each key's value in data, and URL-encode only the value. Only channel's value contains characters that need encoding — the space becomes %20 and the | becomes %7C:
| Key | Value | URL-encoded value |
|---|---|---|
amount | 1 | 1 |
channel | online | e-commerce | online%20%7C%20e-commerce |
currency | EGP | EGP |
kashierOrderId | 9ad06b17-755b-4e21-9774-aff3e2726ac9 | 9ad06b17-755b-4e21-9774-aff3e2726ac9 |
merchantOrderId | 1653481557813 | 1653481557813 |
method | card | card |
orderReference | TEST-ORD-38855 | TEST-ORD-38855 |
status | SUCCESS | SUCCESS |
transactionId | TX-249893963 | TX-249893963 |
transactionResponseCode | 00 | 00 |
4. Join each pair with = and all pairs with &. That produces exactly the signature payload string shown above:
amount=1&channel=online%20%7C%20e-commerce¤cy=EGP&kashierOrderId=9ad06b17-755b-4e21-9774-aff3e2726ac9&merchantOrderId=1653481557813&method=card&orderReference=TEST-ORD-38855&status=SUCCESS&transactionId=TX-249893963&transactionResponseCode=005. HMAC-SHA256 that string, keyed with your Payment API Key. This is the same credential used for the Payment Hash — not your Secret Key. If the "Payment API Key" vs. "Secret Key" vs. "MID" terminology is unclear, see Credentials at a glance for how each maps to its actual header or field name. Using the same illustrative key (11111) as the Request hashing examples, the resulting hex digest is:
9610477b2255b2a8ef84fd89adfaa5f1305ff9c20324205851890f1ea03109f46. Compare the digest with the x-kashier-signature request header. If they're equal (byte-for-byte, case-insensitive hex comparison), the request is authentic and the data payload is safe to save and use — this is exactly what the if (kashierSignature === signature) check does in the code samples below.
const app = require('express')();
// Use body-parser to retrieve the raw body as a buffer
const bodyParser = require('body-parser');
const crypto = require('crypto');
const queryString = require('query-string');
const _ = require('underscore');
router.post('/', (req, res) => {
const { data, event } = req.body;
data.signatureKeys.sort();
const objectSignaturePayload = _.pick(data, data.signatureKeys);
const signaturePayload = queryString.stringify(objectSignaturePayload);
const signature = crypto
.createHmac('sha256', PaymentApiKey)
.update(signaturePayload)
.digest('hex');
const kashierSignature = req.header('x-kashier-signature');
if (kashierSignature === signature) {
console.log('valid signature');
} else {
console.log('invalid signature');
}
});
app.listen(8000, () => console.log('Running on port 8000'));<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw_payload = file_get_contents('php://input');
$json_data = json_decode($raw_payload, true);
$data_obj = $json_data['data'];
$event = $json_data['event'];
sort($data_obj['signatureKeys']);
$headers = getallheaders();
// Lower case all keys
$headers = array_change_key_case($headers);
$kashierSignature = $headers['x-kashier-signature'];
$data = [];
foreach ($data_obj['signatureKeys'] as $key) {
$data[$key] = $data_obj[$key];
}
$queryString = http_build_query($data, $numeric_prefix = "",
$arg_separator = '&', $encoding_type = PHP_QUERY_RFC3986);
$signature = hash_hmac('sha256', $queryString, $paymentApiKey, false);;
if ($signature == $kashierSignature) {
// do some actions
echo 'valid signature';
http_response_code();
} else {
echo 'invalid signature';
die();
}
}
?>using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Web;
using System.Web.Mvc;
public class SignatureValidator
{
public bool ValidateSignature(string requestBody, string receivedSignature, string secretKey)
{
string path = "";
using (JsonDocument document = JsonDocument.Parse(requestBody))
{
JsonElement data = document.RootElement.GetProperty("data");
List<string> signatureKeys = data.GetProperty("signatureKeys")
.EnumerateArray()
.Select(key => key.GetString())
.OrderBy(key => key, StringComparer.Ordinal)
.ToList();
foreach (string key in signatureKeys)
{
string value = data.GetProperty(key).ToString();
path = path + "&" + key + "=" + Uri.EscapeDataString(value);
}
}
string message = path.Substring(1);
System.Text.ASCIIEncoding encoding = new System.Text.ASCIIEncoding();
byte[] keyByte = encoding.GetBytes(secretKey);
byte[] messageBytes = encoding.GetBytes(message);
string computedSignature;
using (HMACSHA256 hmacsha256 = new HMACSHA256(keyByte))
{
byte[] hashmessage = hmacsha256.ComputeHash(messageBytes);
computedSignature = BitConverter.ToString(hashmessage).Replace("-", "").ToLowerInvariant();
}
return receivedSignature != null
&& receivedSignature.Equals(computedSignature, StringComparison.OrdinalIgnoreCase);
}
}
public class WebhookController : Controller
{
[HttpPost]
public ActionResult ReceiveWebhook()
{
string requestBody = string.Empty;
using (var reader = new System.IO.StreamReader(Request.InputStream))
{
requestBody = reader.ReadToEnd();
}
string receivedSignature = Request.Headers["x-kashier-signature"];
string secretKey = "YOUR_API_KEY";
SignatureValidator validator = new SignatureValidator();
bool isValid = validator.ValidateSignature(requestBody, receivedSignature, secretKey);
if (isValid)
{
// Perform necessary actions
return Json(new { message = "Valid signature" }, JsonRequestBehavior.AllowGet);
}
else
{
return Json(new { message = "Invalid signature" }, JsonRequestBehavior.AllowGet);
}
}
}Idempotency
Kashier de-dupes delivery on {transactionId}::{webhookUrl}::{status}, but the same transactionId and status can still reach you more than once — retries, races, and replays all produce repeats. Design the handler for it:
- Key your processing on
transactionId+status, not on receipt of the request. - Return
200fast, before any downstream work. - Return
409for an event you have already processed. Kashier counts409as delivered and stops retrying. - Never treat a repeat as a second payment, refund, or fulfilment.
Order-level replays are visible in the payload: a repeated attempt on an already-paid order carries the ORDER_PAID_BEFORE code, and a replayed notification arrives as event: "idempotency". Reconcile both against the order you already have rather than creating a new one.