Skip to content

Payments API

Payment processing with Mada, Visa, Mastercard, Stripe, Apple Pay, and STC Pay. Includes idempotency and webhook support.

Payment Methods

MethodEndpointDescription
madaPOST /payments/create-intentSaudi debit card via Stripe
visaPOST /payments/create-intentVisa card via Stripe
mastercardPOST /payments/create-intentMastercard via Stripe
stripePOST /payments/create-intentGeneric Stripe payment
apple_payPOST /payments/apple-payApple Pay with token-based confirmation
stc_payPOST /payments/stcpaySTC Pay direct payment

All payment endpoints require authentication. The userId is always derived from the JWT context — never accepted from the request body (security: T-05-10).

Method Availability by Order Type

MethodLocal OrderseSIM Store Orders
madaYesNo — returns 422 PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER
visaYesYes (via card processor)
mastercardYesYes (via card processor)
stripeYesYes (via card processor)
apple_payYesYes (via card processor)
stc_payYesNo — returns 422 PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER

Store orders charge in USD through the card processor. mada and STC Pay are Saudi-local rails not available for USD-denominated store charges.


POST /payments/create-intent

Create a payment intent for card-like methods (Mada, Visa, Mastercard, Stripe). Includes idempotency key for safe retries.

Requires authentication.

Per T-05-04: amount is not accepted from the request body — it is derived from the order's stored totalAmount and currency in the database.

Local PAYMENT_PROVIDER=mock intents settle immediately to completed, mark the order paid, and enqueue payment settlement for activation handoff. Final SIM activation still requires completed identity verification; if payment finishes first, handoff is deferred until identity completion re-enqueues settlement. Real providers such as Stripe remain asynchronous and can only complete after a verified webhook/callback.

Request Body

FieldTypeRequiredDescription
orderIdstringYesUUID of the order to pay for
methodstringYesPayment method: mada, visa, mastercard, stripe
idempotencyKeystringYesUnique key (8–128 characters) for safe retries

Example Request

POST /payments/create-intent
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
  "method": "mada",
  "idempotencyKey": "pay-2026-0430-ahmed-001"
}

Response

201 Created — Local mock provider completed:

json
{
  "paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
  "status": "completed",
  "clientSecret": "mock_client_secret_c4d5e6f7-a8b9-0123-cdef-234567890123",
  "gatewayTransactionId": "mock-payment-c4d5e6f7-a8b9-0123-cdef-234567890123"
}

201 Created — Real provider intent created, awaiting webhook/callback:

json
{
  "paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
  "status": "processing",
  "clientSecret": "pi_xxxxxxxxxxxxx_secret_yyyyy",
  "gatewayTransactionId": "pi_xxxxxxxxxxxxx"
}

Error Responses

CodeStatusCondition
PAYMENT_INTENT_FAILED402Payment intent creation failed
PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER422Payment method not available for this order (e.g. mada/STC Pay on an eSIM store order). Also returned when the store provider's payment_mode is 'external' (STORE_PROVIDER_PAYMENT_EXTERNAL — fail-closed, no default-to-internal branch)
PAYMENT_IDEMPOTENCY_CONFLICT402Same idempotency key with different parameters
PAYMENT_ORDER_INVALID_STATUS402Order is not in pending_payment status
ORDER_NOT_FOUND404Order not found
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid request body

Idempotency conflict example:

json
{
  "error": {
    "code": "PAYMENT_IDEMPOTENCY_CONFLICT",
    "message": "A payment with this idempotency key already exists with different parameters",
    "details": {
      "existingPaymentId": "d5e6f7a8-b9c0-1234-defa-567890123456"
    }
  }
}

POST /payments/apple-pay

Create an Apple Pay payment using a payment token from Apple.

Requires authentication.

Local PAYMENT_PROVIDER=mock uses the same mock intent path as card-like methods and settles immediately. Real Apple Pay provider flows remain asynchronous and settle only after verified server-side confirmation.

Request Body

FieldTypeRequiredDescription
orderIdstringYesUUID of the order to pay for
applePayTokenstringYesApple Pay payment token (non-empty string)
idempotencyKeystringYesUnique key (8–128 characters) for safe retries

Example Request

POST /payments/apple-pay
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
  "applePayToken": "eyJwYXltZW50IHRva2VuIjoiLi4uIn0=",
  "idempotencyKey": "apple-pay-2026-0430-ahmed-001"
}

Response

201 Created

json
{
  "paymentId": "d5e6f7a8-b9c0-1234-defa-567890123456",
  "status": "completed",
  "clientSecret": "mock_client_secret_d5e6f7a8-b9c0-1234-defa-567890123456",
  "gatewayTransactionId": "mock-payment-d5e6f7a8-b9c0-1234-defa-567890123456"
}

Error Responses

CodeStatusCondition
PAYMENT_APPLE_PAY_FAILED402Apple Pay payment failed
PAYMENT_IDEMPOTENCY_CONFLICT402Same idempotency key with different parameters
PAYMENT_ORDER_INVALID_STATUS402Order is not in pending_payment status
ORDER_NOT_FOUND404Order not found
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid request body

