Skip to content
Developers

POS integration, flow by flow.

Everything a till does with Custas, in the order it does it: recognise or sign up a member, price the basket, take gift cards and points, commit the sale with its receipt, and handle the return.

This guide is about order and intent. The reference is the authority on every field. Examples are trimmed to what matters for the step, and their amounts are illustrative.

Before the first sale

An API key with the till’s scopes

Create one key per store or till installation in the portal, and send it as Authorization: Bearer cust_live_…. A cust_test_… key works against a fully separate test environment where messaging runs dry, so build and certify against that.

Scope For
members:read Lookup, member panel, points, stamp cards, wallet links
members:write Sign-up, adding identifiers, recording consent
carts:evaluate Pricing
redemptions:read Finding a sale for a return, digital receipt status
redemptions:write Reserve, commit, release, return, reverse; earning and redeeming points; sending a Kivra receipt
giftcards:read Gift card balance
giftcards:write Issuing and topping up cards; standalone redeem and refund
settings:read Identity rules and consent wordings, read at start-up
messaging:write (optional) Double opt-in emails, emailing a gift card that was sold

Conventions the till must follow

  • Money is integer minor units. 19900 is 199.00 kr. Percentages are basis points: 2500 is 25 %. Never send or compute decimals.
  • Prices are VAT-inclusive unless the tenant switched to net (GET /v1/tenant).
  • Print the VAT split Custas returns. Don't recompute per-line net and VAT: two independent roundings eventually disagree, and the difference lands in a VAT filing.
  • Idempotency-Key (8 to 255 characters, one per logical operation) is required on reserve, commit, release, reverse and returns, and on issuing or emailing gift cards. A retry with the same key and body replays the first answer. Generate the key when the cashier presses the button, and keep it until you get an answer.
  • Errors are RFC 9457 problem+json. Branch on code, never on title.

Read the tenant’s configuration at start-up

GET /v1/tenant/identity-rules
GET /v1/consent-wordings

Identity rules say which identifiers this tenant accepts (email, phone, personnummer, member_no, card_no, pan_alias, …), which are unique, and which are required at sign-up. Build the sign-up form from them: a type with no rule is refused.

Consent wordings are the exact texts a customer may be shown when asked for marketing consent. Cache the active one per channel, purpose and language. You need its id when recording consent.

1. Identify the customer

The customer scans a member card, wallet pass or app QR, or tells the cashier their phone number or personnummer.

POST /v1/members/lookup

{ "keyType": "phone", "value": "070-123 45 67" }

# → 200
{
  "id": "0199a4f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
  "status": "active",
  "displayName": "Anna Svensson",
  "identities": [
    { "keyType": "phone", "displayHint": "+4670*****67", "isPrimary": true },
    { "keyType": "personnummer", "displayHint": "19811218-****", "isPrimary": false }
  ],
  "tags": [{ "name": "VIP", "color": "gold" }],
  "pointsBalance": 1240,
  "orderCount": 17,
  "lifetimeSpendMinor": 1245000
}
  • Lookup is a POST, so a personnummer never appears in a URL or access log. Any written form matches: 811218-9876 and 198112189876 are the same person, and phone numbers are normalised.
  • 404: not a member. Offer sign-up.
  • 429 on personnummer: too many misses from this key in the past hour. Fall back to another identifier.
  • status:
    • active: a normal member.
    • blocked: priced as a guest, and earns and spends nothing. Treat it as anonymous.
    • restricted: still a member at the till, but receives no messages or offers.
  • Identifiers come back only as masked displayHints. Show those; never ask for the full value.

Keep id for the rest of the transaction. You can also skip the lookup and pass the scanned identifier straight into the cart, but looking up first lets the cashier greet the customer and see their balance.

The member panel

GET /v1/members/{id}/points        # balance, what it's worth now, tier, points about to expire
GET /v1/members/{id}/stamp-cards   # stamps per card, and unused reward codes

Stamp-card rewards with status: "active" are codes the member has earned. Show them so the cashier can offer “you have a free coffee, use it now?”. Using one means adding its code to the cart.

Personal offer codes are not applied automatically. The member shows them from the member app, their wallet pass or an email, where each has a QR code and a Code 128 barcode. Scan them into the cart’s codes.

