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

FieldTypeRequiredDescription
identifierstringYesCustomer identifier (card number, email, etc.)
idempotency_keystringYesUnique key to prevent duplicate processing
total_amountintegerYesTransaction amount in cents. A whole number, minimum 1, that fits a signed 32-bit integer.
descriptionstringNoDescription of the purchase
external_referencestringNoReference to the transaction in your system
branchstringNoYour 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.
itemsarrayNoOrder 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:

FieldTypeRequiredDescription
namestringYesProduct name
quantityintegerYesQuantity purchased, minimum 1, signed 32-bit integer
unit_priceintegerYesPrice per unit in cents, signed 32-bit integer
skustringNoProduct SKU / code
categorystringNoProduct 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.

FieldTypeDescription
rule_idintegerThe earn rule's id, or the campaign's id when rule_type is campaign
rule_namestringThe vendor's own name for the rule or campaign. Never translated server-side.
rule_typestringbase_rate or campaign
points_earnedintegerPoints this entry contributed. 0 is a normal value. See below.
familystring|nulltransaction or customer_moment. null for an earn rule.
momentstring|nullThe lifecycle moment that fired, for a customer_moment campaign. null otherwise.
campaign_versioninteger|nullThe campaign version in force when the purchase was scored. null for an earn rule.
suppressed_byinteger|nullThe id of the campaign that outranked this one. null when nothing suppressed it.
reasonstring|nullStable token explaining a zero-point outcome. Currently only suppressed_by_stronger_campaign.
line_breakdownarray|nullPer-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_amount the 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", and suppressed_by set 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_earned and rules_applied report 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

CodeStatusDescription
BRANCH_INACTIVE422The 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_FOUND422No 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_DEACTIVATED422The customer exists but is deactivated, so no points can be earned
CUSTOMER_NOT_FOUND404No customer found with the given identifier
PLAN_LIMIT_EXCEEDED429Monthly transaction limit reached

List customer transactions

GET /api/v1/customers/{customer}/transactions

Retrieve a paginated list of transactions for a specific customer.

Path parameters

ParameterTypeDescription
customerstringThe customer ID

Query parameters

ParameterTypeRequiredDescription
date_fromstringNoOnly transactions created at or after this point. Compared against created_at, so a bare date starts at midnight UTC
date_tostringNoOnly 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
        }
    }
}