OPT Retention
For developers

API & Webhooks

Connect your POS, store or in-house system to OPT Retention: customers and sales recorded automatically, an instant webhook on every event. A key per integration, a request log, and replay on failure.

5-minute start

The API lets your POS, e-commerce store or any in-house system register customers and sales automatically in your account, and receive an instant webhook on every meaningful event: a sign-up, a sale, a redeemed reward, a review, an appointment.

  1. In the console: Admin → API & Webhooks. Included in the Pro plan, or as the "API access" add-on on other plans.
  2. Click + API key, name it (e.g. "Main branch POS") and choose scopes. The secret is shown once — copy it right away.
  3. Make your first call:
curl https://retention.aimenu.me/api/v1/customers?limit=5 \
  -H "Authorization: Bearer grs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Base URL: https://retention.aimenu.me/api/v1 · JSON in and out, UTF-8 · dates in ISO 8601 (2026-09-22T14:05:00Z) · amounts as integer minor units (amount_minor, halalas/piastres) or as a decimal (amount).

Authentication & scopes

Send the key as Authorization: Bearer <key> (or X-Api-Key). A key belongs to one business and can never see another.

ScopeAllows
customers:readList and search customers
customers:writeCreate a customer
transactions:writeRecord a sale (points are earned immediately)
customers:* / *Every scope of the group / everything

A key stops working automatically when the subscription lapses or the business moves to a plan without API access (403).

Customers

GET /api/v1/customers

Cursor-paginated list.

ParameterTypeDescription
limit1–100Page size (default 50)
cursorstringmeta.next_cursor from the previous page
searchstringMatches name or phone
lifecycle_statestringnew · active · at_risk · churned
{
  "ok": true,
  "data": [
    { "id": "cust_8f2…", "name": "Sarah Alotaibi", "phone": "+9665xxxxxxxx",
      "lifecycle_state": "active", "visit_count": 12, "points": 340,
      "last_visit_at": "2026-09-18T16:20:00Z", "return_due_at": "2026-10-02T16:20:00Z" }
  ],
  "meta": { "limit": 50, "count": 1, "next_cursor": null, "has_more": false }
}

GET /api/v1/customers/{id}

One customer, same shape. Unknown id → 404.

POST /api/v1/customers

FieldRequiredRules
nameyesup to 120 characters
phoneyesmobile number; normalised with the business's country code (05xxxxxxxx becomes +9665xxxxxxxx). Duplicate phone → 422
expected_return_daysno1–3650 (default 30) — this customer's expected return cycle
branch_idnoone of your branch ids
preferred_localenodefaults to ar-SA
curl -X POST https://retention.aimenu.me/api/v1/customers \
  -H "Authorization: Bearer grs_…" -H "Content-Type: application/json" \
  -d '{"name":"Sarah Alotaibi","phone":"0551234567"}'
# 201 → { "ok": true, "data": { "id": "cust_…", … } }

Record a sale

POST /api/v1/transactions

Records a sale for the customer and immediately applies points, tier and referral rewards, then triggers the automations (post-visit thank-you, review request).

The Idempotency-Key header is required (8–128 characters, e.g. your invoice number or a UUID). Resending the same request with the same key never records the sale twice — it returns the original with "duplicate": true. Same key with different data → 409.
FieldRequiredRules
customer_idyesa customer id of your business
amount or amount_minoryes47.50 or 4750 (minor units) — greater than zero
currencyno3 letters; defaults to the business currency (SAR)
occurred_atnoISO 8601; defaults to now
external_transaction_idnoyour invoice number — stored, and also prevents duplicates
branch_idnodefaults to the customer's preferred branch
curl -X POST https://retention.aimenu.me/api/v1/transactions \
  -H "Authorization: Bearer grs_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: INV-2026-000123" \
  -d '{"customer_id":"cust_8f2…","amount":180.00,"external_transaction_id":"INV-2026-000123"}'
# 200 → { "ok": true, "data": { "transaction": { "id": "…", "amount_minor": 18000, "currency": "SAR", "status": "completed" }, "duplicate": false } }

POS tip: send the sale after the invoice is closed and use the invoice number as the idempotency key — retries after a network drop are then safe.