2. Sign up a new member at the till

Sign-up: the till looks the customer up, gets 404, creates the member, records consent if given, and shows a wallet pass QR. Customer Till Custas POST /v1/members/lookup 404 not_found phone, email, consent? POST /v1/members 201 member PUT …/consents/email/marketing GET …/wallet/google wallet QR on display only if they said yes

Create the member

POST /v1/members

{
  "displayName": "Anna Svensson",
  "locale": "sv-SE",
  "country": "SE",
  "identities": [
    { "keyType": "phone", "value": "0701234567", "isPrimary": true },
    { "keyType": "email", "value": "Anna.Svensson@example.com" }
  ],
  "attributes": { "signupStore": "uppsala-01", "signupTill": "4" }
}
  • Send at least one identifier, and every identifier the identity rules mark required.
  • A personnummer also sets birthDate, which birthday promotions use. Otherwise you may send birthDate yourself.
  • 409 identity_conflict: a unique identifier already belongs to someone. On purpose, the response doesn’t say who. Look the customer up with the same identifier and continue with that member; don’t create a second one.
  • 422: an identifier type this tenant doesn’t accept, or a malformed value. errors[].field points at it.

Any welcome automation the tenant has set up (a welcome email, a welcome code) runs on its own. The till does nothing more.

Record marketing consent, only if the customer agreed

Membership and marketing consent are separate: joining the programme must not imply consent. Ask, and record only an explicit yes.

PUT /v1/members/{id}/consents/email/marketing

{
  "state": "granted",
  "source": "pos",
  "sourceDetail": "till 4, Uppsala",
  "wordingId": "0199a4f1-…",   # the consent text shown on the customer display
  "actor": "staff:1043"        # the cashier
}
  • The channel is email, sms, push, post or phone; the purpose is marketing or profiling. Each pair is its own record.
  • Always send wordingId. A consent that can’t show what the person was told is weak evidence.
  • For the strongest evidence, send a double opt-in instead and the customer confirms by link: POST /v1/members/{id}/consent-confirmations with channel, purpose, templateKey and wordingId.
  • Withdrawing ("state": "withdrawn") is never refused. Offer it at the till too.

Give them their card

GET /v1/members/{id}/wallet/google   # → { "saveUrl": "https://pay.google.com/…" }
GET /v1/members/{id}/wallet/apple    # → .pkpass file

Show the Google saveUrl as a QR code on the customer display. The pass updates itself as points, tier and name change, and its barcode is what you scan next time.

3. Price the cart

Call POST /v1/carts/evaluate every time the basket changes: a line is added or removed, a quantity changes, a member is identified, or a code is scanned. It’s read-only and reserves nothing, so calling it often is safe.

POST /v1/carts/evaluate?explain=true

{
  "currency": "SEK",
  "channel": "pos",
  "country": "SE",
  "storeId": "uppsala-01",
  "member": { "memberId": "0199a4f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b" },
  "codes": ["WELCOME50"],
  "lines": [
    {
      "id": "L1", "sku": "TSH-SPORT-2PK", "quantity": 2,
      "unitPriceMinor": 19900, "taxRateBp": 2500,
      "categories": ["clothing"], "brands": ["Acme"],
      "description": "Sport T-shirt (2-pack)"
    },
    {
      "id": "L2", "sku": "COF-LATTE", "quantity": 1,
      "unitPriceMinor": 4500, "taxRateBp": 1200,
      "categories": ["cafe"],
      "description": "Caffe latte"
    }
  ]
}

Identifying the member

Use { "memberId": … } after a lookup, or pass the scanned identifier directly as { "keyType": "card_no", "value": "…" }. To recognise a member by the card they pay with, send your payment provider’s card alias (Adyen’s alias) as { "keyType": "pan_alias", "value": "M469509594859802" } — never the card number itself, which is refused. Member-only, tier and birthday promotions apply only when a member is identified.

