Partner submission API

HMAC-signed REST endpoint for organisational partners submitting books to the Real Life Stories curation queue. Contact us to receive a key ID and secret.

Endpoint

POST /api/public/books/submit

Authentication

Every request must include these headers:

  • x-rls-key-id — your public key ID.
  • x-rls-timestamp — unix seconds. Rejected if more than 5 minutes skewed.
  • x-rls-signature hex(hmac_sha256(sha256(secret), `$${ts}.$${sha256(body)}`)).

The signing key is sha256(secret), not the raw secret. We only store that hash; if you leak your secret, request rotation.

Body

JSON with these fields (see the Zod schema on the server for exact limits):

  • title — 3–300 chars.
  • synopsis — 20–4000 chars.
  • authorName — optional; author is created if new.
  • language — ISO 639-1 (default en).
  • originCountry — ISO 3166-1 alpha-2, optional.
  • category — free-form, ≤ 80 chars.
  • pageCount — integer, 1–50 000.
  • imageCount — integer, default 0.
  • coverUrl, sourceUrl, manuscriptUrl — optional https URLs.
  • manuscriptFormatepub | pdf | mobi | audio, optional.

Pricing formula

Price is locked at submission: round((pageCount × 4¢ + imageCount × 25¢) × 1.5). 50% flows to the storyteller.

Response

201 Created { id, slug, status: "submitted" }. The book enters the curation queue with status = "draft" and curation_status = "submitted", and only becomes public after an editor approves it.

Errors: 401 auth, 400 validation, 413 body over 100 KB.

Node.js example

import { createHash, createHmac } from "crypto";

const keyId = process.env.RLS_KEY_ID!;
const secret = process.env.RLS_SECRET!;

const body = JSON.stringify({
  title: "The Salt Path",
  synopsis: "A memoir of walking the South West Coast Path...",
  authorName: "Raynor Winn",
  language: "en",
  originCountry: "GB",
  category: "memoir",
  pageCount: 288,
  imageCount: 0,
});

const ts = Math.floor(Date.now() / 1000).toString();
const bodyHash = createHash("sha256").update(body).digest("hex");
// Signing key is sha256(secret)
const signingKey = createHash("sha256").update(secret).digest("hex");
const signature = createHmac("sha256", signingKey)
  .update(`${ts}.${bodyHash}`)
  .digest("hex");

const res = await fetch("https://reallifestories.store/api/public/books/submit", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-rls-key-id": keyId,
    "x-rls-timestamp": ts,
    "x-rls-signature": signature,
  },
  body,
});
console.log(res.status, await res.json());

Handoff kit — “Create a Book from This Story”

Connected story projects (Candle Light Cloud, Remembered Here) hand a single story to Real Life Stories server-to-server. Your project calls POST /api/public/handoff/start, receives a one-time redirectUrl, and sends the author there. The author then reviews exactly what will be copied, confirms rights, and pays the one-off $10 book-creation fee before any draft is made.

Signing is identical to the submission API, with its own headers: x-rls-source (your source slug), x-rls-timestamp (unix seconds, ±5 minutes) and x-rls-signature = hex(hmac_sha256(secret, `$${timestamp}.$${sha256(rawBody)}`)).

  • externalUserRef — your stable user id (required).
  • externalUserEmail — optional, used to match an existing Real Life Stories account.
  • sourceContentId, sourceContentUrl — the story in your system.
  • content.title, content.bodyText, content.authorName, content.publishedAt.
  • content.assets[] — up to 200 items of { kind, url, caption, credit, width, height, bytes, mimeType }, where kind is image | video | audio | link | document.
  • rightsDeclaration, attributionText — optional; attribution defaults to your project name.

Response: 200 { token, expiresAt, redirectUrl }. The token is single-use and expires in 30 minutes — redirect the author immediately and never log it. Errors: 401 unknown source, bad signature or stale timestamp; 400 validation.

import { createHash, createHmac } from "crypto";

// Shared secret agreed with Real Life Stories for your source project.
const secret = process.env.RLS_HANDOFF_SECRET!;
const sourceSlug = "real-life-stories";

const body = JSON.stringify({
  externalUserRef: user.id,
  externalUserEmail: user.email,
  sourceContentId: story.id,
  sourceContentUrl: `https://reallifestories.example/stories/${story.slug}`,
  content: {
    title: story.title,
    bodyText: story.plainText,
    authorName: user.displayName,
    publishedAt: story.publishedAt,
    assets: story.images.map((img) => ({
      kind: "image",
      url: img.url,
      caption: img.caption ?? null,
    })),
  },
  attributionText: `Originally published on Real Life Stories`,
});

const ts = Math.floor(Date.now() / 1000).toString();
const bodyHash = createHash("sha256").update(body).digest("hex");
const signature = createHmac("sha256", secret)
  .update(`${ts}.${bodyHash}`)
  .digest("hex");

const res = await fetch("https://reallifestories.store/api/public/handoff/start", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-rls-source": sourceSlug,
    "x-rls-timestamp": ts,
    "x-rls-signature": signature,
  },
  body,
});

const { redirectUrl } = await res.json();
// Send the author straight there — the token is single-use and short-lived.
return Response.redirect(redirectUrl, 303);