External API — Partner Integration Guide
Create invoices, query status, share payment links, and receive signed webhooks.
| Environment | Base URL |
|---|---|
| Production | https://integration.rsompay.com/api/external/v1 |
| Sandbox | https://demo-integration.rsompay.com/api/external/v1 |
| Environment | Payment URL pattern |
|---|---|
| Production | https://payment.rsompay.com/pay/invoice/{token} |
| Sandbox | https://demo-payment.rsompay.com/pay/invoice/{token} |
| Item | Description |
|---|---|
| External client account | Provisioned by RsomPay for B2B integration |
| Integration API token | Bearer token named client-external-api |
| Webhook signing secret | Provided by RsomPay IT on onboarding — used with header X-Rsom-Signature (see §8.4) |
flowchart TB
subgraph partner [Partner system]
ERP[ERP backend]
WH[Webhook endpoint]
RET[return_url handler]
end
subgraph rsom [RsomPay]
INT[integration.rsompay.com]
PAY[payment.rsompay.com]
end
ERP -->|POST /invoices| INT
INT -->|payment_url| ERP
ERP -->|share link| PAY
PAY -->|browser redirect first| RET
RET -->|GET by-reference| INT
INT -.->|webhook later async| WH
Send the integration token on every request:
Authorization: Bearer YOUR_CLIENT_EXTERNAL_API_TOKEN Content-Type: application/json Accept: application/json
client-external-api.403.client_invoices.create, client_invoices.view. Settlement endpoints additionally require client_settlements.view — already granted by default, no extra setup needed.curl -X POST "https://integration.rsompay.com/api/external/v1/invoices" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d @invoice.json
RsomPay also signs every webhook it sends to you (payment/invoice status updates) with a secret specific to your account, so you can verify the notification is genuine. See §8.4 for the webhook signature — this is separate from the access token above and is not something you need to send on your requests to RsomPay.
Successful responses:
{
"status": true,
"message": "Mission completed successfully",
"data": { }
}
Paginated GET /transactions adds meta: total, per_page, current_page, last_page.
A Payment is a fast, one-shot charge — separate from an Invoice. Use it when you just need to charge a customer now and don't need a long-lived billing document: a failed or abandoned Payment never creates an invoice and never appears in your invoice list.
| Invoice | Payment | |
|---|---|---|
| Purpose | Long-lived billing document — customer can visit the link and pay whenever | Immediate, one-shot charge |
| Line items | Yes — items[], discounts, tax per line | No — single amount |
| New card | Hosted checkout page | Hosted checkout page (checkout_url) |
| Saved card | Not supported | Instant, server-to-server (saved_card_token) |
POST /payments
Two ways to charge, chosen by whether you send saved_card_token:
saved_card_token): the response includes a checkout_url — redirect the customer there to enter their card once. return_url is required on this path.saved_card_token present): charged instantly, server-to-server, no redirect. Requires the customer to already have an active saved card (see tokenization, §5). return_url is not required on this path.| Field | Required | Type | Description |
|---|---|---|---|
external_id | Yes | string | Partner idempotency key. Max 80. Unique per client; a retry with the same value returns the same payment, never a duplicate charge. |
amount | Yes | number | Amount to charge. Min 2. |
notification_url | Yes | URL | HTTPS webhook endpoint. Max 2048. |
return_url | Required for the new-card path | URL | HTTPS customer redirect after checkout. Ignored when saved_card_token is present. Max 2048. |
customer | Yes | object | customer.id (required, max 255) and customer.fullname (required, max 255) — plus optional customer.email, customer.phone (max 32). |
saved_card_token | No | string | A token issued via tokenization (§5). When present, charges instantly against that saved card — see behaviour above. |
save_card | No | boolean | New-card path only: offer to save the card for future instant charges. Cannot be combined with saved_card_token (422 if both are set). |
currency_code | No | string | Only SAR is accepted. Default SAR. |
brand | No | string | Preferred payment method for the new-card checkout page: mada, credit_card (Visa/Mastercard), or apple_pay. Ignored on the saved-card path. |
description | No | string | Free-text description. Max 255. |
{
"external_id": "PAY-PARTNER-001",
"amount": 150,
"notification_url": "https://partner.example.com/webhooks/rsom",
"return_url": "https://partner.example.com/payment/return",
"save_card": true,
"customer": {
"id": "CUST-9",
"fullname": "Ahmed Ali",
"email": "customer@example.com",
"phone": "+966501234567"
}
}
{
"status": true,
"message": "Mission completed successfully",
"data": {
"external_id": "PAY-PARTNER-001",
"status": "pending",
"amount": 150.0,
"currency_code": "SAR",
"brand": null,
"description": null,
"checkout_url": "https://payment.rsompay.com/pay/checkout/...",
"paid_at": null,
"created_at": "2026-09-13 10:00:00",
"updated_at": "2026-09-13 10:00:00"
}
}
{
"external_id": "PAY-PARTNER-002",
"amount": 75,
"notification_url": "https://partner.example.com/webhooks/rsom",
"saved_card_token": "rsp_sct_AbCdEf0123456789...",
"customer": {
"id": "CUST-9",
"fullname": "Ahmed Ali"
}
}
{
"status": true,
"message": "Mission completed successfully",
"data": {
"external_id": "PAY-PARTNER-002",
"status": "pending",
"amount": 75.0,
"currency_code": "SAR",
"brand": null,
"description": null,
"checkout_url": null,
"paid_at": null,
"created_at": "2026-09-13 10:05:00",
"updated_at": "2026-09-13 10:05:00"
}
}
201 response means "created", not "paid." Both paths confirm asynchronously — poll GET /payments/by-reference or wait for the payment.direct.paid webhook (§4.4).
| HTTP | Cause |
|---|---|
404 | saved_card_token valid but no active saved card exists for this customer |
422 | Validation failure, or saved_card_token is invalid / does not belong to this customer, or both save_card and saved_card_token were sent |
GET /payments/by-reference?external_id={value}
Look up a payment by your external_id. Response shape is the same as POST /payments, with status reflecting the current state (pending, paid, failed, refunded, partially_refunded).
| HTTP | Cause |
|---|---|
404 | Payment not found for your client |
POST /payments/{reference}/refund
{reference} is the payment's external_id. Same request/response shape and rules as refunding an invoice (§6.6): omit amount for a full refund, provide it for a partial refund; the 200 response means "submitted", confirmed later by refund_status on the matching transaction (GET /transactions) or the webhook in §4.4.
| HTTP | Cause |
|---|---|
403 | Refunds not enabled for your account, or the payment is currently mid-settlement / already settled |
404 | Payment not found for your client, or no completed collection exists on it |
422 | Refund amount invalid or exceeds the remaining refundable amount, or the gateway rejected the request |
payment.direct.* belong to the standalone Payment resource documented on this page. Events starting with payment.* (no direct segment — e.g. payment.completed) belong to an Invoice's underlying payment attempt instead — see §6.7. The two are never the same event.
Payment webhooks use the exact same delivery mechanism, signature (X-Rsom-Signature), and retry policy as invoice webhooks — see §8. Only the event names and payload shape differ. Card-save events (recurring.card_saved) are documented under tokenization (§5.2).
| Event | When |
|---|---|
payment.direct.paid | Payment succeeded (either path) |
payment.direct.failed | Payment failed — gateway rejection, declined saved-card charge, or amount-verification mismatch |
payment.direct.refunded | A refund was confirmed and the payment is now fully refunded |
payment.direct.partially_refunded | A refund was confirmed but the payment still has a refundable remainder |
payment.direct.paid{
"event": "payment.direct.paid",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-09-13T10:00:00+00:00",
"payment": {
"external_id": "PAY-PARTNER-001",
"customer_external_id": "CUST-9",
"customer_name": "Ahmed Ali",
"status": "paid",
"amount": 150.0,
"currency_code": "SAR",
"brand": "mada",
"paid_at": "2026-09-13 10:00:05"
}
}
payment.direct.refunded{
"event": "payment.direct.refunded",
"event_id": "660e8400-e29b-41d4-a716-446655440001",
"occurred_at": "2026-09-13T11:00:00+00:00",
"payment": { "external_id": "PAY-PARTNER-001", "status": "refunded", "amount": 150.0, ... },
"transaction": {
"uuid": "770e8400-e29b-41d4-a716-446655440002",
"reference": "REF-...",
"status": "completed",
"settlement_status": "reversed",
"gross_amount": 150.0,
"net_amount": 147.0
}
}
recurring.card_saved{
"event": "recurring.card_saved",
"event_id": "880e8400-e29b-41d4-a716-446655440003",
"occurred_at": "2026-09-13T10:00:05+00:00",
"customer_external_id": "CUST-9",
"mandate_uuid": "990e8400-e29b-41d4-a716-446655440004",
"brand": "mada",
"last4": "4242"
}
Tokenization lets you charge a customer again later without them re-entering their card. RsomPay supports one flow: save a card while completing a payment, then retrieve a token for that saved card to charge it instantly on a future POST /payments call.
| Step | Endpoint |
|---|---|
| 1. Save a card during a payment | §5.2 — POST /payments with save_card: true |
| 2. Retrieve the saved-card token | §5.3 — POST /customers/{id}/saved-card-token |
| 3. Charge with the token (any time later) | §5.4 — POST /payments with saved_card_token |
| 4. View or remove the saved card | §5.4 — GET / DELETE on saved-cards |
Set save_card: true on the new-card path of POST /payments (no saved_card_token in the same request — the two are mutually exclusive). The customer enters their card once on the hosted checkout page as usual; nothing about the checkout experience changes.
{
"external_id": "PAY-PARTNER-001",
"amount": 150,
"notification_url": "https://partner.example.com/webhooks/rsom",
"return_url": "https://partner.example.com/payment/return",
"save_card": true,
"customer": { "id": "CUST-9", "fullname": "Ahmed Ali" }
}
Once the payment completes and the card is saved, RsomPay sends a recurring.card_saved webhook to the payment's notification_url — this is your signal to call §5.3 and obtain a usable token:
{
"event": "recurring.card_saved",
"event_id": "880e8400-e29b-41d4-a716-446655440003",
"occurred_at": "2026-09-13T10:00:05+00:00",
"customer_external_id": "CUST-9",
"mandate_uuid": "990e8400-e29b-41d4-a716-446655440004",
"brand": "mada",
"last4": "4242"
}
POST /customers/{customer_external_id}/saved-card-token
Call this once you receive recurring.card_saved (§5.2). It returns the token you'll pass as saved_card_token — store it against your own customer record.
| Field | Required | Type | Description |
|---|---|---|---|
saved_card_id | Only if the customer has more than one saved card | string | Which saved card to issue a token for — the id from §5.4's list-saved-cards response. If the customer has exactly one active saved card, omit this and that card is used automatically. |
{
"status": true,
"message": "Mission completed successfully",
"data": { "saved_card_token": "rsp_sct_AbCdEf0123456789..." }
}
| HTTP | Cause |
|---|---|
404 | No matching active saved card for this customer under your account (or saved_card_id doesn't match one) |
422 | The customer has more than one active saved card and saved_card_id was omitted — specify which card |
Pass the token from §5.3 as saved_card_token on POST /payments — the charge is instant and server-to-server, no checkout page involved. Full request/response shapes are documented in §4.1; only the token-related fields are repeated here.
{
"external_id": "PAY-PARTNER-002",
"amount": 75,
"notification_url": "https://partner.example.com/webhooks/rsom",
"saved_card_token": "rsp_sct_AbCdEf0123456789...",
"customer": { "id": "CUST-9", "fullname": "Ahmed Ali" }
}
GET /customers/{customer_external_id}/saved-cards
Returns every active saved card for this customer — zero, one, or several — enough to let your customer pick which one to use (brand, last 4 digits), never the full card number.
{
"status": true,
"message": "Mission completed successfully",
"data": [
{ "id": "b1b2c3d4-...", "brand": "mada", "last4": "4242", "created_at": "2026-09-01 10:00:00" },
{ "id": "c2c3d4e5-...", "brand": "apple_pay", "last4": "4242", "created_at": "2026-09-05 10:00:00" }
]
}
DELETE /customers/{customer_external_id}/saved-cards/{id}
{id} is the value returned by the list call above.
| HTTP | Cause |
|---|---|
404 | saved_card_token invalid or no longer valid, or no matching active saved card for this customer under your account |
422 | saved_card_token does not belong to this payment's customer |
An Invoice is a long-lived billing document with line items — the customer can visit its payment_url and pay whenever they're ready. For a fast one-shot charge with no line items, see Payments (§4) instead.
POST /invoices
| Field | Required | Type | Description |
|---|---|---|---|
external_id | Yes | string | Partner idempotency key. Max 255. Unique per client; duplicates return the same invoice. |
notification_url | Yes | URL | HTTPS webhook endpoint. Publicly reachable; no localhost/private IPs. Max 2048. |
return_url | Yes | URL | HTTPS customer redirect after checkout. Max 2048. |
customer | Yes | object | Customer block — see table below. |
items | Yes | array | Line items — min 1 object; see table below. |
currency_code | No | string | Currency code. Max 8. Default SAR. |
due_date | No | date | Due date YYYY-MM-DD. |
issued_date | No | date | Issue date YYYY-MM-DD. |
payment_method | No | string | Preferred payment method: mada, credit_card (Visa/Mastercard), apple_pay, or tamara. When set, the hosted checkout page skips its own payment-method screen and opens directly into that method's form. Requesting tamara for an account without Tamara enabled returns 422. mada/credit_card/apple_pay requested for a disabled method fall back to showing the picker instead. |
notes | No | string | Invoice notes. Max 2000. |
customer)| Field | Required | Type | Description |
|---|---|---|---|
customer.fullname | Yes | string | Full name on payment page. Max 255. |
customer.id | Yes | string | Your stable customer reference (e.g. CUST-9). Max 255. |
customer.email | No | string | Valid email. Max 255. |
customer.phone | No | string | Phone (e.g. +966501234567). Max 32. |
items[])Each array element is one line item. At least one item is required.
| Field | Required | Type | Description |
|---|---|---|---|
items[].description | Yes | string | Line description. Max 255. |
items[].quantity | No | number | Qty. Min 0.001. Default 1. |
items[].unit_price | Yes | number | Unit price before discount and tax. Min 0.01. Invoice total is computed from all lines. |
items[].discount_amount | No | number | Line discount amount. Min 0. |
items[].tax_rate | No | number | Tax % (e.g. 15 = 15% VAT). |
items[].item_type | No | string | Category label (e.g. service). Max 32. |
items[].reference_id | No | string | Your line reference. Max 128. |
external_id → 201, same invoice, no duplicate.issued.amount field — each line requires unit_price. data.amount and data.totals.grand_total are computed from lines (must be > 0).One line: qty 1, unit 100, discount 10, tax 15% → grand_total: 103.5
{
"external_id": "INV-PARTNER-001",
"notification_url": "https://partner.example.com/webhooks/rsom",
"return_url": "https://partner.example.com/payment/return",
"currency_code": "SAR",
"due_date": "2026-12-31",
"issued_date": "2026-05-18",
"payment_method": "credit_card",
"notes": "Optional invoice note",
"customer": {
"id": "CUST-9",
"fullname": "Ahmed Ali",
"email": "customer@example.com",
"phone": "+966501234567"
},
"items": [
{
"description": "Service fee",
"item_type": "service",
"quantity": 1,
"unit_price": 100,
"discount_amount": 10,
"tax_rate": 15,
"reference_id": "LINE-1"
}
]
}
{
"status": true,
"message": "Mission completed successfully",
"data": {
"external_id": "INV-PARTNER-001",
"reference_number": "INV-42-001",
"status": "issued",
"amount": 103.5,
"payment_url": "https://payment.rsompay.com/pay/invoice/...",
"customer": {
"id": "CUST-9",
"fullname": "Ahmed Ali",
"email": "customer@example.com",
"phone": "+966501234567"
},
"totals": {
"subtotal": 100,
"discount_total": 10,
"tax_total": 13.5,
"grand_total": 103.5
},
"items": [
{
"description": "Service fee",
"item_type": "service",
"quantity": 1,
"unit_price": 100,
"discount_amount": 10,
"tax_rate": 15,
"tax_amount": 13.5,
"total_amount": 103.5,
"reference_id": "LINE-1"
}
]
}
}
GET /invoices/by-reference?{parameter}={value}
Look up an invoice by your reference. Use this after webhooks or before fulfilling an order.
Provide exactly one query parameter:
| Parameter | Description |
|---|---|
external_id | Your idempotency key (recommended) |
reference_number | RsomPay invoice number (e.g. INV-42-001) |
GET /invoices/by-reference?external_id=INV-PARTNER-001 GET /invoices/by-reference?reference_number=INV-42-001
data object is the same as POST /invoices (create invoice). Only HTTP status differs: 200 OK here vs 201 Created on create. Fields such as status reflect the current invoice state (e.g. issued before payment, paid after — see §6.4).
{
"status": true,
"message": "Mission completed successfully",
"data": {
"external_id": "INV-PARTNER-001",
"reference_number": "INV-42-001",
"status": "paid",
"amount": 103.5,
"payment_url": "https://payment.rsompay.com/pay/invoice/...",
"customer": {
"id": "CUST-9",
"fullname": "Ahmed Ali",
"email": "customer@example.com",
"phone": "+966501234567"
},
"totals": {
"subtotal": 100,
"discount_total": 10,
"tax_total": 13.5,
"grand_total": 103.5
},
"items": [
{
"description": "Service fee",
"item_type": "service",
"quantity": 1,
"unit_price": 100,
"discount_amount": 10,
"tax_rate": 15,
"tax_amount": 13.5,
"total_amount": 103.5,
"reference_id": "LINE-1"
}
]
}
}
| HTTP | Cause |
|---|---|
422 | No query parameter — provide external_id or reference_number |
404 | Invoice not found for your client |
payment_url.payment.rsompay.com.return_url with query parameters (UX only).GET /invoices/by-reference (primary) — call from your return_url handler or right after redirect.payment.completed / payment.failed arrives later, asynchronously on notification_url (after the redirect) — see §6.7.return_url first. RsomPay sends the webhook to notification_url afterward (async). Do not wait for the webhook on the return page — confirm with GET /invoices/by-reference first.
On your return_url page, use GET /invoices/by-reference as your main check before you fulfill an order. Confirm data.status is paid; poll briefly if it's still issued (payment may still be confirming).
| Channel | When | Role |
|---|---|---|
return_url | Right after checkout | UX — show success/failure; not proof of payment alone |
GET /invoices/by-reference | On return page (primary) | Authoritative — confirm paid before fulfilling |
Webhook payment.completed | Later (async) | Backup — optional reconciliation; still use by-reference if webhook is delayed |
return_url query parameters| Parameter | Description |
|---|---|
status | success, failed, or pending |
external_id | Your invoice id |
reference_number | RsomPay invoice number (e.g. INV-42-001) |
payment_reference | Payment reference (if available) |
amount_paid | Amount for this payment |
currency_code | e.g. SAR |
payment_method | How the customer paid — e.g. card, tamara, or card brand (mada, visa, …) when available |
https://partner.example.com/payment/return?status=success&external_id=INV-PARTNER-001&reference_number=INV-42-001&payment_reference=GW-123&amount_paid=103.5¤cy_code=SAR&payment_method=card
On data.status and webhook invoice.status for external API invoices:
| Status | Meaning |
|---|---|
issued | Issued — awaiting payment |
paid | Paid in full |
canceled | Cancelled — do not collect |
failed | Last payment attempt failed; customer may try again via payment_url |
List customer payments across your invoices.
GET /transactions?customer_id={id}&page=1&per_page=20
| Parameter | Required | Default | Max |
|---|---|---|---|
customer_id | Yes | — | 255 |
page | No | 1 | — |
per_page | No | 20 | 100 |
Same envelope as other endpoints (status, message, data). data is an array of payment records; paginated responses include meta. payment_method reflects how the customer paid — e.g. card or tamara. refund_status is only present once a refund has been requested on that transaction — poll it to track asynchronous refund confirmation.
{
"status": true,
"message": "Mission completed successfully",
"data": [
{
"reference": "TX-REF-1",
"status": "completed",
"refund_status": null,
"amount": 103.5,
"currency_code": "SAR",
"payment_method": "card",
"invoice_external_id": "INV-PARTNER-001",
"captured_at": "2026-05-18 12:00:00",
"created_at": "2026-05-18 11:55:00"
},
{
"reference": "TX-REF-2",
"status": "completed",
"refund_status": "confirmed",
"amount": 250,
"currency_code": "SAR",
"payment_method": "tamara",
"invoice_external_id": "INV-PARTNER-002",
"captured_at": "2026-05-17 15:30:00",
"created_at": "2026-05-17 15:28:00"
}
],
"meta": {
"total": 2,
"per_page": 20,
"current_page": 1,
"last_page": 1
}
}
Refund a previously captured Dhamen payment on one of your invoices — in full or in part.
POST /invoices/{reference}/refund
{reference} is your invoice's external_id or its RsomPay reference_number (same identifiers accepted by GET /invoices/by-reference).
| Field | Required | Type | Description |
|---|---|---|---|
amount | No | number | Omit for a full refund of the remaining captured amount. Provide for a partial refund (minimum 1). |
reason | No | string | Free-text reason, max 500 characters — recorded for audit purposes. |
403 until settlement finishes.200 response below means "refund request accepted", not "refund completed." RsomPay submits the refund to the payment gateway and waits for the gateway's own confirmation before the refund is final. Poll GET /transactions and check the refund_status field on the matching transaction to see the outcome:
refund_status | Meaning |
|---|---|
pending_gateway | Submitted, awaiting gateway confirmation — not refunded yet. |
confirmed | Refund confirmed by the gateway — money has moved. |
stuck | No confirmation received after an extended period — under investigation by RsomPay. Contact support if you see this. |
submit_failed | The gateway rejected the request outright (see the error response below) — safe to correct and retry. |
{
"amount": 50.00,
"reason": "Customer requested partial refund"
}
{
"status": true,
"message": "Mission completed successfully",
"data": {
"reference": "DHAMEN-REFUND-9f2c1e6a-...",
"refund_status": "pending_gateway",
"amount": 50.00,
"currency_code": "SAR",
"created_at": "2026-07-28 10:15:00"
}
}
| HTTP | Cause |
|---|---|
403 | Refunds not enabled for your account, or the payment is currently mid-settlement / already settled |
404 | Invoice not found for your client, or no completed Dhamen payment exists on it |
422 | Refund amount invalid or exceeds the remaining refundable amount, or the gateway rejected the request |
payment.*, no direct segment) describe an Invoice's underlying payment attempt. Events named payment.direct.* belong to the separate, standalone Payment resource instead — see §4.4. The two are never the same event.
Invoice webhooks use the shared delivery mechanism, signature, and retry policy documented in Outbound webhooks (§8). No webhook is sent when the invoice is created — only after payment activity.
| Event | When |
|---|---|
invoice.status_changed | Status changes after payment (previous_status in payload) |
payment.completed | Payment succeeded |
payment.failed | Payment failed (declined by gateway, cancelled by customer, etc. — see payment.reason) |
payment.expired | The checkout session expired before the customer completed payment |
payment.refunded | A refund on a completed payment was confirmed |
payment.completed{
"event": "payment.completed",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-18T12:00:00+00:00",
"customer": { "id": "CUST-9", "fullname": "Ahmed Ali", ... },
"totals": { "subtotal": 100, "discount_total": 10, "tax_total": 13.5, "grand_total": 103.5 },
"invoice": {
"external_id": "INV-PARTNER-001",
"reference_number": "INV-42-001",
"status": "paid",
"amount": 103.5,
"payment_url": "https://payment.rsompay.com/pay/invoice/..."
},
"payment": {
"uuid": "660e8400-e29b-41d4-a716-446655440001",
"payment_method": "card",
"status": "completed",
"amount": 103.5,
"paid_at": "2026-05-18 12:00:00"
}
}
payment_method reflects how the customer paid — e.g. card (Mada/Visa/Mastercard), tamara (Tamara BNPL). Card payments may also return a specific brand when available (e.g. mada, visa).
payment.failed and payment.expired both carry an additional payment.reason field so you can distinguish a gateway decline from a checkout-session expiry from a customer cancellation:
{
"event": "payment.expired",
"event_id": "770e8400-e29b-41d4-a716-446655440002",
"occurred_at": "2026-08-03T12:00:00+00:00",
"invoice": { "external_id": "INV-PARTNER-001", "reference_number": "INV-42-001", "status": "failed", ... },
"payment": {
"uuid": "660e8400-e29b-41d4-a716-446655440001",
"payment_method": "card",
"status": "failed",
"amount": 103.5,
"paid_at": "2026-08-03 12:00:00",
"reason": "dhamen_invoice_payment_expired"
}
}
payment.refunded carries a transaction object instead of payment — the refund state lives on the underlying transaction, not the invoice status:
{
"event": "payment.refunded",
"event_id": "880e8400-e29b-41d4-a716-446655440003",
"occurred_at": "2026-08-03T12:00:00+00:00",
"invoice": { "external_id": "INV-PARTNER-001", "reference_number": "INV-42-001", "status": "issued", ... },
"transaction": {
"uuid": "990e8400-e29b-41d4-a716-446655440004",
"reference": "TXN-42-001",
"status": "completed",
"settlement_status": "reversed",
"gross_amount": 103.5,
"net_amount": 101.5
}
}
A Subscription charges one of your customers a fixed amount on a fixed cadence, on a saved card. It builds on Tokenization (§5) — the customer's first payment always goes through a hosted checkout link that also saves their card, so every later cycle can be charged without the customer present.
billing_mode | Who triggers each cycle | When to use it |
|---|---|---|
automatic (default) | RsomPay's own scheduler — it charges the saved card (or issues a pay-link, see collection_method below) every cycle on its own. | Standard recurring billing — you just create the subscription once and RsomPay runs it. |
merchant_managed | You — RsomPay never auto-charges. You call POST /{id}/charge yourself whenever you want that cycle billed. | You already track usage/renewal in your own system and want to control exactly when each charge fires. |
Your integration token needs the client_subscriptions.view and client_subscriptions.manage abilities (see §2). Tokens created before Subscriptions was enabled on your account may need to be regenerated to pick these up — if every call below 403s, that's the first thing to check.
| Method | Path | Purpose |
|---|---|---|
POST | /subscriptions | Create a subscription (idempotent on external_id) |
GET | /subscriptions | List your subscriptions |
GET | /subscriptions/{id} | Fetch one subscription |
GET | /subscriptions/by-external-id/{external_id} | Fetch by your own external_id |
GET | /subscriptions/{id}/cycles | List its billing cycles |
GET | /subscriptions/{id}/payment-link | Fetch the hosted link for whatever cycle is currently due, if any |
PATCH | /subscriptions/{id} | Change amount / interval / billing_mode / collection_method — applies to the next cycle only |
POST | /subscriptions/{id}/charge | Trigger one charge now (merchant_managed only) |
POST | /subscriptions/{id}/pause | Pause — no cycles are billed until resumed |
POST | /subscriptions/{id}/resume | Resume a paused subscription |
POST | /subscriptions/{id}/cancel | Cancel, immediately or at the end of the current period |
POST | /subscriptions/{id}/payment-method | Point the subscription at a different saved card, or request a new capture link |
{id} above is the subscription's id field (a UUID) from any response — not your external_id, which has its own lookup endpoint.
POST /subscriptions
Idempotent on external_id, scoped to your account: calling this again with an external_id you've already used returns the existing subscription (HTTP 200) instead of creating a duplicate. A genuinely new external_id returns 201.
| Field | Required | Type | Description |
|---|---|---|---|
external_id | Yes | string | Your idempotency key for this subscription. Max 64, unique per client. |
customer | Yes | object | customer.id_number (required — 10-digit Saudi national ID / CR) plus optional customer.name, customer.email, customer.phone, customer.external_id (your own reference for this customer). |
title | Yes | string | Max 150. Shown to the customer on the hosted checkout and in receipts. |
amount | Yes | number | SAR, decimal (not halala). Min 1, max 1,000,000. |
interval | Yes | object | interval.unit (required: day, week, month, or year) and interval.count (optional, default 1, max 60 — e.g. {"unit":"month","count":3} bills quarterly). |
billing_mode | No | string | automatic (default) or merchant_managed — see §7 intro. |
collection_method | No | string | auto (default) or manual. Only meaningful when billing_mode=automatic: auto charges the saved card directly each cycle, manual has the scheduler email/issue a pay-link instead and wait for the customer to pay it. Under merchant_managed this field is ignored on input and the subscription always reports it as null — that billing mode has no scheduler-driven collection to configure. |
billing_anchor_day | No | integer | 1–28. Pins the billing date to a fixed day of the month (e.g. always the 1st). Only valid when interval.unit is month or year — sending it with day/week returns a 422. |
trial_days | No | integer | 0–365, default 0. The first cycle is still charged immediately via the hosted link (arming the saved card) — trial_days extends that first period by this many free days before the second charge, it does not skip the first charge. Default 0. |
start_at | No | datetime | Defaults to now. When the first period begins. |
total_cycles | No | integer | Min 1, max 1200. Stop after this many cycles have been billed. |
ends_at | No | datetime | Must be after start_at. Stop once this date is reached. |
currency | No | string | Only SAR is accepted today. Accepted as a field for forward compatibility; default and only valid value is SAR. |
description | No | string | Max 2000. |
notification_url | No | URL | HTTPS. Per-subscription webhook target (see §7.9) — falls back to your account-level notification URL when omitted. |
total_cycles and ends_at are independent — you can set either, both, or neither. When both are set, the subscription stops at whichever is reached first: e.g. a monthly subscription with total_cycles: 12 and an ends_at three months out will stop after cycle 3, not run all 12.
{
"external_id": "SUB-PARTNER-001",
"customer": {
"id_number": "1234567890",
"name": "Ahmed Ali",
"email": "customer@example.com",
"phone": "+966501234567"
},
"title": "Monthly gym membership",
"amount": 149.00,
"interval": { "unit": "month", "count": 1 },
"billing_anchor_day": 1,
"trial_days": 7,
"notification_url": "https://partner.example.com/webhooks/rsom"
}
{
"status": true,
"message": "Mission completed successfully",
"data": { "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "external_id": "SUB-PARTNER-001", "status": "incomplete", "billing_mode": "automatic", "collection_method": "auto", "..." : "see §7.7 for the full object" },
"first_payment": {
"first_payment_url": "https://payment.rsompay.com/pay/checkout/...",
"first_payment_expires_at": "2026-09-18T12:00:00+00:00"
}
}
Redirect the customer to first_payment_url to collect the first charge and save their card. The subscription stays incomplete until that payment is confirmed — see §7.9 for the subscription.activated webhook.
PATCH /subscriptions/{id}
All fields are optional and applied to the next cycle only — never the one already running. Accepts amount, interval (unit/count), billing_anchor_day, billing_mode, collection_method. Sending billing_mode and collection_method together always resolves consistently: if the resulting mode is merchant_managed, collection_method ends up null regardless of what you sent.
merchant_managed only)POST /subscriptions/{id}/charge
Triggers one off-session charge on the saved card right now. Returns 202 — confirmation is asynchronous, via the subscription.renewed or subscription.payment_failed webhook (§7.9). Rejected with 422 on an automatic subscription.
| Field | Required | Type | Description |
|---|---|---|---|
idempotency_key | Yes | string | Max 80. A repeat call with the same key returns the same cycle without charging again — safe to retry on a timeout. |
amount | No | number | SAR, decimal. Defaults to the subscription's own amount. Must fall within a bounded multiplier of it — an out-of-range amount returns 422. |
description | No | string | Max 255. |
period_start / period_end | No | datetime | Override the billed period shown on the resulting cycle/invoice. period_end must be after period_start. |
// Request
{ "idempotency_key": "invoice-2026-10-cycle-4", "amount": 149.00 }
// Response (202) — a SubscriptionCycle, see §7.6's cycle fields
{
"status": true,
"message": "Mission completed successfully",
"data": { "sequence": 4, "status": "charging", "origin": "merchant_charge", "amount": 149.0, "..." : "..." }
}
POST /subscriptions/{id}/pause (no body)
POST /subscriptions/{id}/resume (no body)
POST /subscriptions/{id}/cancel
POST /subscriptions/{id}/payment-method
| Endpoint | Field | Description |
|---|---|---|
/cancel | at_period_end (boolean, default false) | false cancels immediately and skips every not-yet-billed cycle. true keeps it active/billing until the current period ends, then cancels. |
/payment-method | mandate_ref (string) | Point the subscription at a different saved card you already have a mandate reference for. Required unless send_link is used. |
/payment-method | send_link (boolean) | Instead of switching cards directly, request a hosted link so the customer can (re)enter a card themselves. Response carries it under a top-level payment_link key. Required unless mandate_ref is used — send exactly one of the two. |
Resuming requires the saved card behind the subscription to still be active — a deactivated card returns 422; use /payment-method first.
GET /subscriptions accepts status (one of the values in §7.7's status table), billing_mode (automatic/merchant_managed), external_id, and perPage (max 100). Response shape:
{
"status": true,
"message": "Mission completed successfully",
"data": [ /* array of subscription objects, §7.7 */ ],
"pagination": { "current_page": 1, "last_page": 3, "per_page": 20, "total": 57, "has_more_pages": true }
}
GET /subscriptions/{id}/cycles returns one entry per billing period — sequence, status (scheduled/invoiced/charging/paid/failed/skipped/refunded), origin, period_start/period_end/due_at, amount, attempts, idempotency_key, paid_at, paid_via.
GET /subscriptions/{id}/payment-link returns whatever hosted link is currently open for this subscription under {"payment_url": "..."} — HTTP 200, or 204 with no body when nothing is currently due (e.g. an automatic/auto-collection subscription with no manual step pending).
| Field | Description |
|---|---|
id | UUID — use this in every {id} path above. |
external_id, status, billing_mode, collection_method | As set/resolved — see §7.2. |
customer | name, id_number, email, phone. |
title, description, amount, currency, interval, billing_anchor_day | As set — amount is a SAR decimal, not halala. |
trial_days, trial_ends_at | Trial configuration and, once active, when it ends. |
started_at, current_period_start, current_period_end, next_action_at | Lifecycle timestamps. next_action_at is null for merchant_managed — nothing is scheduled, you drive it. |
ends_at, total_cycles, cycles_completed | See the "whichever comes first" note in §7.2. |
cancel_at_period_end, canceled_at, cancel_reason | Cancellation state. |
paused_at, pause_reason | Pause state. |
card | {brand, last4} of the currently active saved card, when one is attached. |
notification_url, first_payment_expires_at, created_at, updated_at |
| Status | Meaning |
|---|---|
incomplete | Created, waiting on the first payment link to be paid. |
trialing | Active, currently inside the free trial extension. |
active | Billing normally. |
past_due | A charge failed and a retry is scheduled (dunning) — automatic only. |
paused | No cycles are billed until resumed. |
canceled | Terminal. |
completed | Terminal — reached total_cycles or ends_at. |
| HTTP | Cause |
|---|---|
404 | Subscription not found, or belongs to a different client than your token |
422 | Validation error (standard field-keyed shape, see §3/§9), or a domain rule — e.g. /charge on an automatic subscription, an amount outside the allowed range, a state transition that isn't valid from the subscription's current status |
500 | Server error — logged on our side |
Subscription events use the same delivery mechanism, envelope, and signature as every other webhook (§8) — sent to the subscription's own notification_url if set, otherwise your account-level one.
| Event | When |
|---|---|
subscription.created | Subscription created (still incomplete) |
subscription.first_payment_link_issued | A first-payment link was (re)issued |
subscription.activated | First payment confirmed — active or trialing |
subscription.trial_started / subscription.trial_ended | Trial period boundaries |
subscription.renewed | A cycle was successfully charged |
subscription.payment_failed | A charge attempt failed |
subscription.retry_scheduled | Dunning scheduled another retry after a failure (automatic only) |
subscription.paused / subscription.resumed | Pause state changed |
subscription.payment_method_updated | The saved card behind the subscription changed |
subscription.amount_updated / subscription.interval_updated / subscription.billing_mode_changed | Fired from PATCH /subscriptions/{id} |
subscription.canceled / subscription.completed | Terminal transitions |
subscription.cycle_refunded | A cycle's payment was refunded |
A few internal transitions (cycle_invoiced, cycle_charged, dunning_exhausted, collection_method_updated) are recorded on our side but not dispatched as webhooks — they're implementation detail, not partner-facing state changes.
{
"event": "subscription.renewed",
"event_id": "9c0e8400-e29b-41d4-a716-446655440099",
"occurred_at": "2026-10-01T09:00:03+00:00",
"data": {
"subscription": {
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"external_id": "SUB-PARTNER-001",
"status": "active",
"billing_mode": "automatic",
"collection_method": "auto",
"customer_identifier": "1234567890",
"customer_name": "Ahmed Ali",
"title": "Monthly gym membership",
"amount": 149.0,
"currency": "SAR",
"interval": { "unit": "month", "count": 1 },
"trial_days": 7,
"current_period_end": "2026-11-01T00:00:00+00:00",
"next_action_at": "2026-11-01T00:00:00+00:00",
"cycles_completed": 4,
"total_cycles": null,
"cancel_at_period_end": false
},
"event_data": { "sequence": 4 }
}
}
RsomPay POSTs outbound webhooks to notify you of activity — Invoice events (§6.7), Payment events (§4.4), and account-level Settlement events (below) all share the same delivery mechanism, signature, and retry policy documented here. Delivery is asynchronous — respond with HTTP 2xx quickly and process the payload in the background.
POST {notification_url}
Content-Type: application/json
X-Rsom-Signature: a1b2c3d4e5f6...
Event details are in the JSON body. For authenticity, read header X-Rsom-Signature (see §8.4).
Every event shares these top-level fields, plus a resource-specific object (invoice, payment, or settlement) documented alongside that resource:
{
"event": "...",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-18T12:00:00+00:00"
}
| Event | When |
|---|---|
settlement.updated | A settlement batch changed state (e.g. moved to transfer_pending or failed) |
settlement.paid | A settlement batch was paid out in full |
settlement.* events are sent to your account-level notification URL (configured once for your integration), not to a per-invoice or per-payment notification_url — a settlement batch aggregates many transactions. Ask your integration contact to register this URL.
{
"event": "settlement.paid",
"event_id": "aa0e8400-e29b-41d4-a716-446655440005",
"occurred_at": "2026-08-03T12:00:00+00:00",
"settlement": {
"uuid": "bb0e8400-e29b-41d4-a716-446655440006",
"batch_no": "STL-20260803120000-AB12",
"status": "settled",
"channel": "dhamen",
"currency_code": "SAR",
"net_total": 980.00,
"transactions_count": 12,
"settled_at": "2026-08-03 12:00:00",
"failure_reason": null
}
}
X-Rsom-Signature)RsomPay includes a signature on every webhook so you can confirm the request is genuine, computed with a signing secret unique to your account.
| Header | Value |
|---|---|
X-Rsom-Timestamp |
Unix timestamp (seconds) of when RsomPay sent the webhook. |
X-Rsom-Signature |
Hex-encoded HMAC-SHA256 signature. Read this header from the incoming request and verify it on your server before processing the JSON body. |
Verification:
expected_signature = HMAC_SHA256(your_signing_secret, timestamp + "." + raw_request_body) # reject the webhook if expected_signature != X-Rsom-Signature
Your signing secret is generated for your account and shown (masked, with a copy button) right next to your access token on the "Integration Settings" page in the RsomPay client dashboard. It is independent from the access token — regenerating one does not affect the other. If you rotate it, update your verification code first, since new webhooks are signed with the new secret immediately.
2xx within a few seconds.event_id in the JSON body.X-Rsom-Signature before trusting the body (see §8.4).event_id across retries — deduplicate on your side.| HTTP | Typical cause |
|---|---|
401 | Missing or invalid token |
403 | Wrong token type or suspended client |
404 | Invoice not found |
422 | Validation error |
500 | Server error |
| Issue | Fix |
|---|---|
| URL rejected | notification_url / return_url must be HTTPS and publicly reachable |
| by-reference 422 | Provide external_id or reference_number |
customer_id on by-reference | Not supported — use GET /transactions instead |
payment_method: "tamara" rejected (422 on payment_method) | Tamara isn't enabled for your account — omit the field, use mada/credit_card/apple_pay, or contact RsomPay to enable Tamara |
Validation messages may be localized — rely on HTTP status and field keys.
client-external-api token from RsomPay.X-Rsom-Signature (see §8.4).POST /invoices with a unique external_id — save payment_url.GET /invoices/by-reference?external_id=... — confirm status is issued.payment_url — use one of the test cards below.return_url with query parameters (happens before webhook).GET /invoices/by-reference from your return handler — status is paid.payment.completed webhook on notification_url arrives later (if delivered).| Brand | Card number | Expiry | CVV |
|---|---|---|---|
| mada | 4464040000000007 | 01/39 | 100 |
| Mastercard | 5123450000000008 | 01/39 | 100 |
Cardholder name can be anything (e.g. Test) — it isn't validated in the sandbox. These numbers only work on the sandbox environment (§12, Environments) and never charge real money.
Read-only settlement/payout status for reconciliation. Settlement batches are created and processed internally by RsomPay — this endpoint only exposes their status and constituent transactions, scoped to your own account. Requires the client_settlements.view permission (already granted on all external-partner accounts).
GET /settlements?status=settled&page=1&per_page=15
| Parameter | Required | Notes |
|---|---|---|
status | No | pending, in_progress, transfer_pending, settled, failed, canceled |
date_from / date_to | No | Filter by batch creation date (ISO date) |
page / per_page | No | Default 15, max 100 |
{
"status": true,
"message": "Mission completed successfully",
"data": [
{
"id": 42,
"reference": "DHM-INS-12-0007",
"status": "settled",
"period_from": "2026-07-01",
"period_to": "2026-07-15",
"currency_code": "SAR",
"gross_total": 1500,
"fee_total": 75,
"net_total": 1425,
"transactions_count": 12,
"bank_transfer_reference": "BANK-REF-001",
"settled_at": "2026-07-18 09:00:00",
"created_at": "2026-07-16 08:00:00"
}
],
"meta": { "total": 1, "per_page": 15, "current_page": 1, "last_page": 1 }
}
GET /settlements/{id}
id is the numeric id returned by the list endpoint. Returns 404 if the settlement doesn't exist or doesn't belong to your account — settlements are never shared across clients.
{
"status": true,
"message": "Mission completed successfully",
"data": {
"id": 42,
"reference": "DHM-INS-12-0007",
"status": "settled",
"period_from": "2026-07-01",
"period_to": "2026-07-15",
"currency_code": "SAR",
"gross_total": 1500,
"fee_total": 75,
"net_total": 1425,
"transactions_count": 12,
"bank_transfer_reference": "BANK-REF-001",
"settled_at": "2026-07-18 09:00:00",
"failure_reason": null,
"items": [
{
"invoice_reference": "INV-42-000123",
"status": "settled",
"gross_amount": 125,
"fee_amount": 6.25,
"net_amount": 118.75,
"settled_at": "2026-07-18 09:00:00",
"transaction": {
"reference": "TX-REF-1",
"status": "completed",
"payment_method": "card",
"captured_at": "2026-07-16 08:05:00"
}
}
],
"created_at": "2026-07-16 08:00:00"
}
}
No Dhamen/Zoho internal identifiers, ledger references, or platform balance snapshots are exposed on this endpoint by design — only the fields your reconciliation needs. There is currently no outbound webhook for settlement status changes — poll this endpoint periodically (e.g. daily) until one is introduced.
Guide version v1.6.0 — API base path /api/external/v1 (unchanged).
| Method | Path | Purpose |
|---|---|---|
POST | /payments | Create a one-shot payment — new card or instant saved-card charge |
GET | /payments/by-reference | Query payment status by external_id |
POST | /payments/{reference}/refund | Refund a payment, full or partial (requires refunds enabled on your account) |
POST | /customers/{id}/saved-card-token | Issue (or rotate) a saved-card retrieval token |
GET | /customers/{id}/saved-cards | List a customer's current saved card, if any |
DELETE | /customers/{id}/saved-cards/{card_id} | Locally deactivate a saved card |
POST | /invoices | Create invoice |
GET | /invoices/by-reference | Query status (external_id or reference_number) |
POST | /invoices/{reference}/refund | Refund a captured payment, full or partial (requires refunds enabled on your account) |
POST | /subscriptions | Create a recurring subscription on a saved card (idempotent on external_id) |
GET | /subscriptions | List subscriptions |
GET | /subscriptions/{id} | Fetch one subscription |
GET | /subscriptions/by-external-id/{external_id} | Fetch by your own external_id |
GET | /subscriptions/{id}/cycles | List its billing cycles |
GET | /subscriptions/{id}/payment-link | Fetch the hosted link for the currently due cycle, if any |
PATCH | /subscriptions/{id} | Change amount / interval / billing_mode / collection_method for the next cycle |
POST | /subscriptions/{id}/charge | Trigger one charge now (merchant_managed only) |
POST | /subscriptions/{id}/pause | Pause |
POST | /subscriptions/{id}/resume | Resume |
POST | /subscriptions/{id}/cancel | Cancel, immediately or at period end |
POST | /subscriptions/{id}/payment-method | Change the saved card, or request a new capture link |
GET | /transactions | Customer transactions (customer_id required) |
GET | /settlements | List settlement/payout batches |
GET | /settlements/{id} | Settlement detail with settled items |
| Production | Sandbox | |
|---|---|---|
| Integration API | integration.rsompay.com | demo-integration.rsompay.com |
| Payment page | payment.rsompay.com | demo-payment.rsompay.com |
Download Postman collection (JSON)
customer_id in Postman is only for GET /transactions, not invoice lookup.
Machine-generated from the live route/validation definitions (Scribe) — the authoritative parameter shapes and types behind this hand-written guide. Regenerate after any External API change: php artisan scribe:generate in backend-api, then copy storage/app/private/scribe/openapi.yaml here.
Technical support: tech@bseeds.sa
Website: https://rsompay.com
| Date | Notes |
|---|---|
| 2026-10-01 | Guide v1.6.0 — added the Subscriptions API (§7): POST /subscriptions (idempotent on external_id, returns a hosted first-payment link that saves the card), list/lookup/cycles/payment-link, PATCH for the next cycle, POST /{id}/charge for merchant_managed subscriptions, pause/resume/cancel/payment-method, and the full subscription.* webhook event set. Builds on Tokenization (§5) and reuses the shared webhook mechanism (§8). |
| 2026-09-13 | Guide v1.5.0 — standalone-Payment webhook events renamed direct_payment.* → payment.direct.* (paid/failed/refunded/partially_refunded) for a name closer to Invoice's own payment.* family while staying unambiguous — see the naming callouts at the top of §4.4 and §6.7. Multiple saved cards per customer are now fully supported (§5): POST /customers/{id}/saved-card-token takes an optional saved_card_id (auto-selected when the customer has exactly one card), and GET /customers/{id}/saved-cards now returns every active card, not just one. Payments now capture the real card/wallet brand used (including Apple Pay and Samsung Pay) after completion, not just the pre-payment preference. |
| 2026-09-13 | Guide v1.4.0 — added the standalone Payments API (§4): POST /payments (new-card checkout or instant saved-card charge, returns 201), GET /payments/by-reference, POST /payments/{reference}/refund, and new webhook events — a Payment is separate from an Invoice, see the comparison table at the top of §4. Added a dedicated Tokenization section (§5) covering saving a card, issuing/rotating its retrieval token, charging with it, and viewing/removing it, with the recurring.card_saved webhook event moved there. Restructured the whole guide so Payments and Tokenization come right after the foundational sections, all Invoice endpoints (create/query/pay/refund/statuses/transactions/webhooks) are consolidated into one Invoice section (§6), and Outbound webhooks (§8) now holds only the shared delivery/signature/retry mechanics plus account-level settlement events — resource-specific events live with their resource. |
| 2026-08-02 | Guide v1.3.0 — added GET /settlements and GET /settlements/{id} for read-only settlement/payout reconciliation, scoped to your own account (client_settlements.view). |
| 2026-07-28 | Guide v1.2.0 — added POST /invoices/{reference}/refund (full/partial refunds, disabled by default, requires an enabled account) and a refund_status field on GET /transactions to track asynchronous refund confirmation. |
| 2026-07-13 | Guide v1.1.0 — added optional payment_method field to POST /invoices to pre-select mada, credit_card (Visa/Mastercard), apple_pay, or tamara, and skip the payment-method screen on the hosted checkout page. |
| 2026-05-19 | Guide v1.0.1 — partner-facing fields, payment flow, webhook signature docs |
| 2026-05-18 | Initial partner guide; docs site split to static hosting (docs.rsompay.com) |