API reference
Transaction endpoints
Endpoints for recording purchase transactions and viewing transaction history. Submitting a transaction automatically awards points based on the vendor's active earn rules.
Submit a transaction
POST /api/v1/transactions
Record a purchase and earn points for the customer. The customer is identified by one of their active identifiers.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
identifier | string | Yes | Customer identifier (card number, email, etc.) |
idempotency_key | string | Yes | Unique key to prevent duplicate processing |
total_amount | integer | Yes | Transaction amount in cents. A whole number, minimum 1, that fits a signed 32-bit integer. |
description | string | No | Description of the purchase |
external_reference | string | No | Reference to the transaction in your system |
branch | string | No | Your own key for the branch this purchase happened at: the external_id you gave it, at most 64 characters. It overrides the branch the API credential defaults to, so one shared credential can name a different shop per request. Omit it to use the credential's default, and omit both to leave the purchase unattributed. |
items | array | No | Order line items (see below). Optional. Omit for a header-only transaction. |
Line items
Send items to record what was purchased. Line items power the vendor's
statistics (top products, revenue by category, order volume). Each item:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Product name |
quantity | integer | Yes | Quantity purchased, minimum 1, signed 32-bit integer |
unit_price | integer | Yes | Price per unit in cents, signed 32-bit integer |
sku | string | No | Product SKU / code |
category | string | No | Product category (used for revenue-by-category stats) |
line_total is derived server-side
Each item's line_total is computed as quantity × unit_price; do not send it. Items are not required to sum
to total_amount. The total may include tax, discounts, or rounding. Items are stored only when the transaction is
first created; an idempotent replay never duplicates them.
Example request
{
"identifier": "CARD-001",
"idempotency_key": "order-2024-001",
"total_amount": 2500,
"description": "Lunch order",
"external_reference": "POS-42-001",
"branch": "string",
"items": [
{
"name": "Latte",
"quantity": 2,
"unit_price": 350,
"sku": "COF-01",
"category": "drinks"
}
]
}
curl -X POST https://puntjes.app/api/v1/transactions \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"identifier":"CARD-001","idempotency_key":"order-2024-001","total_amount":2500,"description":"Lunch order","external_reference":"POS-42-001","branch":"string","items":[{"name":"Latte","quantity":2,"unit_price":350,"sku":"COF-01","category":"drinks"}]}'
Response (201 Created)
{
"data": {
"id": 42,
"customer_id": 1,
"idempotency_key": "order-2024-001",
"total_amount": 2500,
"description": "Lunch order",
"external_reference": "POS-42-001",
"branch": "string",
"created_at": "2024-03-15T12:30:00Z",
"points_earned": 500,
"rules_applied": [
{
"campaign_version": null,
"family": null,
"line_breakdown": null,
"moment": null,
"points_earned": 250,
"reason": null,
"rule_id": 3,
"rule_name": "Standard rate",
"rule_type": "base_rate",
"suppressed_by": null
}
],
"items": [
{
"id": 101,
"name": "Latte",
"sku": "COF-01",
"quantity": 2,
"unit_price": 350,
"line_total": 700,
"category": "drinks"
}
]
}
}
The rules_applied breakdown
rules_applied is the full audit of everything that was considered for this purchase: both the
vendor's standing earn rules and their campaigns. Every entry carries the same ten keys;
the last six are always present and are null for anything that is not a campaign.
| Field | Type | Description |
|---|---|---|
rule_id | integer | The earn rule's id, or the campaign's id when rule_type is campaign |
rule_name | string | The vendor's own name for the rule or campaign. Never translated server-side. |
rule_type | string | base_rate or campaign |
points_earned | integer | Points this entry contributed. 0 is a normal value. See below. |
family | string|null | transaction or customer_moment. null for an earn rule. |
moment | string|null | The lifecycle moment that fired, for a customer_moment campaign. null otherwise. |
campaign_version | integer|null | The campaign version in force when the purchase was scored. null for an earn rule. |
suppressed_by | integer|null | The id of the campaign that outranked this one. null when nothing suppressed it. |
reason | string|null | Stable token explaining a zero-point outcome. Currently only suppressed_by_stronger_campaign. |
line_breakdown | array|null | Per-line detail for a product-scoped fixed-points award. null otherwise. |
rule_type `campaign` replaced the old `multiplier` entry
Bonus multipliers are owned by campaigns, not by earn rules. A client that switches on rule_type must handle
campaign; the value multiplier no longer appears in rules_applied. The key names changed too. Entries use
rule_id / rule_name / rule_type / points_earned, not name / type / points.
Zero-point entries are reported, not dropped
An entry with points_earned: 0 is informational and appears in two situations:
- An earn rule that matched nothing, for example a rule whose
min_transaction_amountthe purchase did not reach. Every active earn rule is listed whether or not it paid. - A suppressed campaign. When two campaigns that refuse to share a purchase both match, only
the strongest pays. The others come back with
points_earned: 0,reason: "suppressed_by_stronger_campaign", andsuppressed_byset to the id of the winner. Ties go to the lower campaign id. Campaigns marked combinable are exempt and all pay.
Points from campaigns are calculated on what the base rate actually credited. A purchase below
the base rule's own min_transaction_amount earns no base points, and therefore earns no
multiplier bonus on top of them either.
Product-scoped campaigns and sku
A campaign limited to specific products only bonuses line items whose sku exactly matches one of
the products configured on the campaign. Line items with no sku, or with a sku the campaign
does not list, earn no bonus and never cause an error. A transaction sent with only
total_amount and no items is unaffected by product scope.
When such a campaign pays, line_breakdown shows how the total was reached:
{
"rule_id": 9,
"rule_name": "Coffee bonus",
"rule_type": "campaign",
"points_earned": 100,
"family": "transaction",
"moment": null,
"campaign_version": 3,
"suppressed_by": null,
"reason": null,
"line_breakdown": [{ "sku": "COF-01", "quantity": 2, "points": 100 }]
}
line_breakdown can sum higher than points_earned
The breakdown reports every matching line, then the campaign's per-purchase ceiling is applied to the finished
total. When the ceiling cuts the award, the line figures still show what matched, so their sum may exceed
points_earned.
Idempotency
The idempotency_key must be unique per vendor. If you submit a transaction with an idempotency_key that already exists:
- The original transaction is returned
- Points are not re-awarded
- No new ledger entry is created
points_earnedandrules_appliedreport what the original call awarded, not zero
This is critical for POS systems where network retries may cause duplicate requests.
Safe to read points_earned off a retry
A replay returns the same points_earned and the same rules_applied breakdown as
the first call, including any rule that evaluated to 0 points. You can record the
response of a retried request exactly as you would record the response of the first
one.
Transactions created before this behaviour shipped have no stored breakdown, so a
replay of one of those still reports points_earned: 0. Their ledger entries are
unaffected and remain the record of what was actually awarded.
Choose idempotency keys carefully
Use a value that uniquely identifies the purchase in your system, such as an order ID or receipt number. Avoid a random UUID that changes on retry: it defeats the purpose of idempotency.
Errors
| Code | Status | Description |
|---|---|---|
BRANCH_INACTIVE | 422 | The branch exists but you have deactivated it. Only a NEW transaction is refused. Replaying one that was recorded before the branch closed still returns the original, so a till retrying an old sale is never blocked by a shop closing in between. |
BRANCH_NOT_FOUND | 422 | No branch of yours has this key. Deactivated branches still count as found, so this means the key is wrong rather than the shop being closed. |
CUSTOMER_DEACTIVATED | 422 | The customer exists but is deactivated, so no points can be earned |
CUSTOMER_NOT_FOUND | 404 | No customer found with the given identifier |
PLAN_LIMIT_EXCEEDED | 429 | Monthly transaction limit reached |
List customer transactions
GET /api/v1/customers/{customer}/transactions
Retrieve a paginated list of transactions for a specific customer.
Path parameters
| Parameter | Type | Description |
|---|---|---|
customer | string | The customer ID |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
date_from | string | No | Only transactions created at or after this point. Compared against created_at, so a bare date starts at midnight UTC |
date_to | string | No | Only transactions created at or before this point. A bare date therefore stops at midnight, excluding that day |
Paginate with ?page=. It is read from the query string by the paginator itself, so it
never appears in the parameter table above.
Response
{
"data": {
"data": [
{
"id": 42,
"customer_id": 1,
"idempotency_key": "order-2024-001",
"total_amount": 2500,
"description": "Lunch order",
"external_reference": "POS-42-001",
"branch": "string",
"created_at": "2024-03-15T12:30:00Z",
"points_earned": 500,
"rules_applied": [
{
"campaign_version": null,
"family": null,
"line_breakdown": null,
"moment": null,
"points_earned": 250,
"reason": null,
"rule_id": 3,
"rule_name": "Standard rate",
"rule_type": "base_rate",
"suppressed_by": null
}
],
"items": [
{
"id": 101,
"name": "Latte",
"sku": "COF-01",
"quantity": 2,
"unit_price": 350,
"line_total": 700,
"category": "drinks"
}
]
}
],
"links": {
"first": "string",
"last": "string",
"prev": "string",
"next": "string"
},
"meta": {
"current_page": 1,
"last_page": 3,
"per_page": 15,
"total": 42
}
}
}