Skip to content

Payments API ​

Mock, Moyasar and Paymob are selectable in Admin → Plugins → Payments (the checkout settings sheet). Moyasar and Paymob are the external gateways; Paymob is wired for its KSA region only and charges SAR only. Stripe and direct STC Pay adapters are retired.

All /payments/* routes are end_user only; other roles get 403 AUTH_FORBIDDEN.

Configuration ​

Owner (super_admin) settings apply without restart. Choose Mock, Moyasar or Paymob, Sandbox or Production per gateway, and whether to accept new payments. Moyasar profiles use matching pk_test_/sk_test_ or pk_live_/sk_live_ keys plus a separate webhook secret (16–256 characters). Copy the full keys, not masked IDs containing asterisks; newly generated secret keys are displayed only once. Blank key fields preserve encrypted saved values. Both profiles survive provider switches.

Paymob profiles use a public key (pk_test_/pk_live_, optionally region-prefixed such as sau_pk_test_…), a matching secret key (sk_…), the account API key and a numeric card integration ID. Apple Pay and STC Pay (wallet) integration IDs are optional; an empty string clears one. The HMAC secret (at least 16 characters; old dashboard → Settings → Account info) is optional: it adds a signature check to callbacks. The first setup of a sandbox needs only the public key, secret key and card integration ID; production also needs the API key (422 PAYMOB_CREDENTIALS_REQUIRED). A sandbox without an API key confirms payments from Paymob's callback (see webhooks), because Paymob offers no other way to look up a transaction. Keys are write-only; integration IDs are returned in status.

Settings are stored encrypted in platform_settings. The checkout-wide selection (provider, enabled) and the Moyasar keys stay in the original plugin.payment.moyasar row; Paymob keys live in plugin.payment.paymob. No schema change or data migration was needed. PAYMENT_PROVIDER=mock|moyasar|paymob is only the fallback before settings exist. Development defaults to enabled Mock; unconfigured production starts disabled. Enabling Mock when the API runs with NODE_ENV=production is rejected with 422 PAYMENT_MOCK_FORBIDDEN, including owner settings updates.

Credential rotation atomically saves a new encrypted generation (for Paymob, new keys or integration IDs start one). All prior API credential generations are retained indefinitely, with no automatic pruning. Original-generation lookup is preserved: in-flight payments reconcile using their saved original generation; legacy unversioned payments use the original retained credentials. Retained API credentials remain decryptable even when their associated webhook secrets are revoked. Retention cannot restore access to API keys revoked by the gateway. Old webhook secrets (Paymob: HMAC secrets) remain accepted for their environment until explicitly revoked. Credential versions are internal bookkeeping, not public API fields.

In the admin checkout settings sheet, pressing a gateway shows its environment switch, its key fields and its return URL. Each key field's hint says where the key lives in the gateway dashboard. The Moyasar webhook secret, the return URL and a Paymob callbacks note show, with a copy button, the callback addresses: from GET /admin/plugins/payment endpoints (moyasarWebhook, paymobWebhook, returnPage) when PUBLIC_API_URL pins them, else on the API address the admin is configured with (NEXT_PUBLIC_API_BASE_URL), else on the address the API reports the admin reached it on (endpointsPinned: false), with a warning when the address is local. For Paymob the sandbox view shows the public key, secret key, integration IDs and the optional HMAC secret (set it so sandbox callbacks must be signed); the API key (required) appears in the production view only. Paymob needs none of them entered in its dashboard. Use switches the live provider, and Accept new payments saves on its own. Test connection checks the shown gateway's saved keys with a read-only request, even when that gateway is not live (Paymob exchanges the API key for an auth token). Save edits first. Success does not prove publishable-key pairing, webhook delivery, wallet setup or end-to-end payment. A successful sandbox connection test is not evidence that a real payment has been verified.

Return destination and local testing ​

Callback addresses need no setup: each checkout sends Paymob its webhook (/webhooks/paymob) and return page (/payment-return) on the API address the customer's request reached, honoring X-Forwarded-Proto/X-Forwarded-Host behind a proxy. PUBLIC_API_URL, when set, pins that address instead. An address Paymob cannot reach (http:// or localhost) is left out of the intention, so the integration's dashboard Webhook URL and Redirect URL apply if set; for local testing use an HTTPS tunnel. Moyasar's SDK return page uses the same address; a custom HTTPS return URL overrides it for either gateway, and clearing it restores the default.

Public GET /payment-return is a bilingual neutral page. It ignores query parameters and does not confirm payment success or redirect to arbitrary destinations. Clients must retrieve verified status from the API. Mobile SDKs can detect the return URL in their authentication web view and close it; web checkout may use a separate window and confirm on return.

Mock works locally without credentials or tunnels. Gateway credential testing needs outbound internet; full sandbox webhook/return testing needs a public HTTPS API address or tunnel. Wallet merchant/domain setup is separate.

POST /payments/create-intent ​

Authenticated (session cookie or bearer token). Request: orderId (UUID), method (mada, visa, mastercard), idempotencyKey (8–128 characters). Ownership is derived from auth; amount/currency come from the order. Moyasar minimum is 100 minor units (1.00 SAR/USD). The order must be pending_payment; a draft order is refused with PAYMENT_ORDER_INVALID_STATUS.

Moyasar returns HTTP 201:

json
{
  "paymentId": "22222222-2222-4222-8222-222222222222",
  "status": "processing",
  "clientSecret": null,
  "gatewayTransactionId": "22222222-2222-4222-8222-222222222222",
  "environment": "sandbox",
  "checkout": {
    "publishableKey": "pk_test_EXAMPLE",
    "givenId": "22222222-2222-4222-8222-222222222222",
    "amount": 1000,
    "currency": "SAR",
    "callbackUrl": "https://api.example.com/payment-return",
    "methods": ["creditcard"],
    "supportedNetworks": ["visa", "mastercard"],
    "metadata": {
      "payment_id": "22222222-2222-4222-8222-222222222222",
      "order_id": "11111111-1111-4111-8111-111111111111",
      "user_id": "33333333-3333-4333-8333-333333333333"
    }
  }
}

Map givenId to Moyasar given_id (SDK givenID), and map the publishable key/callback to SDK settings. Preserve amount, currency, metadata and allowed networks. Send card/wallet credentials directly to Moyasar. Null clientSecret is expected; do not use Stripe confirmation.

Moyasar checkout objects may carry kind: "moyasar"; older saved rows have no kind.

Paymob returns HTTP 201 with the intention client secret in clientSecret and checkout.kind: "paymob":

json
{
  "checkout": {
    "kind": "paymob",
    "publicKey": "pk_test_EXAMPLE",
    "clientSecret": "EXAMPLE_INTENTION_CLIENT_SECRET",
    "checkoutUrl": "https://ksa.checkout.paymob.com/?publicKey=pk_test_EXAMPLE&clientSecret=EXAMPLE_INTENTION_CLIENT_SECRET",
    "intentionId": "EXAMPLE_INTENTION_ID",
    "amount": 1000,
    "currency": "SAR",
    "callbackUrl": "https://api.example.com/payment-return",
    "methods": ["card", "apple_pay", "stc_pay"],
    "metadata": { "payment_id": "…", "order_id": "…", "user_id": "…" }
  }
}

The API creates the intention with Paymob's Intention API (POST https://ksa.paymob.com/v1/intention/, Authorization: Token <secret key>): amount in halalas, special_reference = the Skyte payment ID, extras = payment/order/user IDs, non-PII placeholder billing_data, notification_url = PUBLIC_API_URL/webhooks/paymob, redirection_url = the return page, and a 3600-second expiry. The intention offers every saved integration (card plus optional Apple Pay and STC Pay), so methods lists those. The requested method must be one of them (422 PAYMENT_METHOD_UNAVAILABLE); non-SAR orders return 422 PAYMENT_CURRENCY_UNSUPPORTED and amounts under 1.00 return 422 PAYMENT_AMOUNT_UNSUPPORTED. Clients pass publicKey and clientSecret to the Paymob Flutter SDK (payWithPaymob), or open checkoutUrl in a WebView. Apple Pay works only through the SDK. The mobile app does not consume Paymob checkouts yet.

Mock returns completed status, deterministic mock transaction ID/client secret, and no SDK checkout. It marks the order paid and enqueues settlement; identity verification is still required for activation.

POST /payments/apple-pay ​

Authenticated. Send only orderId and idempotencyKey. Same response shape, with checkout.methods: ["applepay"] for Moyasar. The SDK sends the token directly to the gateway; do not send applePayToken to Skyte. With Paymob live, the Apple Pay integration ID must be saved.

POST /payments/stcpay ​

Authenticated. Send only orderId and idempotencyKey. Same response shape, with checkout.methods: ["stcpay"] and redirectUrl: null for Moyasar. The SDK collects mobile number and OTP directly. No separate STC Pay merchant settings exist; with Paymob live, the wallet (STC Pay) integration ID must be saved.

Store orders allow Visa/Mastercard and Apple Pay funded by those networks. Mada/STC Pay are physical-order only. The operator must be active and use internal payment mode. stripe and google_pay are unsupported runtime methods.

POST /payments/:id/confirm ​

Authenticated and owner-scoped. Supply the checkout payment UUID. The API synchronously fetches authoritative state from the gateway the payment was created with, using the original environment and credential generation, and verifies status, amount, net received/captured amount, currency, metadata and (for store orders) Visa/Mastercard network. Response: paymentId, status, environment. Webhooks use the same reconciliation service asynchronously through a worker. Never trust redirect status or amount. Retry confirmation after outages instead of minting another charge ID. For Paymob, the API gets an auth token (POST /api/auth/tokens with the API key) and inquires by merchant_order_id (the payment ID) via POST /api/ecommerce/orders/transaction_inquiry; an order with no transaction yet leaves the payment processing.

Idempotency and method switching ​

Matching retries return the saved checkout. A processing Moyasar payment can switch supported methods using its original idempotency key and payment UUID. Original environment, public key, callback and metadata are preserved. If an old tab already submitted that UUID to Moyasar, confirm that payment rather than charging again.

A new key for an active checkout returns 409 PAYMENT_ALREADY_STARTED, with the owned payment's paymentId and original idempotencyKey in error.details. Retry using that key. Incompatible reuse returns 402 PAYMENT_IDEMPOTENCY_CONFLICT. After verified terminal failure, use a new key. Order locking serializes chargeable checkouts. For Paymob, whose checkout is opened at the gateway, this conflict is checked before the intention is created, so a refused request leaves no unused Paymob checkout behind.

Settlement and receipts ​

Verified sandbox payments record completed test payments and labeled receipts, advance Mock SIM (mock) and Mock eSIM (mock-esim) orders through simulated fulfillment, but leave real-provider orders unpaid and skip their fulfillment. Missing or mixed real/mock provider linkage is blocked. Reconfirming a completed sandbox payment or retrying its original idempotency key can recover an eligible pending mock order without charging again. Production uses BullMQ settlement. Webhook handoff failures require redelivery; subsequent reconciliation failures use bounded worker retries. Authenticated confirmation surfaces failures synchronously for retry. Mock follows local successful settlement and is distinct from sandbox.

GET /payments/:id/receipt ​

Authenticated and owner-scoped. Returns receiptNumber (RCPT- plus 12 UUID hex characters), paymentId, orderId, amountSar, currency, method, status, paidAt, messageEn, messageAr and environment when present. amountSar is a legacy field name; its value is in the accompanying currency, including USD. Sandbox messages explicitly identify test payments.

See errors, webhooks and configuration.

Admin plugin API ​

Owner-only endpoints (all use session/bearer authentication):

MethodPathBody / response
GET/admin/plugins/paymentRedacted checkout status in data: provider, enabled, gateways.moyasar and gateways.paymob, each { environment, configured, sandbox, production } where sandbox/production carry configured, callbackUrl and (Paymob) integrations
PATCH/admin/plugins/payment{ enabled } only (accept new payments); returns the checkout status
PATCH/admin/plugins/payment/moyasarSame body as the deprecated PATCH /admin/plugins/moyasar below; returns the checkout status
PATCH/admin/plugins/payment/paymobenvironment, enabled, optional publicKey, secretKey, apiKey, hmacSecret, cardIntegrationId, applePayIntegrationId, walletIntegrationId, callbackUrl, revokePreviousWebhookSecrets; the saved environment becomes Paymob's environment for new payments; returns the checkout status
POST/admin/plugins/payment/{gateway}/testgateway is moyasar or paymob; body environment; read-only probe of that gateway's saved keys
GET/admin/plugins/moyasarDeprecated. Redacted status in data: provider, enabled, environment, configured, sandbox/production configured + callbackUrl
PATCH/admin/plugins/moyasarDeprecated. provider, environment, enabled, optional publishableKey, secretKey, webhookSecret, callbackUrl, revokePreviousWebhookSecrets; returns redacted data
POST/admin/plugins/moyasar/testDeprecated. environment; returns data.healthy, environment, checkedAt (Mock also returns provider)

The live provider is switched with PUT /admin/plugins/services/payment/provider (see service providers); it is refused with SERVICE_PROVIDER_NOT_CONFIGURED when the gateway's selected environment has no keys, and choosing a provider also turns payments on. Paymob writes accept the same revokePreviousWebhookSecrets flag as Moyasar, applied to that environment's historical HMAC secrets. Writes are audited as admin.plugin.payment.updated, admin.plugin.moyasar.updated and admin.plugin.paymob.updated, with key-presence flags only.

Omitted provider defaults to moyasar for compatibility. Omitted callbackUrl preserves an existing override; an empty string clears it. First Moyasar setup requires all three credentials, but callbackUrl is optional. Mock needs no credentials and preserves Moyasar profiles. Writes are audited without keys. The test endpoint uses saved credentials, never creates a charge, and Mock sends no gateway request.

Explicit webhook-secret revocation ​

The backend-only, super_admin-authorized PATCH /admin/plugins/moyasar accepts optional revokePreviousWebhookSecrets: true for the selected environment. It atomically marks every historical webhook secret in that environment as revoked, including the outgoing profile when the same update rotates credentials. Omitting the flag leaves revocation behavior unchanged: rotation alone does not revoke old webhook secrets. provider: "mock" rejects the flag. The other environment is unaffected.

Sending the flag alone revokes only prior histories; the current webhook secret remains valid. To invalidate a currently leaked webhook token, send a different webhookSecret together with the flag in the same update. For an already configured sandbox profile, this example uses a placeholder, not a real secret:

json
{
  "provider": "moyasar",
  "environment": "sandbox",
  "enabled": true,
  "webhookSecret": "REPLACE-WITH-A-NEW-UNIQUE-WEBHOOK-SECRET",
  "revokePreviousWebhookSecrets": true
}

Replace the placeholder with a newly generated secret different from the current token, choose the intended environment and enabled state, and update that environment's Moyasar dashboard Secret Token to match. Historical revocation is sticky across later settings updates and rotations; do not reuse a compromised token as a new current secret. Revocation only excludes historical secrets from webhook authentication: it does not delete API credential generations, disable their decryption or original/in-flight/legacy lookup, or undo provider-side API key revocation. This flag is an API operation; it does not change UI or mobile flows.

Deployment caveat: during a rolling downgrade or mixed-version rollout, old-version instances must not write payment settings because their schemas lack revocation markers and can discard them.

Internal documentation - Activation System