Line rules

  • id must stay stable for the life of the basket. It’s the allocation key: if it changes between calls, a discount can move between lines.
  • Send everything promotions may target: categories, collections, brands, tags, isSale, productId, variantId. A promotion on “clothing” can’t match a line that doesn’t say it’s clothing.
  • excludeFromDiscounts: true covers tobacco, deposits (pant) and anything else the law or the shop keeps out of discounts.
  • isGiftCard: true marks a gift card being sold. It’s never discounted.
  • description is required if the tenant sends digital receipts.
  • Goods sold by weight: quantity: 1, unitPriceMinor is the price of the weighed amount, and measure: { "value": 0.532, "unit": "kg" }.

The quote

{
  "quoteHash": "7f3a9c…e21b",          # keep it: you reserve against it
  "memberId": "0199a4f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b",
  "subtotalMinor": 44300,
  "totalDiscountMinor": 8980,
  "totalMinor": 35320,
  "amountDueMinor": 35320,
  "lines": [
    { "lineId": "L1", "baseMinor": 39800, "discountMinor": 8422, "totalMinor": 31378,
      "netMinor": 25102, "vatMinor": 6276,
      "allocations": [
        { "promotionId": "…", "amountMinor": 3980, "netMinor": 3184, "vatMinor": 796 },
        { "promotionId": "…", "codeId": "…", "amountMinor": 4442, "netMinor": 3554, "vatMinor": 888 }
      ] },
    { "lineId": "L2", "baseMinor": 4500, "discountMinor": 558, "totalMinor": 3942,
      "netMinor": 3520, "vatMinor": 422, "allocations": [ … ] }
  ],
  "appliedPromotions": [
    { "reference": "Members: 10% off clothing", "klass": "product", "amountMinor": 3980 },
    { "reference": "Welcome 50 kr", "codeId": "…", "klass": "order", "amountMinor": 5000 }
  ],
  "rejectedCodes": [],
  "vatBreakdown": [
    { "taxRateBp": 2500, "netMinor": 25102, "vatMinor": 6276, "grossMinor": 31378 },
    { "taxRateBp": 1200, "netMinor": 3520, "vatMinor": 422, "grossMinor": 3942 }
  ]
}
Field The till
lines[].totalMinor, discountMinor Shows each line’s price after discount, on screen and receipt
appliedPromotions[] Prints one row per offer: “Welcome 50 kr −50,00”
rejectedCodes[] Tells the cashier why a code didn’t apply. The rest of the cart is still priced.
vatBreakdown Prints it as the receipt’s VAT summary, verbatim
amountDueMinor Charges it as the money part
quoteHash Keeps it for the reservation

?explain=true adds a trace for every promotion: applied, skipped with a reason, or not considered (for example needs_code or wrong_channel). Use it to answer “why didn’t my offer apply?” and to drive upsell prompts. Leave it off in the hot path if you don’t use it.

Codes can be scanned or typed. Case, spaces, dashes and the confusable I/L/O are normalised, so welcome-50 and WELCOME 5O both match WELCOME50.

Reference: evaluate a cart

4. Gift cards and points as payment

Gift cards and points are tenders, not discounts. They apply after every discount and reduce amountDueMinor only, never a line or its VAT. Put them in the same cart:

POST /v1/carts/evaluate

{
  …the same cart…,
  "tenders": [
    { "type": "gift_card", "code": "GC-7Q4M-XK2P-9TND" },
    { "type": "points", "points": 500 }
  ]
}

# → in the quote
"amountDueMinor": 20320,
"tenders": [
  { "position": 0, "type": "gift_card", "status": "applied", "lastFour": "9TND",
    "amountMinor": 10000, "remainingMinor": 0 },
  { "position": 1, "type": "points", "status": "applied", "points": 500,
    "amountMinor": 5000, "remainingPoints": 740 }
]
  • Tenders apply in the order sent, up to five. Leave out amountMinor or points to use as much as is due.
  • A rejected tender pays nothing and says why in reason: not_found, expired, no_balance, no_member, below_step, and others. The sale can still go ahead.
  • Print remainingMinor and remainingPoints on the receipt.
  • To check a card before tendering, call POST /v1/gift-cards/lookup with { "code": "…" }. What’s spendable now is balanceMinor minus heldMinor.
  • What the member’s points are worth: redeemablePoints and redeemableMinor on GET /v1/members/{id}/points.

Gift card codes are bearer credentials. Never log them, and never print them in full.

