Before you start
Call Custas from your backend, never the browser
An API key can spend gift cards and read members, so it must never reach a browser. The API doesn’t accept calls from other websites’ pages anyway: its CORS policy allows only the Custas portal. Your storefront talks to your backend, and your backend talks to Custas.
A key with the shop’s scopes
| Scope | For |
|---|---|
members:read |
Looking up members, points, stamp cards, wallet passes |
members:write |
Creating members, recording consent, cart events (§8) |
carts:evaluate |
Pricing |
redemptions:write |
Reserve, commit, release, returns, reversal |
redemptions:read |
Finding an order’s sale; order history in My account |
giftcards:read, giftcards:write |
Gift card balance; selling, emailing and refunding gift cards (only if you take or sell them) |
settings:read |
Identity rules and consent wordings |
messaging:write (optional) |
Double opt-in emails, emailing a gift card that was sold |
Build against a cust_test_… key: the test environment is fully separate
from live. Keys are rate-limited per minute by plan (Starter 60, Growth 600,
Enterprise 6 000 requests), which matters for pricing (§3).
The same conventions as a till
-
Money is integer minor units (
19900is 199.00 kr), prices are VAT-inclusive unless the tenant uses net prices, and the VAT split Custas returns is the one to show and invoice. -
Idempotency-Keyis required on reserve, commit, release, reverse and returns, and on issuing and emailing gift cards. Derive it from your order, such asWEB-100231-commit, so a retry, a double-clicked button and a payment webhook arriving alongside the redirect all send the same one. -
Every cart says
"channel": "online", and a stablestoreIdfor the shop, such as"web". Promotions can be limited to channels and to stores, so agree that id with the merchant: an offer limited to physical stores correctly doesn’t apply online.
1. Shop accounts and members
A till recognises a member by a card or phone number they show. A webshop knows its customers from their shop account, and that login is what decides who they are. Link the two once, and use the member id from then on.
Link a signed-in customer to their member
POST /v1/members/lookup
{ "keyType": "email", "value": "anna.svensson@example.com" }
# → 200: the member, with "id". Store it on the shop account.
# → 404: not a member yet. Offer to join (§2).
- Look up only the verified email of a signed-in account: one your shop has confirmed the customer controls, for example with a confirmation link when the account was created.
-
Store the member
idon the shop account and use it everywhere after that. If the customer changes their email in your shop, the link still holds. -
Ask the merchant to make
emaila unique identifier in the identity rules (GET /v1/tenant/identity-rulesshows them). If it isn’t unique, two members can share an address, and a lookup returns one of them.
Never let an anonymous visitor trigger a lookup. Lookup returns the member’s points, tier and spend, and Custas can’t tell your shop that the person typing an email address owns it. A guest checkout field that looks up "your member email" would let anyone check who is a member, see their balance, and spend their points. Guests check out as guests; membership benefits need a signed-in account.
Guests
Price a guest’s cart without a member. Public promotions and codes still
apply; member-only and tier offers don’t. After the order, offer to create an account
and join, so the next order earns.
2. Sign-up and consent
Create the member when a customer joins the loyalty programme: at account creation, or with a “join and earn points” tick at checkout. Again, only for an email your shop has verified.
POST /v1/members
{
"displayName": "Anna Svensson",
"locale": "sv-SE",
"country": "SE",
"externalRef": "shop-customer-58213", # your customer id, unique per tenant
"identities": [
{ "keyType": "email", "value": "anna.svensson@example.com", "isPrimary": true }
],
"attributes": { "signupChannel": "web" }
}
-
Send every identifier type the identity rules mark required. A form that only asks
for an email fails with
422if the merchant requires a phone number. -
409 identity_conflict: the email already belongs to a member, perhaps one who joined in a store. Look them up and link that member; don’t create another. -
This call takes no
Idempotency-Key. If the response is lost, retrying is still safe: the second attempt answers409(the email or yourexternalRefis taken), and you look the member up.
Marketing consent: a separate question, unticked
Joining the programme is not consent to marketing. Ask with its own checkbox,
unticked, showing the exact text of the tenant’s active consent wording (GET /v1/consent-wordings). Record only a yes:
PUT /v1/members/{id}/consents/email/marketing
{
"state": "granted",
"source": "api",
"sourceDetail": "webshop sign-up form, shop.example.com",
"wordingId": "0199a4f1-…",
"actor": "customer:shop-customer-58213"
}
-
sourceisapifor a webshop. Describe where it happened insourceDetail; it’s part of the evidence. - Custas records the request’s IP address and user agent, which here are your server’s, not the customer’s. If you keep the customer’s own IP for your records, that’s yours to store.
-
Double opt-in is the stronger evidence online, where anyone can
type anyone’s address. Send it instead of granting directly:
POST /v1/members/{id}/consent-confirmationswithchannel,purpose,templateKeyandwordingId(needsmessaging:write). The customer confirms on a Custas page, and the link works for seven days. -
If the merchant set a minimum age for marketing and the member’s birth date is below
it, a grant answers
422withreason: "below_marketing_min_age". Membership itself has no age limit. -
Offer withdrawal in the account settings too (
"state": "withdrawn"). It’s never refused.
3. Price the cart
Evaluate when the cart page is shown, when the cart or a code changes, and at each checkout step that changes shipping or tenders. It’s read-only and reserves nothing.
POST /v1/carts/evaluate
{
"currency": "SEK",
"channel": "online",
"storeId": "web",
"country": "SE",
"member": { "memberId": "0199a4f0-1c2d-7e3f-8a4b-5c6d7e8f9a0b" },
"codes": ["SUMMER20"],
"lines": [
{ "id": "item-8812", "sku": "TSH-SPORT-2PK", "quantity": 2,
"unitPriceMinor": 19900, "taxRateBp": 2500,
"categories": ["clothing"], "brands": ["Acme"], "description": "Sport T-shirt (2-pack)" }
],
"shipping": { "methodId": "postnord-home", "amountMinor": 4900, "taxRateBp": 2500 }
}
-
Line ids stay stable for the life of the cart: use your cart item
id, not the line’s position. Send every attribute promotions might target
(
categories,brands,collections,tags,isSale,productId,variantId), as in the POS guide. -
Shipping goes in
shipping, with its own VAT rate, not as a line. Free-shipping and shipping-discount promotions act on it, and the quote returnsshippingMinorandshippingDiscountMinor. -
excludeFromDiscounts: truefor anything the shop never discounts;isGiftCard: truefor gift cards being sold (§10). - Mind the rate limit. Evaluate on events, not on every render, and reuse the last quote while the cart hasn’t changed. On Starter’s 60 requests per minute, a quote per page view runs out quickly.
The discount code field
Put what the customer typed in codes. Case, spaces, dashes and the
look-alike I/L/O are forgiven. A code that doesn’t apply never fails the request: the
rest of the cart is priced, and the code comes back in rejectedCodes
with a reason to explain under the field.
reason |
Tell the customer |
|---|---|
not_found |
That code doesn’t exist |
not_started, expired |
That code isn’t valid yet / any more |
exhausted, budget_exhausted |
That offer has run out |
member_limit_reached |
You’ve already used this code as many times as you can |
assigned_to_other_member |
This is a personal code for someone else |
no_member |
Sign in to use this member code |
wrong_channel, wrong_store |
This code is for use in store |
below_min_subtotal |
Spend a little more to use this code |
combination_conflict |
This code can’t be combined with your other offer |
| anything else | That code can’t be used on this order |
New reasons can be added, so always have the last row. To find out why an automatic
offer didn’t apply, add ?explain=true while you debug.
Show the quote’s per-line prices, appliedPromotions and
vatBreakdown as they are, and keep quoteHash for placing the
order.
4. Gift cards and points as payment
Both are tenders in the same cart, exactly as at a till (POS guide §4): they reduce amountDueMinor, and you take the rest through your
payment provider.
"tenders": [
{ "type": "gift_card", "code": "GC-7Q4M-XK2P-9TND" },
{ "type": "points", "points": 500 }
]
-
Points only for a signed-in member. Show what they’re worth from
GET /v1/members/{id}/points(redeemablePoints,redeemableMinor). Points can’t pay for gift cards being bought. - Gift cards have no PIN: the code alone spends the balance. Put the gift card field behind the same bot protection as your login (a CAPTCHA and a per-visitor limit), and never return a balance for a code to a visitor who hasn’t applied it to their own order.
-
A rejected tender says why in
reason(not_found,expired,no_balance, …). The rest of the order can still be paid.
5. Place order and pay
The difference from a till: an online payment takes time and confirms asynchronously, often by a webhook from your payment provider, minutes after the customer left the checkout. Custas holds the discounts, code uses, gift card amounts and points for that time, so nobody else can take them.
Reserve when the customer presses “Place order”
Evaluate once more with the final cart, then reserve against that quote:
POST /v1/redemptions
Idempotency-Key: WEB-100231-1-reserve
{
"cart": { …exactly the cart you just evaluated… },
"quoteHash": "7f3a9c…e21b",
"externalRef": "WEB-100231-1",
"ttlSeconds": 1800
}
-
Any
409carries a freshquote: the price changed since the customer last saw it (a code ran out, an offer ended, the merchant edited a promotion). Show the new total, let the customer confirm, and reserve again onquote.quoteHashwith a new key. This is the same single path as at a till (POS guide §5). -
Store the reservation’s
idon the order, and charge itsamountDueMinor, which is gift cards and points already deducted. -
An order from a guest with no discount, code, gift card or points has nothing to
hold, and reserving it answers
409 conflict. Skip Custas for those.
externalRef: your order number, plus an attempt
externalRef is unique per tenant, including
reservations that were released or expired. If a customer’s first payment fails and
they try again, the order needs a new hold, and it can’t reuse the same reference. Use
the order number with an attempt counter (WEB-100231-1,
WEB-100231-2, …). To find the sale later, search for the reference of the
attempt that was committed, which you stored on the order.
ttlSeconds: longer than the payment can take
The hold lasts ttlSeconds: 900 (15 minutes) by default, and at most 3600
(an hour). It can’t be extended. Pick a value that covers the longest your payment
page may stay open, and
make the payment session expire before the hold does. Most payment
providers let you set the session’s lifetime. Then a customer can never pay after the
discounts they were promised have gone back to the pool.
Commit when the payment provider confirms
POST /v1/redemptions/{id}/commit
Idempotency-Key: WEB-100231-1-commit
{
"payments": [
{ "method": "invoice", "label": "Klarna", "reference": "kl-7f2a91c0",
"amountMinor": 33740 }
]
}
- Commit from wherever you learn the payment succeeded: the return redirect, your provider’s webhook, or both. Both send the same Idempotency-Key, so whichever arrives second gets the first one’s answer, and the sale is committed once.
-
paymentsmust add up to the reservation’samountDueMinor.methodiscard,swish,invoice,voucherorother. Name the provider inlabel("Klarna","PayPal"), and put its transaction id inreference. For a card, send only the masked number if you have it: a full card number is refused. -
receiptis optional, and built for tills: itsnumberis an integer receipt sequence. Leave it out unless the merchant sends digital receipts to Kivra; your order number is already inexternalRef. - Commit even when nothing was discounted, for a signed-in member. That’s how they earn points and stamps, and how the order counts towards their history.
Release when the payment fails or is abandoned
POST /v1/redemptions/{id}/release
Idempotency-Key: WEB-100231-1-release
{ "reason": "cancelled" }
Release as soon as you know: when the provider reports a failure, or the payment session expires. That returns a code’s last use to the next customer at once, instead of when the hold lapses.
Reference: redemptions6. When the payment confirms after the hold
If the payment session outlived the hold despite everything, the commit answers
409 reservation_not_held, and the customer has paid. Custas can’t commit
a sale whose discounts went back to the pool, so:
-
Evaluate the order’s cart again and reserve it under the next attempt’s reference
(
WEB-100231-2). -
If the new
amountDueMinorequals what the customer paid, commit it straight away. Nothing is lost. -
If it’s higher (a code ran out in the meantime), release it. Honour the order at the
price the customer paid, which is between you and them, and credit their points for
it without the discount:
POST /v1/members/{id}/points/earnwithamountMinorand the bare order number asexternalRef(WEB-100231). It’s idempotent on that reference, and it must not be one a reservation already used.
Subscribe to redemption.released. A delivery with
reason: "ttl" for an order you still consider open is your early warning
that the hold lapsed.
7. My account
For a signed-in member, these work with the shop’s key:
GET /v1/members/{id}/points # balance, worth, tier, points about to expire
GET /v1/members/{id}/stamp-cards # stamps, and reward codes earned
GET /v1/members/{id}/wallet/google # → { "saveUrl" } for an "Add to Google Wallet" button
GET /v1/members/{id}/wallet/apple # → .pkpass for "Add to Apple Wallet"
GET /v1/redemptions?memberId={id} # sales with Custas benefits, newest first
-
Stamp-card reward codes with
status: "active"are the member’s to use: show them in the account, and they apply when typed into the code field. - Points earned on an order can be spent straight away; there’s no waiting period.
- The member’s personal offers aren’t available to an API key. They’re shown in the Custas member app, so link to it from the account page.
8. Abandoned carts
To send “you left something in your cart”, tell Custas what a signed-in member has in their cart, from your backend, whenever it changes. The merchant sets up a cart left behind automation in the portal (say, 4 hours later, with a code), and Custas sends it, with consent checked like any marketing message. No script runs in the browser, and nothing is recorded for visitors who haven’t signed in.
POST /v1/webshop-carts/events
{
"type": "cart_updated", # or checkout_started, cart_abandoned,
# checkout_completed, cart_cleared
"cartId": "c-7f3a91", # your cart id
"member": { "memberId": "0190f0f0-…" }, # or { "keyType": "email", "value": … }
"currency": "SEK",
"lines": [
{ "sku": "W-DIVER-300", "name": "Diver 300", "quantity": 1, "unitPriceMinor": 489500,
"imageUrl": "https://shop.example.se/img/diver-300.jpg" }
],
"recoveryUrl": "https://shop.example.se/cart"
}
# → 200: { "cart": { …, "status": "open", "ignored": false } }
-
Send the whole cart on every
cart_updatedandcheckout_started; it replaces what was there. Sending on every change is fine. The reminder counts from the last one. -
Send
checkout_completedwhen the order is placed, andcart_clearedwhen the cart is emptied. Committing a sale for the member (§5) also stops the reminder, so a missedcheckout_completeddoesn’t lead to a reminder for something they bought. -
Each cart is reminded once. If the same
cartIdis used again after it was closed (completed, cleared, or followed by a purchase), it starts afresh and can be reminded again. Long-lived carts for logged-in customers work as they are. -
Sending from a queue? Pass
occurredAt. An event older than the cart’s last activity comes back"ignored": trueand changes nothing, so a latecart_updatedcan’t reopen a finished cart. -
recoveryUrlis the link in the message ({{cart.url}}): your own https page that restores the cart once the customer signs in. The template can also print{{cart.items}},{{cart.value}}and{{cart.imageUrl}}. - Carts are personal data: Custas keeps them 90 days after their last activity, erases them with the member, and includes them in the member’s data export.
9. Returns
Find the sale from the reference you stored on the order:
GET /v1/redemptions?externalRef=WEB-100231-1
Some items come back
POST /v1/redemptions/{id}/returns
Idempotency-Key: WEB-100231-return-1
{
"lines": [{ "lineId": "item-8812", "quantity": 1 }],
"reason": "wrong size"
}
-
Each unit is refunded at what it cost as sold, its discount
included. Read the amount, with its VAT, from the new entry in the response’s
returns, and refund exactly that through your payment provider. Don’t compute it yourself. - The points those items earned are taken back, as far as the member still has them.
- Shipping isn’t a line, so it can’t be returned this way. If your policy refunds shipping, refund it through your payment provider; it doesn’t change anything in Custas.
-
A partial return doesn’t give back the code or offer, or the gift card or points
that paid. To put money back on a gift card, use
POST /v1/gift-cards/{id}/refundwith the amount and anexternalRef. - Custas enforces no return window. Your shop’s returns policy decides what’s accepted.
The whole order is cancelled or returned
POST /v1/redemptions/{id}/reverse
Idempotency-Key: WEB-100231-reverse
{ "reason": "order cancelled" }
This undoes everything: code uses and offer budgets come back, and gift cards and points that paid are refunded to the card and the member. After partial returns it takes back only what’s left, and a second reversal changes nothing.
10. Selling gift cards
-
Put the gift card in the cart as a line with
"isGiftCard": true. Nothing discounts it, and points can’t pay for it. Evaluate, reserve, pay and commit as usual. -
After the commit, issue the card. Derive the key from the order line, so a retry
returns the same card instead of funding a second one:
POST /v1/gift-cards Idempotency-Key: WEB-100231-gc-item-8813 { "amountMinor": 50000, "memberId": "0199a4f0-…", "note": "order WEB-100231" } -
Email it to the recipient. The template must contain the code; the merchant sets it
up in the portal.
POST /v1/gift-cards/{id}/deliveries Idempotency-Key: WEB-100231-gc-item-8813-send { "templateKey": "gift-card", "email": "erik@example.com", "message": "Happy birthday!", "locale": "sv-SE" }
-
Emailing needs
messaging:write. Sending to a typed address also needsmembers:reveal, unless the same key issued the card within the last 30 minutes, which is the normal case straight after checkout. - There’s no scheduled send. For “deliver on her birthday”, keep the card id and call deliveries on the day.
-
The issued card’s
codeis in the issue response only. Don’t store or log it beyond handing it to the delivery.
11. Webhooks
The order flow works without webhooks: every step’s answer comes back on the call. Subscribe to keep the shop in step with what happens elsewhere (needs the Growth plan or above):
| Event | Why a shop wants it |
|---|---|
redemption.released |
A hold lapsed (reason: "ttl") on an order you think is open
|
member.merged |
Repoint the shop account from the merged member id to the kept one |
member.deleted |
The member was erased: unlink them, and delete what you copied |
consent.changed |
Keep your newsletter tool in step when consent changes in a store |
tier.changed |
Move the customer to another customer group in the shop |
12. Errors
| Answer | Where | The shop |
|---|---|---|
404 not_found |
Lookup | Offers to join |
404 with mergedInto |
Get member | Stores the new id on the shop account |
409 identity_conflict |
Create member | Looks up by the same email and links that member |
409 with a quote |
Reserve | Shows the new total; reserves again on quote.quoteHash |
409 conflict |
Reserve |
The externalRef is already used (use the next attempt), or an
anonymous order has nothing to hold (skip Custas)
|
409 reservation_not_held |
Commit | Follows §6 |
409 idempotency_request_in_progress |
Any keyed call | Waits retryAfter, retries with the same key |
422 with below_marketing_min_age |
Consent | Explains that marketing isn’t available below that age |
422 validation_failed |
Any | errors[].field says which field to fix |
429 rate_limited |
Any | Backs off for Retry-After; evaluates less often |
5xx, timeout |
Any | Retries keyed calls with the same key. If Custas can’t be reached at "Place order", sell at the normal price, without codes or loyalty, rather than blocking checkout |
13. Checklist
- The API key lives on the backend; the storefront never calls Custas
- Shop accounts store the member id; lookup only for verified, signed-in emails
- Guests are priced without a member; no anonymous lookup anywhere
- Sign-up sends the tenant’s required identifiers; a conflict leads to linking
- Marketing consent asked separately, unticked, with the wording’s id
-
Every cart has
channel: "online"and the agreedstoreId - Shipping sent as
shipping, not as a line - Rejected codes explained under the code field, with a fallback message
- Gift card field protected against guessing
- Reserve on “Place order”, with one 409 path that shows the new total
externalRefcarries an attempt number- The payment session expires before the hold
- Commit from redirect and provider webhook with the same key
- Release on a failed or abandoned payment
- A plan for a payment that confirms after the hold
-
Cart events sent for signed-in members, with
checkout_completedon every order - Returns by line, with shipping refunded outside Custas
- Gift cards issued and emailed with keys derived from the order
member.mergedandmember.deletedhandled- Built against a
cust_test_key before going live