Bookshop — API

Script the demo storefront in eight languages

API tokens Open the app

Bookshop runs entirely on the SkillSafe App API — plain JSON over HTTPS — so everything the web page does, a script of yours can do too: mint a token, page through the catalog of public-domain classics, read one title, record an order, list your orders back and delete one. Every step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Read this before you write any code

Bookshop is a demo storefront, not a bookstore. Placing an order writes one record to the caller's own orders collection. It does not charge credits and it does not deliver a book file, a download link or a reading copy — no record in books carries a file, a URL, or any text of the work. Every listed title is a public-domain classic that can be read free from a library or a public-domain text archive.

price_credits is catalog data shown as a list price and is never billed (10,000 credits = $1). The only thing your calls cost is the platform's data-op accrual described under quotas and cost.

Bookshop has no model bound (model: ""), so /estimate, /run and /run-stream are not part of this app's API surface and are not documented here. Likewise there is no in-app purchase endpoint on this deployment: POST /v1/app-api/purchase answers 404 not_found.

Stock never decreases. The books collection is acl_write: "server", so no client call — yours or the app's — can change a stock count or any other catalog field.

Basics

Base URL https://api.skillsafe.ai/v1/app-api, app slug bookshop. Every request carries Authorization: Bearer <token> (tokens start with aut_) and, when it has a body, Content-Type: application/json.

Every response — success or failure — is the same envelope:

{"ok": true,  "data": { ... }, "meta": { ... }}

{"ok": false, "error": {"code": "...", "message": "...", "status": 404, "details": { ... }},
              "meta": { ... }}

Check ok (or the HTTP status) before touching data. Pagination, when a call has any, lives in meta.pagination, never in data.

Browsers enforce CORS on this API, so run these examples from a terminal, a script or your own server — not from another website's frontend.

Step 0 — A tiny client

Every later step is one HTTP call, so start with a helper that adds the auth header, sends JSON, raises on ok: false and hands back the whole envelope (you will want meta as well as data in step 3).

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"    # step 1 mints one

# Every call on this page is one of two shapes:
#   curl -s "$API/<path>" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
#   curl -s "$API/<path>" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
#        -H "Content-Type: application/json" -d '{"...": "..."}'
#
# Unwrap the envelope with jq, failing loudly when ok is false:
#   ... | jq -e 'if .ok then . else error(.error.code + ": " + .error.message) end'
import os, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")   # step 1 mints one

def api(method, path, body=None, token=None):
    res = requests.request(
        method, API + path, json=body,
        headers={"Authorization": "Bearer " + (token or TOKEN)},
        timeout=30,
    )
    env = res.json()
    if not env.get("ok"):
        err = env.get("error", {})
        raise RuntimeError("%s %s -> %s %s: %s" % (
            method, path, err.get("status"), err.get("code"), err.get("message")))
    return env          # {"ok": True, "data": ..., "meta": ...}
// Node 18+ (built-in fetch). No dependencies.
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // or read it from your environment

async function api(method, path, body, token) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${token || TOKEN}`,
      "Content-Type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const env = await res.json();
  if (!env.ok) {
    const e = env.error || {};
    throw Object.assign(new Error(`${e.code}: ${e.message}`), { status: e.status });
  }
  return env; // { ok: true, data, meta }
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // step 1 mints one

type Envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Meta  json.RawMessage `json:"meta"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
		Status  int    `json:"status"`
	} `json:"error"`
}

func call(method, path string, body any) (*Envelope, error) {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return nil, err
		}
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err // network failure — NOT a bad token
	}
	defer res.Body.Close()
	var env Envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if !env.OK {
		return &env, fmt.Errorf("%s %s: %d %s: %s",
			method, path, env.Error.Status, env.Error.Code, env.Error.Message)
	}
	return &env, nil
}
// Java 17+ with Jackson (com.fasterxml.jackson.core:jackson-databind).
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Bookshop {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static String token = System.getenv("SKILLSAFE_TOKEN"); // step 1 mints one
    static final HttpClient HTTP = HttpClient.newHttpClient();
    static final ObjectMapper M = new ObjectMapper();

    static JsonNode api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + token)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        JsonNode env = M.readTree(res.body());
        if (!env.path("ok").asBoolean()) {
            JsonNode e = env.path("error");
            throw new RuntimeException(method + " " + path + ": "
                + e.path("status").asInt() + " " + e.path("code").asText()
                + ": " + e.path("message").asText());
        }
        return env; // {"ok":true,"data":…,"meta":…}
    }
}
require "net/http"
require "json"
require "uri"

API   = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # step 1 mints one

def api(method, path, body = nil, token = TOKEN)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{token}"
  req["Content-Type"]  = "application/json"
  req.body = JSON.generate(body) if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  env = JSON.parse(res.body)
  unless env["ok"]
    e = env["error"] || {}
    raise "#{method} #{path}: #{e['status']} #{e['code']}: #{e['message']}"
  end
  env # {"ok" => true, "data" => …, "meta" => …}
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // step 1 mints one

function api(string $method, string $path, ?array $body = null, ?string $token = null): array {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . ($token ?: $TOKEN),
            "Content-Type: application/json",
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw = curl_exec($ch);
    curl_close($ch);
    $env = json_decode($raw, true);
    if (empty($env["ok"])) {
        $e = $env["error"] ?? [];
        throw new RuntimeException(sprintf("%s %s: %s %s: %s", $method, $path,
            $e["status"] ?? "?", $e["code"] ?? "?", $e["message"] ?? "unknown"));
    }
    return $env; // ["ok" => true, "data" => …, "meta" => …]
}
// .NET 6+ — System.Net.Http + System.Text.Json, no extra packages.
using System.Text;
using System.Text.Json;

