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

ResourceLink
OpenAPI 3.1 specification (source of truth)openapi.yaml
Postman collectionsetu-commerce-api.postman_collection.json
Conformance tool (Node.js 18+, no dependencies)setu-conformance.mjs

The connection has two parts, both on your side:

  1. 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.
  2. 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

  1. Implement GET /store, GET /products, GET /products/{id}, POST /checkout/validate, POST /orders and GET /orders/{id} under your Store API URL. GET /categories is optional. Shipping quotes, online payments and order lookup by number are optional capabilities you can add later.
  2. Protect them with an API key: generate it in your admin, store only its hash, compare in constant time.
  3. Make POST /orders idempotent and honour "test": true.
  4. Run the conformance tool against your deployment until every check passes.
  5. 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/json

When 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" from GET /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

RuleRequirement
GenerationAt least 256 bits from a CSPRNG, e.g. "sk_setu_" + base64url(randomBytes(32)). The fixed prefix helps secret scanners recognise leaked keys.
StorageStore 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.
DisplayShow the plaintext once, right after generate or rotate. No endpoint returns it again.
VerificationHash the presented key and compare with a constant-time function (crypto.timingSafeEqual, hash_equals, hmac.compare_digest).
RotationReplace the stored hash. The previous key fails immediately; there is no grace period.
RevocationClear the stored hash or set a revoked timestamp. Disabling the integration also refuses every key.
AuditRecord 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).
AuthorizationOnly store administrators can manage keys, enforced on the server, not only by hiding the page.
Node.js reference (any language works the same way)
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

TopicRule
TransportHTTPS on port 443 only. API paths must not redirect: Setu does not follow redirects.
Content typeapplication/json; charset=utf-8
CachingSend Cache-Control: no-store on every authenticated response.
Field namessnake_case. Ignore unknown request fields.
MoneyIntegers in the currency's minor unit (ISO 4217 exponent). NPR 1,850.00 → 185000; USD 12.50 → 1250; JPY 900 → 900.
TimestampsRFC 3339 UTC, e.g. 2026-10-10T08:15:30.000Z.
LimitsAnswer 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
}
FieldTypeNotes
idstring ≤128Stable store id. Setu refuses to silently swap one store for another.
namestring ≤200Shown in Setu.
currencyISO 4217All prices are in this currency.
website_urlstring | nullPublic storefront.
timezoneIANA zone | null
payment_methodsstring[]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_optionsobject[]Required with payments.sessions, optional otherwise. See payment options.
shipping_quotesbooleantrue when /checkout/validate returns shipping_minor for an address.
api_version"1"Required.
capabilitiesstring[]Required. Must include catalog.read and orders.write; may add the optional capabilities. Unknown values are ignored.
product_countinteger | nullOptional. 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.

CapabilityRequiredWhat it means
catalog.readYesSetu may read products (GET /products) and check live prices and stock.
orders.writeYesSetu may create orders with POST /orders and read them with GET /orders/{id}.
shipping.quotesNoYou price delivery with POST /shipping/quotes, and orders must reference the quote. Setu never chooses the delivery fee itself.
payments.sessionsNoYou offer online payment options and start payments with POST /orders/{id}/payment-sessions.
orders.lookup_by_numberNoGET /orders/{id} also accepts the customer-facing order number (details).
orders.cancel, orders.fulfil, orders.cash_paymentNoThe 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.manageNoSetu 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.

GET /store for a store with every optional capability
{
  "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:

SQL sketch
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_cursor

updated_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
}
FieldNotes
idStable, ≤128 characters. Never reuse an id for a different product.
statusactive | draft | archived. Setu sells only active.
imagesAbsolute https URLs customers can open. Setu uses up to 10, ordered by position.
urlOptional absolute https URL of the product page; Setu may share it with customers.
variants[].idStable and unique within the store. A product without options has one variant titled "Default".
variants[].price_minorCurrent selling price, integer ≥ 0.
variants[].compare_at_minor"Was" price or null; shown only when greater than the price.
variants[].availableUnits sellable now (stock minus reservations), integer ≥ 0.
variants[].activefalse = 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.