5. Checkout: reserve, pay, commit

Checkout: the till evaluates, reserves against the quote hash, charges the payment terminal, then commits. A 409 carries a fresh quote to reserve again; a failed payment releases the hold. Payment terminal Till Custas POST /v1/carts/evaluate quote · quoteHash POST /v1/redemptions 201 held · amountDueMinor 409? Show the attached quote, reserve again on its quoteHash charge amountDueMinor approved POST …/{id}/commit 200 committed Declined? POST …/{id}/release

Reserve, when the cashier presses “Pay”

POST /v1/redemptions
Idempotency-Key: till4-20261020-000731-reserve

{
  "cart": { …exactly the cart you last evaluated, tenders included… },
  "quoteHash": "7f3a9c…e21b",
  "externalRef": "UPS01-T4-000731",
  "ttlSeconds": 600,
  "staff": { "id": "1043", "name": "Erik" }
}
  • Custas re-prices the cart on the server and compares it with quoteHash. Your quote is never trusted as input.
  • On 201 (status: "held"), every code use, promotion budget, gift card amount and point in the quote is set aside, and no other till can take them. Charge the reservation’s amountDueMinor.
  • externalRef is your receipt or transaction number. It’s unique per tenant, and it’s how you find the sale again at return time.
  • ttlSeconds is 30 to 3600, 900 by default. If the till crashes, the hold lapses and everything goes back on its own.

One recovery path for every 409

quote_stale, code_exhausted, budget_exhausted, member_limit_reached and tender_unavailable all mean the cart no longer prices as quoted. Each carries the current quote:

{
  "type": "https://app.custas.se/docs/problems/code-exhausted",
  "status": 409,
  "code": "code_exhausted",
  "title": "Code has no redemptions left",
  "quote": { "quoteHash": "9b21d4…07ac", "totalMinor": 40320, "rejectedCodes": [ … ], … }
}

Show the new total, let the customer accept it, and reserve again against quote.quoteHash with a new Idempotency-Key. Don’t branch on which of the five codes you got: when two tills race for the last use of a code, which one the loser sees is a matter of timing, not meaning.

Take payment, or release

Charge amountDueMinor by card, cash, Swish, and so on. If payment fails or the customer walks away, release the hold straight away rather than waiting for it to lapse. That frees the last use of a code for the next customer in the queue.

POST /v1/redemptions/{id}/release
Idempotency-Key: till4-20261020-000731-release

{ "reason": "cancelled" }

Commit, once payment has gone through

POST /v1/redemptions/{id}/commit
Idempotency-Key: till4-20261020-000731-commit

{
  "receipt": {
    "number": 731,
    "purchasedAt": "2026-10-20T12:04:11+02:00",
    "store": {
      "name": "Acme Uppsala",
      "organizationNumber": "556677-8899",
      "unitId": "uppsala-01",
      "address": { "streetAddress": "Kungsgatan 1", "postalCode": "753 21",
                   "locality": "Uppsala", "country": "SE" }
    },
    "register": { "id": "T4", "serialNumber": "CR-0042-1187" },
    "controlUnit": { "id": "KE-118833", "code": "A1B2C3D4E5" },
    "cashier": { "id": "1043", "name": "Erik" },
    "roundingMinor": -20
  },
  "payments": [
    { "method": "card", "amountMinor": 20300,
      "card": { "maskedPan": "535583******7361", "brand": "Mastercard",
                "approvalCode": "123456", "terminalId": "TID0042",
                "token": { "scheme": "par", "value": "5001…" } } }
  ]
}
  • payments must add up to amountDueMinor plus receipt.roundingMinor (öresavrundning), or you get 422 and nothing is committed. Gift cards and points are already in the reservation’s tenders; don’t list them again.
  • roundingMinor is between -100 and 100: at most one whole krona (or other currency unit) either way.
  • Never send a full card number: it’s refused. Send the masked PAN or BIN, and the PAR token if your terminal provides one. A PAR token lets a digital receipt find a card-paying customer who didn’t identify themselves.
  • receipt and payments don’t change the price, but they’re what the digital receipt (Kivra) and the member’s receipt history are built from. Send them.
  • 409 reservation_not_held: the hold expired or was released. Evaluate and reserve again before you print.
  • Lost response? Retry with the same Idempotency-Key. Concurrent commits of one reservation resolve to exactly one success.