static class Bookshop
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static string Token =
        Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
    static readonly HttpClient Http = new HttpClient();

    public static async Task<JsonElement> Call(string method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(new HttpMethod(method), Api + path);
        req.Headers.Add("Authorization", "Bearer " + Token);
        if (body != null)
            req.Content = new StringContent(JsonSerializer.Serialize(body),
                                            Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!env.GetProperty("ok").GetBoolean())
        {
            var e = env.GetProperty("error");
            throw new Exception($"{method} {path}: {e.GetProperty("status").GetInt32()} "
                + $"{e.GetProperty("code").GetString()}: {e.GetProperty("message").GetString()}");
        }
        return env; // {"ok":true,"data":…,"meta":…}
    }
}

Step 1 — Get a token

POST /v1/app-api/guest

The fastest token is a guest token: post the app slug, get back a bearer token, the guest identity it belongs to, and its expiry. No sign-in, no browser.

request   {"slug": "bookshop"}
data      {"token": "aut_…", "guest_id": "gst_…", "expires_at": "2026-08-08T10:00:00.000Z"}

For a personal token — one tied to your signed-in SkillSafe account, so the orders you write from a script are the same rows the web app shows you — sign in in the browser and copy the token from bookshop.skillsafe.ai/tokens.html. That page exists precisely so you never have to go digging in developer tools for it.

Whichever kind you get, put it in a SKILLSAFE_TOKEN environment variable and keep it out of your source tree; it is a credential.

export API="https://api.skillsafe.ai/v1/app-api"

# Mint a guest token and export it in one go.
export SKILLSAFE_TOKEN=$(curl -s "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"bookshop"}' \
  | jq -r 'if .ok then .data.token else error(.error.message) end')

echo "${SKILLSAFE_TOKEN:0:8}…"   # aut_…

# A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="aut_paste_it_here"
# A guest token needs no auth header of its own.
env = api("POST", "/guest", {"slug": "bookshop"}, token="")
session = env["data"]

TOKEN = session["token"]           # aut_…
print(session["guest_id"], "expires", session["expires_at"])

# Prefer a personal token? Copy it from https://bookshop.skillsafe.ai/tokens.html
# and export SKILLSAFE_TOKEN=… before running this script.
// A guest token needs no auth header of its own.
const { data: session } = await api("POST", "/guest", { slug: "bookshop" }, "");

const token = session.token;       // aut_…
console.log(session.guest_id, "expires", session.expires_at);

// Prefer a personal token? Copy it from https://bookshop.skillsafe.ai/tokens.html
// and put it in SKILLSAFE_TOKEN before running this script.
type Session struct {
	Token     string `json:"token"`
	GuestID   string `json:"guest_id"`
	ExpiresAt string `json:"expires_at"`
}

func guestToken() (Session, error) {
	var s Session
	env, err := call("POST", "/guest", map[string]string{"slug": "bookshop"})
	if err != nil {
		return s, err
	}
	err = json.Unmarshal(env.Data, &s)
	return s, err
}

// in main():
//   s, err := guestToken()
//   token = s.Token   // reuse it for every later call
//
// A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
// and export SKILLSAFE_TOKEN before running.
// A guest token needs no auth header of its own.
JsonNode session = api("POST", "/guest", "{\"slug\":\"bookshop\"}").path("data");

token = session.path("token").asText();          // aut_… reuse for every later call
System.out.println(session.path("guest_id").asText()
    + " expires " + session.path("expires_at").asText());

// A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
// and set SKILLSAFE_TOKEN in the environment.
# A guest token needs no auth header of its own.
session = api("POST", "/guest", { "slug" => "bookshop" }, "")["data"]

token = session["token"]          # aut_…
puts "#{session['guest_id']} expires #{session['expires_at']}"

# A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
# and export SKILLSAFE_TOKEN before running this script.
<?php
// A guest token needs no auth header of its own.
$session = api("POST", "/guest", ["slug" => "bookshop"], "")["data"];

$TOKEN = $session["token"];        // aut_… reused by api() from here on
printf("%s expires %s\n", $session["guest_id"], $session["expires_at"]);

// A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
// and set SKILLSAFE_TOKEN in the environment.
// A guest token needs no auth header of its own.
var session = (await Bookshop.Call("POST", "/guest",
    new { slug = "bookshop" })).GetProperty("data");

var token = session.GetProperty("token").GetString();      // aut_…
Console.WriteLine($"{session.GetProperty("guest_id").GetString()} expires "
    + session.GetProperty("expires_at").GetString());

// A personal token instead: copy it from https://bookshop.skillsafe.ai/tokens.html
// and set SKILLSAFE_TOKEN in the environment.

Each POST /guest mints a brand-new guest identity. Store the token you get and reuse it; calling /guest again gives you a different guest_id that cannot see the previous one's orders. See step 6.

Step 2 — Who am I

GET /v1/app-api/me

A cheap round-trip that tells you whether the token is a signed-in user or a guest, which identity rows will be owned by, and the credit balance. Bookshop never spends that balance on an order — it is shown for the platform's own data-op accrual.

data  {"subject_type": "user" | "guest", "subject_id": "usr_… | gst_…", "credits": 12500}
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq -e 'if .ok then .data else error(.error.message) end'

# {
#   "subject_type": "guest",
#   "subject_id": "gst_9f2c…",
#   "credits": 0
# }
me = api("GET", "/me")["data"]

print(me["subject_type"], me["subject_id"], me["credits"], "credits")
if me["subject_type"] == "guest":
    print("Heads up: orders written now belong to this guest id only.")
const { data: me } = await api("GET", "/me");

console.log(me.subject_type, me.subject_id, me.credits, "credits");
if (me.subject_type === "guest") {
  console.log("Heads up: orders written now belong to this guest id only.");
}
type Me struct {
	SubjectType string `json:"subject_type"` // "user" or "guest"
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}

env, err := call("GET", "/me", nil)
if err != nil {
	return err
}
var me Me
if err := json.Unmarshal(env.Data, &me); err != nil {
	return err
}
fmt.Println(me.SubjectType, me.SubjectID, me.Credits, "credits")
JsonNode me = api("GET", "/me", null).path("data");

System.out.println(me.path("subject_type").asText() + " "
    + me.path("subject_id").asText() + " "
    + me.path("credits").asLong() + " credits");

if ("guest".equals(me.path("subject_type").asText())) {
    System.out.println("Heads up: orders written now belong to this guest id only.");
}
me = api("GET", "/me")["data"]

puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']} credits"
puts "Heads up: orders written now belong to this guest id only." if me["subject_type"] == "guest"
<?php
$me = api("GET", "/me")["data"];

printf("%s %s %d credits\n", $me["subject_type"], $me["subject_id"], $me["credits"]);
if ($me["subject_type"] === "guest") {
    echo "Heads up: orders written now belong to this guest id only.\n";
}
var me = (await Bookshop.Call("GET", "/me")).GetProperty("data");

var kind = me.GetProperty("subject_type").GetString();
Console.WriteLine($"{kind} {me.GetProperty("subject_id").GetString()} "
    + $"{me.GetProperty("credits").GetInt64()} credits");

if (kind == "guest")
    Console.WriteLine("Heads up: orders written now belong to this guest id only.");

Step 3 — Read the catalog

POST /v1/app-api/collections/books/query

The catalog is the shared books collection. This is exactly the body the web app sends on load:

{"where": {"active": {"eq": true}},
 "sort":  {"field": "title", "dir": "asc"},
 "limit": 100}

data.records is an array of records; meta.pagination carries the cursor. A record looks like this — a real row, verbatim:

{"record_id":"frankenstein","owner_type":"app","owner_id":"","doc":{"genre":"gothic","stock":10,"title":"Frankenstein","active":true,"author":"Mary Shelley","description":"The original science-gone-wrong story, written before science had really gotten going.","price_credits":11000},"created_at":"2026-07-08T06:22:44.919Z","updated_at":"2026-07-27T23:24:36.731Z"}

The doc shape

{"title", "author", "genre", "description", "price_credits", "stock", "active"} — and that is the whole of it. There is no file, no url, no content field, and no other key that could carry or point at the text of a book. If you are building something that expects to receive a work, this API cannot give you one; every title here is public-domain and readable free elsewhere.

Paging

meta.pagination is {"has_more": bool, "next_cursor": string | null}. To get the next page, resend the same body with "cursor": <next_cursor> added, and repeat until has_more is false. The app itself does exactly this — see queryAll in its app.js. Do not assume one page is the whole answer.

Query language

Operators: eq, ne, lt, lte, gt, gte, in (1–20 values) and contains (case-sensitive substring). limit defaults to 50 and caps at 100.

You can filter and sort on the declared fields — title, author, price_credits, stock, genre, active — plus record_id, owner_id, created_at and updated_at. description is stored and returned but is not declared, so it cannot be filtered or sorted on; filter it client-side.

# Page 1 — the app's own catalog query.
curl -s "$API/collections/books/query" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"active":{"eq":true}},"sort":{"field":"title","dir":"asc"},"limit":100}' \
  | jq '{books: [.data.records[]
                 | {id: .record_id, title: .doc.title,
                    author: .doc.author, list_price: .doc.price_credits}],
         page: .meta.pagination}'

