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/guestThe 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/meA 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/queryThe 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.
| Status | Code | What it means here |
|---|---|---|
400 | invalid_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. |
401 | unauthorized |
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. |
403 | forbidden |
The ACL says no. Writing to books lands here: it is acl_write: "server", so no client token can change the catalog. |
404 | not_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. |
402 | payment_required |
An unpaid data-op accrual needs settling before more ops are served. Not an order charge — orders never charge. |
409 | conflict |
A concurrent write to the same record lost the race. Re-read and retry with the same id. |
429 | rate_limited |
Over 120 requests per minute on data endpoints. Back off and retry. |
500 | internal |
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:
- 64 KB per record
doc. - 10,000 records per collection.
- 1,000 records per collection per owner — your personal ceiling on stored orders.
- 120 requests/minute on data endpoints before
429 rate_limited.
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
| Collection | acl_read | acl_write | Declared 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.