POST /payments/stcpay

Initiate an STC Pay direct payment using a Saudi mobile number.

Requires authentication.

Request Body

FieldTypeRequiredDescription
orderIdstringYesUUID of the order to pay for
mobileNumberstringYesSaudi mobile number format 05XXXXXXXX
idempotencyKeystringYesUnique key (8–128 characters) for safe retries

Example Request

POST /payments/stcpay
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
  "mobileNumber": "0512345678",
  "idempotencyKey": "stcpay-2026-0430-ahmed-001"
}

Response

201 Created — STC Pay initiated:

json
{
  "paymentId": "e6f7a8b9-c012-3456-efab-789012345678",
  "status": "processing",
  "gatewayTransactionId": "stcpay_txn_xyz789",
  "redirectUrl": "https://api.stcpay.com.sa/redirect/abc123"
}

Error Responses

CodeStatusCondition
PAYMENT_STCPAY_FAILED402STC Pay initiation failed
PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER422Payment method not available for this order (STC Pay is not available for eSIM store orders); also STORE_PROVIDER_PAYMENT_EXTERNAL for external-provider store orders
PAYMENT_ORDER_INVALID_STATUS402Order is not in pending_payment status
ORDER_NOT_FOUND404Order not found
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid mobile number format

GET /payments/:id/receipt

Get a payment receipt with bilingual messages. Owner-scoped — only the payment owner can access their receipt.

Requires authentication.

Per T-05-10: ReceiptService queries by paymentId + userId from JWT (owner check). The route never accepts userId from the request body.

Path Parameters

ParameterTypeDescription
idstringUUID of the payment

Example Request

GET /payments/c4d5e6f7-a8b9-0123-cdef-234567890123/receipt
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response

200 OK — Successful payment receipt:

json
{
  "receiptNumber": "RCP-2026-000001",
  "paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
  "orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
  "amountSar": 49.99,
  "currency": "SAR",
  "method": "mada",
  "status": "completed",
  "paidAt": "2026-05-05T08:30:00.000Z",
  "messageEn": "Payment successful. Thank you for your purchase.",
  "messageAr": "تم الدفع بنجاح. شكراً لشرائك."
}

amountSar naming note: The response key amountSar is a historical wire alias preserved for Flutter mobile consumers. Its value is the payment amount in the payment's currency (currency field). For a local-lane order (SAR) it is the SAR amount; for a store order (USD) it is the USD amount. The separate currency field carries the actual currency code.

200 OK — Failed payment receipt (includes retry instructions):

json
{
  "receiptNumber": "RCP-2026-000002",
  "paymentId": "d5e6f7a8-b9c0-1234-defa-567890123456",
  "orderId": "c3d4e5f6-a7b8-9012-bcde-f12345678901",
  "amountSar": 49.99,
  "currency": "SAR",
  "method": "visa",
  "status": "failed",
  "paidAt": null,
  "messageEn": "Payment failed. Please try again or use a different payment method.",
  "messageAr": "فشل الدفع. يرجى المحاولة مرة أخرى أو استخدام طريقة دفع مختلفة."
}

Error Responses

CodeStatusCondition
NOT_FOUND404Payment not found or not owned by user
PAYMENT_RECEIPT_FAILED402Receipt retrieval failed
AUTH_REQUIRED401Missing or invalid token

Payment Status Flow

pending → processing → completed
                     ↘ failed

In local development with PAYMENT_PROVIDER=mock, POST /payments/create-intent and POST /payments/apple-pay can move directly to completed. Stripe and future real providers must pass through a verified webhook/callback before completion. Activation completion requires both a paid order and identityStatus=completed.

Webhook Verification

Before marking a payment completed from a Stripe webhook, the API verifies the provider-reported data against the persisted payment row:

  • payment_intent.status must be 'succeeded'
  • payment_intent.currency (uppercased) must equal the payment's currency
  • payment_intent.amount and amount_received must equal the payment's amount converted to integer minor units (decimal-exact, no float multiplication)
  • Metadata keys payment_id, order_id, and user_id must all be present and match

On mismatch the payment is not completed and settlement is not enqueued. The payment row moves to failed with reason PAYMENT_PROVIDER_AMOUNT_MISMATCH (guarded so a late duplicate can never downgrade a completed payment). A structured log record with code PAYMENT_PROVIDER_AMOUNT_MISMATCH is emitted (no raw payload or PII). Mismatches are also written to the payment_webhook_events table with the mismatch detail. Both writes run in one transaction, so a mismatch is never half recorded. The webhook still returns 200 to Stripe so retry storms are prevented. If the recording itself fails, the webhook returns 500 with code PAYMENT_MISMATCH_RECORD_FAILED instead, so the provider redelivers the event and the mismatch is not lost. This verification is provider-agnostic and applies to every adapter's callback boundary. The mock gateway bypasses this verification.

StatusDescription
pendingPayment intent created, awaiting confirmation
processingPayment is being processed by the gateway
completedPayment successfully completed
failedPayment failed (with reason in failedReason)
refundedPayment has been refunded (reserved — the enum value exists and no flow writes it today)

Internal documentation - Activation System