# Page 2 — the SAME body, plus the cursor you were just handed.
curl -s "$API/collections/books/query" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"active":{"eq":true}},"sort":{"field":"title","dir":"asc"},"limit":100,
       "cursor":"PASTE_next_cursor_HERE"}'

# Other filters: gothic titles under 12,000 credits, cheapest first.
curl -s "$API/collections/books/query" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"where":{"genre":{"eq":"gothic"},"price_credits":{"lt":12000}},
       "sort":{"field":"price_credits","dir":"asc"},"limit":20}'
def query_all(collection, q):
    """Follow next_cursor instead of pretending one page is the whole answer."""
    records, cursor = [], None
    while True:
        body = dict(q)
        if cursor:
            body["cursor"] = cursor
        env = api("POST", "/collections/%s/query" % collection, body)
        records += env["data"]["records"]
        page = (env.get("meta") or {}).get("pagination") or {}
        if not page.get("has_more") or not page.get("next_cursor"):
            return records
        cursor = page["next_cursor"]

books = query_all("books", {
    "where": {"active": {"eq": True}},
    "sort": {"field": "title", "dir": "asc"},
    "limit": 100,
})

for rec in books:
    d = rec["doc"]
    print("%-24s %-22s %6d credits  stock %d"
          % (rec["record_id"], d["author"], d["price_credits"], d["stock"]))

# `description` is not a declared field: filter it here, not in `where`.
gothic = [r for r in books if "science" in r["doc"].get("description", "")]
// Follow next_cursor instead of pretending one page is the whole answer.
async function queryAll(collection, q) {
  const records = [];
  let cursor = null;
  for (;;) {
    const body = cursor ? { ...q, cursor } : q;
    const env = await api("POST", `/collections/${collection}/query`, body);
    records.push(...env.data.records);
    const page = env.meta?.pagination;
    if (!page?.has_more || !page.next_cursor) return records;
    cursor = page.next_cursor;
  }
}

const books = await queryAll("books", {
  where: { active: { eq: true } },
  sort: { field: "title", dir: "asc" },
  limit: 100,
});

for (const rec of books) {
  const d = rec.doc;
  console.log(rec.record_id, d.author, d.price_credits, "credits, stock", d.stock);
}

// `description` is not a declared field: filter it here, not in `where`.
const sciencey = books.filter((r) => (r.doc.description || "").includes("science"));
type Record struct {
	RecordID  string          `json:"record_id"`
	OwnerType string          `json:"owner_type"`
	OwnerID   string          `json:"owner_id"`
	Doc       json.RawMessage `json:"doc"`
	CreatedAt string          `json:"created_at"`
	UpdatedAt string          `json:"updated_at"`
}

