PerxDevelopers

Building the integration

Sale events

One POST per completed sale. This endpoint is the entire integration for most vendors.

POST/webhooks/genericAvailable

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

FieldTypeReqNotes
eventIdstring ≤255yesUnique per event you emit — collapses your own retries. No such id? Use the transaction id plus a suffix.
eventTypeenumyessale.completed | sale.refunded | sale.voided
occurredAtISO 8601yesWhen the sale was finalised, not when you sent it. Include the offset or use Z.
store.idstring ≤255yesMust equal the X-Perx-Store header. Mismatch is a 400.
store.namestringnoDisplay only. Helps support identify the outlet.
transaction.idstring ≤255yesThe idempotency key. Reuse it as the reference when refunding.
transaction.totalnumber > 0yesWhat the customer actually paid, after discounts. Points are calculated on this.
transaction.currencystring ≤10yesISO 4217, e.g. MVR.
transaction.channelstring ≤50nopos, web, app, kiosk, or your own. Lets merchants write channel-specific rules.
transaction.customerobjectnoOmit entirely for a walk-in. See below.
transaction.items[]arrayyesMay be empty, but then item-based stamp rewards cannot match.
items[].namestring ≤255yesUsed for keyword matching on stamp rules.
items[].skustring ≤255noUsed for exact matching. Send it if you have it — far more reliable than names.
items[].quantityinteger ≥1yes
items[].unitPricenumber > 0yesPrice for one unit, before multiplying by quantity.
transaction.discountCodes[]arraynoHow 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.

typevalue
phonePreferred. Send full E.164 with the leading plus: +9607123456. A bare local Maldivian number (7123456) also works.
emailCase-insensitive.
membershipIdThe Perx membership UUID. Fastest and unambiguous — store it after a first successful match and send it thereafter.
externalIdYour own customer ID, if the merchant has mapped it into Perx.
Do not send a country code without the plus

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 amount actually discounted, and the type if you know it.
  • transaction.total must 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

json
{
  "status": "processed",
  "membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31",
  "pointsEarned": 45,
  "stampsUpdated": [
    { "rewardId": "b21e77a0-...", "rewardTitle": "Coffee Club", "current": 5, "required": 5, "completed": true }
  ],
  "warning": null
}
statusMeaning
processedAccepted and applied.
duplicateAlready seen this transaction.id. Success — stop retrying, nothing was double-counted.
ignoredAccepted but not loyalty-relevant, e.g. an event type Perx does not act on.
Print the result on the receipt

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.