Request
{
  "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" }
}
Response 200 (a cart with problems is not an error)
{
  "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.

Request
{
  "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.

Response 200
{
  "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_delivery may be null. payment_options lists the options allowed for this total.
  • If any line is unknown, inactive or short on stock, answer 200 with "quote": null and the problems, 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.

Request
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.

FieldRule
payment.methodOne 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_idRequired when you list shipping.quotes; shipping_minor must equal the quote's. See shipping quotes.
shipping_addressMay carry the optional line2, region, postal_code, latitude and longitude sent with the quote.

In one transaction, your server must:

  1. Re-read live prices and stock. Never trust prices from the request.
  2. Answer 409 out_of_stock with details.problems if a line is unknown, inactive or short on stock.
  3. Answer 409 price_changed with details.lines (the live prices) if any price differs.
  4. With shipping.quotes: answer 409 quote_expired or 409 quote_mismatch if the quote does not fit.
  5. 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 answer 201. Orders with an online payment option answer "payment_status": "pending", payment_method and "payment_required": true; see payments.
Response 201
{
  "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.

SituationResponse
New keyProcess 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 finishedReturn the stored status and body with Idempotent-Replayed: true.
Same key and body, still running409 idempotency_in_progress
Same key, different body422 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 committingRelease 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 statusShown in Setu as
pending, confirmed, processingConfirmed, with your status shown as is
shipped, deliveredFulfilled (delivered is final)
cancelledCancelled (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.

CapabilityEndpointAllowed whenOtherwise
orders.cancelPOST /orders/{id}/cancelStatus 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.fulfilPOST /orders/{id}/fulfilStatus confirmed or processing, and cash on delivery or already paid. Usually moves the order to shipped.409 invalid_transition
orders.cash_paymentPOST /orders/{id}/cash-paymentCash on delivery only, not cancelled, not already paid. Sets payment_status to paid.409 payment_method_mismatch for online payments; 409 invalid_transition otherwise
Request (every field optional; reason is for cancel only, up to 200 characters)
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" }
200 response: the updated order
{ "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-Key is required. Store the outcome, including a 409 refusal, for 24 hours and replay it with Idempotent-Replayed: true. Restock, emails and status history must not repeat on a replay. The same key for another action, order or body is 422 idempotency_mismatch.
  • Conflicts: if expected_status or expected_updated_at no longer match, answer 409 order_changed and change nothing. Check, change and record in one transaction with the order locked.
  • After a change: bump updated_at, recompute allowed_actions, and send order.updated if a webhook is registered. Orders not created through the Setu API are 404 not_found.
  • Setu stores your answer as its copy and never changes your order on its own. On order_changed or invalid_transition it re-reads GET /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.

FieldNotes
idMatches ^[a-z0-9][a-z0-9_.-]{0,63}$. cash_on_delivery is reserved for cash on delivery.
kindcash_on_delivery (pay the courier) or online (pay through a payment session before shipping).
labelCustomer-facing name, e.g. Pay by QR.
descriptionOptional.
min_total_minorOptional. Totals below this cannot use the option.
max_total_minorOptional. 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>"}.

Response 201
{
  "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
  }
}
  • status is pending | paid | failed | expired | cancelled. url is an https payment page on your own domain; qr_payload is optional (null when you have none).
  • Idempotency works like orders: the same key and body replay the stored response. A new key while a session is still pending returns that same session: one live session per order.
  • Errors: unknown order 404 not_found; an order that is already paid, cancelled or cash on delivery 409 payment_not_allowed; a method that is not one of your online options 422 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 url or qr_payload it 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.manage can also send a signed payment.updated event with data: { "order_id", "payment_session_id" }. It uses the same Setu-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.updated Setu re-reads GET /payment-sessions/{id} before it tells the customer or the merchant anything.
payment.updated event body
{
  "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": [ … ] } } }
codeHTTPWhen
invalid_request400Malformed JSON, failed validation, missing Idempotency-Key
invalid_token401Missing, unknown, revoked or rotated key, or integration disabled
not_found404Unknown product or order
price_changed409A live price differs from the request
out_of_stock409A line is unknown, inactive or short on stock
quote_expired409The shipping quote has expired
quote_mismatch409Lines, destination or shipping differ from the quote, or the quote is unknown or already used
payment_not_allowed409The order is already paid, cancelled, or cash on delivery
invalid_transition409Order actions: the order's current state does not allow the action
order_changed409Order actions: expected_status or expected_updated_at is out of date
payment_method_mismatch409Cash payment on an order that is not cash on delivery
idempotency_in_progress409The same key is still being processed
idempotency_mismatch422The same key with a different body
delivery_unavailable422The store does not deliver to this destination
payment_method_unavailable422The payment option is unknown, not online, or not allowed for this total
rate_limited429Too many requests; send Retry-After
server_error500Unexpected failure
unavailable503Maintenance 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 /products until next_cursor is null. The shop keeps its Setu catalogue until this sync succeeds.
  • Every 15 minutes: GET /products?updated_since=<watermark>, then GET /orders/{id} for orders that are not yet delivered or cancelled. A 401 on 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.

  1. GET /store: identity, capabilities and payment_options.
  2. GET /products: paged, then incrementally with updated_since.
  3. POST /shipping/quotes [shipping.quotes], or POST /checkout/validate for base stores.
  4. The customer picks a payment option.
  5. POST /orders with an Idempotency-Key, shipping_quote_id (with quotes) and payment.method.
  6. If the option is online [payments.sessions]: POST /orders/{id}/payment-sessions; Setu sends the url or QR code to the customer.
  7. Poll GET /payment-sessions/{id}, or receive payment.updated [webhooks.manage] and then re-read it.
  8. GET /orders/{id} for status until the order is delivered or cancelled.
  9. When the merchant acts in Setu's Orders panel [orders.cancel, orders.fulfil, orders.cash_payment]: POST /orders/{id}/cancel, /fulfil or /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:

StepRequestPasses when
Checking secure connection—https, port 443, no credentials, query or fragment in the URL; every DNS address is public
Authenticating storeGET /store200 (401 means the key was not accepted)
Reading store informationsame responseValid JSON with id, name, ISO 4217 currency, api_version: "1"
Checking product accessGET /products?limit=1catalog.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-quote

Without --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 /store returns api_version "1", both required capabilities, and cash_on_delivery or an online option with payments.sessions.
  • A wrong, revoked or rotated key gets 401 invalid_token on every endpoint.
  • Pagination returns every product exactly once; ids are stable.
  • Prices are integers in minor units; images and product URLs are https.
  • POST /orders rejects 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 gets 409 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 gets 409 payment_method_mismatch; a stale expected_status gets 409 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 showsLikely 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 internetThe host resolves to a private or loopback address, or uses a port other than 443.
The store redirected the requestThe API URL redirects (e.g. apex → www, trailing slash). Paste the final URL.
The API key was not acceptedWrong, revoked or rotated key, or the integration is disabled in your admin.
The store's response was not in the expected formatGET /store or GET /products doesn't match the OpenAPI schema. Run the conformance tool.
The store does not allow product access / order creationcapabilities 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 respondEach 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_review on orders (required, reason: paid_after_cancellation, cancelled_after_payment or other, since). While it is open, allowed_actions is 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.fulfil and orders.cash_payment (POST /orders/{id}/cancel, /fulfil, /cash-payment); optional allowed_actions on orders; error codes invalid_transition, order_changed, payment_method_mismatch. Setu now shows shipped as Fulfilled. Stores without these capabilities need no changes.
  • 12 October 2026 · v1 (additive): optional capabilities shipping.quotes (POST /shipping/quotes, shipping_quote_id on orders), payments.sessions (payment_options, online payment options, POST /orders/{id}/payment-sessions, GET /payment-sessions/{id}, the payment.updated webhook), orders.lookup_by_number and webhooks.manage; optional address detail (line2, region, postal_code, coordinates); payment_status pending; error codes quote_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, capabilities and product_count on GET /store; product url; order discount_minor, tax_minor, note, customer.email, source and test. All additive.
  • 10 October 2026 · v1: first release (OAuth mode, products, checkout validation, idempotent orders, webhooks).