type Book struct {
	Title        string `json:"title"`
	Author       string `json:"author"`
	Genre        string `json:"genre"`
	Description  string `json:"description"`
	PriceCredits int64  `json:"price_credits"`
	Stock        int    `json:"stock"`
	Active       bool   `json:"active"`
	// No file, url or content field exists on this collection.
}

// Follow next_cursor instead of pretending one page is the whole answer.
func queryAll(collection string, q map[string]any) ([]Record, error) {
	var all []Record
	cursor := ""
	for {
		body := map[string]any{}
		for k, v := range q {
			body[k] = v
		}
		if cursor != "" {
			body["cursor"] = cursor
		}
		env, err := call("POST", "/collections/"+collection+"/query", body)
		if err != nil {
			return nil, err
		}
		var d struct {
			Records []Record `json:"records"`
		}
		json.Unmarshal(env.Data, &d)
		all = append(all, d.Records...)

		var m struct {
			Pagination struct {
				HasMore    bool   `json:"has_more"`
				NextCursor string `json:"next_cursor"`
			} `json:"pagination"`
		}
		json.Unmarshal(env.Meta, &m)
		if !m.Pagination.HasMore || m.Pagination.NextCursor == "" {
			return all, nil
		}
		cursor = m.Pagination.NextCursor
	}
}

// usage:
books, err := queryAll("books", map[string]any{
	"where": map[string]any{"active": map[string]any{"eq": true}},
	"sort":  map[string]any{"field": "title", "dir": "asc"},
	"limit": 100,
})
for _, rec := range books {
	var b Book
	json.Unmarshal(rec.Doc, &b)
	fmt.Printf("%-24s %-22s %6d credits  stock %d\n", rec.RecordID, b.Author, b.PriceCredits, b.Stock)
}
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayList;
import java.util.List;

// Follow next_cursor instead of pretending one page is the whole answer.
static List<JsonNode> queryAll(String collection, ObjectNode q) throws Exception {
    List<JsonNode> all = new ArrayList<>();
    String cursor = null;
    while (true) {
        ObjectNode body = q.deepCopy();
        if (cursor != null) body.put("cursor", cursor);
        JsonNode env = api("POST", "/collections/" + collection + "/query",
                           M.writeValueAsString(body));
        env.path("data").path("records").forEach(all::add);

        JsonNode page = env.path("meta").path("pagination");
        if (!page.path("has_more").asBoolean() || page.path("next_cursor").isNull()) return all;
        cursor = page.path("next_cursor").asText();
    }
}

// usage:
ObjectNode q = M.createObjectNode();
q.putObject("where").putObject("active").put("eq", true);
q.putObject("sort").put("field", "title").put("dir", "asc");
q.put("limit", 100);

for (JsonNode rec : queryAll("books", q)) {
    JsonNode d = rec.path("doc");
    System.out.printf("%-24s %-22s %6d credits  stock %d%n",
        rec.path("record_id").asText(), d.path("author").asText(),
        d.path("price_credits").asLong(), d.path("stock").asInt());
}
# Follow next_cursor instead of pretending one page is the whole answer.
def query_all(collection, q)
  records = []
  cursor  = nil
  loop do
    body = cursor ? q.merge("cursor" => cursor) : q
    env  = api("POST", "/collections/#{collection}/query", body)
    records.concat(env["data"]["records"])

    page = env.dig("meta", "pagination") || {}
    return records unless page["has_more"] && page["next_cursor"]
    cursor = page["next_cursor"]
  end
end

books = query_all("books", {
  "where" => { "active" => { "eq" => true } },
  "sort"  => { "field" => "title", "dir" => "asc" },
  "limit" => 100,
})

books.each do |rec|
  d = rec["doc"]
  printf("%-24s %-22s %6d credits  stock %d\n",
         rec["record_id"], d["author"], d["price_credits"], d["stock"])
end

# `description` is not a declared field: filter it here, not in `where`.
sciencey = books.select { |r| r["doc"]["description"].to_s.include?("science") }
<?php
// Follow next_cursor instead of pretending one page is the whole answer.
function query_all(string $collection, array $q): array {
    $records = [];
    $cursor  = null;
    while (true) {
        $body = $q;
        if ($cursor !== null) { $body["cursor"] = $cursor; }
        $env = api("POST", "/collections/$collection/query", $body);
        $records = array_merge($records, $env["data"]["records"]);

        $page = $env["meta"]["pagination"] ?? [];
        if (empty($page["has_more"]) || empty($page["next_cursor"])) { return $records; }
        $cursor = $page["next_cursor"];
    }
}

$books = query_all("books", [
    "where" => ["active" => ["eq" => true]],
    "sort"  => ["field" => "title", "dir" => "asc"],
    "limit" => 100,
]);

foreach ($books as $rec) {
    $d = $rec["doc"];
    printf("%-24s %-22s %6d credits  stock %d\n",
        $rec["record_id"], $d["author"], $d["price_credits"], $d["stock"]);
}

// `description` is not a declared field: filter it here, not in `where`.
$sciencey = array_filter($books, fn($r) => str_contains($r["doc"]["description"] ?? "", "science"));
// Follow next_cursor instead of pretending one page is the whole answer.
static async Task<List<JsonElement>> QueryAll(string collection, Dictionary<string, object> q)
{
    var all = new List<JsonElement>();
    string? cursor = null;
    while (true)
    {
        var body = new Dictionary<string, object>(q);
        if (cursor != null) body["cursor"] = cursor;

        var env = await Bookshop.Call("POST", $"/collections/{collection}/query", body);
        foreach (var r in env.GetProperty("data").GetProperty("records").EnumerateArray())
            all.Add(r.Clone());

        var page = env.GetProperty("meta").GetProperty("pagination");
        var next = page.GetProperty("next_cursor");
        if (!page.GetProperty("has_more").GetBoolean() || next.ValueKind == JsonValueKind.Null)
            return all;
        cursor = next.GetString();
    }
}

