Overview
Each merchant has its own credentials, so a POS only ever sees that merchant's customers. The flow is:
- The Nuqta team generates a client ID and client secret for the merchant in the admin panel. The secret is shown once; store it in your POS's secret store.
- Your POS exchanges them for an access token (valid 1 hour).
- Every API call sends
Authorization: Bearer <access_token>. When the token expires, request a new one.
All amounts are decimal strings in the merchant's currency (JOD has 3 decimals: "18.750"). Points are integers. Times are ISO 8601.
Authentication
POST /pos/oauth/token — OAuth 2.0 client credentials grant. Send the credentials as form fields, JSON, or HTTP Basic auth.
curl -X POST https://nuqtaloyalty.com/api/v1/pos/oauth/token \
-H "Accept: application/json" \
-d grant_type=client_credentials \
-d client_id=nq_xxxxxxxxxxxxxxxxxxxxxxxx \
-d client_secret=nqs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
HTTP/1.1 200 OK
{
"access_token": "41|Qm9vZ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "pos:lookup pos:earn pos:redeem pos:void",
"merchant": "demo-cafe"
}
| Error | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | client_id or client_secret missing |
unsupported_grant_type | 400 | grant_type must be client_credentials |
invalid_client | 401 | Wrong or revoked credentials, or the merchant is paused |
invalid_client | 429 | Too many failed attempts for this client (see Retry-After) |
The scope lists what the credentials may do. A call outside that scope returns 403 FORBIDDEN. Revoking the credentials in the admin panel ends every token issued with them immediately.
Verify a card
POST /pos/lookup (scope pos:lookup). Send one of: barcode (what you scanned from the Wallet pass), membership_number, or phone_number in E.164 (+962791234567).
curl -X POST https://nuqtaloyalty.com/api/v1/pos/lookup \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"barcode": "WP10000001"}'
{
"data": {
"membership_number": "WP10000001",
"full_name": "Rana Saleh",
"points_balance": 1250,
"monetary_value": { "minor": 12500, "amount": "12.500", "currency": "JOD", "formatted_en": "JOD 12.500" },
"tier": { "code": "GOLD", "name_en": "Gold", "points_to_next_tier": 750 },
"redemption": { "min_redeem_points": 100, "can_redeem": true },
"wallet": { "apple_pass_url": "…", "google_save_url": "…" }
}
}
Unknown cards return 404 CUSTOMER_NOT_FOUND. Lookups never change a balance.
Issue points
POST /pos/transactions/earn (scope pos:earn). Points are calculated by Nuqta from the merchant's earn rate and the customer's tier.
curl -X POST https://nuqtaloyalty.com/api/v1/pos/transactions/earn \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: INV-2231" \
-d '{
"barcode": "WP10000001",
"gross_amount": "18.750",
"currency": "JOD",
"receipt_number": "INV-2231",
"occurred_at": "2026-09-28T10:38:15Z"
}'
| Field | Required | Notes |
|---|---|---|
barcode / membership_number / phone_number | one of | Identifies the card |
gross_amount | yes | Decimal string, at most the currency's decimals |
idempotency_key | yes | Body field or Idempotency-Key header |
receipt_number | no | Up to 100 characters; shown in reports |
occurred_at | no | Within the last 30 days |
metadata | no | Up to 25 keys of your own (e.g. cashier id) |
Returns 201 with data.transaction (points, balance before and after, tier change) and the updated data.customer. The customer's Wallet card updates within seconds.
Redeem
POST /pos/transactions/redeem (scope pos:redeem). Spend points as a discount on the current sale.
curl -X POST https://nuqtaloyalty.com/api/v1/pos/transactions/redeem \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"idempotency_key": "burn-INV-2232", "membership_number": "WP10000001", "points": 500}'
The balance is checked under a lock, so two tills can never spend the same points. Not enough points returns 422 INSUFFICIENT_BALANCE with details.available_points; below the merchant's minimum returns 422 INVALID_POSTING. The response's gross_amount is the discount value to apply on the receipt.
Void
POST /pos/transactions/void (scope pos:void). Reverses an earn or a redemption exactly, for refunds and mistakes. A transaction can be voided once.
curl -X POST https://nuqtaloyalty.com/api/v1/pos/transactions/void \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"idempotency_key": "void-INV-2231", "original_transaction": "INV-2231", "reason": "Returned goods"}'
original_transaction is the transaction UUID from the earlier response, or the idempotency key you used for it.
Idempotency
Every earn, redeem and void needs a unique idempotency_key (your receipt number works well). If the network drops, retry with the same key: Nuqta returns the original result instead of posting twice. Reusing a key with a different payload returns 409 IDEMPOTENCY_KEY_REUSED.
Errors & limits
Every error from the POS endpoints has the same shape:
{ "error": { "code": "INSUFFICIENT_BALANCE", "message": "…", "details": { "available_points": 18 } } }
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED | 422 | details lists field errors |
UNAUTHENTICATED / FORBIDDEN | 401 / 403 | Missing or expired token, or outside the token's scope |
TERMINAL_INACTIVE / TENANT_INACTIVE / CUSTOMER_INACTIVE | 423 | Credentials revoked, merchant paused, or card closed |
CUSTOMER_NOT_FOUND / TRANSACTION_NOT_FOUND | 404 | Unknown card or transaction for this merchant |
INSUFFICIENT_BALANCE | 422 | Not enough points (also when voiding an earn already spent) |
INVALID_POSTING | 422 | Below the minimum redemption, or a purchase too small to earn |
TRANSACTION_NOT_VOIDABLE | 422 | Already voided, or not an earn or redemption |
IDEMPOTENCY_KEY_REUSED | 409 | Same key, different payload |
RATE_LIMITED | 429 | Default 600 requests per minute per credential; the token endpoint allows 30 per minute per IP |
Go-live checklist
- Keep the client secret on your server or in the POS's encrypted store; never in a browser or a mobile app.
- Cache the access token and request a new one only when it expires (
expires_in) or you get401. - Use your receipt number as the idempotency key and retry on timeouts.
- Send
gross_amountas a string with the right decimals ("5.000", not5). - If your POS calls from fixed IP addresses, ask the Nuqta team to lock the credentials to them.
- Lost or leaked secret? Ask the team to revoke it and generate a new pair; revocation takes effect immediately.