Paymob uses the Unified Intention API for hosted checkout. The adapter lives in @paykernel/core as paymobGateway (BuiltInGatewayName "paymob"). This page documents PayKernel’s mapping. Upstream: Paymob developers.
Amounts are Money. Conversion uses ISO 4217 exponents via getCurrencyExponent (optional currencyExponentOverrides). Example: money("20.125", "OMR") → 20125 (×1000). Confirm OMR scaling with your Paymob Oman account; override only after Paymob confirms.
Configuration
import { createPaymentClient, paymobGateway } from "@paykernel/core";
const client = createPaymentClient({
gateways: {
paymob: paymobGateway({
secretKey: process.env.PAYMOB_SECRET_KEY!,
publicKey: process.env.PAYMOB_PUBLIC_KEY!,
hmacSecret: process.env.PAYMOB_HMAC_SECRET!,
integrationId: 123456,
authIntegrationId: 456789, // required for capture: false unless per-request override
region: "ksa", // default ksa
timeoutMs: 30000,
// idempotencyStore: redisBackedPaymobIdempotencyStore, // required for capture/refund/void
}),
},
defaultGateway: "paymob",
});Factory requires secretKey or legacy apiKey as a non-empty string. Unified Intention checkout wants secretKey + publicKey. hmacSecret is required in production webhook handling (constructor warns if secretKey is set without it — verification fails closed until configured).
publicKey launches Unified Checkout; it is not used for capture/refund/void or HMAC verify.
allowUnverifiedWebhooks: true is for local/test only (NODE_ENV=test / development or APP_ENV=local). The SDK refuses unverified webhooks when the environment is production or unidentified.
Regions
Default is ksa. Egypt merchants must set region: "eg" or an explicit baseUrl (e.g. https://accept.paymob.com); otherwise requests go to the KSA host.
| Region | Base URL | Notes |
|---|---|---|
ksa (default) |
https://ksa.paymob.com |
Default when region and baseUrl are omitted |
eg |
https://accept.paymob.com |
Required for Egypt accounts |
pk |
https://pakistan.paymob.com |
Experimental / unofficial — prefer explicit baseUrl |
om |
https://oman.paymob.com |
Confirm OMR exponent with Paymob |
ae |
https://uae.paymob.com |
baseUrl overrides region.
Create payment
import { money } from "@paykernel/core";
const result = await client.createPayment(
{
amount: money("100", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/payment-result",
orderId: "order_123",
metadata: {
paymentId: "payment_123",
email: "customer@example.com",
firstName: "Mohammed",
lastName: "Ali",
phone: "+966500000000",
},
},
"paymob",
);
if (result.redirectUrl) {
redirect(result.redirectUrl);
} else if (result.outcome === "indeterminate" || result.reconciliationRequired) {
// Do not createPayment again.
}Create gatewayId is the Paymob intention ID (often pi_...). nextAction exposes checkout URL, intention ID, client secret, and payment keys.
Capture, refund, void, and inquiry require the numeric Paymob transaction ID from a verified processed webhook (obj.id) or the dashboard — not the intention ID. Passing pi_... or any non-numeric value is rejected before the SDK calls Paymob.
Deprecated iframe flow: gatewayId is a non-numeric legacy:… handle; ecommerce order id is on orderId / nextAction.orderId only. Never pass create gatewayId from iframe checkout into refund/capture/void/inquiry.
There is no returnUrl on PaymobCreatePaymentParams. Optional callbackUrl is sent as both Intention notification_url and redirection_url. TOKEN (saved-card) server callbacks typically go to the dashboard Integration Transaction Processed Callback, not that URL.
Auth / capture dual model
createPayment with capture: false:
- Resolves
payment_methodsfromauthIntegrationIdwhen no per-requestpaymobIntegrationId/paymobPaymentMethodsoverride is supplied. SaleintegrationIdis not a silent fallback. - Sets
is_auth: trueandpayment_type: "AUTH"on the Intention body.
Without authIntegrationId and without a per-request override, the SDK rejects the request.
Capture, void, refund
import { money } from "@paykernel/core";
await client.capturePayment(
{
gatewayPaymentId: "123456789", // numeric transaction ID
amount: money("100", "SAR"),
currency: "SAR",
idempotencyKey: "capture-order-123",
},
"paymob",
);
await client.voidPayment(
{
gatewayPaymentId: "123456789",
idempotencyKey: "void-order-123",
},
"paymob",
);
await client.refundPayment(
{
gatewayPaymentId: "123456789",
amount: money("50", "SAR"),
currency: "SAR",
idempotencyKey: "refund-order-123",
},
"paymob",
);If amount is omitted, the SDK inquires first and sends remaining capturable / refundable. Explicit amount still inquires to verify currency and remaining balance. reason on refund is ignored.
result.gatewayId on capture is always the parent transaction id you passed in. A distinct child capture id is dual-written on captureId / references.relatedIds.captureId only.
Pending capture/refund/void (pending: true) → status: "pending" / outcome: "requires_action". Pending capture omits capturedAmount; pending refund omits totalRefunded. Do not treat that as completed + totalRefunded: 0.
Refund/capture of unpaid sales is refused (InvalidRequestError) before the mutating POST when inquiry is pending: true or success: false without a positive captured_amount. Refund also refuses uncaptured authorizations (use void).
When inquiry reports terminal is_captured / is_refunded without a positive cumulative amount, remaining math is refused (fail-closed).
capturePayment success without a positive cumulative captured total maps to processing (not paid / not isPaidOutcome). When the provider omits captured_amount, the SDK estimates cumulative as inquiry prior + this request amount — it does not treat response amount_cents as this-op.
Fence: network / Paymob 5xx or 408 / 409 / 425 / 429, abort after POST, or HTTP 200 with empty/malformed body marks the key unknown and blocks automatic replay. 408/409/425/429 is not a definite reject. Reconcile via processed callback, inquiry, or dashboard before a new mutation.
Get payment
const payment = await client.getPayment(
{ gatewayPaymentId: "123456789" },
"paymob",
);Inquiry empty / non-JSON HTTP 200 throws GatewayApiError — not declined. Do not map that to failed (that would mark a captured payment failed). Identified inquiry with missing success maps processing / requires_action — never failed / declined. Explicit success: false still maps failed.
getPayment is_captured (or signed is_capture) without a positive cumulative captured_amount → processing (not paid). Prefer isPaidOutcome(result) after inquiry.
Auth holds dual-write outcome: "succeeded" (hold placed) but are not paid-like. Partial captures dual-write outcome: "requires_action" with status partially_captured.
Webhooks
Pass the body and HMAC (query param, body field, or payload.hmac). Parsed objects are fine — HMAC is over selected fields, not the raw HTTP body. Still do not mutate fields before verifying.
const hmac = req.query.hmac ?? req.body.hmac;
const event = await client.handleWebhook("paymob", req.body, hmac);
// verifies only — never fulfill hereHMAC covers is_auth, is_capture, is_refunded, is_voided, success, pending, amount_cents, error_occured, has_parent_transaction — not is_captured, captured_amount, or refunded_amount_cents. After verify the SDK strips unsigned money-total slots.
Consequences:
- Auth-only (
is_auth+ notis_capture) staysauthorizedeven if the payload injectsis_captured. - Signed
is_capture+ success without trusted cumulativecaptured_amount→processing(notpaid/ notcapture.completed);amountomitted. - Signed
is_refunded: true→refund_completed(incomplete money) — not fullrefunded. Inquire for totals. - HMAC-covered
error_occured: true→failed/payment.failed. - TOKEN callbacks →
setup_completed.paymentIdis undefined.gatewayPaymentIdis HMAC-coveredorder_id(never unsignednext_payment_intention).
Never fulfill in onWebhookVerified. After inbox claim, fulfill only on rematched payment.succeeded / capture.completed and payment.status === "paid", bound to gatewayPaymentId.