// usage:
var books = await QueryAll("books", new Dictionary<string, object> {
    ["where"] = new { active = new { eq = true } },
    ["sort"]  = new { field = "title", dir = "asc" },
    ["limit"] = 100,
});

foreach (var rec in books)
{
    var d = rec.GetProperty("doc");
    Console.WriteLine($"{rec.GetProperty("record_id").GetString(),-24} "
        + $"{d.GetProperty("author").GetString(),-22} "
        + $"{d.GetProperty("price_credits").GetInt64(),6} credits  "
        + $"stock {d.GetProperty("stock").GetInt32()}");
}

Writing to books is impossible by design. The collection is acl_write: "server", so a PUT or DELETE against it answers 403 forbidden no matter who you are. Stock counts, prices and availability only ever change when the publisher republishes the catalog.

Step 4 — Read one book

GET /v1/app-api/collections/books/records/{record_id}

When you already know the id — from a query, or because you stored it on an order — fetch the single record. The response is data.record, one record object with the same shape as a query row.

data  {"record": {"record_id": "frankenstein", "owner_type": "app", "owner_id": "",
                 "doc": { … }, "created_at": "…", "updated_at": "…"}}

An unknown id answers 404 not_found; it is not an empty record.

curl -s "$API/collections/books/records/frankenstein" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq -e 'if .ok then .data.record else error(.error.code) end'

# Only catalog metadata comes back — no file, no url, no text of the work.
rec = api("GET", "/collections/books/records/frankenstein")["data"]["record"]

d = rec["doc"]
print(d["title"], "by", d["author"])
print("list price", d["price_credits"], "credits (never billed)")
print("fields:", sorted(d))   # no 'file', no 'url', no 'content'
const { data } = await api("GET", "/collections/books/records/frankenstein");
const rec = data.record;

console.log(`${rec.doc.title} by ${rec.doc.author}`);
console.log("list price", rec.doc.price_credits, "credits (never billed)");
console.log("fields:", Object.keys(rec.doc).sort()); // no file/url/content
env, err := call("GET", "/collections/books/records/frankenstein", nil)
if err != nil {
	return err // a 404 here means the id is unknown
}
var d struct {
	Record Record `json:"record"`
}
json.Unmarshal(env.Data, &d)

var b Book
json.Unmarshal(d.Record.Doc, &b)
fmt.Printf("%s by %s — list price %d credits (never billed)\n", b.Title, b.Author, b.PriceCredits)
JsonNode rec = api("GET", "/collections/books/records/frankenstein", null)
    .path("data").path("record");

JsonNode d = rec.path("doc");
System.out.println(d.path("title").asText() + " by " + d.path("author").asText());
System.out.println("list price " + d.path("price_credits").asLong() + " credits (never billed)");
d.fieldNames().forEachRemaining(System.out::println); // no file/url/content
rec = api("GET", "/collections/books/records/frankenstein")["data"]["record"]

d = rec["doc"]
puts "#{d['title']} by #{d['author']}"
puts "list price #{d['price_credits']} credits (never billed)"
puts d.keys.sort.join(", ")   # no 'file', no 'url', no 'content'
<?php
$rec = api("GET", "/collections/books/records/frankenstein")["data"]["record"];

$d = $rec["doc"];
printf("%s by %s\n", $d["title"], $d["author"]);
printf("list price %d credits (never billed)\n", $d["price_credits"]);
echo implode(", ", array_keys($d)), "\n";   // no file/url/content
var rec = (await Bookshop.Call("GET", "/collections/books/records/frankenstein"))
    .GetProperty("data").GetProperty("record");

var d = rec.GetProperty("doc");
Console.WriteLine($"{d.GetProperty("title").GetString()} by {d.GetProperty("author").GetString()}");
Console.WriteLine($"list price {d.GetProperty("price_credits").GetInt64()} credits (never billed)");
foreach (var p in d.EnumerateObject()) Console.WriteLine(p.Name); // no file/url/content

Step 5 — Place an order

PUT /v1/app-api/collections/orders/records/{order_id}

This is the whole of "buying" in Bookshop: one upsert into your own orders collection. The web app sends exactly this document:

{"doc": {"book_id": "frankenstein",
         "title": "Frankenstein",
         "price_credits": 11000,
         "status": "recorded",
         "placed_at": "2026-08-07T10:00:00.000Z",
         "charged_credits": 0,
         "delivered": "none"}}

This call moves no money. It is a record, not a payment: no credits leave your balance, charged_credits is 0 because nothing was charged, and delivered: "none" is literal — no file, link or copy of the book is delivered, then or later. price_credits is copied from the catalog as a list price so the row reads like a receipt; it is not an amount owed.

The order id, and why you mint it first

order_id must match ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ and is unique per (app, collection)not per user. Two different people cannot hold the same order id, so it has to be globally unique: the app uses "ord-" + a UUID, and so should you.

PUT is an idempotent upsert. Mint the order id once, before the first attempt, and reuse that same id on every retry. That is precisely what makes a timeout-and-retry safe: the second PUT overwrites the row the first one may or may not have written, and you still end up with exactly one order. Never mint a new id inside a retry loop — that is how you turn one uncertain order into two real ones.

# 1. Mint the id ONCE, outside any retry.
ORDER_ID="ord-$(uuidgen | tr 'A-Z' 'a-z')"
echo "$ORDER_ID"

