Wasla

Integration docs

POS Transaction Ingest API

v1.0 · Last updated August 2026

Status: implemented and live. The endpoint below is running in production and waiting on credentials. Ask us for a key and we will issue one, map your store references to the right brands, and you can start posting transactions the same day. Field names are not frozen — if something does not fit how your system emits transactions, say so.

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 phone

The 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:

HeaderValue
X-Wasla-KeyYour key ID. Identifies the partner, not the merchant.
X-Wasla-TimestampUnix seconds at the moment the request was signed.
X-Wasla-SignatureHMAC-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

FieldTypeReq.Meaning
storeRefstringyesYour identifier for the store or branch. Mapped to a Wasla merchant during onboarding.
txnIdstring ≤64yesYour transaction identifier. Used as the idempotency key — must be stable across retries and unique per store.
typesale · refund · voidyesDirection of the event.
phonestringyesCustomer phone as captured on the bill. Any local or international format — see section 12.
amountnumberyesBill total in major currency units, e.g. 12.50. Always positive; direction comes from type.
currencyISO 4217yese.g. JOD, SAR, AED.
occurredAtRFC 3339yesWhen the bill closed, with timezone offset.
originalTxnIdstringrefundsThe txnId being reversed. Required when type is refund or void.
customerNamestringnoUsed only if we auto-enroll this customer.
consentbooleannoWhether the customer agreed to join the loyalty program. See section 11.
cashierRefstringnoCashier or terminal id, kept for audit.
receiptRefstringnoHuman-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
}
statusMeaning
creditedMember matched, balance moved.
duplicateThis txnId was already processed. The original result is returned unchanged. Not an error.
reversedA refund or void was applied and points were deducted.
pending_enrollmentNo member for this phone yet. Points are held and an invitation was sent. See section 11.
ignoredAccepted 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 429 and any 5xx, 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; occurredAt is what we record.

8Errors

Errors return a JSON body of the form { "error": "<code>", "message": "…" }.

HTTPerrorCause
400invalid_payloadA required field is missing or malformed. The message names the field.
400amount_too_largeA single transaction would award more than the per-transaction ceiling.
401invalid_signatureBad HMAC, unknown key, or timestamp outside the 300s window. The message says which.
403unknown_storestoreRef is not mapped to a Wasla merchant, or the mapping is deactivated.
409txn_conflictA refund whose originalTxnId names no sale, or names a sale that belongs to a different customer.
422invalid_phonePhone could not be normalized to a valid number.
429rate_limitedSlow down and retry with backoff.
503temporarily_unavailableOur 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:

ProgramBehaviour
PointsMerchant sets points per currency unit. floor(amount × rate), minimum 1.
VisitsOne visit per qualifying transaction. amount is recorded but does not scale the award.
CashbackA 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:

ModeBehaviour
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_enrollUnknown 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

  1. 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.
  2. You send us the list of storeRef values for the merchants going live, and we map each to a Wasla brand, with its enrolment mode.
  3. You fire test transactions at the sandbox. We confirm the ledger entries and the wallet pushes together, on a call.
  4. We agree per-merchant settings: enrollment mode, and whether consent is captured at the till.
  5. 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:

  1. Can your system fire an outbound webhook on transaction close, to a per-merchant URL and secret?
  2. Does the closed-bill payload include the customer phone number when the cashier enters one?
  3. Do you emit refunds and voids as their own events, linked to the original transaction?
  4. What identifies a store or branch in your system, and is it stable?
  5. Can the till capture a loyalty opt-in, or should we assume no consent?
  6. Is HMAC signing acceptable, or do you prefer mutual TLS or IP allow-listing?
  7. 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