Setu Custom Commerce API · v1
Connect a website to Setu
Last updated 12 October 2026 · contract version 1
Setu answers a shop's customers on Instagram and other channels, recommends products and turns conversations into orders. This guide is everything a website developer needs to let a shop sell its own website's catalogue through Setu. You implement a small, versioned HTTPS API on your website and an admin page that issues an API key. The shop owner pastes two values into Setu and is connected.
Overview
| Resource | Link |
|---|---|
| OpenAPI 3.1 specification (source of truth) | openapi.yaml |
| Postman collection | setu-commerce-api.postman_collection.json |
| Conformance tool (Node.js 18+, no dependencies) | setu-conformance.mjs |
The connection has two parts, both on your side:
- An API under a base URL you choose, the Store API URL, for example
https://shop.example.com/api/setu/v1. Setu reads your store, products and stock, and creates orders through it. - A "Setu Integration" page in your admin that shows the Store API URL and issues an API key.
Setu stores the key encrypted and sends it as a bearer token on every request. Setu polls your API, so webhooks are optional (see capabilities). Your website stays the system of record: Setu never charges a price or promises stock without asking your API first, and every order is created by your own business rules.
All examples in this guide are fictional (shop.example.com, "Example Store"). A store that prefers OAuth sign-in instead of an API key can implement the OAuth mode of the same contract, described in the OpenAPI specification; the resource endpoints are identical.
Quick start
- Implement
GET /store,GET /products,GET /products/{id},POST /checkout/validate,POST /ordersandGET /orders/{id}under your Store API URL.GET /categoriesis optional. Shipping quotes, online payments and order lookup by number are optional capabilities you can add later. - Protect them with an API key: generate it in your admin, store only its hash, compare in constant time.
- Make
POST /ordersidempotent and honour"test": true. - Run the conformance tool against your deployment until every check passes.
- The shop owner opens Setu → Connections → Custom Website/API → Connect store, pastes the Store API URL and key, and clicks Test and connect.
Authentication
Every request from Setu carries the key as a bearer token:
GET /api/setu/v1/store HTTP/1.1
Host: shop.example.com
Authorization: Bearer sk_setu_EXAMPLEdoNotUse0000000000000000000000000
Accept: application/jsonWhen the header is missing or malformed, the key is unknown, revoked or rotated, or the integration is disabled, answer 401 with the same body every time, so the response reveals nothing about which case applied:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
{"error":{"code":"invalid_token","message":"The API key is invalid."}}One key grants what GET /store advertises in capabilities: always catalog.read (Setu may read products) and orders.write (Setu may create orders), plus any optional capabilities you list.
Versioning
- The contract version is 1. Put it in your base path (for example
/api/setu/v1) and return"api_version": "1"fromGET /store. Setu refuses versions it does not support. - Within v1, Setu only makes additive changes: new optional request fields, new optional response fields, new enum values in fields documented as open. Ignore request fields you don't know; Setu ignores response fields it doesn't know.
- A breaking change would be published as v2 at a new base path, with v1 kept running during a migration period.
- Changes are listed in the changelog and in the OpenAPI
info.version.
API key lifecycle
| Rule | Requirement |
|---|---|
| Generation | At least 256 bits from a CSPRNG, e.g. "sk_setu_" + base64url(randomBytes(32)). The fixed prefix helps secret scanners recognise leaked keys. |
| Storage | Store only a hash (SHA-256 hex is fine for a 256-bit random key) and a short display prefix. Never store, log or email the plaintext. |
| Display | Show the plaintext once, right after generate or rotate. No endpoint returns it again. |
| Verification | Hash the presented key and compare with a constant-time function (crypto.timingSafeEqual, hash_equals, hmac.compare_digest). |
| Rotation | Replace the stored hash. The previous key fails immediately; there is no grace period. |
| Revocation | Clear the stored hash or set a revoked timestamp. Disabling the integration also refuses every key. |
| Audit | Record enable, disable, generate, rotate and revoke with the admin and the time, never the key. Optionally record when the key was last used (throttled, e.g. once a minute). |
| Authorization | Only store administrators can manage keys, enforced on the server, not only by hiding the page. |
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
export function generateApiKey() {
const key = "sk_setu_" + randomBytes(32).toString("base64url");
return { key, hash: hashApiKey(key), prefix: key.slice(0, 12) }; // show key once; store hash + prefix
}
export function hashApiKey(key) {
return createHash("sha256").update(key, "utf8").digest("hex");
}
export function verifyApiKey(presented, storedHash) {
if (typeof presented !== "string" || !storedHash) return false;
const a = Buffer.from(hashApiKey(presented), "hex");
const b = Buffer.from(storedHash, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
// Authorization: Bearer <key>
export function bearer(req) {
const m = /^Bearer ([\x21-\x7e]{16,512})$/.exec(req.headers.get("authorization") ?? "");
return m ? m[1] : null;
}The admin page
Add a page called Setu Integration to your admin, visible to administrators only, with:
- Status: Disabled · Ready (a key exists, not used yet) · Connected (the key was used recently) · Key rotated (Setu must be given the new key).
- Enable / disable. While disabled, every request gets 401.
- Store API URL with a copy button.
- Generate API key: shows the key once with a copy button and the warning "This key will not be shown again."
- Rotate key and Revoke key, each behind a confirmation that says: "Setu stops syncing until you paste a new key in Setu → Connections → Update credentials."
- A link to this guide.
Conventions
| Topic | Rule |
|---|---|
| Transport | HTTPS on port 443 only. API paths must not redirect: Setu does not follow redirects. |
| Content type | application/json; charset=utf-8 |
| Caching | Send Cache-Control: no-store on every authenticated response. |
| Field names | snake_case. Ignore unknown request fields. |
| Money | Integers in the currency's minor unit (ISO 4217 exponent). NPR 1,850.00 → 185000; USD 12.50 → 1250; JPY 900 → 900. |
| Timestamps | RFC 3339 UTC, e.g. 2026-10-10T08:15:30.000Z. |
| Limits | Answer within 10 seconds; responses must be smaller than 5 MB. |
GET /store
Identifies the store. Setu calls it when connecting, when updating credentials and on Test connection.
{
"id": "store_123",
"name": "Example Store",
"currency": "NPR",
"website_url": "https://shop.example.com",
"timezone": "Asia/Kathmandu",
"payment_methods": ["cash_on_delivery"],
"shipping_quotes": false,
"api_version": "1",
"capabilities": ["catalog.read", "orders.write"],
"product_count": 42
}| Field | Type | Notes |
|---|---|---|
id | string ≤128 | Stable store id. Setu refuses to silently swap one store for another. |
name | string ≤200 | Shown in Setu. |
currency | ISO 4217 | All prices are in this currency. |
website_url | string | null | Public storefront. |
timezone | IANA zone | null | |
payment_methods | string[] | Ids of the payment options Setu may use. Base stores list cash_on_delivery. With payments.sessions, list the same ids as payment_options. |
payment_options | object[] | Required with payments.sessions, optional otherwise. See payment options. |
shipping_quotes | boolean | true when /checkout/validate returns shipping_minor for an address. |
api_version | "1" | Required. |
capabilities | string[] | Required. Must include catalog.read and orders.write; may add the optional capabilities. Unknown values are ignored. |
product_count | integer | null | Optional. How many products GET /products returns. |
Capabilities
capabilities on GET /store tells Setu what your API supports. Two are required; the rest are optional and additive within v1. Setu uses an optional capability only when you list it, and ignores values it doesn't know.
| Capability | Required | What it means |
|---|---|---|
catalog.read | Yes | Setu may read products (GET /products) and check live prices and stock. |
orders.write | Yes | Setu may create orders with POST /orders and read them with GET /orders/{id}. |
shipping.quotes | No | You price delivery with POST /shipping/quotes, and orders must reference the quote. Setu never chooses the delivery fee itself. |
payments.sessions | No | You offer online payment options and start payments with POST /orders/{id}/payment-sessions. |
orders.lookup_by_number | No | GET /orders/{id} also accepts the customer-facing order number (details). |
orders.cancel, orders.fulfil, orders.cash_payment | No | The merchant can cancel, mark fulfilled or record a cash payment from Setu's Orders panel. Your API does the change under your own rules (details). List each one you implement. |
webhooks.manage | No | Setu may register a webhook so you can push events such as payment.updated. |
A store that lists none of the optional capabilities keeps the original behaviour: cash-on-delivery orders, with shipping_minor taken from POST /checkout/validate. You can add capabilities one at a time.
{
"id": "store_123",
"name": "Example Store",
"currency": "NPR",
"website_url": "https://shop.example.com",
"timezone": "Asia/Kathmandu",
"payment_methods": ["cash_on_delivery", "wallet_qr"],
"payment_options": [
{ "id": "cash_on_delivery", "kind": "cash_on_delivery", "label": "Cash on delivery" },
{ "id": "wallet_qr", "kind": "online", "label": "Pay by QR", "max_total_minor": 50000000 }
],
"shipping_quotes": true,
"api_version": "1",
"capabilities": ["catalog.read", "orders.write", "shipping.quotes", "payments.sessions", "orders.lookup_by_number"]
}Pagination
GET /products and GET /categories accept limit (1–100, default 50), cursor (the previous page's next_cursor) and, for products, updated_since (RFC 3339; rows with updated_at >= updated_since). They answer { "data": [ … ], "next_cursor": "…" } with next_cursor: null on the last page.
Order rows by (updated_at, id) ascending and encode the last pair in the cursor. A row updated during a sync then moves to the end instead of being skipped, and the cursor never expires:
SELECT * FROM products
WHERE updated_at >= :updated_since -- when given
AND (updated_at, id) > (:cursor_updated_at, :cursor_id) -- when a cursor is given
ORDER BY updated_at, id
LIMIT :limit + 1; -- the extra row tells you whether to return next_cursorupdated_at must use your clock and change whenever anything Setu reads changes: title, description, status, categories, tags, images, any variant, any price, or stock.
GET /products
Return every product, including draft and archived ones, so Setu can archive what is no longer sold. Return deleted products as archived.
{
"data": [
{
"id": "p_42",
"title": "Linen Wrap Dress",
"description": "Breathable linen, wrap fit, midi length.",
"status": "active",
"category_ids": ["cat_dresses"],
"tags": ["linen", "summer"],
"images": [{ "url": "https://cdn.example.com/p_42/1.jpg", "alt": "Front view", "position": 0 }],
"url": "https://shop.example.com/products/linen-wrap-dress",
"variants": [
{
"id": "v_42_m_black",
"title": "M / Black",
"sku": "LWD-M-BLK",
"options": { "Size": "M", "Colour": "Black" },
"price_minor": 285000,
"compare_at_minor": 320000,
"available": 7,
"active": true,
"updated_at": "2026-10-09T14:20:00.000Z"
}
],
"updated_at": "2026-10-09T14:20:00.000Z"
}
],
"next_cursor": null
}| Field | Notes |
|---|---|
id | Stable, ≤128 characters. Never reuse an id for a different product. |
status | active | draft | archived. Setu sells only active. |
images | Absolute https URLs customers can open. Setu uses up to 10, ordered by position. |
url | Optional absolute https URL of the product page; Setu may share it with customers. |
variants[].id | Stable and unique within the store. A product without options has one variant titled "Default". |
variants[].price_minor | Current selling price, integer ≥ 0. |
variants[].compare_at_minor | "Was" price or null; shown only when greater than the price. |
variants[].available | Units sellable now (stock minus reservations), integer ≥ 0. |
variants[].active | false = cannot be ordered regardless of stock. |
Setu's copy of prices and stock is only a search cache. Setu never quotes or orders from it; it always calls /checkout/validate first.
GET /products/{id}
Returns { "product": { … } } in the same shape, or 404 not_found. The id is URL-encoded in the path.
POST /checkout/validate
Re-reads live prices and stock. Read-only: it must not reserve stock, and Setu may retry it.
{
"lines": [{ "product_id": "p_42", "variant_id": "v_42_m_black", "quantity": 2 }],
"shipping_address": { "line1": "12 Example Road", "city": null, "area": "Lalitpur", "country": "NP" }
}{
"currency": "NPR",
"lines": [
{
"product_id": "p_42", "variant_id": "v_42_m_black", "quantity": 2,
"title": "Linen Wrap Dress", "variant_title": "M / Black",
"unit_price_minor": 285000, "line_total_minor": 570000, "available": 7
}
],
"subtotal_minor": 570000,
"shipping_minor": null,
"problems": []
}Every requested line appears either in lines or in problems with code not_found, inactive or insufficient_stock (with available). shipping_minor is your delivery fee for the address, or null when you don't quote shipping.
Stores that list shipping.quotes still implement this endpoint (Setu uses it for quick availability checks), but the delivery fee comes from POST /shipping/quotes.
POST /shipping/quotes
Optional, for stores that list shipping.quotes. Setu sends the cart and the delivery address once the customer has given it; you re-read live prices and stock (like /checkout/validate) and price delivery with your own rules. Scope: inventory:read in OAuth mode. It must not reserve stock.
{
"lines": [{ "product_id": "p_42", "variant_id": "v_42_m_black", "quantity": 2 }],
"destination": {
"line1": "12 Example Road",
"line2": "Flat 3",
"city": "Kathmandu",
"area": "Baneshwor",
"region": "Bagmati",
"postal_code": "44600",
"country": "NP",
"latitude": 27.6915,
"longitude": 85.342
}
}destination has the address fields of POST /orders (line1, city, area, country) plus the optional line2, region, postal_code, latitude and longitude. latitude and longitude are sent together or not at all.
{
"quote": {
"id": "q_7Hk2",
"expires_at": "2026-10-12T08:30:00.000Z",
"currency": "NPR",
"lines": [
{
"product_id": "p_42", "variant_id": "v_42_m_black", "quantity": 2,
"title": "Linen Wrap Dress", "variant_title": "M / Black",
"unit_price_minor": 285000, "line_total_minor": 570000, "available": 7
}
],
"subtotal_minor": 570000,
"shipping_minor": 15000,
"total_minor": 585000,
"service": { "code": "standard", "label": "Standard delivery" },
"estimated_delivery": { "min_days": 2, "max_days": 4, "text": "2–4 days" },
"payment_options": [
{ "id": "cash_on_delivery", "kind": "cash_on_delivery", "label": "Cash on delivery" },
{ "id": "wallet_qr", "kind": "online", "label": "Pay by QR" }
]
},
"problems": []
}total_minor=subtotal_minor + shipping_minor.estimated_deliverymay benull.payment_optionslists the options allowed for this total.- If any line is unknown, inactive or short on stock, answer
200with"quote": nulland theproblems, exactly as in/checkout/validate. - If you don't deliver to the destination, answer
422 delivery_unavailable. - A quote is bound to the exact lines (product, variant, quantity), the destination and its
shipping_minor. It expires: 15 minutes is recommended, 24 hours at most.
POST /orders must then include shipping_quote_id with the quote's shipping_minor, the same lines and the same shipping_address. Reject an expired quote with 409 quote_expired, and different lines, destination or shipping, an unknown quote or a quote already used by another order with 409 quote_mismatch. Setu then asks for a new quote.
POST /orders
Creates the order once the customer confirms. Requires an Idempotency-Key header. Base stores receive cash-on-delivery orders; stores with optional capabilities also receive shipping_quote_id and online payment.method ids.
POST /api/setu/v1/orders
Authorization: Bearer <API key>
Idempotency-Key: setu:ws_demo:chk_demo:1
Content-Type: application/json
{
"external_reference": "ext_9fQ2kP",
"lines": [{ "product_id": "p_42", "variant_id": "v_42_m_black", "quantity": 2, "unit_price_minor": 285000 }],
"currency": "NPR",
"expected_subtotal_minor": 570000,
"shipping_minor": 15000,
"discount_minor": 0,
"tax_minor": 0,
"expected_total_minor": 585000,
"customer": { "name": "Customer Name", "phone": "+9779800000000", "email": null },
"shipping_address": { "line1": "12 Example Road", "city": null, "area": "Lalitpur", "country": "NP" },
"payment": { "method": "cash_on_delivery" },
"channel": "instagram",
"note": null,
"source": { "platform": "setu", "channel": "instagram", "conversation_ref": "conv_demo", "setu_order_ref": "ext_9fQ2kP" },
"test": false
}expected_total_minor = expected_subtotal_minor + shipping_minor − discount_minor + tax_minor. discount_minor, tax_minor, note, customer.email, source and test are optional.
| Field | Rule |
|---|---|
payment.method | One of your payment option ids (^[a-z0-9][a-z0-9_.-]{0,63}$); cash_on_delivery for base stores. An id you don't offer, or one not allowed for this total, gets 422 payment_method_unavailable. |
shipping_quote_id | Required when you list shipping.quotes; shipping_minor must equal the quote's. See shipping quotes. |
shipping_address | May carry the optional line2, region, postal_code, latitude and longitude sent with the quote. |
In one transaction, your server must:
- Re-read live prices and stock. Never trust prices from the request.
- Answer
409 out_of_stockwithdetails.problemsif a line is unknown, inactive or short on stock. - Answer
409 price_changedwithdetails.lines(the live prices) if any price differs. - With
shipping.quotes: answer409 quote_expiredor409 quote_mismatchif the quote does not fit. - Otherwise create the order with your normal business rules (numbering, stock, status history), record that it came from Setu (
source,channel,external_reference), mark COD orders unpaid, and answer201. Orders with an online payment option answer"payment_status": "pending",payment_methodand"payment_required": true; see payments.
{
"order": {
"id": "o_1001",
"number": "EX-1001",
"status": "pending",
"payment_status": "unpaid",
"currency": "NPR",
"subtotal_minor": 570000,
"shipping_minor": 15000,
"discount_minor": 0,
"tax_minor": 0,
"total_minor": 585000,
"external_reference": "ext_9fQ2kP",
"test": false,
"created_at": "2026-10-10T08:15:30.000Z",
"updated_at": "2026-10-10T08:15:30.000Z"
}
}Idempotency
Setu retries POST /orders with the same key and body after a timeout or 5xx, so a retry must never create a second order. Keys are 8–255 visible ASCII characters; treat them as opaque.
| Situation | Response |
|---|---|
| New key | Process normally. Store the key, a SHA-256 hash of the canonical body (sorted keys), the status and the response body. |
| Same key and body, first request finished | Return the stored status and body with Idempotent-Replayed: true. |
| Same key and body, still running | 409 idempotency_in_progress |
| Same key, different body | 422 idempotency_mismatch |
First request failed with a deterministic 4xx (price_changed, out_of_stock) | Store and replay that error like a success. |
| First request crashed before committing | Release the key so the retry runs fresh. |
Make the idempotency record atomic with the order (a unique index plus INSERT … ON CONFLICT DO NOTHING, or the same transaction), keep keys for at least 24 hours, and put a unique index on external_reference as a second safety net.
Setu's keys look like setu:{workspace}:{checkoutSession}:{attempt} for orders and setu:{workspace}:{checkoutSession}:pay:{n} for payment sessions, which follow the same rules, scoped per connection.
Test orders
When "test": true, validate the order exactly like a real one (prices, stock, totals, idempotency) and record it clearly marked as a test, but do not deduct or reserve stock, capture or request payment, book a courier, or notify the customer. Return it from GET /orders/{id} with "test": true. The merchant can cancel it from the admin.
GET /orders/{id}
Returns { "order": { … } } in the same shape. Only orders created through the Setu API are visible; any other id is 404 not_found. status is pending | confirmed | processing | shipped | delivered | cancelled; payment_status is unpaid | pending | paid | refunded | failed. pending means an online payment has not completed yet. Orders may also include payment_method and payment_required, and, with an order-action capability, allowed_actions.
| Your status | Shown in Setu as |
|---|---|
pending, confirmed, processing | Confirmed, with your status shown as is |
shipped, delivered | Fulfilled (delivered is final) |
cancelled | Cancelled (final) |
Order lookup by number
Optional, for stores that list orders.lookup_by_number. GET /orders/{id} also accepts the customer-facing order number (URL-encoded), so Setu can find an order a customer quotes in a conversation. Try the id first, then the number. The same visibility rules apply: orders not created through the Setu API are 404 not_found.
GET /api/setu/v1/orders/EX-1001
Authorization: Bearer <API key>Order actions
Optional. Each action is its own capability, so you can offer any subset. Setu shows a button in its Orders panel only when you list the capability and the order's allowed_actions includes the action; otherwise the panel says "Managed in your website". Your endpoint has the last word and must refuse anything your rules forbid.
Payment review (optional): if a payment lands after an order was cancelled, record it (never discard it), keep the order cancelled, don't ship it, and add "payment_review": { "required": true, "reason": "paid_after_cancellation", "since": "…" } to the order. Other reasons are cancelled_after_payment and other. While a review is open, send an empty allowed_actions and refuse every action; Setu shows "Payment needs review in your website" and tells the customer the store will contact them about a refund. Resolve it in your admin, then stop sending the field. Setu never moves money.
| Capability | Endpoint | Allowed when | Otherwise |
|---|---|---|---|
orders.cancel | POST /orders/{id}/cancel | Status pending, confirmed or processing, and not paid or refunded. Apply your own stock, payment and delivery rules (for example, return reserved stock once). | 409 invalid_transition |
orders.fulfil | POST /orders/{id}/fulfil | Status confirmed or processing, and cash on delivery or already paid. Usually moves the order to shipped. | 409 invalid_transition |
orders.cash_payment | POST /orders/{id}/cash-payment | Cash on delivery only, not cancelled, not already paid. Sets payment_status to paid. | 409 payment_method_mismatch for online payments; 409 invalid_transition otherwise |
POST /api/setu/v1/orders/o_1001/cancel
Authorization: Bearer <API key>
Idempotency-Key: setu:<workspace>:<order>:cancel:<order version>
Content-Type: application/json
{ "expected_status": "confirmed", "expected_updated_at": "2026-10-12T08:15:30.000Z", "reason": "Customer asked to cancel" }{ "order": { "id": "o_1001", "number": "EX-1001", "status": "cancelled", "payment_status": "unpaid", "updated_at": "2026-10-12T08:16:02.000Z", "allowed_actions": [], … } }- Never mark an online payment paid through
cash-payment. Online payments are confirmed only by your payment provider. - Idempotency:
Idempotency-Keyis required. Store the outcome, including a409refusal, for 24 hours and replay it withIdempotent-Replayed: true. Restock, emails and status history must not repeat on a replay. The same key for another action, order or body is422 idempotency_mismatch. - Conflicts: if
expected_statusorexpected_updated_atno longer match, answer409 order_changedand change nothing. Check, change and record in one transaction with the order locked. - After a change: bump
updated_at, recomputeallowed_actions, and sendorder.updatedif a webhook is registered. Orders not created through the Setu API are404 not_found. - Setu stores your answer as its copy and never changes your order on its own. On
order_changedorinvalid_transitionit re-readsGET /orders/{id}and shows the merchant the current state.
Payment options and sessions
Every store accepts at least one payment option. Base stores list only cash_on_delivery in payment_methods. Stores that list payments.sessions also publish payment_options on GET /store and may offer online payment.
| Field | Notes |
|---|---|
id | Matches ^[a-z0-9][a-z0-9_.-]{0,63}$. cash_on_delivery is reserved for cash on delivery. |
kind | cash_on_delivery (pay the courier) or online (pay through a payment session before shipping). |
label | Customer-facing name, e.g. Pay by QR. |
description | Optional. |
min_total_minor | Optional. Totals below this cannot use the option. |
max_total_minor | Optional. Totals above this cannot use the option. |
payment_methods lists the same ids. The customer picks an option in the conversation and Setu sends its id as payment.method on POST /orders. An online order is created with "payment_status": "pending", payment_method and "payment_required": true. Don't ship it until it is paid. Whether you reserve or decrement stock meanwhile is your choice, but never oversell.
POST /orders/{id}/payment-sessions
Starts the payment with your own payment gateway. Scope orders:write; Idempotency-Key required. The body is optional: {} uses the order's payment option, or send {"method": "<online option id>"}.
{
"payment_session": {
"id": "ps_4Tq9",
"order_id": "o_1002",
"method": "wallet_qr",
"status": "pending",
"amount_minor": 585000,
"currency": "NPR",
"url": "https://shop.example.com/pay/ps_4Tq9",
"qr_payload": "EXAMPLEPAY:ps_4Tq9:585000",
"expires_at": "2026-10-12T08:40:00.000Z",
"updated_at": "2026-10-12T08:30:00.000Z",
"test": false
}
}statusispending|paid|failed|expired|cancelled.urlis an https payment page on your own domain;qr_payloadis optional (nullwhen you have none).- Idempotency works like orders: the same key and body replay the stored response. A new key while a session is still
pendingreturns that same session: one live session per order. - Errors: unknown order
404 not_found; an order that is already paid, cancelled or cash on delivery409 payment_not_allowed; amethodthat is not one of your online options422 payment_method_unavailable.
GET /payment-sessions/{id}
Scope orders:read. Re-check the payment with your gateway and return { "payment_session": { … } } with the current status. Sessions of other connections are 404 not_found.
Security
- Your gateway credentials stay on your server. Setu never receives gateway secrets, card or bank data: only the
urlorqr_payloadit relays to the customer, and your references. - Test orders (
"test": true) must never contact a real gateway.
Payment status updates
- Polling (always): Setu polls
GET /payment-sessions/{id}with backoff until the status is final (paid,failed,expired,cancelled) or the session expires. - Webhook (optional): stores that list
webhooks.managecan also send a signedpayment.updatedevent withdata: { "order_id", "payment_session_id" }. It uses the sameSetu-Signature: t=…,v1=…HMAC-SHA256 scheme as the other events, a 300-second timestamp tolerance, and Setu processes each event id once. - Webhooks are hints, not authority: after every
payment.updatedSetu re-readsGET /payment-sessions/{id}before it tells the customer or the merchant anything.
{
"id": "evt_9c1",
"type": "payment.updated",
"created_at": "2026-10-12T08:33:10.000Z",
"data": { "order_id": "o_1002", "payment_session_id": "ps_4Tq9" }
}Errors
{ "error": { "code": "price_changed", "message": "Prices changed.", "details": { "lines": [ … ] } } }| code | HTTP | When |
|---|---|---|
invalid_request | 400 | Malformed JSON, failed validation, missing Idempotency-Key |
invalid_token | 401 | Missing, unknown, revoked or rotated key, or integration disabled |
not_found | 404 | Unknown product or order |
price_changed | 409 | A live price differs from the request |
out_of_stock | 409 | A line is unknown, inactive or short on stock |
quote_expired | 409 | The shipping quote has expired |
quote_mismatch | 409 | Lines, destination or shipping differ from the quote, or the quote is unknown or already used |
payment_not_allowed | 409 | The order is already paid, cancelled, or cash on delivery |
invalid_transition | 409 | Order actions: the order's current state does not allow the action |
order_changed | 409 | Order actions: expected_status or expected_updated_at is out of date |
payment_method_mismatch | 409 | Cash payment on an order that is not cash on delivery |
idempotency_in_progress | 409 | The same key is still being processed |
idempotency_mismatch | 422 | The same key with a different body |
delivery_unavailable | 422 | The store does not deliver to this destination |
payment_method_unavailable | 422 | The payment option is unknown, not online, or not allowed for this total |
rate_limited | 429 | Too many requests; send Retry-After |
server_error | 500 | Unexpected failure |
unavailable | 503 | Maintenance or overload |
message is for developers and logs; Setu never shows your error text to the merchant or customer. A non-JSON or 3xx response is treated as an invalid response.
Rate limits and retries
Setu's traffic is modest: a full sync reads pages of 50 products one at a time, a check runs every 15 minutes, and each customer checkout makes 1–3 validate or quote calls, one order call and, for online payments, one payment session plus a few status checks. Rate-limit per key at about 120 requests per minute and answer 429 rate_limited with a Retry-After header (seconds).
Setu retries GETs and idempotent POSTs on network errors, timeouts (10 s) and 408, 425, 429, 500, 502, 503, 504: 3 attempts in total with backoff, honouring Retry-After (capped at 10 s). 401 is never retried in API-key mode.
After 5 consecutive failures Setu opens a circuit breaker: it pauses calls to your API for 2 minutes and keeps serving the last good catalogue meanwhile.
Security requirements
- HTTPS on port 443 with a valid certificate, on a public DNS name. Setu refuses private, loopback and link-local addresses at every connection.
- No redirects on API paths; serve the final URL directly.
- Store only the key hash; compare in constant time; never log the Authorization header or the key.
- Key management is admin-only and checked on the server.
- Never trust prices, totals or stock from the request; recompute them.
- Return only what the contract needs: no internal ids, costs, supplier data or other customers' data.
- Send
Cache-Control: no-store; exclude the API from page caches and CDNs that ignore authorization.
On Setu's side the key is encrypted at rest (AES-256-GCM), never displayed again, never logged, and deleted on disconnect.
How Setu syncs
- Initial sync after connecting: pages through
GET /productsuntilnext_cursoris null. The shop keeps its Setu catalogue until this sync succeeds. - Every 15 minutes:
GET /products?updated_since=<watermark>, thenGET /orders/{id}for orders that are not yet delivered or cancelled. A401on any request marks the connection "Authentication failed". - Full re-sync: products you no longer list are archived in Setu, never deleted, so conversation and order history is kept.
- Products and variants are matched by id: re-syncing never duplicates anything. One sync runs at a time per shop.
- A failed sync leaves the last good catalogue in place.
Full integration sequence
The calls Setu makes for one store, from connecting to a paid order. Steps in brackets need the named capability.
GET /store: identity,capabilitiesandpayment_options.GET /products: paged, then incrementally withupdated_since.POST /shipping/quotes[shipping.quotes], orPOST /checkout/validatefor base stores.- The customer picks a payment option.
POST /orderswith anIdempotency-Key,shipping_quote_id(with quotes) andpayment.method.- If the option is online [
payments.sessions]:POST /orders/{id}/payment-sessions; Setu sends theurlor QR code to the customer. - Poll
GET /payment-sessions/{id}, or receivepayment.updated[webhooks.manage] and then re-read it. GET /orders/{id}for status until the order is delivered or cancelled.- When the merchant acts in Setu's Orders panel [
orders.cancel,orders.fulfil,orders.cash_payment]:POST /orders/{id}/cancel,/fulfilor/cash-payment.
Connecting in Setu
The shop owner pastes the Store API URL and the key and clicks Test and connect. Nothing is saved unless every step passes:
| Step | Request | Passes when |
|---|---|---|
| Checking secure connection | — | https, port 443, no credentials, query or fragment in the URL; every DNS address is public |
| Authenticating store | GET /store | 200 (401 means the key was not accepted) |
| Reading store information | same response | Valid JSON with id, name, ISO 4217 currency, api_version: "1" |
| Checking product access | GET /products?limit=1 | catalog.read advertised and a valid page returned |
| Checking order capability | — | orders.write advertised, and cash_on_delivery accepted or an online option offered with payments.sessions. No order is created. |
Setu then shows the store name, domain, currency, capabilities and product count, runs the initial sync and switches the shop to your catalogue. Every error shows a reference ID (for example Reference 7K2Q9D) that Setu support can find in its logs.
Testing your implementation
Download setu-conformance.mjs (Node.js 18 or newer, no dependencies) and run it against your deployment. It runs the same checks as the Setu wizard, pages through every product, validates every field, checks that ids are stable across two passes, and checks that an invalid key gets 401. Section 6 checks the optional capabilities you list; section 7 places the test orders.
SETU_STORE_KEY='<your key>' node setu-conformance.mjs --url https://shop.example.com/api/setu/v1
# Also place two identical test orders with one Idempotency-Key (proves replay; no stock or payment):
SETU_STORE_KEY='<your key>' node setu-conformance.mjs --url https://shop.example.com/api/setu/v1 --order-test
# Stores with shipping.quotes: choose a destination you deliver to (or skip the quote)
SETU_STORE_KEY='<your key>' node setu-conformance.mjs --url https://shop.example.com/api/setu/v1 --city Kathmandu --lat 27.7 --lng 85.32
SETU_STORE_KEY='<your key>' node setu-conformance.mjs --url https://shop.example.com/api/setu/v1 --no-quoteWithout --order-test the tool creates nothing, except one shipping quote that expires on its own when you list shipping.quotes (skip it with --no-quote). After the test orders it prints both the order number and the internal order id. The key is read from the environment and never printed. Test orders use the customer name "Setu Conformance Test" and "test": true; cancel them in your admin afterwards. You can also import the Postman collection, set baseUrl and apiKey, and call each endpoint by hand.
Checklist
GET /storereturnsapi_version "1", both required capabilities, andcash_on_deliveryor an online option withpayments.sessions.- A wrong, revoked or rotated key gets
401 invalid_tokenon every endpoint. - Pagination returns every product exactly once; ids are stable.
- Prices are integers in minor units; images and product URLs are https.
POST /ordersrejects stale prices and missing stock, and replays the same key and body.- Test orders don't touch stock, payment, courier or notifications.
- With
shipping.quotes: an unknown variant gets"quote": null; orders without a matching, unexpired quote are rejected. - With
payments.sessions: unknown sessions and orders get 404; a cash-on-delivery order gets409 payment_not_allowed; test orders never reach a real gateway. - With
orders.lookup_by_number:GET /orders/{number}returns the same order as the id. - With order actions: each action works once and replays with the same key; a second cancel or cash payment gets
409 invalid_transition; cash on an online order gets409 payment_method_mismatch; a staleexpected_statusgets409 order_changed; stock comes back exactly once on cancel. - The conformance tool passes with and without
--order-test.
Rotation and revocation
- Rotate in your admin when a key may have leaked or staff change. The old key fails at once. Setu shows "Authentication failed"; the owner clicks Update credentials in Setu and pastes the new key. The catalogue stays in place meanwhile.
- Revoke to stop Setu's access completely.
- Disconnect in Setu deletes the encrypted key, stops syncing, archives Setu's copy of the catalogue (history is kept) and switches the shop back to its Setu catalogue. Revoke the key in your admin as well.
Troubleshooting
| Setu shows | Likely cause |
|---|---|
| The URL must start with https:// | Paste the full https Store API URL, not the storefront address. |
| This address is not reachable from the internet | The host resolves to a private or loopback address, or uses a port other than 443. |
| The store redirected the request | The API URL redirects (e.g. apex → www, trailing slash). Paste the final URL. |
| The API key was not accepted | Wrong, revoked or rotated key, or the integration is disabled in your admin. |
| The store's response was not in the expected format | GET /store or GET /products doesn't match the OpenAPI schema. Run the conformance tool. |
| The store does not allow product access / order creation | capabilities lacks catalog.read / orders.write, or the store lacks cash_on_delivery or an online option with payments.sessions. |
| The store took too long to respond | Each request must answer within 10 seconds and be smaller than 5 MB. |
| Authentication failed (after it worked) | The key was rotated or revoked. Use Update credentials in Setu. |
When contacting Setu support, include the reference ID shown with the error. Never send your API key to anyone, including Setu support.
Changelog
- 12 October 2026 · v1 (additive): optional
payment_reviewon orders (required,reason:paid_after_cancellation,cancelled_after_paymentorother,since). While it is open,allowed_actionsis empty and Setu offers no order actions. Stores that don't send it need no changes. - 12 October 2026 · v1 (additive): optional order actions
orders.cancel,orders.fulfilandorders.cash_payment(POST /orders/{id}/cancel,/fulfil,/cash-payment); optionalallowed_actionson orders; error codesinvalid_transition,order_changed,payment_method_mismatch. Setu now showsshippedas Fulfilled. Stores without these capabilities need no changes. - 12 October 2026 · v1 (additive): optional capabilities
shipping.quotes(POST /shipping/quotes,shipping_quote_idon orders),payments.sessions(payment_options, online payment options,POST /orders/{id}/payment-sessions,GET /payment-sessions/{id}, thepayment.updatedwebhook),orders.lookup_by_numberandwebhooks.manage; optional address detail (line2,region,postal_code, coordinates);payment_statuspending; error codesquote_expired,quote_mismatch,payment_not_allowed,delivery_unavailable,payment_method_unavailable; rate-limit and retry guidance. Stores without optional capabilities need no changes. - 11 October 2026 · v1: API-key authentication;
api_version,capabilitiesandproduct_countonGET /store; producturl; orderdiscount_minor,tax_minor,note,customer.email,sourceandtest. All additive. - 10 October 2026 · v1: first release (OAuth mode, products, checkout validation, idempotent orders, webhooks).