# 2. PUT it. Re-running this exact command is safe: same id, same row.
curl -s -X PUT "$API/collections/orders/records/$ORDER_ID" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg id frankenstein --arg t Frankenstein \
        --arg now "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
        '{doc:{book_id:$id,title:$t,price_credits:11000,status:"recorded",
                placed_at:$now,charged_credits:0,delivered:"none"}}')" \
  | jq -e 'if .ok then .data.record else error(.error.code) end'

# No credits moved. No file was delivered. A row exists; that is all.
import uuid
from datetime import datetime, timezone

book = api("GET", "/collections/books/records/frankenstein")["data"]["record"]

# Mint the id ONCE, before the first attempt.
order_id = "ord-" + str(uuid.uuid4())

doc = {
    "book_id": book["record_id"],
    "title": book["doc"]["title"],
    "price_credits": book["doc"]["price_credits"],  # list price, not a charge
    "status": "recorded",
    "placed_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
    "charged_credits": 0,
    "delivered": "none",
}

saved = None
for attempt in range(3):
    try:
        # Same order_id every time — PUT is an idempotent upsert.
        saved = api("PUT", "/collections/orders/records/" + order_id, {"doc": doc})["data"]["record"]
        break
    except RuntimeError as e:
        if any(s in str(e) for s in (" 400 ", " 401 ", " 402 ", " 403 ")):
            raise            # permanent: retrying cannot help
        time.sleep(0.4 * (attempt + 1))

print("recorded", order_id, "— 0 credits charged, nothing delivered")
import { randomUUID } from "node:crypto";

const { data: { record: book } } =
  await api("GET", "/collections/books/records/frankenstein");

// Mint the id ONCE, before the first attempt.
const orderId = "ord-" + randomUUID();

const doc = {
  book_id: book.record_id,
  title: book.doc.title,
  price_credits: book.doc.price_credits, // list price, not a charge
  status: "recorded",
  placed_at: new Date().toISOString(),
  charged_credits: 0,
  delivered: "none",
};

let saved = null;
for (let attempt = 0; attempt < 3; attempt++) {
  try {
    // Same orderId every time — PUT is an idempotent upsert.
    ({ data: { record: saved } } =
      await api("PUT", `/collections/orders/records/${orderId}`, { doc }));
    break;
  } catch (e) {
    if ([400, 401, 402, 403].includes(e.status)) throw e; // permanent
    await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
  }
}

console.log("recorded", orderId, "— 0 credits charged, nothing delivered");
import (
	"time"

	"github.com/google/uuid"
)

// Mint the id ONCE, before the first attempt.
orderID := "ord-" + uuid.NewString()

doc := map[string]any{
	"book_id":         "frankenstein",
	"title":           "Frankenstein",
	"price_credits":   11000, // list price, not a charge
	"status":          "recorded",
	"placed_at":       time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
	"charged_credits": 0,
	"delivered":       "none",
}

var lastErr error
for attempt := 0; attempt < 3; attempt++ {
	// Same orderID every time — PUT is an idempotent upsert.
	_, err := call("PUT", "/collections/orders/records/"+orderID, map[string]any{"doc": doc})
	if err == nil {
		lastErr = nil
		break
	}
	lastErr = err
	time.Sleep(time.Duration(400*(attempt+1)) * time.Millisecond)
}
if lastErr != nil {
	return fmt.Errorf("order %s was NOT recorded (nothing was charged): %w", orderID, lastErr)
}
fmt.Println("recorded", orderID, "— 0 credits charged, nothing delivered")
import java.time.Instant;
import java.util.UUID;

// Mint the id ONCE, before the first attempt.
String orderId = "ord-" + UUID.randomUUID();

ObjectNode doc = M.createObjectNode();
doc.put("book_id", "frankenstein");
doc.put("title", "Frankenstein");
doc.put("price_credits", 11000);      // list price, not a charge
doc.put("status", "recorded");
doc.put("placed_at", Instant.now().toString());
doc.put("charged_credits", 0);
doc.put("delivered", "none");

ObjectNode body = M.createObjectNode();
body.set("doc", doc);

RuntimeException last = null;
for (int attempt = 0; attempt < 3; attempt++) {
    try {
        // Same orderId every time — PUT is an idempotent upsert.
        api("PUT", "/collections/orders/records/" + orderId, M.writeValueAsString(body));
        last = null;
        break;
    } catch (RuntimeException e) {
        last = e;
        Thread.sleep(400L * (attempt + 1));
    }
}
if (last != null) throw new RuntimeException("order " + orderId
    + " was NOT recorded (nothing was charged)", last);

System.out.println("recorded " + orderId + " — 0 credits charged, nothing delivered");
require "securerandom"
require "time"

# Mint the id ONCE, before the first attempt.
order_id = "ord-#{SecureRandom.uuid}"

doc = {
  "book_id"         => "frankenstein",
  "title"           => "Frankenstein",
  "price_credits"   => 11_000,       # list price, not a charge
  "status"          => "recorded",
  "placed_at"       => Time.now.utc.iso8601(3),
  "charged_credits" => 0,
  "delivered"       => "none",
}

saved = nil
3.times do |attempt|
  begin
    # Same order_id every time — PUT is an idempotent upsert.
    saved = api("PUT", "/collections/orders/records/#{order_id}", { "doc" => doc })["data"]["record"]
    break
  rescue => e
    raise e if e.message =~ / (400|401|402|403) /   # permanent
    sleep(0.4 * (attempt + 1))
  end
end

puts "recorded #{order_id} — 0 credits charged, nothing delivered"
<?php
// Mint the id ONCE, before the first attempt.
$orderId = "ord-" . bin2hex(random_bytes(16));

$doc = [
    "book_id"         => "frankenstein",
    "title"           => "Frankenstein",
    "price_credits"   => 11000,       // list price, not a charge
    "status"          => "recorded",
    "placed_at"       => gmdate("Y-m-d\TH:i:s.000\Z"),
    "charged_credits" => 0,
    "delivered"       => "none",
];

