
Integration docs
POS Transaction Ingest API
v1.0 · Last updated August 2026
1How it works
Wasla issues each of a merchant's customers a digital loyalty card that lives in Apple Wallet or Google Wallet. The card carries the customer's balance, tier and current reward, and updates on their phone within seconds of a change.
Today, points are added when a staff member scans the customer's card. This integration removes that step:
Customer pays at the till, phone number is on the bill
↓
Your POS closes the transaction
↓
Your system POSTs the transaction to Wasla
↓
Wasla matches the phone number to a member,
converts the bill amount to points, writes the ledger entry
↓
The customer's wallet card updates on their phoneThe customer never has to present a card, and staff never have to remember to scan. Your system sends money and a phone number; the loyalty rules stay on our side.
The customer is notified on their phone as part of this — Apple Wallet and Google Wallet both raise a notification showing their new balance, in Arabic or English to match the card. You do not need to send anything.
2Integration model
Preferred — you push to us. Your system fires an HTTP POST to a Wasla URL each time a bill is closed. Real-time, no polling, and no state for either side to reconcile. This is the model the rest of this document describes.
Fallback — we pull from you. If your architecture cannot make outbound calls, we can poll an endpoint on your side instead. That path is described in section 13, but it is slower for the customer and more work for both of us.
The one question that decides everything: can your system fire an outbound webhook on transaction close, to a URL and secret we configure per merchant? If yes, we build to that and this integration is small.
3Authentication
We issue you a key ID and a shared secret. Every request carries three headers:
| Header | Value |
|---|---|
X-Wasla-Key | Your key ID. Identifies the partner, not the merchant. |
X-Wasla-Timestamp | Unix seconds at the moment the request was signed. |
X-Wasla-Signature | HMAC-SHA256, hex encoded, prefixed sha256= |
The signature is computed over the timestamp and the exact raw request body, joined by a period:
base = "{timestamp}." + "{raw JSON body}"
sig = HMAC_SHA256(secret, base)
header = "sha256=" + hex(sig)We reject any request whose timestamp is more than 300 seconds from our clock, which bounds replay. Sign the body exactly as sent — do not re-serialize it, because key order and whitespace change the signature.
All traffic is TLS. If mutual TLS or IP allow-listing is easier on your side than HMAC, say so — we can support either instead.
4Endpoint
POST https://wasla-loyalty.com/api/v1/pos/transaction Content-Type: application/json
A sandbox host is issued during onboarding and behaves identically, against test merchants, with no wallet pushes sent to real customers.
5Request body
| Field | Type | Req. | Meaning |
|---|---|---|---|
storeRef | string | yes | Your identifier for the store or branch. Mapped to a Wasla merchant during onboarding. |
txnId | string ≤64 | yes | Your transaction identifier. Used as the idempotency key — must be stable across retries and unique per store. |
type | sale · refund · void | yes | Direction of the event. |
phone | string | yes | Customer phone as captured on the bill. Any local or international format — see section 12. |
amount | number | yes | Bill total in major currency units, e.g. 12.50. Always positive; direction comes from type. |
currency | ISO 4217 | yes | e.g. JOD, SAR, AED. |
occurredAt | RFC 3339 | yes | When the bill closed, with timezone offset. |
originalTxnId | string | refunds | The txnId being reversed. Required when type is refund or void. |
customerName | string | no | Used only if we auto-enroll this customer. |
consent | boolean | no | Whether the customer agreed to join the loyalty program. See section 11. |
cashierRef | string | no | Cashier or terminal id, kept for audit. |
receiptRef | string | no | Human-readable receipt number, kept for audit. |
Full example:
curl -X POST https://wasla-loyalty.com/api/v1/pos/transaction \
-H "Content-Type: application/json" \
-H "X-Wasla-Key: trust_live_7f21" \
-H "X-Wasla-Timestamp: 1755600000" \
-H "X-Wasla-Signature: sha256=9c1f…" \
-d '{
"storeRef": "TRUST-STORE-4412",
"txnId": "INV-2026-08-19-000871",
"type": "sale",
"phone": "0790000000",
"amount": 12.50,
"currency": "JOD",
"occurredAt": "2026-08-19T18:42:11+03:00",
"customerName":"Sara",
"consent": true,
"cashierRef": "till-02",
"receiptRef": "871"
}'Send one request per closed bill. Do not batch — batching makes partial failure ambiguous and costs you the per-transaction idempotency guarantee.
6Response
A successful call returns 200 and states exactly what happened:
{
"status": "credited",
"txnId": "INV-2026-08-19-000871",
"memberStatus": "existing",
"awarded": 12,
"unit": "points",
"balance": 148,
"walletUpdated": true
}| status | Meaning |
|---|---|
credited | Member matched, balance moved. |
duplicate | This txnId was already processed. The original result is returned unchanged. Not an error. |
reversed | A refund or void was applied and points were deducted. |
pending_enrollment | No member for this phone yet. Points are held and an invitation was sent. See section 11. |
ignored | Accepted and intentionally not credited — e.g. no phone on the bill, or the merchant runs in strict mode and the customer is not a member. |
walletUpdated reports whether the push to Apple or Google succeeded. A false here does not mean the points were lost — the ledger is already written, and the card catches up on its next refresh.
pending_enrollment additionally returns enrollUrl — the link that adds the card, already carrying the points just credited. ignored returns a reason, one of not_a_member, consent_required, unknown_member, merchant_inactive or nothing_to_reverse, so you can tell a policy decision apart from a mistake.
7Idempotency & retries
txnIdis the idempotency key. Send the same one twice and the second call returns the first call's result with "status": "duplicate" — the balance does not move again. This is deliberate: if your request times out, you do not know whether we processed it, and the safe action is always to retry.
Recommended retry policy:
- Retry on
429and any5xx, with exponential backoff, for up to 24 hours. - Do not retry other
4xx— the payload will not become valid on its own. - Queue offline. A transaction that arrives late still credits correctly;
occurredAtis what we record.
8Errors
Errors return a JSON body of the form { "error": "<code>", "message": "…" }.
| HTTP | error | Cause |
|---|---|---|
| 400 | invalid_payload | A required field is missing or malformed. The message names the field. |
| 400 | amount_too_large | A single transaction would award more than the per-transaction ceiling. |
| 401 | invalid_signature | Bad HMAC, unknown key, or timestamp outside the 300s window. The message says which. |
| 403 | unknown_store | storeRef is not mapped to a Wasla merchant, or the mapping is deactivated. |
| 409 | txn_conflict | A refund whose originalTxnId names no sale, or names a sale that belongs to a different customer. |
| 422 | invalid_phone | Phone could not be normalized to a valid number. |
| 429 | rate_limited | Slow down and retry with backoff. |
| 503 | temporarily_unavailable | Our side. Always safe to retry — the endpoint is idempotent. |
Note what is not an error: re-sending a txnId we already processed answers 200 with duplicate, and a transaction we deliberately did not credit answers 200 with ignored. Only the rows above need handling in your error path.
9Refunds & voids
Without reversal events, a merchant pays out points on refunded bills. Send type: "refund" or type: "void" with the original originalTxnId and its own unique txnId:
{
"storeRef": "TRUST-STORE-4412",
"txnId": "RFND-2026-08-19-000112",
"originalTxnId": "INV-2026-08-19-000871",
"type": "refund",
"phone": "0790000000",
"amount": 12.50,
"currency": "JOD",
"occurredAt": "2026-08-19T19:10:00+03:00"
}Partial refunds are supported — send the refunded amount, not the original total, and we deduct proportionally against what was actually awarded, never against a recomputed rate. Successive partial refunds of the same sale are tracked, so three JD 40 refunds of a JD 100 bill take back exactly the original award and never more. A void takes back the whole remaining award whatever amount you send.
On a stamp-card (visits) program a stamp is indivisible: a partial refund removes nothing and answers ignored; only a void or a full-value refund removes the stamp.
If the member has already spent the points, the balance is allowed to go negative rather than silently absorbing the loss; the merchant sees it in their ledger.
Please confirm your system emits refunds and voids. If it does not, we will need a different mitigation and the merchant should know about it up front.
10How points are calculated
You send money. We own the loyalty math, because it is configured per merchant and changes without a deploy. Three program types exist:
| Program | Behaviour |
|---|---|
| Points | Merchant sets points per currency unit. floor(amount × rate), minimum 1. |
| Visits | One visit per qualifying transaction. amount is recorded but does not scale the award. |
| Cashback | A percentage of the bill is returned as spendable balance, held in minor units. |
Tier promotions, reward unlocks and the wallet push all follow automatically from the ledger entry. Your side does not need to model any of it.
11Customers who are not members yet
A phone number on a bill does not mean that person has joined the merchant's loyalty program. Each merchant is configured to one of two modes:
| Mode | Behaviour |
|---|---|
| strict (default) | Unknown phone → nothing is stored, response is ignored with reason not_a_member. The customer must join through the merchant's signup page first. |
| auto_enroll | Unknown phone with consent → we create the member, credit the points, mint their card, and return pending_enrollment with an enrollUrl. |
We do not send the invitation. Wasla has no SMS channel today, so enrollUrlcomes back to you and delivering it is yours or the merchant's to do — print it on the receipt, show it as a QR on the customer display, or send it from your own messaging. The points are already on the card by the time they open it.
Consent is enforced, not assumed.Under Jordan's personal data protection rules we should not enroll someone who did not ask to be enrolled, so auto-enrolment requires consent: true on the transaction. Without it the response is ignored with reason consent_required and nothing is stored — not even the phone number. If your till cannot capture an opt-in, tell us and we will leave those merchants on strict mode.
12Phone number format
Send whatever the POS captured. We normalize to E.164 before matching, and we are Jordan-aware:
0790000000 → +962790000000 +962790000000 → +962790000000 962 79 000 0000 → +962790000000 00962790000000 → +962790000000
Foreign numbers with a country code are preserved as given. A number that cannot be normalized to a valid E.164 value returns 422. Do not strip or reformat on your side — inconsistent formatting is the single most common cause of one customer becoming two records.
13If you cannot send webhooks
If outbound calls are not possible, we will poll you instead. In that case we need:
- An endpoint returning transactions since a cursor or timestamp, with stable ordering.
- The same fields listed in section 5, however they are named on your side.
- Pagination with a cursor we can persist, so a restart does not replay or skip.
- Refunds and voids present in the same feed.
- Authentication details, and whatever rate limit you want us to stay under.
Expect a one to two minute delay before the customer's card updates, versus a few seconds with webhooks. That difference is visible to the customer standing at the till, which is why we prefer the push model.
14Onboarding steps
- We issue you a key ID and secret. We cannot retrieve the secret later, so store it the moment you receive it — a lost one is replaced by issuing a new key, not by looking the old one up.
- You send us the list of
storeRefvalues for the merchants going live, and we map each to a Wasla brand, with its enrolment mode. - You fire test transactions at the sandbox. We confirm the ledger entries and the wallet pushes together, on a call.
- We agree per-merchant settings: enrollment mode, and whether consent is captured at the till.
- We issue production credentials and enable the first merchant.
Realistically this is days, not weeks, once section 15 is answered.
15What we need from you
These are the only answers blocking implementation:
- Can your system fire an outbound webhook on transaction close, to a per-merchant URL and secret?
- Does the closed-bill payload include the customer phone number when the cashier enters one?
- Do you emit refunds and voids as their own events, linked to the original transaction?
- What identifies a store or branch in your system, and is it stable?
- Can the till capture a loyalty opt-in, or should we assume no consent?
- Is HMAC signing acceptable, or do you prefer mutual TLS or IP allow-listing?
- Do you have a sandbox we can test against?
Wasla is operated by Global Gulf Gate LLC, Amman, Jordan. Technical contact: rawanwasla@gmail.com · All docs · Privacy