Identified member, nothing discounted

Still reserve and commit. That’s how the member earns points and stamps, and how the sale counts towards their order count and lifetime spend. Only an anonymous sale with nothing redeemed is refused (409 conflict); for those, don’t call Custas.

Reference: redemptions

6. What happens after commit

The till does nothing for any of these. They happen on commit:

What Where it shows
Points earned on the money part (not on points spent, not on gift cards bought) GET /v1/members/{id}/points, points.earned
Tier upgraded tier.changed
Stamps added, and a reward code issued when a card fills GET /v1/members/{id}/stamp-cards, stamps.rewarded
Code uses and promotion budget consumed redemption.committed
Digital receipt to the member’s Kivra inbox, if the tenant uses Kivra and the member has a readable personnummer GET /v1/redemptions/{id}/kivra
The receipt in the member app The member’s own receipt list

To print “You now have 1 740 points” or “2 more stamps until a free coffee”, read GET /v1/members/{id}/points and GET /v1/members/{id}/stamp-cards after the commit.

If a Kivra receipt failed or was skipped, POST /v1/redemptions/{id}/kivra queues it again. One that was already sent is refused, so the customer never gets two copies.

7. Returns and voids

Find the original sale

Scan the receipt barcode, then look up its number:

GET /v1/redemptions?externalRef=UPS01-T4-000731

The sale’s lines[] hold the split as it was sold (discount, net and VAT per line), plus returnedQuantity so far.

Partial return: some units come back

POST /v1/redemptions/{id}/returns
Idempotency-Key: till2-20261102-000088-return

{
  "lines": [{ "lineId": "L1", "quantity": 1 }],
  "reason": "wrong size",
  "receipt": { "number": 88, "purchasedAt": "2026-11-02T15:30:00+01:00",
               "store": { "name": "Acme Uppsala", "unitId": "uppsala-01" },
               "register": { "id": "T2" }, "cashier": { "id": "2001" } },
  "payments": [{ "method": "card", "amountMinor": 15689,
                 "card": { "maskedPan": "535583******7361" } }]
}
  • Each unit is refunded at its share of the line as sold, discount included, never at today’s price. Read the amount, with its net and VAT, from the new entry in the response’s returns; don’t compute it.
  • The points those units earned are taken back, and the member’s lifetime spend goes down.
  • A partial return doesn’t undo the code or promotion, or the gift cards and points that paid. Refund that value through payments, or put it back on the card with POST /v1/gift-cards/{id}/refund.
  • 422: more units back than were sold. 409: the sale was already reversed.

Full reversal: a void or a whole-receipt return

POST /v1/redemptions/{id}/reverse
Idempotency-Key: till2-20261102-000088-reverse

{ "reason": "return, receipt 88" }

This undoes everything. Code uses come back (an exhausted code becomes active again), the promotion budget is credited, and gift cards and points that paid are refunded to the card and the member. Reversing twice changes nothing the second time, and after partial returns a reversal takes back only what’s left.

8. Selling and topping up gift cards

Sell a new card

  1. Put the card in the basket as a line with "isGiftCard": true, then evaluate, reserve, pay and commit as usual. No promotion discounts it.
  2. After the commit, issue the card for the amount sold. The response carries the code once: print it, or encode it on the card. A retry with the same key returns the same card rather than funding a second one.
  3. Optionally, email it as a present with POST /v1/gift-cards/{id}/deliveries.
POST /v1/gift-cards
Idempotency-Key: till4-20261020-000731-gc-L3

{
  "amountMinor": 50000,
  "memberId": "0199a4f0-…",
  "note": "receipt UPS01-T4-000731",
  "code": "6012345678901234"   # only for a pre-printed card; leave out to generate one
}

Top up an existing card

Sell the top-up as an isGiftCard line, commit, then:

POST /v1/gift-cards/lookup        { "code": "…" }   # → id
POST /v1/gift-cards/{id}/top-up   { "amountMinor": 20000, "externalRef": "UPS01-T4-000732" }

