Kashier
npm i @sarrafjs/kashierBuilding an app? Start with the step-by-step guide: Accept payments with Kashier (Express and Next.js).
Credentials
Section titled “Credentials”| Option | Used for |
|---|---|
merchantId |
Your MID (MID-xxx-xxx) |
apiKey |
Payment API key: signs the hosted-page hash and verifies redirect and webhook signatures |
secretKey |
Server APIs: order lookup, refunds, Payment Sessions (beta) |
mode |
"test" or "live". Test and live MIDs and keys are different |
amountFormat |
"fixed" (250.00, default) or "trimmed" (250) |
currencies |
Currencies your MID accepts. Default ["EGP", "USD"] |
urls |
Override the checkout, api and fep base URLs (proxies, tests). Defaults are in KASHIER_URLS |
Capabilities
Section titled “Capabilities”| Feature | Without secretKey |
With secretKey |
|---|---|---|
| Hosted checkout, webhooks, redirect verification | ✅ supported | ✅ supported |
payments.get / reconcile, refunds, voids |
unsupported | ✅ supported |
Methods card, wallet |
✅ supported | ✅ supported |
Methods installments, bnpl (valU) |
beta | beta |
Payment Sessions (flow: "session") |
unsupported | beta |
Capture (payments.capture) |
unsupported | unsupported |
Beta features need new Sarraf({ allowBeta: true, … }).
Checkout (hosted payment page)
Section titled “Checkout (hosted payment page)”Sarraf builds a signed URL to https://checkout.kashier.io/:
hash = HMAC-SHA256(apiKey, "/?payment=" + merchantId + "." + orderId + "." + amount + "." + currency)✅ Confirmed against the Kashier sandbox: a checkout signed this way was paid successfully. The hosted page does not check the hash when it loads, only when the customer presses Pay, so a wrong hash shows up as a failed payment, not a broken page.
The same amount string goes into the hash and the URL. One mismatched character gives “invalid hash” on the hosted page. orderId (your reference) must be unique per attempt.
methods maps to allowedMethods: card, wallet, installments → bank_installments, bnpl → valu (the last two are beta).
Checkout options
Section titled “Checkout options”| Input | Sent to Kashier as |
|---|---|
reference |
orderId (it comes back as merchantOrderId in webhooks). Unique per attempt |
returnUrl / webhookUrl |
merchantRedirect / serverWebhook |
locale |
display (en by default, or ar) |
metadata |
metaData (JSON) |
providerOptions.allowedMethods |
Raw allowedMethods, overriding methods |
providerOptions.extraParams |
Extra query params, e.g. brandColor |
providerOptions.customerReference |
customerReference, also appended to the hash (saved cards). ⚠️ unconfirmed |
checkout.payment.providerPaymentId is your reference at checkout time. Kashier’s own order id arrives in the webhook (kashierOrderId) and is what refunds use.
Payment Sessions (beta)
Section titled “Payment Sessions (beta)”await pay.checkout.create({ provider: "kashier", /* … */ providerOptions: { flow: "session" } });This uses POST /v3/payment/sessions with both the Authorization: <secretKey> and api-key: <apiKey> headers.
Webhooks
Section titled “Webhooks”Kashier POSTs { event, data } with the signature in the x-kashier-signature header.
Signature recipe
Section titled “Signature recipe”- Take
data.signatureKeysand sort them. - Pick those keys from
data. - Serialize exactly like the
query-stringlibrary’sstringify: strict RFC 3986 encoding (*→%2A,(→%28, …), arrays as repeated keys, andnullas a bare key. - HMAC-SHA256 with the Payment API key, hex.
✅ Confirmed against a real sandbox webhook (fixture). Kashier signs amount, channel, currency, kashierOrderId, merchantOrderId, method, orderReference, status, transactionId, transactionResponseCode; channel is online | e-commerce, so the signed string contains channel=online%20%7C%20e-commerce. amount arrives as a JSON number in major units (10) and is signed as-is.
Plain encodeURIComponent does not encode * ( ) ' !, and that is the #1 reported integration bug. Sarraf uses query-string itself, and its fixtures cover special characters and Arabic text.
Redirect verification
Section titled “Redirect verification”merchantRedirect gets query parameters plus signature. Sarraf signs every parameter except signature and mode, in the order received, as key=value pairs joined by &, using HMAC-SHA256 with the API key. Use this for the thank-you page only.
✅ Confirmed against a real sandbox redirect (fixture). Kashier sends paymentStatus, cardDataToken, maskedCard, merchantOrderId, orderId, cardBrand, orderReference, transactionId, amount, currency, mode, signature. Note that amount comes back trimmed (10) even when the checkout sent 10.00.
Status mapping
Section titled “Status mapping”| Kashier | Sarraf |
|---|---|
pay + SUCCESS |
succeeded ✅ |
PENDING |
pending |
FAILURE / FAILED |
failed |
authorize + SUCCESS |
requires_action |
refund + SUCCESS |
partially_refunded / refunded ✅ (totals from the order lookup) |
void + SUCCESS |
canceled |
✅ = confirmed with a recorded sandbox fixture. The other rows still need recorded failure, authorize/capture and void events.
Order lookup
Section titled “Order lookup”Needs secretKey. Look payments up by your reference:
const payment = await pay.payments.get({ provider: "kashier", reference: "order_8841" });✅ Confirmed in the sandbox: GET https://test-api.kashier.io/v3/payment/orders?search=<merchantOrderId>. Kashier’s search is a partial match on your reference, so Sarraf picks the order whose merchantOrderId matches exactly. It does not accept Kashier’s own order id, so payments.get({ providerPaymentId }) is rejected with INVALID_REQUEST.
A refunded order stays CAPTURED at Kashier; Sarraf derives partially_refunded / refunded from totalRefundedAmount against the order amount.
Refunds
Section titled “Refunds”Need secretKey. providerPaymentId is the Kashier order id (kashierOrderId in webhooks, the redirect’s orderId), not your reference.
// Partial refundawait pay.refunds.create({ provider: "kashier", providerPaymentId, amount: { value: 300, currency: "EGP" } });// Full refund of whatever is left: pass your reference tooawait pay.refunds.create({ provider: "kashier", providerPaymentId, reference: "order_8841" });✅ Partial and full refunds confirmed in the sandbox: PUT /v3/orders/:orderId with { apiOperation: "REFUND", reason, transaction: { amount } } and the secret key in Authorization. The refund outcome is response.result; response.status is the order’s new state (PARTIALLY_REFUNDED / REFUNDED).
Kashier’s docs call amount optional, but the sandbox answers HTTP 500 to a refund without one, and nothing is refunded. This was checked on fresh, never-refunded orders with all three documented shapes: transaction: {}, no transaction at all, and transaction: { targetTransactionId } pointing at the pay transaction. So for a full refund Sarraf looks the order up by reference and sends the remaining amount. Without reference it throws INVALID_REQUEST instead of guessing.
Refund webhooks
Section titled “Refund webhooks”✅ Confirmed in the sandbox: Kashier’s refund webhook carries only this refund’s amount, not the order total or earlier refunds. With secretKey configured, Sarraf looks the order up when a refund webhook arrives, so event.payment has the real totals (partially_refunded, amountRefunded). Without secretKey it logs a warning, and event.payment describes only the refund.
Needs secretKey. Cancels a payment before Kashier settles it, which is the same day only; after that, refund instead.
const payment = await pay.payments.cancel({ provider: "kashier", reference: "order_8841" });// payment.status === "canceled"✅ Confirmed in the sandbox (fixture). Sarraf looks the order up by reference, finds the successful pay transaction and sends PUT /v3/orders/:orderId with { apiOperation: "VOID", transaction: { targetTransactionId, amount } }. Kashier’s docs say a full void can omit transaction; Sarraf always sends both fields, because the same “optional” fields make refunds fail with HTTP 500. It then returns the order as Kashier reports it (CANCELLED → canceled). Kashier also sends a void webhook, which becomes payment.canceled.
A void that Kashier does not accept throws PAYMENT_DECLINED.
Testing helpers
Section titled “Testing helpers”@sarrafjs/kashier/testing builds Kashier payloads signed the way Kashier signs them:
import { kashierWebhookData, signKashierRedirect, signKashierWebhook } from "@sarrafjs/kashier/testing";
const webhook = signKashierWebhook("pay", kashierWebhookData({ merchantOrderId: "order_8841" }), apiKey);await pay.webhooks.verifyAndParse("kashier", webhook); // → payment.succeeded
const query = signKashierRedirect({ paymentStatus: "SUCCESS", merchantOrderId: "order_8841" }, apiKey);await pay.redirects.verify("kashier", query); // → authenticated: trueNot supported yet
Section titled “Not supported yet”- Authorize then capture (
capture: "unsupported"). Kashier’sauthorizewebhook maps torequires_action, but there is no capture call. - Kiosk / reference-code payments, payment links and invoices, subscriptions, and charging saved cards without the customer. Paymob covers those today.
- The live base URLs come from Kashier’s docs and haven’t been confirmed with a live account. If Kashier gives you different ones, override them with
urls.