Skip to content
Developers

Webshop integration, from cart to return.

The same API the tills use, from a shop’s backend: link shop accounts to members, price the cart with codes and shipping, hold the discounts while an online payment completes, and handle the return weeks later.

Read alongside the POS integration guide: the calls are the same, and this guide covers where a webshop does them differently. The reference is the authority on every field. Examples are trimmed, and their amounts are illustrative.

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 (19900 is 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-Key is required on reserve, commit, release, reverse and returns, and on issuing and emailing gift cards. Derive it from your order, such as WEB-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 stable storeId for 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 id on 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 email a unique identifier in the identity rules (GET /v1/tenant/identity-rules shows 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 422 if 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 answers 409 (the email or your externalRef is 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"
}
  • source is api for a webshop. Describe where it happened in sourceDetail; 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-confirmations with channel, purpose, templateKey and wordingId (needs messaging: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 422 with reason: "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 returns shippingMinor and shippingDiscountMinor.
  • excludeFromDiscounts: true for anything the shop never discounts; isGiftCard: true for 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.

Reference: evaluate a cart

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.

Online checkout: the shop evaluates, reserves with a hold long enough for the payment, starts a payment session that expires first, and commits when the payment provider confirms. A failed or abandoned payment releases the hold. Customer Payment provider Shop backend Custas Place order POST /v1/carts/evaluate quoteHash POST /v1/redemptions 201 held session, expires first pays (3-D Secure…) payment confirmed POST …/{id}/commit 200 committed order confirmed Failed? POST …/{id}/release

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 409 carries a fresh quote: 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 on quote.quoteHash with a new key. This is the same single path as at a till (POS guide §5).
  • Store the reservation’s id on the order, and charge its amountDueMinor, 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.
  • payments must add up to the reservation’s amountDueMinor. method is card, swish, invoice, voucher or other. Name the provider in label ("Klarna", "PayPal"), and put its transaction id in reference. For a card, send only the masked number if you have it: a full card number is refused.
  • receipt is optional, and built for tills: its number is an integer receipt sequence. Leave it out unless the merchant sends digital receipts to Kivra; your order number is already in externalRef.
  • 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: redemptions

6. 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:

  1. Evaluate the order’s cart again and reserve it under the next attempt’s reference (WEB-100231-2).
  2. If the new amountDueMinor equals what the customer paid, commit it straight away. Nothing is lost.
  3. 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/earn with amountMinor and the bare order number as externalRef (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_updated and checkout_started; it replaces what was there. Sending on every change is fine. The reminder counts from the last one.
  • Send checkout_completed when the order is placed, and cart_cleared when the cart is emptied. Committing a sale for the member (§5) also stops the reminder, so a missed checkout_completed doesn’t lead to a reminder for something they bought.
  • Each cart is reminded once. If the same cartId is 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": true and changes nothing, so a late cart_updated can’t reopen a finished cart.
  • recoveryUrl is 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}/refund with the amount and an externalRef.
  • 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

  1. 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.
  2. 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" }
  3. 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 needs members: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 code is 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
The webhooks guide: signatures, retries, duplicates

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 agreed storeId
  • 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
  • externalRef carries 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_completed on every order
  • Returns by line, with shipping refunded outside Custas
  • Gift cards issued and emailed with keys derived from the order
  • member.merged and member.deleted handled
  • Built against a cust_test_ key before going live