Building the integration
Sale events
One POST per completed sale. This endpoint is the entire integration for most vendors.
Send one request per sale, as soon as it is finalised and paid. Do not send open, parked or quoted orders — send the event that means “money changed hands”.
Payload
{
"eventId": "evt_2026-08-27_00041",
"eventType": "sale.completed",
"occurredAt": "2026-08-27T09:14:22Z",
"store": {
"id": "12345",
"name": "Majeedhee Magu"
},
"transaction": {
"id": "INV-2026-0042",
"total": 45.00,
"currency": "MVR",
"channel": "pos",
"customer": {
"type": "phone",
"value": "+9607123456"
},
"items": [
{ "id": "prod-8891", "name": "Flat White", "sku": "SKU-COFFEE-001", "quantity": 2, "unitPrice": 5.50 },
{ "id": "prod-2210", "name": "Chicken Sandwich", "sku": "SKU-FOOD-014", "quantity": 1, "unitPrice": 34.00 }
],
"discountCodes": [
{ "code": "PERX-K7Q2M9XW", "amount": 50.00, "type": "fixed_amount" }
]
}
}Field reference
| Field | Type | Req | Notes |
|---|---|---|---|
eventId | string ≤255 | yes | Unique per event you emit — collapses your own retries. No such id? Use the transaction id plus a suffix. |
eventType | enum | yes | sale.completed | sale.refunded | sale.voided |
occurredAt | ISO 8601 | yes | When the sale was finalised, not when you sent it. Include the offset or use Z. |
store.id | string ≤255 | yes | Must equal the X-Perx-Store header. Mismatch is a 400. |
store.name | string | no | Display only. Helps support identify the outlet. |
transaction.id | string ≤255 | yes | The idempotency key. Reuse it as the reference when refunding. |
transaction.total | number > 0 | yes | What the customer actually paid, after discounts. Points are calculated on this. |
transaction.currency | string ≤10 | yes | ISO 4217, e.g. MVR. |
transaction.channel | string ≤50 | no | pos, web, app, kiosk, or your own. Lets merchants write channel-specific rules. |
transaction.customer | object | no | Omit entirely for a walk-in. See below. |
transaction.items[] | array | yes | May be empty, but then item-based stamp rewards cannot match. |
items[].name | string ≤255 | yes | Used for keyword matching on stamp rules. |
items[].sku | string ≤255 | no | Used for exact matching. Send it if you have it — far more reliable than names. |
items[].quantity | integer ≥1 | yes | |
items[].unitPrice | number > 0 | yes | Price for one unit, before multiplying by quantity. |
transaction.discountCodes[] | array | no | How Perx online reward codes get redeemed. See below. |
Identifying the customer
Perx matches the sale to a member using one identifier, in whatever form your POS captured it.
| type | value |
|---|---|
phone | Preferred. Send full E.164 with the leading plus: +9607123456. A bare local Maldivian number (7123456) also works. |
email | Case-insensitive. |
membershipId | The Perx membership UUID. Fastest and unambiguous — store it after a first successful match and send it thereafter. |
externalId | Your own customer ID, if the merchant has mapped it into Perx. |
9607123456 is not currently normalised correctly and the customer will silently fail to match — you will get a 200 that looks like an ordinary non-member. Normalise to + form on your side. +9607123456 and 7123456 are both safe.
When the customer is unknown to Perx
The webhook still succeeds with 200, pointsEarned: 0 and a warning. Perx separately sends that phone number a one-time SMS inviting them to sign up. This is a normal business outcome — do not surface it to staff as an error, and do not retry.
When there is no customer at all
Omit transaction.customer entirely. The sale is accepted and ignored for loyalty — unless it carries a PERX- discount code, which identifies the member by itself.
Discount codes and online rewards
Perx can issue a customer a single-use discount code, always prefixed PERX-, which they redeem in your checkout like any other coupon. To close that loop:
- Report every code applied to the order in
discountCodes. - Include the
amountactually discounted, and thetypeif you know it. transaction.totalmust be the amount after the discount.
Perx matches PERX- codes against its issued codes, marks the reward redeemed and deducts the points. Non-Perx codes are ignored harmlessly, so send all of them.
Response
{
"status": "processed",
"membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31",
"pointsEarned": 45,
"stampsUpdated": [
{ "rewardId": "b21e77a0-...", "rewardTitle": "Coffee Club", "current": 5, "required": 5, "completed": true }
],
"warning": null
}| status | Meaning |
|---|---|
processed | Accepted and applied. |
duplicate | Already seen this transaction.id. Success — stop retrying, nothing was double-counted. |
ignored | Accepted but not loyalty-relevant, e.g. an event type Perx does not act on. |
pointsEarned and a completed stamp card on the customer’s receipt is the single highest-value thing you can build here, and it is one line of your existing receipt template.
Next: idempotency and retries — what to do when Perx is unreachable, and why you must never block a sale on it.