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.
- In the console: Admin → API & Webhooks. Included in the Pro plan, or as the "API access" add-on on other plans.
- Click + API key, name it (e.g. "Main branch POS") and choose scopes. The secret is shown once — copy it right away.
- Make your first call:
curl https://retention.aimenu.me/api/v1/customers?limit=5 \
-H "Authorization: Bearer grs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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.
| Scope | Allows |
|---|---|
customers:read | List and search customers |
customers:write | Create a customer |
transactions:write | Record 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.
| Parameter | Type | Description |
|---|---|---|
limit | 1–100 | Page size (default 50) |
cursor | string | meta.next_cursor from the previous page |
search | string | Matches name or phone |
lifecycle_state | string | new · 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
| Field | Required | Rules |
|---|---|---|
name | yes | up to 120 characters |
phone | yes | mobile number; normalised with the business's country code (05xxxxxxxx becomes +9665xxxxxxxx). Duplicate phone → 422 |
expected_return_days | no | 1–3650 (default 30) — this customer's expected return cycle |
branch_id | no | one of your branch ids |
preferred_locale | no | defaults 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).
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.| Field | Required | Rules |
|---|---|---|
customer_id | yes | a customer id of your business |
amount or amount_minor | yes | 47.50 or 4750 (minor units) — greater than zero |
currency | no | 3 letters; defaults to the business currency (SAR) |
occurred_at | no | ISO 8601; defaults to now |
external_transaction_id | no | your invoice number — stored, and also prevents duplicates |
branch_id | no | defaults 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"] } } }
| HTTP | code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Key invalid or revoked |
| 403 | insufficient_scope · feature_not_available · subscription_inactive | Scope too narrow / plan without API / subscription inactive |
| 400 | idempotency_key_required | Header missing |
| 404 | not_found | Unknown route or customer |
| 409 | idempotency_conflict | Same key, different data |
| 422 | validation_failed | Invalid fields — details in fields |
| 429 | rate_limited | More 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
| Event | When | Main data fields |
|---|---|---|
customer.created | Customer sign-up (QR, Wi-Fi, POS, API) | customer_id, display_name |
customer.merged | Two customers merged | source_customer_id, target_customer_id |
transaction.completed | Sale recorded (POS/manual/API) | transaction_id, customer_id, amount_minor, gross_minor, discount_minor, currency |
transaction.voided | Sale voided | transaction_id, customer_id |
sale.paid / sale.refunded | Platform POS sale / refund | number, total_minor, customer_id, invoice_id / amount_minor, full |
reward.redeemed | Reward redeemed | customer_id, reward_id, transaction_id |
loyalty.adjusted | Manual points adjustment | customer_id, delta |
tier.changed | Customer tier changed | customer_id, to, direction |
coupon.issued / coupon.used | Coupon issued / used | customer_id, coupon_id, code / transaction_id, discount_minor |
referral.joined / referral.qualified / referral.rewarded | Friend joined / bought / referrer rewarded | referrer_customer_id, referee_customer_id / transaction_id / grant_type |
review.rated | Post-visit rating | customer_id, rating, positive |
appointment.created / appointment.rescheduled / appointment.canceled | Bookings | appointment_id, customer_id, starts_at, source / by |
game.won | Spin-the-wheel prize | customer_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-Keyandexternal_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.