PerxDevelopers

Reference

API reference

Public integration surface for POS, ERP and eCommerce vendors.

Generated from the OpenAPI spec

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 here
https://beta-connect.perx.mvBeta — pre-production merchant pilots
https://connect.perx.mvProduction — issued at go-live

Authentication

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-Vendor

Your vendor slug, assigned by Perx at onboarding. Lowercase and permanent.

X-Perx-Store

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

Unix 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

Available
POST/webhooks/generic

Send 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

FieldTypeDescription
eventIdstringrequiredUnique 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.
occurredAtstringrequiredWhen the sale was finalised, not when you sent it. Backdated values are accepted, so a queued event delivered hours late still earns correctly.
storeStorerequired
idstringrequiredMust 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.
namestringDisplay only. Helps support identify the outlet.
transactionTransactionrequired
idstringrequiredYour order/invoice/bill number. **This is the idempotency key** and the reference you reuse for refunds.
totalnumberrequiredWhat the customer actually paid, **after** discounts, in `currency`. Points are calculated on this.
currencystringrequiredISO 4217.
channelstringOrder channel — lets merchants write channel-specific rules.
customerCustomerIdentifierOmit 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.
itemsarray<Item>requiredMay be empty, but then item-based stamp rewards cannot match. Send lines whenever you have them.
discountCodesarray<DiscountCode>Report **every** code applied. Perx matches `PERX-` prefixed codes against issued online rewards; other codes are ignored harmlessly.
refundRefundPresent only on `sale.refunded`. `transaction.id` must be the **original** sale's id.
idstringrequiredUnique id for this refund — the idempotency key for the reversal.
amountnumberOmit 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

200

Accepted. Inspect `status` in the body — `duplicate` and `ignored` are successes, not errors.

400

Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.

401

Bad 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.

422

Well-formed JSON that fails validation — unknown fields, wrong types, non-positive totals. `fields` names each problem. Do not retry.

429

Too many requests. Retry, honouring `Retry-After`.

500

Perx-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

Coming
POST/v1/redemption/eligibility

Populates 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

FieldTypeDescription
customerCustomerIdentifierrequiredOmit 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
valuestringrequiredFor `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.
orderValuenumberCurrent cart total — used to evaluate minimum-spend eligibility.
json
{
  "customer": {
    "type": "phone",
    "value": "+9607123456"
  },
  "orderValue": 45
}

Response

json
{
  "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

200

Resolved balance and reward list.

400

Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.

401

Bad 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.

502

Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.

Redeem a reward or points

Coming
POST/v1/redemption/redeem

Call 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

FieldTypeDescription
customerCustomerIdentifierrequiredOmit 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
valuestringrequiredFor `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.
externalTransactionIdstringrequiredMust match the `transaction.id` you will send on the earn webhook.
rewardIdstring
pointsToRedeeminteger
orderValuenumber
{
  "customer": {
    "type": "phone",
    "value": "+9607123456"
  },
  "externalTransactionId": "INV-2026-0042",
  "rewardId": "9c04e2b1-77aa-4c31-8f0d-2b6e91a4d770",
  "orderValue": 45
}

Response

json
{
  "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

200

Redeemed, or already redeemed on a previous identical call.

400

Not eligible, insufficient points, or both/neither of rewardId and pointsToRedeem supplied. `message` is written for the cashier — display it verbatim.

401

Bad 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.

404

Customer is not a Perx member. (Eligibility returns 200 + `warning` for this case; redeem returns 404.)

409

Already redeemed against this order — treat as success.

502

Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.

Reverse loyalty for a refunded sale

Coming
POST/v1/refund

Direct 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

FieldTypeDescription
externalTransactionIdstringrequiredThe **original** sale's id.
refundTransactionIdstringrequiredUnique id of this refund — the idempotency key.
refundAmountnumberOmit for a full refund.
json
{
  "externalTransactionId": "INV-2026-0042",
  "refundTransactionId": "CRN-2026-0007",
  "refundAmount": 45
}

Response

json
{
  "status": "REVERSED",
  "pointsReversed": 45,
  "pointsReCredited": 1000
}

Responses

200

Reversal outcome. `NOTHING_TO_REVERSE` is a success — it means the original sale never reached Perx or earned nothing.

400

Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.

401

Bad 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.

502

Perx 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

Coming
POST/v1/catalog/sync

Full-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

FieldTypeDescription
storeStorerequired
idstringrequiredMust 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.
namestringDisplay only. Helps support identify the outlet.
itemsarray<CatalogItem>required
externalIdstringrequiredItem id on your side — the stable key across syncs.
namestringrequired
skustring
categorystring
pricenumber
isActivebooleanrequired
json
{
  "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

json
{
  "status": "synced",
  "itemsReceived": 2,
  "itemsCreated": 0,
  "itemsUpdated": 2,
  "itemsDeactivated": 0
}

Responses

200

Catalog accepted.

400

Malformed body, missing required field, or `store.id` does not match the `X-Perx-Store` header. Do not retry — fix the payload.

401

Bad 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.

502

Perx loyalty engine temporarily unreachable. Retry once, then complete the sale without loyalty and queue the earn event.

System

Health and diagnostics.

Gateway health check

Available
GET/health

Unauthenticated liveness probe. Use it to verify network reachability and TLS from your environment before debugging signatures.

Responses

200

Gateway is up.