KashierDevelopersKashier Developers
Webhooks

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 localhost or 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 minutes10 minutes30 minutes1 hour2 hours4 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&currency=EGP&kashierOrderId=9ad06b17-755b-4e21-9774-aff3e2726ac9&merchantOrderId=1653481557813&method=card&orderReference=TEST-ORD-38855&status=SUCCESS&transactionId=TX-249893963&transactionResponseCode=00

Hash 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:

KeyValueURL-encoded value
amount11
channelonline | e-commerceonline%20%7C%20e-commerce
currencyEGPEGP
kashierOrderId9ad06b17-755b-4e21-9774-aff3e2726ac99ad06b17-755b-4e21-9774-aff3e2726ac9
merchantOrderId16534815578131653481557813
methodcardcard
orderReferenceTEST-ORD-38855TEST-ORD-38855
statusSUCCESSSUCCESS
transactionIdTX-249893963TX-249893963
transactionResponseCode0000

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&currency=EGP&kashierOrderId=9ad06b17-755b-4e21-9774-aff3e2726ac9&merchantOrderId=1653481557813&method=card&orderReference=TEST-ORD-38855&status=SUCCESS&transactionId=TX-249893963&transactionResponseCode=00

5. 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:

9610477b2255b2a8ef84fd89adfaa5f1305ff9c20324205851890f1ea03109f4

6. 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 200 fast, before any downstream work.
  • Return 409 for an event you have already processed. Kashier counts 409 as 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.

On this page