$saved = null;
for ($attempt = 0; $attempt < 3; $attempt++) {
    try {
        // Same $orderId every time — PUT is an idempotent upsert.
        $saved = api("PUT", "/collections/orders/records/$orderId", ["doc" => $doc])["data"]["record"];
        break;
    } catch (RuntimeException $e) {
        if (preg_match('/ (400|401|402|403) /', $e->getMessage())) { throw $e; }
        usleep(400000 * ($attempt + 1));
    }
}

echo "recorded $orderId — 0 credits charged, nothing delivered\n";
// Mint the id ONCE, before the first attempt.
var orderId = "ord-" + Guid.NewGuid().ToString();

var doc = new {
    book_id         = "frankenstein",
    title           = "Frankenstein",
    price_credits   = 11000,          // list price, not a charge
    status          = "recorded",
    placed_at       = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
    charged_credits = 0,
    delivered       = "none",
};

Exception? last = null;
for (var attempt = 0; attempt < 3; attempt++)
{
    try
    {
        // Same orderId every time — PUT is an idempotent upsert.
        await Bookshop.Call("PUT", $"/collections/orders/records/{orderId}", new { doc });
        last = null;
        break;
    }
    catch (Exception e)
    {
        last = e;
        await Task.Delay(400 * (attempt + 1));
    }
}
if (last != null)
    throw new Exception($"order {orderId} was NOT recorded (nothing was charged)", last);

Console.WriteLine($"recorded {orderId} — 0 credits charged, nothing delivered");

Say it once more, because scripts tend to be written on the assumption that a storefront charges and ships: this call moves no money and delivers no file. It writes one JSON row you own. If your integration needs the book itself, get it from a library or a public-domain text archive — Bookshop has no copy to give you.

Step 6 — List your orders

POST /v1/app-api/collections/orders/query

The app's own orders query, newest first:

{"sort": {"field": "created_at", "dir": "desc"}, "limit": 100}

Same envelope, same meta.pagination, same cursor loop as step 3 — reuse the queryAll helper you already wrote.

The thing that will confuse you

orders is acl_read: "owner": you only ever get back your own rows. There is no where clause that widens that, and none you need to narrow it.

Now combine that with step 1: every POST /guest mints a new guest identity. So if your script calls /guest again on its next run, it authenticates as somebody else — and gets back an empty orders list, even though the rows it wrote yesterday still exist, still belong to the previous guest_id, and are simply invisible to the new one. Nothing was deleted; nobody lost data; you changed identity. This is the single most confusing thing about scripting this API.

The fix is either: persist the guest token between runs, or use a personal token from tokens.html, which is tied to your account and stays the same.

curl -s "$API/collections/orders/query" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sort":{"field":"created_at","dir":"desc"},"limit":100}' \
  | jq '{orders: [.data.records[]
                  | {id: .record_id, title: .doc.title,
                     charged: .doc.charged_credits, delivered: .doc.delivered}],
         page: .meta.pagination}'

# Empty list after re-running `POST /guest`? You are a different guest now.
# Save the token, or use a personal one from /tokens.html.
orders = query_all("orders", {
    "sort": {"field": "created_at", "dir": "desc"},
    "limit": 100,
})

if not orders:
    print("No orders for THIS identity. A fresh guest token sees an empty list; "
          "rows written by a previous guest id still exist and still belong to it.")

for rec in orders:
    d = rec["doc"]
    print(rec["record_id"], d["title"], "charged", d["charged_credits"],
          "delivered", d["delivered"])
const orders = await queryAll("orders", {
  sort: { field: "created_at", dir: "desc" },
  limit: 100,
});

if (orders.length === 0) {
  console.log("No orders for THIS identity. A fresh guest token sees an empty list;"
    + " rows written by a previous guest id still exist and still belong to it.");
}

for (const rec of orders) {
  const d = rec.doc;
  console.log(rec.record_id, d.title, "charged", d.charged_credits, "delivered", d.delivered);
}
orders, err := queryAll("orders", map[string]any{
	"sort":  map[string]any{"field": "created_at", "dir": "desc"},
	"limit": 100,
})
if err != nil {
	return err
}
if len(orders) == 0 {
	fmt.Println("No orders for THIS identity. A fresh guest token sees an empty list;",
		"rows written by a previous guest id still exist and still belong to it.")
}

type Order struct {
	BookID         string `json:"book_id"`
	Title          string `json:"title"`
	PriceCredits   int64  `json:"price_credits"`
	Status         string `json:"status"`
	PlacedAt       string `json:"placed_at"`
	ChargedCredits int64  `json:"charged_credits"`
	Delivered      string `json:"delivered"`
}
for _, rec := range orders {
	var o Order
	json.Unmarshal(rec.Doc, &o)
	fmt.Println(rec.RecordID, o.Title, "charged", o.ChargedCredits, "delivered", o.Delivered)
}
ObjectNode oq = M.createObjectNode();
oq.putObject("sort").put("field", "created_at").put("dir", "desc");
oq.put("limit", 100);

List<JsonNode> orders = queryAll("orders", oq);

if (orders.isEmpty()) {
    System.out.println("No orders for THIS identity. A fresh guest token sees an empty "
        + "list; rows written by a previous guest id still exist and still belong to it.");
}

for (JsonNode rec : orders) {
    JsonNode d = rec.path("doc");
    System.out.println(rec.path("record_id").asText() + " " + d.path("title").asText()
        + " charged " + d.path("charged_credits").asLong()
        + " delivered " + d.path("delivered").asText());
}
orders = query_all("orders", {
  "sort"  => { "field" => "created_at", "dir" => "desc" },
  "limit" => 100,
})

if orders.empty?
  puts "No orders for THIS identity. A fresh guest token sees an empty list; " \
       "rows written by a previous guest id still exist and still belong to it."
end

