Complete REST API reference for integrating Esaalat's payment verification into your own server. Provision receiving wallets and monitoring devices, create payment intents, attach the buyer's wallet reference, and get verified in real time via webhooks.
Using a client SDK?Browser, Flutter and React Native clients share this API. Browse the SDK hub.https://api.esaalat.com/api/v1/merchantPOST /token · GET/POST /wallets/ · GET/POST /devices/ · POST /intents/ · GET/PATCH /intents/{id}/
The Merchant REST API is how your own server integrates with Esaalat. You create a payment intent with the exact amount and the wallet you collect into. When the buyer pays, their wallet shows a transaction reference. You attach that reference to the intent, and Esaalat verifies the payment and notifies you with a signed webhook.
Dashboard → API Keys: a client_id and a client_secret, shown once.pay_url), or render the wallet and amount yourself.| Method | Path |
|---|---|
| POST | /token |
| GET | /providers/ |
| GET | /wallets/ |
| POST | /wallets/ |
| GET/PATCH/DELETE | /wallets/{id}/ |
| GET | /devices/ |
| POST | /devices/ |
| GET/PATCH/DELETE | /devices/{id}/ |
| POST | /devices/{id}/registration-code/ |
| POST | /devices/{id}/attach-wallets/ |
| POST | /devices/{id}/detach-wallets/ |
| POST | /intents/ |
| GET | /intents/ |
| GET | /intents/{id}/ |
| PATCH | /intents/{id}/ |
Authenticate with your client credentials to obtain a short-lived JWT. Credentials are created in Dashboard → API Keys.
/api/v1/merchant/token| Name | Type | Required | Description |
|---|---|---|---|
client_id | 32 hex chars | Required | Public identifier, safe to embed. |
client_secret | urlsafe string | Required | Shown exactly once at creation and never retrievable afterwards. Rotate the key in the dashboard if it leaks. |
curl -sS -X POST https://api.esaalat.com/api/v1/merchant/token \
-H "ESAALAT-CLIENT-ID: <client_id>" \
-H "ESAALAT-CLIENT-SECRET: <client_secret>"Fetch the wallet providers you can open receiving wallets at. Pass a row's value as provider_id when adding a wallet. The dedicated testing provider is never listed.
/api/v1/merchant/providers/| Name | Type | Required | Description |
|---|---|---|---|
value | UUID string | Optional | The provider id. Pass it as provider_id when creating a wallet. |
label | string | Optional | Provider display name, e.g. Kuraimi. |
reference_type | string | Optional | reference, phone or name: what a DIRECT wallet of this provider asks the payer for. Access-point wallets always use the transaction reference. |
name_only_reference | boolean | Optional | true when transfers are identified by the sender's name alone, so two senders with the same name and the same amount are indistinguishable. |
name_parts | array of integers | Optional | For NAME providers: which parts of the sender's name to enter, e.g. [1, 2] = first and second names. |
curl -sS https://api.esaalat.com/api/v1/merchant/providers/ \
-H "Authorization: Bearer <access_token>"Before creating an intent, fetch the wallets you can collect into. Only active wallets with a monitoring device switched ON are returned. A wallet nobody is monitoring can never verify a payment. The dedicated testing wallet is excluded unless you opt in with ?include_test=1.
GET returns a plain array of wallets you can collect into right now. Add ?all=1 for the management list: every wallet on the account, paginated, each row flagged collectable. Both shapes accept ?label=<value> (exact group match), ?provider=<uuid>, ?search=<text> (name, account number and label) and ?ordering=name|created_at|label (prefix with - to reverse)./api/v1/merchant/wallets/| Name | Type | Required | Description |
|---|---|---|---|
id | UUID string | Optional | The wallet UUID. Pass it as wallet when creating an intent. |
name / account_number | string | Optional | What the buyer sees / sends money to. |
label | string ≤120 | Optional | Free-form group label your own platform sets on the wallet (e.g. the merchant's name, "Esaalat Store"). Empty when unset. Pull one group back with ?label=<value>. |
sender_names | array of strings | Optional | The sender names this wallet's payment alerts arrive under. A provider may alert from more than one name, and the monitoring device watches every entry. |
currency | string | Optional | The wallet's currency (YER today). |
provider | object | Optional | The wallet provider's branding: display name, theme color, logo URL. |
is_test | boolean | Optional | true for the account's dedicated testing wallet (only returned with ?include_test=1). |
collectable | boolean | Optional | true when the wallet can verify a payment right now (active, with a monitoring device switched ON). Only the ?all=1 management list carries it. |
curl -sS https://api.esaalat.com/api/v1/merchant/wallets/ \
-H "Authorization: Bearer <access_token>"POST /wallets/| Name | Type | Required | Description |
|---|---|---|---|
provider_id | UUID string | Required | From GET /providers. The dedicated testing provider is not selectable. |
name | string ≤120 | Required | Merchant-facing label, e.g. "Main store wallet". |
account_number | string ≤120 | Required | The wallet's phone number or account at the provider. Belongs to the merchant and must be unique per provider on your account. |
label | string ≤120 | Optional | Optional free-form group label, e.g. the merchant's name ("Acme Store"). Lets one account group its receiving wallets and pull them back with ?label=<value>. Editable later with PATCH. |
is_access_point | boolean | Optional | true (default) = transfers carry a transaction reference. false = direct wallet: the payer's phone number (or name, per the provider) becomes the reference. |
daily_limit / monthly_limit | decimal string | Optional | Optional informational receiving limits shown on the dashboard. |
curl -sS -X POST https://api.esaalat.com/api/v1/merchant/wallets/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"provider_id": "3f0a4b1e-0000-4000-8000-000000000002",
"name": "Kuraimi - Main",
"account_number": "777777777",
"label": "Acme Store",
"is_access_point": true
}'PATCH changes name, label and notes. is_active is read-only over the API: switching a wallet off is the lifecycle step that frees its account number and unlinks its devices, so removal is DELETE.DELETE /wallets/{id}/: a wallet with payment history is archived (200 with {"deactivated": true, "id"}) and its account number is freed so the same number can be added again; a wallet with no history is hard-deleted (204).A monitoring device is the Android phone that watches a wallet's payment SMS and reports it to Esaalat. A wallet with no device switched ON can never verify a payment, so wallets and devices are enrolled together. Creating a device returns a one-time registration code, shown exactly once because only its hash is stored, that a human enters in the Esaalat app on the phone holding the wallet's SIM.
/api/v1/merchant/devices/POST /devices/| Name | Type | Required | Description |
|---|---|---|---|
name | string ≤120 | Required | Merchant-facing label, e.g. "Store phone". |
wallet_ids | array of UUIDs | Required | The wallets this device monitors. At least one; every id must belong to your account. |
curl -sS -X POST https://api.esaalat.com/api/v1/merchant/devices/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "Shop phone 1",
"wallet_ids": ["b48249d2-9b34-4414-b20e-ffd8dfedaf89"]
}'| Name | Type | Required | Description |
|---|---|---|---|
registration_code | string | Optional | The one-time enrollment code. Shown exactly once, so copy it immediately. |
expires_in_minutes | integer | Optional | The code expires after 30 minutes. null = the code never expires (special-case devices flagged in the admin). |
wallets | array of objects | Optional | The wallets the device monitors — each with its id, name, label, account number and provider branding. |
GET /devices/ — Your devices, paginated. ?wallet=<uuid> narrows to the devices monitoring one wallet.GET /devices/{id}/ — One device: name, wallets, is_active, monitoring_enabled, last_seen_at.PATCH /devices/{id}/ — Rename a device: {"name": "New label"}.DELETE /devices/{id}/ — Remove the device (204). Its app token stops working; payment history is kept.POST /devices/{id}/registration-code/ — Mint a fresh one-time code (re-enrolls the phone). Re-issuing replaces the device token, so a phone that linked before must link again.POST /devices/{id}/attach-wallets/ — Attach more wallets: {"wallet_ids": [...]}. No new code is minted. Plan quota applies.POST /devices/{id}/detach-wallets/ — Detach wallets: {"wallet_ids": [...]}. The last wallet cannot be detached, so remove the device instead.monitoring_enabled and last_seen_at are reported by the phone itself: there is no API call that switches monitoring on remotely, a human does it in the app./api/v1/merchant/intents/| Name | Type | Required | Description |
|---|---|---|---|
wallet | UUID string | Required | A wallet of your account that is set up for payment monitoring in the dashboard. Only monitored wallets can receive payments. |
amount | decimal string | Optional | Total to collect, e.g. "1234.56". Required if items is absent. |
items | array of objects | Optional | Order line items; the total is computed from them. Required if amount is absent. If both are sent, amount is ignored. |
currency | string | Optional | Only YER is supported. Defaults to the wallet's currency and must match it. |
external_order_id | string ≤128 | Optional | Your own order reference, echoed back in every response and delivered as order_id in the payment.verified webhook, so you can match the payment to your own order without a lookup. |
notes | string ≤500 | Optional | Free-form note, stored on the intent. |
expires_at is returned.curl -sS -X POST https://api.esaalat.com/api/v1/merchant/intents/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-H "ESAALAT-IDEMPOTENCY-KEY: a1b2c3d4-0000-4000-8000-000000000001" \
-d '{
"wallet": "b48249d2-9b34-4414-b20e-ffd8dfedaf89",
"amount": "1234.56",
"currency": "YER",
"external_order_id": "order-12345",
"notes": "Optional note to the merchant"
}'{
"wallet": "b48249d2-9b34-4414-b20e-ffd8dfedaf89",
"amount": "1234.56",
"currency": "YER",
"external_order_id": "order-12345",
"notes": "Optional note to the merchant"
}{
"name": "Product name", // required, ≤100 chars
"description": "Optional", // ≤500 chars
"price": "1500.00", // decimal string, required
"quantity": 1 // integer ≥ 1, default 1
}ESAALAT-IDEMPOTENCY-KEY header (a UUID) on create to prevent double-booking when retrying a request. The same request replayed is safe./api/v1/merchant/intents/{id}/| Name | Type | Required | Description |
|---|---|---|---|
reference | string ≤128 | Required | The wallet transaction reference, trimmed of whitespace. |
curl -sS -X PATCH https://api.esaalat.com/api/v1/merchant/intents/<intent_id>/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"reference": "12345678"}'{
"reference": "12345678"
}PENDING intents accept a reference. Verified, expired, cancelled and wrong-amount intents are final (400).VERIFIED in the response.duplicate_reference: true) so it never silently double-books.List your intents, newest first, always scoped to your client account. 20 per page by default; ?page_size= (max 100) and ?page= control pagination.
/api/v1/merchant/intents/| Name | Type | Required | Description |
|---|---|---|---|
status | comma list | Optional | PENDING, VERIFIED, EXPIRED, CANCELLED, WRONG_AMOUNT. e.g. ?status=VERIFIED,WRONG_AMOUNT. Invalid value → 400. |
code | string | Optional | Exact payment_id, e.g. ?code=P7K4X92 |
reference | string | Optional | Exact wallet reference, e.g. ?reference=12345678 |
external_order_id | string | Optional | Your own order reference (exact match), as sent at creation. |
wallet | UUID | Optional | Restrict to one wallet. |
created_after | ISO 8601 | Optional | Inclusive (>=). |
created_before | ISO 8601 | Optional | Inclusive (<=). |
search | string | Optional | Free text matching reference, code and external_order_id. |
ordering | string | Optional | created_at, verified_at, payable_amount, status. Prefix with - to reverse. |
curl -sS "https://api.esaalat.com/api/v1/merchant/intents/?status=VERIFIED&created_after=2026-09-09T00:00:00Z&page_size=100" \
-H "Authorization: Bearer <access_token>"Get the current status and full detail of one intent. An intent of another account returns 404.
/api/v1/merchant/intents/{id}/| Name | Type | Required | Description |
|---|---|---|---|
status | string | Optional | Your source of truth: one of the statuses below. |
paid_amount | decimal string | Optional | Set only when status is WRONG_AMOUNT, to show what the buyer actually sent. |
matched_event | object | Optional | The raw SMS event that resolved the intent (its reference, amount, sender). |
device | object | Optional | The device that reported the payment. id stays the same even when the device code is regenerated. |
manual_override / collected_manually | boolean | Optional | true when the merchant booked the payment manually from the unmatched queue. |
curl -sS https://api.esaalat.com/api/v1/merchant/intents/<intent_id>/ \
-H "Authorization: Bearer <access_token>"{
"id": "dd157a06-a065-475d-8723-d98a61698aa0",
"code": "P7K4X92",
"external_order_id": "order-12345",
"reference": "12345678",
"status": "VERIFIED",
"base_amount": "1234.56",
"payable_amount": "1234.56",
"paid_amount": null,
"currency": "YER",
"items": [
{
"name": "Product name",
"description": "",
"price": "1234.56",
"quantity": 1,
"total_price": "1234.56"
}
],
"notes": "",
"wallet": {
"id": "b48249d2-9b34-4414-b20e-ffd8dfedaf89",
"name": "Kuraimi - Main",
"label": "Acme Store",
"sender_names": ["Kuraimi", "Kuraimi Bank"],
"is_test": false,
"sim_slot": "SIM 1",
"account_number": "777777777"
},
"provider": {
"id": "3f0a4b1e-0000-4000-8000-000000000002",
"name": "KURAIMI",
"display_name": "Kuraimi",
"theme_color": "#0b8f4c",
"logo_url": "https://<cdn>/kuraimi_256.png",
"provider_status": "ACTIVE"
},
"expires_at": "2026-09-09T14:30:00+03:00",
"created_at": "2026-09-09T13:30:00.123456+03:00",
"verified_at": "2026-09-09T13:41:22.654321+03:00",
"manual_override": false,
"collected_manually": false,
"matched_event": {
"id": "32fd9972-71fd-4b87-82ce-501061ab7c5d",
"amount": "1234.56",
"sender": "777777777",
"reference": "12345678",
"created_at": "2026-09-09T13:41:21+03:00"
},
"device": {
"id": "5a1f6c3d-0000-4000-8000-000000000004",
"name": "Shop phone 1"
},
"subscription": null
}| Name | Description | Terminal |
|---|---|---|
PENDING | Created, awaiting the buyer's payment + reference. | — |
VERIFIED | Payment detected and matched (wallet + reference + exact amount). | Terminal |
WRONG_AMOUNT | The reference matched but the paid amount differs from payable_amount. paid_amount records what arrived. It never auto-verifies, so the buyer must contact you. | Terminal |
EXPIRED | The 1-hour window passed without verification. | Terminal |
CANCELLED | Cancelled by the merchant (dashboard). | Terminal |
Webhooks are the primary channel for verification; use the list/retrieve endpoints for backfill and reconciliation. Configure endpoints in Dashboard → Webhooks, optionally scoped to a specific wallet.
Every request carries an X-Esaalat-Signature header: t=<unix-ts>,v1=<hex>. v1 is HMAC-SHA256 of "{ts}.{raw_body}" using your endpoint's secret. Find the secret in Dashboard → Webhooks: it is shown once when you add the endpoint (copy it then), and Regenerate Secret replaces it with a new one. Always compare with hmac.compare_digest, and optionally reject timestamps older than ~5 minutes (replay protection).
import hashlib, hmac
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
ts, v1 = (p.split("=", 1)[1] for p in signature.split(","))
digest = hmac.new(
secret.encode(), f"{ts}.{body.decode()}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(digest, v1)2xx within 6 seconds. Anything else counts as a failure and starts the retry schedule.Dashboard → Webhooks and is shown once, when you add the endpoint. Use it to verify the signature.payment.unmatched to that endpoint. It adds that event and leaves the others alone.payment.verifiedThe happy path: a payment was verified, either automatically or collected manually. order_id carries the external_order_id you sent with the intent (an empty string when you sent none), so the event ties straight back to the order in your own system.
{
"event": "payment.verified",
"payment_id": "P7K4X92",
"intent_id": "dd157a06-a065-475d-8723-d98a61698aa0",
"order_id": "order-12345",
"reference": "12345678",
"base_amount": "1234.56",
"amount": "1234.56",
"currency": "YER",
"status": "VERIFIED",
"verified_at": "2026-09-09T13:41:22+03:00",
"manual_override": false
}payment.unmatched (opt-in)Money landed on a real wallet but no intent claimed it. Only delivered if the endpoint explicitly opts into payment.unmatched. Never fires for the testing wallet. There is no order_id on this payload: an intent you never created cannot carry your reference.
{
"event": "payment.unmatched",
"event_id": "32fd9972-71fd-4b87-82ce-501061ab7c5d",
"reference": "12345678",
"amount": "1234.56",
"currency": "YER",
"wallet": "Kuraimi - Main",
"received_at": "2026-09-09T13:41:21+03:00",
"is_test": false
}payment.received (testing wallet only)Fires for any test-wallet payment arrival, even when no intent was created. Lets you exercise your integration end-to-end with the testing wallet. This payload also has no order_id, since a test payment may never have had an intent.
{
"event": "payment.received",
"event_id": "32fd9972-71fd-4b87-82ce-501061ab7c5d",
"reference": "12345678",
"amount": "1234.56",
"currency": "YER",
"resolution": "UNMATCHED",
"received_at": "2026-09-09T13:41:21+03:00",
"is_test": true
}intent_id / event_id.2xx to acknowledge; anything else schedules the next retry.Requests are rate-limited per API credential. When a limit is exceeded you receive 429 with a Retry-After header. Always handle 429 gracefully.
| Path | Burst | Sustained |
|---|---|---|
POST /token | 10/min | 500/day |
POST /intents/, GET/PATCH /intents/{id}/ | 240/min (~4 req/s) | 50,000/day |
GET /intents/ (list) | 60/min | 50,000/day |
GET /providers/, GET/POST /wallets/, GET/PATCH/DELETE /wallets/{id}/ | 240/min (~4 req/s) | 50,000/day |
GET/POST /devices/, GET/PATCH/DELETE /devices/{id}/, POST /devices/{id}/… | 240/min (~4 req/s) | 50,000/day |
Every error uses a standardized envelope:
{
"type": "validation_error",
"errors": [
{ "code": "required", "detail": "This field is required.", "attr": "wallet" }
]
}| Name | Description |
|---|---|
400 | Bad payload, unknown wallet, invalid filter, wallet not set up for payment monitoring, reference on a non-pending intent, quota exceeded. |
401 | Missing, invalid or expired Bearer token. |
403 | A dashboard (user) token used on merchant endpoints. A credential token is required. |
404 | Unknown intent id (or another account's intent). |
429 | Rate limit exceeded. Honor Retry-After. |
500 | Something broke. Report it. |