externalRef makes it safe to retry: the same receipt tops up once. A disabled card answers 409, an expired one 410.

9. Tills that can only make one call

Some tills can only call out once, after the sale. They can use these standalone endpoints, each safe to retry on its receipt number. They hold nothing, so another till could spend the same balance at the same moment: prefer reserve and commit wherever you can.

POST /v1/members/{id}/points/earn     { "amountMinor": 35320, "externalRef": "UPS01-T4-000731" }
POST /v1/members/{id}/points/redeem   { "points": 500, "externalRef": "UPS01-T4-000731" }
POST /v1/gift-cards/redeem            { "code": "…", "amountMinor": 10000,
                                    "externalRef": "UPS01-T4-000731", "allowPartial": true }
POST /v1/gift-cards/{id}/refund       { "amountMinor": 10000, "externalRef": "UPS01-T4-R88" }

Receipt numbers are shared with reservations: a receipt already committed through reserve and commit has earned its points, and sending it to points/earn returns that earning instead of earning twice.

Promotions and codes can’t be applied this way. They need evaluate, reserve and commit.

10. Back office: webhooks

The till itself needs no webhooks. A POS back office usually wants to mirror what happens in Custas:

POST /v1/webhook-endpoints

{
  "url": "https://pos.example.com/hooks/custas",
  "eventTypes": ["redemption.committed", "redemption.reversed", "redemption.returned",
                 "member.created", "member.updated", "member.merged", "member.deleted",
                 "points.earned", "tier.changed", "gift_card.issued", "gift_card.redeemed"]
}
  • The signing secret is returned once. Verify every delivery: custas-signature: v1=<hex> is HMAC-SHA256 over <custas-timestamp>.<raw body>, computed on the bytes you received.
  • Delivery is at least once, so de-duplicate on custas-event-id.
  • member.merged and member.deleted matter if you cache member ids. A merged id answers 404 with mergedInto, the id to use instead. An erased member must disappear from your cache too.

11. Errors and offline

Answer Where The till
404 not_found Lookup Offers sign-up
404 with mergedInto Get member Uses the new id
409 identity_conflict Create member Looks up by the same identifier and uses that member
409 with a quote Reserve Shows the new total, reserves again on quote.quoteHash
409 reservation_not_held Commit Evaluates and reserves again
409 idempotency_request_in_progress Any keyed call Waits retryAfter, retries with the same key
422 idempotency_key_reused Any keyed call A bug: a new operation reused an old key
422 validation_failed Any errors[].field says which field to fix
429 rate_limited Any Backs off for Retry-After
402 feature_not_in_plan Any Hides that feature for this tenant
401, 403 insufficient_scope Any Flags a configuration problem, not a cashier one
5xx, timeout Any Retries keyed calls with the same key

If Custas can’t be reached, sell at shelf price and keep the member identifier and receipt number. When the connection is back, send the receipt to POST /v1/members/{id}/points/earn so the member still earns. Codes and gift cards can’t be honoured offline, because nothing can hold them.

12. Integration checklist

A till that does all of this uses everything Custas offers at the counter.

  • Identity rules and consent wordings read at start-up
  • Member lookup by scan (card, wallet pass, app QR) and by typed identifier
  • Sign-up with the tenant’s required identifiers; a conflict leads to lookup
  • Marketing consent recorded with its wording, source and cashier; withdrawal possible
  • Wallet pass offered at sign-up
  • Cart re-evaluated on every change, with stable line ids and product attributes
  • Codes scanned or typed; rejected codes shown with the reason
  • Stamp-card rewards offered when the member has one
  • Gift card balance check and gift card tender
  • Points shown and offered as payment
  • Reserve on “Pay”, with one 409 path that shows the new total
  • Release on a failed or abandoned payment
  • Commit with receipt, payments (masked card and PAR), staff and öresavrundning
  • Identified sales without offers still reserved and committed
  • Per-line VAT and discount rows printed from the quote, not recomputed
  • Updated points and stamps printed on the receipt
  • Partial return by receipt number; full reversal for voids
  • Gift cards sold and topped up
  • Every money-moving call retried with the same key
  • Offline fallback that earns points later
  • Certified against a cust_test_ key before going live