Reference
API reference
Public integration surface for POS, ERP and eCommerce vendors.
This page renders openapi.json directly, so it cannot drift from the contract. Download it to generate a client, or import it into Postman or Insomnia.
Servers
https://uat-connect.perx.mvUAT — build and certify herehttps://beta-connect.perx.mvBeta — pre-production merchant pilotshttps://connect.perx.mvProduction — issued at go-liveAuthentication
Every request carries X-Perx-Vendor, X-Perx-Store, X-Perx-Timestamp and X-Perx-Signature. The full scheme, with reference implementations in four languages, is on the Authentication page — and the signature playground will tell you exactly what Perx expects for a given body.
X-Perx-VendorYour vendor slug, assigned by Perx at onboarding. Lowercase and permanent.
X-Perx-StoreThe store/location identifier on your side, exactly as the merchant entered it in Perx. Routes the request to the right merchant and outlet.
X-Perx-TimestampUnix epoch seconds at the moment of signing, as a decimal string. Must be within 300 seconds of Perx server time.
X-Perx-Signature`v1=` + base64(HMAC-SHA256(timestamp + "." + rawBody, connectionSecret)).
Webhooks
Push sale and refund events to Perx. Start here — earn is the whole integration for most vendors.
Submit a sale or refund event
AvailableSend one request per finalised sale, as soon as it is paid. Do not send open, parked or quoted orders.
Idempotent on `transaction.id`, scoped to your vendor slug and store — sending the same transaction twice earns points once, enforced both by a 24-hour gateway cache and permanently in the loyalty ledger.
Always returns `200` for events Perx cannot act on (unknown event type, unknown customer) so that a normal business outcome never triggers a retry on your side.
Request body
| Field | Type | Description | |
|---|---|---|---|
eventId | string | required | Unique per event you emit — used to collapse your own retries. If you have no such id, use the transaction id with a suffix. |
eventType | "sale.completed" | "sale.refunded" | "sale.voided" | required | `sale.voided` is accepted and ignored — useful as a no-op for a Test Connection button. |
occurredAt | string | required | When the sale was finalised, not when you sent it. Backdated values are accepted, so a queued event delivered hours late still earns correctly. |
store | Store | required | |
└ id | string | required | Must equal the `X-Perx-Store` header. A mismatch is a `400` — this stops a valid signature for store A carrying a payload for store B. |
└ name | string | Display only. Helps support identify the outlet. | |
transaction | Transaction | required | |
└ id | string | required | Your order/invoice/bill number. **This is the idempotency key** and the reference you reuse for refunds. |
└ total | number | required | What the customer actually paid, **after** discounts, in `currency`. Points are calculated on this. |
└ currency | string | required | ISO 4217. |
└ channel | string | Order channel — lets merchants write channel-specific rules. | |
└ customer | CustomerIdentifier | Omit the whole object for a walk-in with no identifier. If you can store the `membershipId` returned by eligibility, send that on later sales — it removes all matching ambiguity. | |
└ items | array<Item> | required | May be empty, but then item-based stamp rewards cannot match. Send lines whenever you have them. |
└ discountCodes | array<DiscountCode> | Report **every** code applied. Perx matches `PERX-` prefixed codes against issued online rewards; other codes are ignored harmlessly. | |
refund | Refund | Present only on `sale.refunded`. `transaction.id` must be the **original** sale's id. | |
└ id | string | required | Unique id for this refund — the idempotency key for the reversal. |
└ amount | number | Omit for a full refund. When set, points reverse in proportion to the original order total. |
{
"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,
"currency": "MVR",
"channel": "pos",
"customer": {
"type": "phone",
"value": "+9607123456"
},
"items": [
{
"id": "prod-8891",
"name": "Flat White",
"sku": "SKU-COFFEE-001",
"quantity": 2,
"unitPrice": 5.5
},
{
"id": "prod-2210",
"name": "Chicken Sandwich",
"sku": "SKU-FOOD-014",
"quantity": 1,
"unitPrice": 34
}
]
}
}Response
{
"status": "processed",
"membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31",
"pointsEarned": 45,
"stampsUpdated": [
{
"rewardId": "b21e77a0-3c14-4e77-9a02-1d55f0b9c8e2",
"rewardTitle": "Coffee Club",
"current": 5,
"required": 5,
"completed": true
}
],
"warning": null
}Responses
200Accepted. Inspect `status` in the body — `duplicate` and `ignored` are successes, not errors.
400Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.
401Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.
422Well-formed JSON that fails validation — unknown fields, wrong types, non-positive totals. `fields` names each problem. Do not retry.
429Too many requests. Retry, honouring `Retry-After`.
500Perx-side fault. Retry with exponential backoff — suggested 1s, 5s, 30s, 2m, 10m, 1h, then dead-letter. Never block a sale on this.
Redemption
Synchronous calls your POS makes during checkout to spend rewards and points.
List what a customer can redeem
ComingPopulates the cashier's reward picker. Read-only and safe to call every time the cart changes.
A customer who is not a Perx member returns `200` with an empty `membershipId` and a `warning` — **not** a `404`. Check `membershipId` before rendering a picker.
Request body
| Field | Type | Description | |
|---|---|---|---|
customer | CustomerIdentifier | required | Omit the whole object for a walk-in with no identifier. If you can store the `membershipId` returned by eligibility, send that on later sales — it removes all matching ambiguity. |
└ type | "phone" | "email" | "membershipId" | "externalId" | required | |
└ value | string | required | For `phone`, send full E.164 with the leading plus (`+9607123456`). A bare local Maldivian number (`7123456`) is also accepted. Do **not** send a country code without the plus. |
orderValue | number | Current cart total — used to evaluate minimum-spend eligibility. |
{
"customer": {
"type": "phone",
"value": "+9607123456"
},
"orderValue": 45
}Response
{
"membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31",
"walletBalance": 1240,
"redemptionRate": 10,
"eligibleRewards": [
{
"rewardId": "b21e77a0-3c14-4e77-9a02-1d55f0b9c8e2",
"title": "Free Coffee",
"type": "STAMP",
"pointsCost": null,
"discountValue": 45,
"eligible": true,
"reason": null
},
{
"rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770",
"title": "MVR 100 off",
"type": "POINTS",
"pointsCost": 1000,
"discountValue": 100,
"eligible": true,
"reason": null
},
{
"rewardId": "77af0c12-9de4-4b88-a1e7-3c520f9b6a44",
"title": "Birthday Cake",
"type": "FREE_ITEM",
"pointsCost": null,
"discountValue": 250,
"eligible": false,
"reason": "Minimum spend of MVR 300 not met"
}
],
"warning": null
}Responses
200Resolved balance and reward list.
400Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.
401Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.
502Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.
Redeem a reward or points
ComingCall at **payment success**, not at cart time — there is no reserve/hold in v1. A redemption committed against an abandoned sale must be reversed with a `sale.refunded` event.
Provide exactly one of `rewardId` or `pointsToRedeem`. Idempotent per (`externalTransactionId`, reward): a retry returns `ALREADY_PROCESSED` without double-deducting.
`externalTransactionId` must be the same value you later send as `transaction.id` on the earn webhook — that is what binds the redemption to the sale for refunds.
Request body
| Field | Type | Description | |
|---|---|---|---|
customer | CustomerIdentifier | required | Omit the whole object for a walk-in with no identifier. If you can store the `membershipId` returned by eligibility, send that on later sales — it removes all matching ambiguity. |
└ type | "phone" | "email" | "membershipId" | "externalId" | required | |
└ value | string | required | For `phone`, send full E.164 with the leading plus (`+9607123456`). A bare local Maldivian number (`7123456`) is also accepted. Do **not** send a country code without the plus. |
externalTransactionId | string | required | Must match the `transaction.id` you will send on the earn webhook. |
rewardId | string | ||
pointsToRedeem | integer | ||
orderValue | number |
{
"customer": {
"type": "phone",
"value": "+9607123456"
},
"externalTransactionId": "INV-2026-0042",
"rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770",
"orderValue": 45
}Response
{
"status": "REDEEMED",
"membershipId": "0f6a1c9e-4b7d-4c2a-9f31-8a2d5e6b0c31",
"redemptionId": "4d81b0a7-6c33-49f2-b0aa-77e1d2c48f90",
"rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770",
"pointsDeducted": 1000,
"walletBalance": 240
}Responses
200Redeemed, or already redeemed on a previous identical call.
400Not eligible, insufficient points, or both/neither of rewardId and pointsToRedeem supplied. `message` is written for the cashier — display it verbatim.
401Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.
404Customer is not a Perx member. (Eligibility returns 200 + `warning` for this case; redeem returns 404.)
409Already redeemed against this order — treat as success.
502Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.
Reverse loyalty for a refunded sale
ComingDirect equivalent of sending a `sale.refunded` webhook — use whichever fits your architecture. Reverses points earned on the original sale (proportionally when `refundAmount` is set) and re-credits any points reward redeemed against it.
Idempotent on `refundTransactionId`, so two partial refunds of one sale are two distinct events.
Request body
| Field | Type | Description | |
|---|---|---|---|
externalTransactionId | string | required | The **original** sale's id. |
refundTransactionId | string | required | Unique id of this refund — the idempotency key. |
refundAmount | number | Omit for a full refund. |
{
"externalTransactionId": "INV-2026-0042",
"refundTransactionId": "CRN-2026-0007",
"refundAmount": 45
}Response
{
"status": "REVERSED",
"pointsReversed": 45,
"pointsReCredited": 1000
}Responses
200Reversal outcome. `NOTHING_TO_REVERSE` is a success — it means the original sale never reached Perx or earned nothing.
400Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.
401Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.
502Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.
Catalog
Push your item catalog so merchants can build item-level stamp rules against a real menu.
Push your item catalog
ComingFull-snapshot semantics: send your **entire active catalog** on each call. Items absent from the payload are marked inactive, never hard-deleted. A nightly push is plenty.
This is what lets a merchant build "buy 5 flat whites, get one free" by picking from a real menu instead of typing item names. Featured vendors do not build this — Perx pulls from your catalog API instead.
Request body
| Field | Type | Description | |
|---|---|---|---|
store | Store | required | |
└ id | string | required | Must equal the `X-Perx-Store` header. A mismatch is a `400` — this stops a valid signature for store A carrying a payload for store B. |
└ name | string | Display only. Helps support identify the outlet. | |
items | array<CatalogItem> | required | |
└ externalId | string | required | Item id on your side — the stable key across syncs. |
└ name | string | required | |
└ sku | string | ||
└ category | string | ||
└ price | number | ||
└ isActive | boolean | required |
{
"store": {
"id": "12345"
},
"items": [
{
"externalId": "prod-8891",
"name": "Flat White",
"sku": "SKU-COFFEE-001",
"category": "Beverages",
"price": 5.5,
"isActive": true
},
{
"externalId": "prod-2210",
"name": "Chicken Sandwich",
"sku": "SKU-FOOD-014",
"category": "Food",
"price": 34,
"isActive": true
}
]
}Response
{
"status": "synced",
"itemsReceived": 2,
"itemsCreated": 0,
"itemsUpdated": 2,
"itemsDeactivated": 0
}Responses
200Catalog accepted.
400Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.
401Bad or missing signature, unknown vendor or store, timestamp outside the 300s window, or the merchant's connection is inactive. Do not retry — check credentials and your clock. After a previously working integration this usually means the merchant rotated or removed their connection; surface it in your admin UI rather than retrying forever.
502Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.
System
Health and diagnostics.
Gateway health check
AvailableUnauthenticated liveness probe. Use it to verify network reachability and TLS from your environment before debugging signatures.
Responses
200Gateway is up.