Transactions
Verification gate (Phase 26): the org's preset decides which actions need a PIN. Standard never gates the POS API. Balanced gates value-deducting actions (/webhook/redeem, /webhook/coupons/redeem). Strict gates every action including /webhook/pos earn — an attacker with a stolen QR shouldn't even be able to stack stamps on the customer's account through a compromised cashier terminal. Each endpoint below documents its own PIN behaviour. Full retry pattern: Fraud Detection → PIN Validation API Flow.
Earn — POST /webhook/pos
Award stamps or points to a customer after a purchase.
POST /webhook/pos
X-Api-Key: {your_secret_key}
Content-Type: application/jsonRequest body
{
"customer_code": 482193,
"amount": 85.50,
"card_type": "point",
"external_id": "order_9f2a1c",
"verification_pin": "4821",
"manual_code": false
}| Field | Required | Description |
|---|---|---|
customer_code | ✅ | 6-digit customer identifier |
amount | ✅ | Purchase total (positive number) |
card_type | ❌ | "point" (default) or "stamp" |
external_id | ✅ | Your unique order/transaction ID — used for idempotency |
verification_pin | conditional | The PIN read from the customer's dashboard. Required only at Strict. Omit on the first call — the server returns 412 pin_required to start the challenge. Standard and Balanced never gate earn, so these calls succeed without a PIN. |
manual_code | optional | true if the cashier typed the customer code by hand instead of scanning a QR. Strict orgs reject manual entry with manual_code_disabled (422). |
Point campaigns: points earned = round(amount × earn_ratio) (standard rounding). The earn ratio is configured in your merchant app settings.
Stamp campaigns: Pass the number of stamps to award as amount (e.g. "amount": 1 to give 1 stamp). Fractional values are rounded; the minimum awarded is 1 stamp. The value cannot exceed your configured maximum stamp count per card.
Response
{
"data": {
"stamps": 0,
"points": 206
}
}The response reflects the customer's updated balance after the earn.
If a stamp goal is reached and a reward coupon is issued, the response includes the coupon:
{
"data": {
"stamps": 0,
"points": 0,
"coupon": {
"id": "e3b0c442-...",
"name": "Free Coffee",
"qr_token": "abc123..."
}
}
}Error cases (Strict orgs only)
| Condition | HTTP | Error code |
|---|---|---|
| PIN required | 412 | pin_required — body includes challenge_id. Read the PIN from the customer's dashboard and retry with verification_pin set. |
| PIN incorrect | 422 | pin_invalid — body includes remaining_attempts. After 3 wrong attempts the challenge is consumed; retry without verification_pin to issue a fresh one. |
| PIN expired | 422 | pin_expired — challenge older than 90 s. Retry without verification_pin to issue a fresh one. |
| PIN attempts exceeded | 429 | pin_attempts_exceeded |
| Manual entry blocked | 422 | manual_code_disabled |
End-to-end PIN-aware earn examples (Node.js / Python / PHP / cURL): POS Examples → Earn with PIN verification.
Redeem — POST /webhook/redeem
Deduct stamps or points from a customer's balance.
POST /webhook/redeem
X-Api-Key: {your_secret_key}
Content-Type: application/jsonRequest body
{
"customer_code": 482193,
"value": 50,
"card_type": "point",
"external_id": "redeem_a7c3d1",
"verification_pin": "4821",
"manual_code": false
}| Field | Required | Description |
|---|---|---|
customer_code | ✅ | 6-digit customer identifier |
value | ✅ | Number of stamps or points to deduct (positive integer) |
card_type | ❌ | "stamp" (default) or "point" |
external_id | ✅ | Your unique transaction ID — used for idempotency |
verification_pin | conditional | The PIN read from the customer's dashboard. Required at the Balanced and Strict presets. Omit on the first call — the server returns 412 pin_required to start the challenge. |
manual_code | optional | true if the cashier typed the customer code by hand instead of scanning a QR. Strict orgs reject manual entry with manual_code_disabled (422). |
Response
{
"data": {
"stamps": 2,
"points": 0
}
}Error cases
| Condition | HTTP | Error code |
|---|---|---|
| Balance too low | 422 | insufficient_balance |
| Customer not found | 400 | customer not found |
| Subscription expired | 402 | subscription_required |
| PIN required (Balanced / Strict orgs) | 412 | pin_required — body includes challenge_id. Read the PIN from the customer's dashboard and retry with verification_pin set. |
| PIN incorrect | 422 | pin_invalid — body includes remaining_attempts. After 3 wrong attempts the challenge is consumed; retry without verification_pin to issue a fresh one. |
| PIN expired | 422 | pin_expired — challenge older than 90 s. Retry without verification_pin to issue a fresh one. |
| PIN attempts exceeded | 429 | pin_attempts_exceeded |
| Manual entry blocked (Strict orgs) | 422 | manual_code_disabled |
Full retry pattern with code: Fraud Detection → PIN Validation API Flow. End-to-end examples for /webhook/redeem are in POS Examples → Redeem with PIN verification.
Idempotency
Both /webhook/pos and /webhook/redeem are fully idempotent via external_id.
If the same external_id is sent twice (e.g. due to a network retry), the second call returns HTTP 200 with:
{
"data": {
"message": "already processed"
}
}The transaction is not recorded twice. Use your order ID or a UUID generated at checkout time as the external_id.
Always set
external_id. Requests without it are rejected with HTTP 400.