Errors & limits

{ "error": { "code": "validation_failed", "message": "…",
             "request_id": "req_…", "fields": { "phone": ["must be a valid phone number"] } } }
HTTPcodeMeaning
401invalid_api_keyKey invalid or revoked
403insufficient_scope · feature_not_available · subscription_inactiveScope too narrow / plan without API / subscription inactive
400idempotency_key_requiredHeader missing
404not_foundUnknown route or customer
409idempotency_conflictSame key, different data
422validation_failedInvalid fields — details in fields
429rate_limitedMore than 240 requests/minute for the key — back off and retry

Quote the request_id when contacting support.

Webhooks (outbound notifications)

On the same page click + Webhook: an HTTPS URL on your side plus the events you want (empty = all). The secret whsec_… is shown once — keep it to verify signatures.

Every event is delivered as a POST:

POST https://your-system.example/hooks/retention
Content-Type: application/json
User-Agent: RetentionOS-Webhook/1.0
X-Retention-Event: transaction.completed
X-Retention-Event-Id: evt_…
X-Retention-Signature: sha256=3f1a…

{ "event": "transaction.completed", "timestamp": "2026-09-22T14:05:00Z",
  "data": { "transaction_id": "…", "customer_id": "cust_…", "amount_minor": 18000, "gross_minor": 18000, "discount_minor": 0, "currency": "SAR" },
  "meta": { "event_id": "evt_…", "correlation_id": null } }
  • Answer 2xx within seconds, then process in the background. Anything else is retried automatically after 1 min, 5 min, 15 min, 1 h, 6 h (5 attempts).
  • A 4xx (other than 408/429) means your system rejected the event: no retry; it shows as "failed" in the console with a Replay button.
  • Events can arrive more than once — make handling idempotent using event_id.
  • The URL must be public HTTPS (no localhost or private addresses).

Event catalogue

EventWhenMain data fields
customer.createdCustomer sign-up (QR, Wi-Fi, POS, API)customer_id, display_name
customer.mergedTwo customers mergedsource_customer_id, target_customer_id
transaction.completedSale recorded (POS/manual/API)transaction_id, customer_id, amount_minor, gross_minor, discount_minor, currency
transaction.voidedSale voidedtransaction_id, customer_id
sale.paid / sale.refundedPlatform POS sale / refundnumber, total_minor, customer_id, invoice_id / amount_minor, full
reward.redeemedReward redeemedcustomer_id, reward_id, transaction_id
loyalty.adjustedManual points adjustmentcustomer_id, delta
tier.changedCustomer tier changedcustomer_id, to, direction
coupon.issued / coupon.usedCoupon issued / usedcustomer_id, coupon_id, code / transaction_id, discount_minor
referral.joined / referral.qualified / referral.rewardedFriend joined / bought / referrer rewardedreferrer_customer_id, referee_customer_id / transaction_id / grant_type
review.ratedPost-visit ratingcustomer_id, rating, positive
appointment.created / appointment.rescheduled / appointment.canceledBookingsappointment_id, customer_id, starts_at, source / by
game.wonSpin-the-wheel prizecustomer_id, prize, kind

Verify the signature

Signature = sha256= + HMAC-SHA256 of the raw request body with your whsec_… secret. Verify before doing anything else:

// PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_RETENTION_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
$event = json_decode($raw, true);
http_response_code(200); // then process $event in the background
// Node.js (Express — use the raw body, not the parsed one)
app.post('/hooks/retention', express.raw({ type: '*/*' }), (req, res) => {
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const given = req.get('X-Retention-Signature') || '';
  if (expected.length !== given.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  res.sendStatus(200); // then process event
});

Go-live checklist

  • One API key per system (POS, store, ERP) with the narrowest scopes; any key can be revoked from the console without touching the others.
  • The invoice number is both the Idempotency-Key and external_transaction_id.
  • Create the customer first (or look them up by phone), then send the sale with their id.
  • The webhook handler answers 2xx immediately and ignores duplicates by event_id.
  • Watch the "API & Webhooks" page: last use per key, delivery health per webhook, and replay on failure.

Integration support: contact us with the request_id or event_id.