Payments API
Payment processing with Mada, Visa, Mastercard, Stripe, Apple Pay, and STC Pay. Includes idempotency and webhook support.
Payment Methods
| Method | Endpoint | Description |
|---|---|---|
mada | POST /payments/create-intent | Saudi debit card via Stripe |
visa | POST /payments/create-intent | Visa card via Stripe |
mastercard | POST /payments/create-intent | Mastercard via Stripe |
stripe | POST /payments/create-intent | Generic Stripe payment |
apple_pay | POST /payments/apple-pay | Apple Pay with token-based confirmation |
stc_pay | POST /payments/stcpay | STC 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
| Method | Local Orders | eSIM Store Orders |
|---|---|---|
mada | Yes | No — returns 422 PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER |
visa | Yes | Yes (via card processor) |
mastercard | Yes | Yes (via card processor) |
stripe | Yes | Yes (via card processor) |
apple_pay | Yes | Yes (via card processor) |
stc_pay | Yes | No — 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:
amountis not accepted from the request body — it is derived from the order's storedtotalAmountandcurrencyin the database.
Local
PAYMENT_PROVIDER=mockintents settle immediately tocompleted, mark the orderpaid, 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
| Field | Type | Required | Description |
|---|---|---|---|
orderId | string | Yes | UUID of the order to pay for |
method | string | Yes | Payment method: mada, visa, mastercard, stripe |
idempotencyKey | string | Yes | Unique key (8–128 characters) for safe retries |
Example Request
POST /payments/create-intent
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"method": "mada",
"idempotencyKey": "pay-2026-0430-ahmed-001"
}Response
201 Created — Local mock provider completed:
{
"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:
{
"paymentId": "c4d5e6f7-a8b9-0123-cdef-234567890123",
"status": "processing",
"clientSecret": "pi_xxxxxxxxxxxxx_secret_yyyyy",
"gatewayTransactionId": "pi_xxxxxxxxxxxxx"
}Error Responses
| Code | Status | Condition |
|---|---|---|
PAYMENT_INTENT_FAILED | 402 | Payment intent creation failed |
PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER | 422 | Payment 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_CONFLICT | 402 | Same idempotency key with different parameters |
PAYMENT_ORDER_INVALID_STATUS | 402 | Order is not in pending_payment status |
ORDER_NOT_FOUND | 404 | Order not found |
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid request body |
Idempotency conflict example:
{
"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
| Field | Type | Required | Description |
|---|---|---|---|
orderId | string | Yes | UUID of the order to pay for |
applePayToken | string | Yes | Apple Pay payment token (non-empty string) |
idempotencyKey | string | Yes | Unique key (8–128 characters) for safe retries |
Example Request
POST /payments/apple-pay
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"applePayToken": "eyJwYXltZW50IHRva2VuIjoiLi4uIn0=",
"idempotencyKey": "apple-pay-2026-0430-ahmed-001"
}Response
201 Created
{
"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
| Code | Status | Condition |
|---|---|---|
PAYMENT_APPLE_PAY_FAILED | 402 | Apple Pay payment failed |
PAYMENT_IDEMPOTENCY_CONFLICT | 402 | Same idempotency key with different parameters |
PAYMENT_ORDER_INVALID_STATUS | 402 | Order is not in pending_payment status |
ORDER_NOT_FOUND | 404 | Order not found |
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid request body |
POST /payments/stcpay
Initiate an STC Pay direct payment using a Saudi mobile number.
Requires authentication.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
orderId | string | Yes | UUID of the order to pay for |
mobileNumber | string | Yes | Saudi mobile number format 05XXXXXXXX |
idempotencyKey | string | Yes | Unique key (8–128 characters) for safe retries |
Example Request
POST /payments/stcpay
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json{
"orderId": "b3c4d5e6-f7a8-9012-bcde-f12345678901",
"mobileNumber": "0512345678",
"idempotencyKey": "stcpay-2026-0430-ahmed-001"
}Response
201 Created — STC Pay initiated:
{
"paymentId": "e6f7a8b9-c012-3456-efab-789012345678",
"status": "processing",
"gatewayTransactionId": "stcpay_txn_xyz789",
"redirectUrl": "https://api.stcpay.com.sa/redirect/abc123"
}Error Responses
| Code | Status | Condition |
|---|---|---|
PAYMENT_STCPAY_FAILED | 402 | STC Pay initiation failed |
PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER | 422 | Payment 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_STATUS | 402 | Order is not in pending_payment status |
ORDER_NOT_FOUND | 404 | Order not found |
AUTH_REQUIRED | 401 | Missing or invalid token |
VALIDATION_ERROR | 400 | Invalid 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+userIdfrom JWT (owner check). The route never acceptsuserIdfrom the request body.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the payment |
Example Request
GET /payments/c4d5e6f7-a8b9-0123-cdef-234567890123/receipt
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK — Successful payment receipt:
{
"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
amountSaris a historical wire alias preserved for Flutter mobile consumers. Its value is the payment amount in the payment's currency (currencyfield). For a local-lane order (SAR) it is the SAR amount; for a store order (USD) it is the USD amount. The separatecurrencyfield carries the actual currency code.
200 OK — Failed payment receipt (includes retry instructions):
{
"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
| Code | Status | Condition |
|---|---|---|
NOT_FOUND | 404 | Payment not found or not owned by user |
PAYMENT_RECEIPT_FAILED | 402 | Receipt retrieval failed |
AUTH_REQUIRED | 401 | Missing or invalid token |
Payment Status Flow
pending → processing → completed
↘ failedIn 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.statusmust be'succeeded'payment_intent.currency(uppercased) must equal the payment'scurrencypayment_intent.amountandamount_receivedmust equal the payment'samountconverted to integer minor units (decimal-exact, no float multiplication)- Metadata keys
payment_id,order_id, anduser_idmust 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.
| Status | Description |
|---|---|
pending | Payment intent created, awaiting confirmation |
processing | Payment is being processed by the gateway |
completed | Payment successfully completed |
failed | Payment failed (with reason in failedReason) |
refunded | Payment has been refunded (reserved — the enum value exists and no flow writes it today) |