REST API recipes
These recipes solve the jobs people reach for the API to do: read every item, create content with an image, keep Raytha in step with another system, generate static files, and render pages in a Next.js app. Every script uses the same small client, and each takes your site address and key from environment variables.
A small client
Save this once as raytha.js. It needs Node 18 or later and two environment variables. plain turns a response field into the value a request accepts, which you need whenever you copy content from one item to another.
const fs = require("node:fs");
const path = require("node:path");
const baseUrl = process.env.RAYTHA_URL;
const apiKey = process.env.RAYTHA_API_KEY;
async function raytha(requestPath, options = {}) {
const response = await fetch(`${baseUrl}/raytha/api/v1${requestPath}`, {
...options,
headers: { "X-API-KEY": apiKey, ...options.headers },
});
const body = await response.json().catch(() => null);
if (!response.ok) {
const detail = body && (body.error || body.detail || body.title);
throw new Error(`Raytha ${response.status}: ${detail || response.statusText}`);
}
return body;
}
// Turns a response field into the plain value a request accepts.
function plain(field) {
if (field === null || field === "") return undefined;
if (typeof field === "object" && "hasValue" in field) {
return field.hasValue ? field.value : undefined;
}
if (typeof field === "object" && "id" in field) return field.id; // related item
return field;
}
function plainContent(content) {
const result = {};
for (const [name, field] of Object.entries(content)) {
const value = plain(field);
if (value !== undefined) result[name] = value;
}
return result;
}
The other JavaScript recipes assume these definitions are in the same file or imported.
Fetch every item
A list returns at most 1000 items. Loop on pageNumber until you have totalCount items. Sort by CreationTime asc so new items added while you loop go to the end.
// Every item that matches the filter, oldest first.
async function listAll(contentType, filter) {
const items = [];
const pageSize = 100;
for (let pageNumber = 1; ; pageNumber++) {
const params = new URLSearchParams({
filter,
orderBy: "CreationTime asc",
pageNumber,
pageSize,
});
const { result } = await raytha(`/ContentItems/${contentType}?${params}`);
items.push(...result.items);
if (result.items.length === 0 || pageNumber * pageSize >= result.totalCount) break;
}
return items;
}
async function main() {
const posts = await listAll("posts", "IsPublished eq 'true'");
console.log(`${posts.length} published posts`);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
using System.Text.Json;
var baseUrl = Environment.GetEnvironmentVariable("RAYTHA_URL")!.TrimEnd('/');
using var http = new HttpClient { BaseAddress = new Uri(baseUrl + "/raytha/api/v1/") };
http.DefaultRequestHeaders.Add("X-API-KEY", Environment.GetEnvironmentVariable("RAYTHA_API_KEY"));
var items = new List<JsonElement>();
const int pageSize = 100;
for (var pageNumber = 1; ; pageNumber++)
{
var query = string.Join("&",
$"filter={Uri.EscapeDataString("IsPublished eq 'true'")}",
$"orderBy={Uri.EscapeDataString("CreationTime asc")}",
$"pageNumber={pageNumber}",
$"pageSize={pageSize}");
// GetStringAsync throws HttpRequestException on any non-2xx status.
using var document = JsonDocument.Parse(await http.GetStringAsync($"ContentItems/posts?{query}"));
var result = document.RootElement.GetProperty("result");
var page = result.GetProperty("items");
foreach (var item in page.EnumerateArray())
{
items.Add(item.Clone()); // keep the element after the document is disposed
}
if (page.GetArrayLength() == 0 || pageNumber * pageSize >= result.GetProperty("totalCount").GetInt32())
{
break;
}
}
Console.WriteLine($"{items.Count} published posts");
foreach (var item in items)
{
Console.WriteLine($"{item.GetProperty("id").GetString()} {item.GetProperty("primaryField").GetString()}");
}
page=1
size=100
while : ; do
body=$(curl -s -G "$RAYTHA_URL/raytha/api/v1/ContentItems/posts" \
-H "X-API-KEY: $RAYTHA_API_KEY" \
--data-urlencode "filter=IsPublished eq 'true'" \
--data-urlencode "orderBy=CreationTime asc" \
--data-urlencode "pageNumber=$page" \
--data-urlencode "pageSize=$size")
echo "$body" | jq -c '.result.items[] | {id, routePath, title: .primaryField}'
total=$(echo "$body" | jq '.result.totalCount')
[ $((page * size)) -ge "$total" ] && break
page=$((page + 1))
done
Upload an image and create a post
Upload the file, then use the object key in an attachment field. This assumes posts has a hero_image attachment field and that you have a template id. See Content items for finding one.
// Uploads a file and returns its object key.
async function uploadFile(filePath, mimeType) {
const data = await fs.promises.readFile(filePath);
const form = new FormData();
form.append("file", new Blob([data], { type: mimeType }), path.basename(filePath));
// Do not set Content-Type yourself. fetch adds the multipart boundary.
const { result } = await raytha("/MediaItems/upload-direct", { method: "POST", body: form });
return result;
}
async function createPostWithImage(templateId, title, imagePath) {
const objectKey = await uploadFile(imagePath, "image/jpeg");
return raytha("/ContentItems/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
templateId,
saveAsDraft: false,
content: { title, hero_image: objectKey },
}),
});
}
For C#, use the upload code from Media and send the returned key in the JSON body of a POST.
Upsert by a key from another system
Raytha has no upsert endpoint, and you cannot filter on the route path. Add a single line text field such as external_id, store your system's id in it, then look the item up by that field before you write.
// Create the item, or replace it if an item with this external_id exists.
// Keep the key in its own single line text field, here external_id.
async function upsert(contentType, templateId, externalId, content) {
const quoted = externalId.replace(/'/g, "''");
const params = new URLSearchParams({ filter: `external_id eq '${quoted}'`, pageSize: 1 });
const { result } = await raytha(`/ContentItems/${contentType}?${params}`);
const request = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
saveAsDraft: false,
templateId,
content: { ...content, external_id: externalId },
}),
};
if (result.items.length > 0) {
// PUT replaces every field, so `content` must be the complete record.
return raytha(`/ContentItems/${contentType}/${result.items[0].id}`, {
method: "PUT",
...request,
});
}
return raytha(`/ContentItems/${contentType}`, { method: "POST", ...request });
}
- Single quotes in the id are doubled, so an id like
it'scannot break the filter. - The edit replaces the whole item. Send every field you want to keep, not only the changed ones.
- Two processes running this at once can both create the same id. Run imports from one place, or add your own lock.
Build a static site
Fetch published content at build time and write it to disk. Your generator, such as Eleventy or Astro, can read the JSON as data. The script reads publishedContent, unwraps it with plain and writes one file per item.
// Writes dist/posts.json and one dist/posts/<route path>.json per published post.
async function buildStatic() {
const items = await listAll("posts", "IsPublished eq 'true'");
const posts = items.map((item) => ({
id: item.id,
path: item.routePath,
title: item.primaryField,
updated: item.lastModificationTime || item.creationTime,
fields: plainContent(item.publishedContent),
}));
await fs.promises.mkdir("dist/posts", { recursive: true });
await fs.promises.writeFile("dist/posts.json", JSON.stringify(posts, null, 2));
for (const post of posts) {
const file = path.join("dist/posts", `${post.path}.json`);
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(file, JSON.stringify(post, null, 2));
}
return posts.length;
}
Run it from your CI job with a read-only key. Trigger the job when content changes: create a webhook that calls your host's deploy hook.
Fetch in Next.js
Call the API from server code only, so the key never reaches the browser. In the App Router, fetch runs on the server and next.revalidate caches the result for the number of seconds you give.
// lib/raytha.js - import it from server components and route handlers only.
const base = `${process.env.RAYTHA_URL}/raytha/api/v1`;
export async function getPosts(page = 1) {
const params = new URLSearchParams({
filter: "IsPublished eq 'true'",
orderBy: "CreationTime desc",
pageNumber: String(page),
pageSize: "20",
});
const response = await fetch(`${base}/ContentItems/posts?${params}`, {
headers: { "X-API-KEY": process.env.RAYTHA_API_KEY },
next: { revalidate: 60 }, // serve a cached copy for up to a minute
});
if (!response.ok) {
throw new Error(`Raytha responded ${response.status}`);
}
const { result } = await response.json();
return {
total: result.totalCount,
posts: result.items.map((item) => ({
id: item.id,
slug: item.routePath,
title: item.primaryField,
body: item.publishedContent.content?.value ?? "",
})),
};
}
Import getPosts in a server component and map the posts to markup. Keep rich text fields as HTML strings, and render them with your framework's raw HTML mechanism only for content you trust. Media paths in the response are already absolute.
Gotchas
- Treat your key like a password. Put it in CI secrets, never in a repository or a client bundle.
- Page by page, not by guess. Do not assume a list is complete after one call.
totalCountis the truth. - Back off on errors. Retry
5xxresponses with a growing delay. Do not retry4xx. See Errors. - Large imports belong in a batch. For hundreds of new items, use the batch endpoint in Content items, not a loop of single creates.
- Check the shape of rich fields. A repeater field comes back differently from a text field. Print one item from your own site before you write a transform.
Next steps
- Content items: every endpoint these recipes call.
- Filtering: narrow what you fetch.
- Function recipes: do similar jobs inside Raytha.