orders.each do |rec|
  d = rec["doc"]
  puts "#{rec['record_id']} #{d['title']} charged #{d['charged_credits']} " \
       "delivered #{d['delivered']}"
end
<?php
$orders = query_all("orders", [
    "sort"  => ["field" => "created_at", "dir" => "desc"],
    "limit" => 100,
]);

if (count($orders) === 0) {
    echo "No orders for THIS identity. A fresh guest token sees an empty list; "
       . "rows written by a previous guest id still exist and still belong to it.\n";
}

foreach ($orders as $rec) {
    $d = $rec["doc"];
    printf("%s %s charged %d delivered %s\n",
        $rec["record_id"], $d["title"], $d["charged_credits"], $d["delivered"]);
}
var orders = await QueryAll("orders", new Dictionary<string, object> {
    ["sort"]  = new { field = "created_at", dir = "desc" },
    ["limit"] = 100,
});

if (orders.Count == 0)
    Console.WriteLine("No orders for THIS identity. A fresh guest token sees an empty list; "
        + "rows written by a previous guest id still exist and still belong to it.");

foreach (var rec in orders)
{
    var d = rec.GetProperty("doc");
    Console.WriteLine($"{rec.GetProperty("record_id").GetString()} "
        + $"{d.GetProperty("title").GetString()} "
        + $"charged {d.GetProperty("charged_credits").GetInt64()} "
        + $"delivered {d.GetProperty("delivered").GetString()}");
}

Step 7 — Delete an order

DELETE /v1/app-api/collections/orders/records/{order_id}

Orders are acl_write: "user", so you can remove your own rows. Since no money ever moved, deleting an order is not a refund — it just drops the record. A repeat delete of an id that is already gone answers 404 not_found; treat that as success if you are cleaning up.

curl -s -X DELETE "$API/collections/orders/records/$ORDER_ID" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  | jq -e 'if .ok then "deleted" else error(.error.code) end'

# Nothing to refund — no credits were ever taken.
try:
    api("DELETE", "/collections/orders/records/" + order_id)
    print("deleted", order_id)
except RuntimeError as e:
    if "not_found" in str(e):
        print("already gone:", order_id)   # idempotent enough for cleanup
    else:
        raise
try {
  await api("DELETE", `/collections/orders/records/${orderId}`);
  console.log("deleted", orderId);
} catch (e) {
  if (e.status === 404) console.log("already gone:", orderId);
  else throw e;
}
if _, err := call("DELETE", "/collections/orders/records/"+orderID, nil); err != nil {
	// A 404 here means it was already gone — fine when cleaning up.
	fmt.Println("delete:", err)
} else {
	fmt.Println("deleted", orderID)
}
try {
    api("DELETE", "/collections/orders/records/" + orderId, null);
    System.out.println("deleted " + orderId);
} catch (RuntimeException e) {
    if (e.getMessage().contains("not_found")) System.out.println("already gone: " + orderId);
    else throw e;
}
begin
  api("DELETE", "/collections/orders/records/#{order_id}")
  puts "deleted #{order_id}"
rescue => e
  raise e unless e.message.include?("not_found")
  puts "already gone: #{order_id}"
end
<?php
try {
    api("DELETE", "/collections/orders/records/$orderId");
    echo "deleted $orderId\n";
} catch (RuntimeException $e) {
    if (!str_contains($e->getMessage(), "not_found")) { throw $e; }
    echo "already gone: $orderId\n";
}
try
{
    await Bookshop.Call("DELETE", $"/collections/orders/records/{orderId}");
    Console.WriteLine($"deleted {orderId}");
}
catch (Exception e) when (e.Message.Contains("not_found"))
{
    Console.WriteLine($"already gone: {orderId}");
}

Errors

Failures come back in the same envelope with ok: false and an error object holding code, message, status and sometimes details. Branch on code, not on the message text.

StatusCodeWhat it means here
400invalid_request Malformed body, an undeclared field in where/sort (such as description), a limit above 100, an in list outside 1–20 values, or an order id that fails the id pattern.
401unauthorized Token missing, expired or revoked. Only this and 403 mean the token is bad — a network failure, a timeout or a DNS error does not, so never react to a connection problem by throwing the token away.
403forbidden The ACL says no. Writing to books lands here: it is acl_write: "server", so no client token can change the catalog.
404not_found Unknown record id, or an endpoint that does not exist on this deployment. POST /v1/app-api/purchase returns this: there is no in-app purchase endpoint here.
402payment_required An unpaid data-op accrual needs settling before more ops are served. Not an order charge — orders never charge.
409conflict A concurrent write to the same record lost the race. Re-read and retry with the same id.
429rate_limited Over 120 requests per minute on data endpoints. Back off and retry.
500internal Transient platform error. Retry with backoff — and, for an order, with the same order id.

The retry loop in step 5 treats 400, 401, 402 and 403 as permanent and everything else as worth another attempt. That is the same rule the app uses, and it is a good default.

Quotas and cost

Orders cost nothing, but the platform meters data ops against the caller in nano-dollars: 100 nanos for a record read, 400 for a write or delete, and 300 plus 20 per row returned for a query. One credit is $0.0001, which is 100,000 nanos, so a full 100-row catalog query costs 2,300 nanos — about a fortieth of a credit. Accruals settle one cent at a time, not per call.

Structural limits, all enforced server-side:

None of this is a book price. price_credits never enters the meter; the only thing you are ever billed for is the data ops above.

Collections

Collectionacl_readacl_writeDeclared fields
books shared server title: string, author: string, price_credits: number, stock: number, genre: string, active: boolean
orders owner user book_id: string, title: string, price_credits: number, status: string, placed_at: timestamp

Declared fields are the ones you may filter and sort on; every request also accepts record_id, owner_id, created_at and updated_at. Undeclared keys — description on a book, charged_credits and delivered on an order — are stored faithfully and returned in full, but they are opaque to where and sort. Filter them in your own code.