@paykernel/gateway-myfatoorah is a portable extra adapter (GatewayAdapter<"myfatoorah", MyFatoorahGateway>) you pass into createPaymentClient. It is not a BuiltInGatewayName ("moyasar" | "paypal" | "paymob" | "stripe"). Core does not import this package. Current version is 1.0.2 (MYFATOORAH_ADAPTER_VERSION). Published on npm as @paykernel/gateway-myfatoorah. Core does not import this package.
Runtime dependency is @paykernel/core only. Manifest apiVersion is "V3". Surface: V3 hosted payments (POST /v3/payments), V2 inquiry/refund (GetPaymentStatus, MakeRefund, GetRefundStatus), Webhook V2 signatures. gatewayId is the InvoiceId. PaymentId (when present) rides references.relatedIds.paymentId. Secrets stay closed over by myfatoorahGateway — they are never copied onto the gateway context or manifest.
Install
Package name @paykernel/gateway-myfatoorah, export "." only (./dist/index.js). Version 1.0.2. Published on npm — bun add @paykernel/gateway-tap @paykernel/core works.
bun add @paykernel/gateway-myfatoorah @paykernel/corePublic exports
From @paykernel/gateway-myfatoorah (packages/gateway-myfatoorah/src/index.ts):
| Export | Kind |
|---|---|
myfatoorahGateway |
factory → GatewayAdapter<"myfatoorah", MyFatoorahGateway> |
MyFatoorahGateway |
class (name: "myfatoorah") |
MYFATOORAH_ADAPTER_VERSION |
"1.0.2" |
MYFATOORAH_CAPABILITIES |
frozen capability map |
MyFatoorahConfig |
config type |
MyFatoorahCountry |
"KWT" | "SAU" | "ARE" | "QAT" | "BHR" | "OMN" | "JOR" | "EGY" |
MyFatoorahCreatePaymentParams |
create payload (myfatoorah* fields) |
MyFatoorahCustomerInput |
optional customer block |
MyFatoorahGetPaymentParams |
get payload (myfatoorahKeyType) |
MyFatoorahRefundParams |
refund payload |
MyFatoorahPaymentMethod |
"INVOICE" | "CARD" | "APPLE_PAY" | "GOOGLE_PAY" | "KNET" |
MyFatoorahGateway methods: createPayment, capturePayment (throws), refundPayment, getPayment, verifyWebhook, parseWebhookEvent. Prefer PaymentClient.handleWebhook("myfatoorah", …) over calling verify/parse yourself. See /guides/webhooks.
There is no public isMyFatoorahPaidTerminal export. Treat Paid as terminal with status === "paid" / isPaidOutcome at the inbox layer.
Quickstart
import { createPaymentClient, isPaidOutcome, money } from "@paykernel/core";
import { myfatoorahGateway } from "@paykernel/gateway-myfatoorah";
const payments = createPaymentClient({
gateways: {
myfatoorah: myfatoorahGateway({
apiToken: process.env.MYFATOORAH_API_TOKEN!, // sandbox: portal API token
country: "KWT",
// live: true, // use the live host for the country
webhookSecret: process.env.MYFATOORAH_WEBHOOK_SECRET, // portal secure key, NOT the API token
webhookUrl: "https://merchant.example/webhooks/myfatoorah",
// defaultPaymentMethod: "KNET", // optional; omitted → all enabled methods
}),
},
defaultGateway: "myfatoorah",
});
const result = await payments.createPayment({
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://merchant.example/return",
idempotencyKey: crypto.randomUUID(), // required — adapter does not mint a UUID
orderId: "ord_01", // required outside KWT/SAU (CustomerReference replay)
// myfatoorahCustomer: { name: "Ada", email: "ada@example.com" }, // optional
});
if (result.outcome === "requires_action" && result.redirectUrl) {
// PaymentURL hosted page — persist it before ACK, redirect the customer, do not fulfill
} else if (isPaidOutcome(result) && result.status === "paid") {
// PaymentCompleted: true (or settled inquiry) — still verify via webhook + inbox claim
} else if (result.outcome === "indeterminate" || result.reconciliationRequired) {
// existing InvoiceId when known — getPayment; do not create a second invoice
} else {
// failed / cancelled — do not mark the order paid
}success: true is not the fulfillment signal. Use isPaidOutcome / outcome (/guides/outcomes). Redirect return URLs are browser callbacks, not webhooks — confirm settlement with GetPaymentStatus / the signed webhook.
With defaultGateway: "myfatoorah", or a MYFATOORAH-only gateways map without defaultGateway, payments.createPayment({ myfatoorahCustomer, … }) is typed as MyFatoorahCreatePaymentParams. Core does not add myfatoorah* to CreatePaymentParams.
Config (MyFatoorahConfig)
| Field | Required | Notes |
|---|---|---|
apiToken |
yes | Portal API token (Bearer …). Trimmed; whitespace-only throws. Never the webhook HMAC key. |
country |
yes | KWT, SAU, ARE, QAT, BHR, OMN, JOR, EGY. Selects the live host when live: true. |
live |
no | Default false (sandbox https://apitest.myfatoorah.com). Must be a boolean when set. |
webhookSecret |
no | Webhook V2 HMAC secret (portal secure key). verifyWebhook fails closed (false) when omitted. |
timeoutMs |
no | Finite > 0. Default 30000. |
webhookUrl |
no | HTTPS IntegrationUrls.Webhook for create. Not localhost / private hosts (MyFatoorah rejects them). No URL credentials. |
defaultPaymentMethod |
no | V3 PaymentMethod. Omitted: all enabled methods on the hosted page. |
Auth header is Authorization: Bearer <apiToken>. Sandbox country host is ignored. Live hosts:
| Country | Live API base | Portal base currency |
|---|---|---|
KWT |
https://api.myfatoorah.com |
KWD |
BHR |
https://api.myfatoorah.com |
BHD |
JOR |
https://api.myfatoorah.com |
JOD |
OMN |
https://api.myfatoorah.com |
OMR |
ARE |
https://api-ae.myfatoorah.com |
AED |
SAU |
https://api-sa.myfatoorah.com |
SAR |
QAT |
https://api-qa.myfatoorah.com |
QAR |
EGY |
https://api-eg.myfatoorah.com |
EGP |
Sandbox (live: false) inquiry/refund base currency is always KWD, regardless of country. ISO lookups: MyFatoorah ISO lookups.
Capabilities
Claimed: payments, immediateCapture, refunds, partialRefunds, tokenization (via myfatoorahToken / myfatoorahSessionId → SourceOfFund.Token / SessionId; direct SourceOfFund.Card PAN is still rejected).
Unclaimed (fail-closed): authorization, partialCapture, voids, hostedCheckout, customers, paymentMethods, marketplaceSplits, disputes, paymentLinks, providerRecurring.
PaymentURL is a redirect, not a Checkout Session product. Authorize / capture / void exist on MyFatoorah but need portal enablement and are not implemented here. Query with gateway.supports("tokenization") etc. See /reference/capabilities.
Charges
createPayment (sale only) calls POST /v3/payments.
Required: amount (> 0), currency (3-letter), HTTPS callbackUrl, and a caller idempotencyKey. Outside KWT/SAU, orderId or myfatoorahCustomer.reference is also required.
Optional: myfatoorahCustomer (all fields optional), myfatoorahPaymentMethod (CARD | APPLE_PAY | GOOGLE_PAY | KNET | INVOICE; default: config defaultPaymentMethod; both omitted → all enabled methods), myfatoorahDisplayPaymentMethods (lowercase tokens card, knet, mada, benefit, stcpay, qpay, omannet, …), myfatoorahLanguage (EN | AR), myfatoorahWebhookUrl (HTTPS), myfatoorahSessionId / myfatoorahToken (mutually exclusive → SourceOfFund). Legacy V2 methods BENEFIT / STC_PAY / MADA / QPAY / OMANNET are DisplayPaymentMethods tokens, not PaymentMethod.
Never sent: OperationType (defaults PAY), SaveCardOptions, raw SourceOfFund.Card blobs (rejected before any fetch). offSession: true throws OperationNotSupportedError (paymentMethods). metadata keys must be UDF1..UDF5 with string values (other keys/types throw InvalidRequestError).
Customer.Reference is myfatoorahCustomer.reference when provided, otherwise orderId. orderId is also sent as Order.ExternalIdentifier. customerId is not Order.ExternalIdentifier and does not become Customer.Reference. Webhook Invoice.ExternalIdentifier (merchant paymentId) is that reference.
Response mapping:
PaymentCompleted: truealone →succeeded/paidwith no redirect, even without nestedInvoice.Status=PAIDorTransaction.Status=SUCCESS.PaymentURLon a paid completion is the Result URL, not checkout — ignored when paid.- Nested PAID/SUCCESS without
PaymentCompleted: trueis not treated as paid (hosted create isPaymentCompleted: false+PaymentURL). - Non-empty
PaymentURLwhen not paid →requires_action/pending+redirectUrl. - Mutating 2xx with neither →
indeterminate(afterProviderSubmit).
Paid create amount is currency-aware: ValueInPayCurrency / ValueInDisplayCurrency / ValueInBaseCurrency is chosen to match the request currency; mismatched currency is omitted rather than published as the wrong amount.
Pending PaymentURL cannot be recovered
GetPaymentStatus has no PaymentURL. GET /v3/invoices/{id} returns "No invoices match this InvoiceId" when there are no transactions. Persist PaymentURL from createPayment before ACK. After a crash, query status but the redirect is lost — to let the customer pay, create a new invoice with a new orderId / CustomerReference. Do not retry createPayment with the same reference expecting a recovered URL.
getPayment
POST /v2/GetPaymentStatus (KeyType InvoiceId by default; myfatoorahKeyType: "PaymentId" for PaymentId lookups). Long gatewayPaymentId (≥14 digits) with InvoiceId throws with a hint to use PaymentId. Uses InvoiceTransactions (legacy Transactions fallback); picks the last SUCCESS / SUCCSS transaction. Never use GET /v3/invoices/{id} for unpaid invoices.
A pending invoice stays pending even when the latest transaction failed or is AUTHORIZE (the customer can retry the same invoice; AUTHORIZE is not fulfilled until capture — which this adapter does not implement). PAID without a success transaction stays pending. Inquiry POSTs are not money-mutating: 5xx / empty / HTML on GetPaymentStatus / GetRefundStatus never becomes afterProviderSubmit / indeterminate.
Refunds
refundPayment requires idempotencyKey and gatewayPaymentId = the InvoiceId (digits) by default. Long (≥14 digit) ids look like a callback PaymentId and are rejected unless you pass myfatoorahKeyType: "PaymentId". GetRefundStatus is always keyed by the resolved InvoiceId.
Flow:
POST /v2/GetRefundStatus(KeyType: InvoiceId). OfficialData.RefundStatusResult[](legacyRefundsstill accepted).2xxwithData: nullor official not-found is empty history.- If any entry has
ExternalIdentifierexactly equal to thisidempotencyKey, that refund is returned immediately — MakeRefund is never re-POSTed, even whenremaining > 0. POST /v2/GetPaymentStatusfor invoice value + currency.InvoiceValueis base currency.remaining = InvoiceValue − (Refunded/Pending refunds). Canceled refunds do not count. Remaining0with no matching key →InvalidRequestError(never re-POST the invoice amount).POST /v2/MakeRefundwithServiceChargeOnCustomer: false,Amountin account base currency,ExternalIdentifier: idempotencyKey, optionalComment(myfatoorahCommentor trimmedreason, max 500 chars).
MakeRefund acceptance is never settlement: the result is pending / refund_pending. Fulfill refunds only after REFUND_STATUS_CHANGED with Refund.Status=REFUNDED (or GetRefundStatus Refunded). currency must match the base currency (RefundStatusResult[].BaseCurrency when present). First refund with empty history: sandbox (live: false) base is always KWD; live infers base from country (KWT→KWD, SAU→SAR, …). A pay-currency currency (e.g. SAR after BaseCurrency: KWD) throws before MakeRefund.
The Idempotency-Key header is only honored in KWT/SAU. A provider “header not supported” validation error retries once without the header (retry: false). The first MakeRefund does not auto-retry 429. Conflicts such as “key already used with a different request” are not retried. MakeRefund is not auto-retried after submit.
Money
MyFatoorah amounts are major units with ISO decimal places, not integer minor units. Internals use @paykernel/core Money / bigint (rounding: "reject"). Outbound Order.Amount (create) and Amount (MakeRefund) are ISO-padded JSON number tokens (10.50 SAR, 1.200 KWD) — never JSON strings and never amount * 100. Zero outbound create/refund amounts throw InvalidRequestError. Grouping commas (12,345.000) are stripped. KD / SR and dotted variants (K.D. / S.R.) alias to KWD / SAR.
Which currency each surface publishes:
| Surface | Currency published |
|---|---|
createPayment (paid) |
pay when available and it matches the request currency; otherwise omit rather than mix |
getPayment |
pay (PaidCurrency + value) when available; else InvoiceValue + Currency (base). Never mix InvoiceValue (base) with PaidCurrency (pay). |
| Webhooks | base (ValueInBaseCurrency). A SAR checkout can webhook as KWD. |
refundPayment / MakeRefund |
base only |
Do not compare webhook amount to the create/getPayment pay amount for fulfillment — use status === "paid" / isPaidOutcome and getPayment. See /guides/money and GetPaymentStatus.
Idempotency and recovery
createPayment requires a caller idempotencyKey, sent as the Idempotency-Key header only in KWT/SAU (MyFatoorah idempotency). The header is cached 250 minutes — after expiry the same key creates a new invoice even in KWT/SAU. Also pass a stable orderId even in KWT/SAU and retain the key server-side until settlement.
Outside KWT/SAU the header is omitted. Create first looks up CustomerReference via POST /v2/GetPaymentStatus (not unique — returns the last invoice per payment inquiry):
| Existing invoice | Adapter |
|---|---|
Paid + amount+currency match |
reuse (no second create) |
Pending / Refunded / PartiallyRefunded / paid-mismatch / missing amount |
indeterminate with existing InvoiceId when known — no second create |
Canceled / Failed (including Expired → failed) |
allows a new POST /v3/payments |
Lookup 429 |
RateLimitError with Retry-After — back off; do not mint a second invoice |
Other lookup 5xx / non-404 4xx / empty success Data |
indeterminate with existing InvoiceId when known |
KWT/SAU may do a best-effort CustomerReference preflight for 250-minute hardening: Paid+match reuses, otherwise falls through to POST without blocking. Create auto-retries (withRetry) when KWT/SAU (header dedupes, including 429). Outside those countries only connect-fail retries run; 429 after /v3/payments is not retried.
Webhooks
Webhook V2 only. Header: MyFatoorah-Signature (case-insensitive). Signature = Base64(HMAC-SHA256 over the canonical string) with a separate webhookSecret — never the API token. Raw string bodies (e.g. Workers request.text()) are JSON-parsed before verify. Unparseable strings fail closed (false). @paykernel/integration-http profile: header MyFatoorah-Signature. Official refund Data model: Webhook V2 refund. Paid-terminal rule: updating payment status.
Canonical strings use fixed field order — never sort keys. Null / missing fields become empty strings.
Payment (PAYMENT_STATUS_CHANGED / Event.Code 1):
Invoice.Id={id},Invoice.Status={status},Transaction.Status={status},Transaction.PaymentId={paymentId},Invoice.ExternalIdentifier={ext}Refund (REFUND_STATUS_CHANGED / Event.Code 2) — official siblings { Refund, Amount, ReferencedInvoice } (legacy nested under Refund still accepted):
Refund.Id={id},Refund.Status={status},Amount.ValueInBaseCurrency={amount},ReferencedInvoice.Id={id}verifyWebhook fails closed (false) when: webhookSecret missing, header missing, unsupported Event.Name (present and unknown — does not fall back to Event.Code), unsupported Event.Code when Name is empty (code 1/2 accepted as number or string), unparseable payload, invalid Base64, or byte mismatch (constant-time). Codes 3–7 (BALANCE_TRANSFERRED, …) are unsupported. Event.Name is authoritative.
Normalized payment event: gatewayPaymentId = Invoice.Id; merchant paymentId = Invoice.ExternalIdentifier (never UserDefinedField); Invoice.Status=PAID is authoritative (paid regardless of Transaction.Status — KNET duplicates must not un-fulfill). AUTHORIZE → pending. Unknown Invoice.Status with Transaction.Status=SUCCESS stays failed (never paid). Transaction.PaymentId rides relatedIds.paymentId. Webhook amount is base currency.
Normalized refund event: gatewayPaymentId = ReferencedInvoice.Id; gatewayObjectId = Refund.Id; paymentId = ReferencedInvoice.ExternalIdentifier only (never the refund’s own ExternalIdentifier idempotency key). REFUNDED → refunded; CANCELED → refund_failed.
const rawBody = await request.text(); // do not request.json() before verify
const signature = request.headers.get("MyFatoorah-Signature") ?? undefined;
const webhookEvent = await payments.handleWebhook("myfatoorah", rawBody, signature);
// handleWebhook verifies and normalizes only. It does not claim, lease, or set HTTP status.Status mapping
Unknown provider values fail closed to failed.
Invoice (Invoice.Status / InvoiceStatus):
| MyFatoorah | Payment status |
|---|---|
PAID / Paid |
paid |
PENDING |
pending |
CANCELED / CANCELLED |
cancelled |
REFUNDED |
refunded |
PARTIALLY_REFUNDED (hyphen/space variants) |
partially_refunded |
anything else (including Expired) |
failed |
GetPaymentStatus only returns Pending / Paid / Canceled (plus Expired → failed). REFUNDED / PARTIALLY_REFUNDED are observed via refund webhooks / GetRefundStatus, not getPayment. getPayment maps PENDING → pending regardless of the last transaction (FAILED / AUTHORIZE / INPROGRESS stay pending because the invoice is retryable). Only PAID + a SUCCESS/SUCCSS transaction becomes paid.
Transaction evidence: SUCCESS / SUCCSS (official V2 typo) = success; FAILED = failed; CANCELED/CANCELLED = cancelled; AUTHORIZE = pending (until auth/capture is implemented); INPROGRESS / IN PROGRESS / IN_PROGRESS = pending; anything else = unknown.
Refund (Refund.Status / RefundStatus):
| MyFatoorah | Refund status | Payment-domain |
|---|---|---|
REFUNDED |
completed |
refunded |
PENDING |
pending |
refund_pending |
CANCELED / CANCELLED |
failed |
refund_failed |
| anything else | failed |
refund_failed |
Runtime
paymentsSdk.portable: true. Production sources use injected fetch and core portable crypto (hmacSha256, bytesToBase64, timingSafeEqualBytes). No node: / bun: / cloudflare: imports. Supported: Node ≥ 18, Bun ≥ 1.0, Deno, Cloudflare Workers (Web APIs). Pass runtime on createPaymentClient to override fetch / clock / UUID. See /guides/runtime.
Production notes
- Configure
webhookSecretseparately fromapiToken. Verify, then inbox-claim, then fulfill. Enforce Paid terminal at the inbox (status === "paid"). - Never fulfill
requires_action/redirectUrlresults. PersistPaymentURLbefore ACK. - HTTPS
callbackUrlandwebhookUrl. MyFatoorah rejects localhost. - No raw cards. Embedded sessions use
myfatoorahSessionId; saved cards usemyfatoorahToken. - Always pass
idempotencyKeyon create/refund andorderId(ormyfatoorahCustomer.reference) for replay safety. - MakeRefund amount is account base currency. Sandbox base is always
KWD. - Caller-side lock for concurrent
refundPaymentoutside KWT/SAU. getPaymentcannot observe refunds — pollGetRefundStatus/ wait forREFUND_STATUS_CHANGED.