SWAP Payments API
Version 1 · Base URL https://payment.swapkuwait.com/api/v1
SWAP Payments is the single payment service for SWAP applications. Your application never talks to MyFatoorah directly: it asks this service to create a payment, sends the customer to the returned payment page, and is told the outcome. This service keeps the record of every attempt, confirms every outcome with MyFatoorah before reporting it, and handles refunds.
1. How a payment works
your app ──POST /api/v1/payments/─────────▶ SWAP Payments ──▶ MyFatoorah
◀── { id, status: initiated, payment_url }
your app sends the customer's browser to payment_url (MyFatoorah's hosted page)
customer pays
MyFatoorah ──▶ SWAP Payments confirms the result with MyFatoorah
──▶ redirects the customer to your return_url?transaction=<id>&status=paid
SWAP Payments ──POST callback_url (signed)──▶ your app (server to server)
Two independent signals tell your app the result: the customer's browser arriving at return_url, and the signed callback to callback_url. Treat the callback, or a GET /payments/{id}/ you make yourself, as the truth. The browser redirect is a convenience for showing the customer a page; a customer can close the tab before it happens.
2. Authentication
Each application has its own API key, issued once by the SWAP Payments team. Send it on every request in either header:
X-API-Key: swp_… Authorization: Bearer swp_…
An application can only see its own transactions. Another app's transaction id answers 404. A missing or wrong key answers 401. Keys start with swp_; keep them server-side only, never in a mobile app or browser.
Along with the key you receive a callback signing secret, used in section 6.
3. Create a payment
POST /api/v1/payments/curl -X POST https://payment.swapkuwait.com/api/v1/payments/ \
-H "X-API-Key: swp_…" -H "Content-Type: application/json" \
-d '{
"reference": "ORDER-1042",
"amount": "12.500",
"currency": "KWD",
"return_url": "https://shop.example/orders/1042/payment",
"description": "Order 1042",
"customer": { "name": "Ali Al-Sabah", "email": "ali@example.com",
"mobile": "12345678", "mobile_country_code": "+965" },
"language": "en",
"payment_method_id": 1,
"idempotency_key": "order-1042-attempt-1",
"metadata": { "order_id": 1042, "cashier": "web" }
}'
| Field | Required | Meaning |
|---|---|---|
reference | yes | Your own id for the order, invoice or booking. Up to 100 characters. Shown in the MyFatoorah portal as <app>:<reference>. Not required to be unique. |
amount | yes | Decimal as a string, up to 3 decimal places. Must be greater than zero. |
currency | no | 3-letter ISO code. Default KWD. |
return_url | yes | Where the customer's browser is sent after paying, with transaction and status appended as query parameters. Must be https and, if your app was configured with allowed origins, on one of them. |
description | no | Shown on the payment page as the item name. Up to 255 characters. |
customer | no | Object with name, email, mobile, mobile_country_code. Shown on the payment page; the name defaults to "Customer" if omitted. |
language | no | en or ar for the payment page. Default en. |
payment_method_id | no | Send the customer straight to one method (ids from section 8, e.g. 1 = KNET, 2 = VISA/MASTER). Omit to let the customer choose on MyFatoorah's page. |
idempotency_key | no | Any string up to 100 characters, unique per attempt on your side. Repeating a create with the same key returns the same transaction with status 200 instead of opening a second invoice. Use it whenever your call might be retried. |
metadata | no | Any JSON object. Stored and echoed back in every response and callback. Not sent to MyFatoorah. |
Response 201 Created (or 200 when the idempotency key matched an existing transaction):
{
"id": "9a02f7a4-102f-4ef4-844e-8752353c0020",
"reference": "ORDER-1042",
"status": "initiated",
"amount": "12.500",
"currency": "KWD",
"paid_amount": null,
"paid_currency": null,
"refunded_amount": "0.000",
"payment_url": "https://portal.myfatoorah.com/KWT/ie/…",
"gateway": { "invoice_id": "226960809", "invoice_reference": null, "payment_id": null,
"transaction_id": null, "payment_gateway": null, "error": null },
"customer": { "name": "Ali Al-Sabah", "email": "ali@example.com", "mobile": "12345678" },
"metadata": { "order_id": 1042, "cashier": "web" },
"created_at": "2026-09-12T16:51:02+00:00",
"paid_at": null,
"expires_at": "2026-09-12T17:51:02+00:00"
}
Send the customer to payment_url. The link is valid for 60 minutes; after that the transaction becomes expired and you create a new one. Store id against your order: it is how you read the result later.
4. Read a payment
GET /api/v1/payments/{id}/Returns the same object as above with the current status. Poll this if you have no callback URL, or whenever you need to be sure.
POST /api/v1/payments/{id}/sync/Forces SWAP Payments to ask MyFatoorah right now and returns the refreshed object. Use it when a customer says they paid but you see initiated. Not needed routinely: a reconciliation job already re-checks every open payment every five minutes.
Lists your transactions, newest first. All filters are optional. Response: { "results": [...], "has_more": false, "next_offset": null }. Maximum limit is 200.
5. The customer's return
After the customer pays, or cancels, MyFatoorah sends the browser to SWAP Payments, which confirms the outcome and redirects to your return_url with two query parameters:
https://shop.example/orders/1042/payment?transaction=9a02f7a4-…&status=paid
status is one of the values in section 9. Use it only to choose which page to show. To mark the order as paid, read the transaction with GET /payments/{id}/ or wait for the callback. Never trust the query string alone: anyone can type it.
If MyFatoorah cannot be reached at that moment the customer sees a short "confirming your payment" page with a link back to your return_url carrying status=pending; the real status follows by callback.
6. Callbacks to your app
If your app was registered with a callback_url, SWAP Payments POSTs to it whenever a transaction reaches a final status and whenever a refund changes. Body:
{ "event": "payment.paid", "transaction": { …the transaction object… } }
{ "event": "refund.refunded", "transaction": { … }, "refund": { …the refund object… } }
| Header | Value |
|---|---|
X-Swap-Event | payment.paid, payment.failed, payment.canceled, payment.expired, refund.pending, refund.refunded, refund.canceled |
X-Swap-Transaction | The transaction id |
X-Swap-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your callback signing secret |
Answer with any 2xx within 10 seconds. Anything else, or a timeout, is recorded as undelivered and retried by a background job and by staff from the dashboard. Handle repeats: the same event may arrive more than once, so make the update idempotent (for example, "set order paid if not already").
Always verify the signature before acting. Compute the HMAC over the exact bytes received, not over re-serialised JSON.
Python (Django)
import hmac, hashlib, json
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
SECRET = settings.SWAP_PAYMENTS_CALLBACK_SECRET
@csrf_exempt
def swap_payments_callback(request):
expected = "sha256=" + hmac.new(SECRET.encode(), request.body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Swap-Signature", "")):
return HttpResponse(status=401)
payload = json.loads(request.body)
txn = payload["transaction"]
if payload["event"] == "payment.paid":
mark_order_paid(reference=txn["reference"], transaction_id=txn["id"],
amount=txn["paid_amount"])
return HttpResponse("ok")
C# (ASP.NET Core)
[HttpPost("payments/callback")]
public async Task<IActionResult> Callback()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var key = Encoding.UTF8.GetBytes(_config["SwapPayments:CallbackSecret"]);
using var hmac = new HMACSHA256(key);
var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))).ToLower();
var received = Request.Headers["X-Swap-Signature"].ToString();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received)))
return Unauthorized();
var payload = JsonDocument.Parse(body).RootElement;
var evt = payload.GetProperty("event").GetString();
var txn = payload.GetProperty("transaction");
if (evt == "payment.paid")
await _orders.MarkPaid(txn.GetProperty("reference").GetString(), txn.GetProperty("id").GetString());
return Ok("ok");
}
7. Refunds
POST /api/v1/payments/{id}/refunds/{ "amount": "4.000", "reason": "One item returned" }
Refunds all or part of a paid transaction. Omit amount to refund everything still refundable. Several partial refunds are allowed up to the paid amount. Response 201:
{
"id": "…", "transaction_id": "…", "amount": "4.000", "status": "pending",
"reason": "One item returned",
"gateway": { "refund_id": "275988", "refund_reference": "2026002024", "error": null },
"created_at": "…", "settled_at": null
}
A refund starts as pending. MyFatoorah settles it later, usually the same day for KNET and cards; the transaction then moves to partially_refunded or refunded and your app receives a refund.refunded callback. MyFatoorah's fee on the original payment is generally not returned.
List a payment's refunds, or read one; sync=1 re-checks it with MyFatoorah first.
8. Payment methods
GET /api/v1/payment-methods/?amount=10¤cy=KWDThe methods MyFatoorah offers for that amount, with the service charge each adds. Use the id as payment_method_id when creating a payment. Ids are stable per environment but differ between sandbox and live, so read them rather than hard-coding.
{ "results": [ { "id": 1, "code": "kn", "name_en": "KNET", "name_ar": "كي نت", "is_direct": false,
"service_charge": 0.15, "total_amount": 10.15, "currency": "KWD", "image_url": "…" }, … ] }
9. Statuses
| Status | Meaning | Final? |
|---|---|---|
created | Row exists, MyFatoorah not yet called. You will rarely see it. | no |
initiated | Payment page issued, customer has not completed payment. | no |
paid | MyFatoorah confirmed the money. paid_amount, paid_at and gateway.payment_gateway are set. | yes |
failed | The bank or card declined. gateway.error carries the reason. The customer may try again with a new payment. | yes |
canceled | Customer or MyFatoorah canceled the invoice. | yes |
expired | The 60-minute link passed without payment. | yes |
partially_refunded | Paid, and part of it has been refunded. refunded_amount says how much. | yes |
refunded | Paid, and fully refunded. | yes |
A transaction that reached paid never goes back to failed or canceled; it can only move on to the refunded states.
10. Errors
Errors are JSON with an error code and usually a detail:
| HTTP | error | When |
|---|---|---|
| 400 | invalid_json | Body is not a JSON object. |
| 401 | unauthorized | Missing, wrong or inactive API key. |
| 404 | – | Transaction or refund not found, or belongs to another app. |
| 422 | validation_error | Bad input. errors maps each field to its problem, e.g. {"amount": "must be greater than zero"}. |
| 502 | gateway_error | MyFatoorah refused or was unreachable. detail carries its message. For a create, the transaction is kept as failed so staff can see the attempt. |
11. Testing
The service runs in one environment at a time. Ask the SWAP Payments team whether it is currently pointed at MyFatoorah's sandbox or live. On the sandbox, no real money moves and only MyFatoorah's test cards work, for example KNET card 8888880000000001, expiry 09/30, any 4-digit PIN. On live, every payment is real.
A good integration test: create a payment with an idempotency key, open payment_url, pay, confirm your return_url received status=paid, confirm your callback endpoint received payment.paid with a valid signature, then refund and confirm refund.refunded arrives.
12. Go-live checklist
- API key stored server-side, never shipped in a client or committed to git.
- Callback endpoint verifies
X-Swap-Signatureand answers 2xx quickly, and tolerates the same event twice. - Order is marked paid from the callback or from
GET /payments/{id}/, not from the return URL's query string. - Every create call sends an
idempotency_keyso retries never charge twice. - Your
return_urldomain is registered with the SWAP Payments team as an allowed origin. - Amounts are sent as strings with up to 3 decimals; KWD has three.
Questions or a new API key: the SWAP Payments dashboard team. Service status: https://payment.swapkuwait.com/healthz.