A copy-pasteable first-call walk-through for integrators. Three sections: bootstrap a token, make a round-trip call, and the patterns you'll use most.

For the long-form contract reference (status codes, error envelope, all conventions) see the API integration guide. For the per-endpoint reference table see the endpoint reference. For change history see the API changelog.

Status: pre-release. The API surface is stable but unannounced. Get in touch before integrating in production so we can flag any in-flight changes that might affect your workload.

1. Bootstrap your first API key

API keys are bound to a specific user account. The first key has to be minted from a logged-in browser session , the API cannot issue its own first token (a chicken-and-egg problem: APIKeyAuth needs a token to authenticate). Subsequent keys can be minted from any authenticated context.

If you need a token for a non-browser context (CI, headless server) and you can't run the browser flow, email [email protected] and we'll provision one for you.

Browser flow (curl)

The browser flow is a two-step: log in to seed a session cookie, then POST to the token endpoint.

# 1. Log in. Replace the email/password with your own.
curl -c cookies.txt -b cookies.txt \
     "https://ourmemorybook.com/accounts/login/" -o /dev/null -s

# Grab the CSRF cookie value Django wrote.
CSRF=$(awk '/csrftoken/ {print $7}' cookies.txt)

curl -c cookies.txt -b cookies.txt \
     -X POST "https://ourmemorybook.com/accounts/login/" \
     -H "Referer: https://ourmemorybook.com/accounts/login/" \
     -d "[email protected]&password=...&csrfmiddlewaretoken=$CSRF" \
     -o /dev/null -s

# 2. Mint a token. The Idempotency-Key is required on every non-GET.
CSRF=$(awk '/csrftoken/ {print $7}' cookies.txt)
curl -c cookies.txt -b cookies.txt \
     -X POST "https://ourmemorybook.com/api/v1/auth/token/" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -H "X-CSRFToken: $CSRF" \
     -H "Referer: https://ourmemorybook.com/api/v1/auth/token/" \
     -d '{"name": "Production integration"}'

# Response (HTTP 201):
# {
#   "id": "01HG4M3R8N2X...",
#   "name": "Production integration",
#   "plaintext": "omb_live_abc...xyz",
#   "created_at": "2026-05-17T12:34:56Z"
# }
#
# Store `plaintext` somewhere durable. It is shown ONCE , the database
# only stores its hash. If you lose it you have to mint a new key.

Retry semantics (read this): the Idempotency-Key on token issuance prevents duplicate tokens — it is not secret recovery. If your first request created the token but you never saw the response (timeout, dropped connection), retrying with the same key returns the token row with "plaintext": null and "replayed": true: the secret is hashed and cannot be re-shown. You'd then hold a live key you can't read. Recovery: delete that token and issue a new one with a fresh Idempotency-Key. Always capture plaintext from the first 201 response.

Browser flow (Python)

import uuid
import requests

session = requests.Session()

# 1. Seed the session with the CSRF cookie.
session.get("https://ourmemorybook.com/accounts/login/")
csrf = session.cookies["csrftoken"]

session.post(
    "https://ourmemorybook.com/accounts/login/",
    data={
        "username": "[email protected]",
        "password": "...",
        "csrfmiddlewaretoken": csrf,
    },
    headers={"Referer": "https://ourmemorybook.com/accounts/login/"},
)

# 2. Mint a token.
csrf = session.cookies["csrftoken"]
response = session.post(
    "https://ourmemorybook.com/api/v1/auth/token/",
    headers={
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
        "X-CSRFToken": csrf,
        "Referer": "https://ourmemorybook.com/api/v1/auth/token/",
    },
    json={"name": "Production integration"},
)
response.raise_for_status()
token = response.json()["plaintext"]
print(token)  # store this; it is shown ONCE

Token name uniqueness. The name field must be unique per user. Collisions return 409 name_taken. Use a stable name per environment (Production CI, Local dev, Better Stack monitor) so the dashboard list stays useful.

2. Make your first API call

A complete round-trip: confirm the token works, list books, create a submission. Each step shows the headers the request needs and what each one does.

Step 1 , confirm the token

GET /auth/me/ is the cheapest authenticated endpoint. A 200 confirms three things: the token is valid, the auth middleware works, and the DB is reachable.

curl -H "Authorization: Bearer omb_live_..." \
     https://ourmemorybook.com/api/v1/auth/me/

# Response:
# {
#   "id": "01HG4M3R8N2X...",
#   "email": "[email protected]",
#   "name": "Your Name",
#   "auth_method": "api_key",
#   "token_name": "Production integration"
# }

Headers explained:

  • Authorization: Bearer <token> , every authenticated request. The token is the plaintext value returned from POST /auth/token/; the prefix omb_live_ is part of the token, do NOT strip it.

Step 2 , list your books

GET /books/ returns the books where the calling user is owner or admin AND the book's plan has the api_access feature. The response is cursor-paginated.

curl -H "Authorization: Bearer omb_live_..." \
     "https://ourmemorybook.com/api/v1/books/?limit=20"

# Response:
# {
#   "items": [
#     {
#       "slug": "grandma-stories",
#       "title": "Grandma stories",
#       "subtitle": null,
#       "owner_id": "01HG4M3R8N2X...",
#       "plan_slug": "family",
#       "submission_access": "open",
#       "visibility": "public",
#       "archived_at": null,
#       "updated_at": "2026-05-17T10:00:00Z",
#       "etag": "\"2026-05-17T10:00:00.000000+00:00\""
#     }
#   ],
#   "next_cursor": null,
#   "has_more": false
# }

Cursor pagination. If has_more: true, pass next_cursor as ?cursor=<value> on the next request. There is no count field , counting at scale is expensive and a moving target.

Step 3 , submit a memory

POST /books/{slug}/submissions/ creates a new memory on a book. Required headers: Authorization, Idempotency-Key, Content-Type.

curl -X POST \
     -H "Authorization: Bearer omb_live_..." \
     -H "Idempotency-Key: 7c2d8e90-1234-4abc-9def-0123456789ab" \
     -H "Content-Type: application/json" \
     -d '{
       "text": "Visiting Grandma in 2003 was when I learned how to make her bread.",
       "contributor_name": "Marcus",
       "contributor_email": "[email protected]"
     }' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/

# Response (HTTP 201):
# {
#   "id": "01HG4M3R8N2X...",
#   "book_slug": "grandma-stories",
#   "moderation_status": "pending",
#   "text": "Visiting Grandma in 2003 was when I learned how to make her bread.",
#   "contributor_name": "Marcus",
#   "contributor_email": "[email protected]",
#   "submitted_at": "2026-05-17T12:34:56Z",
#   "etag": "\"2026-05-17T12:34:56.000000+00:00\""
# }

Headers explained:

  • Authorization: Bearer <token> , as above.
  • Idempotency-Key , required on every non-GET. A UUIDv4 per logical operation. Retry with the same key within 24h returns the cached result without re-creating the submission. Without this header you get 428 idempotency_key_required.
  • Content-Type: application/json , Ninja's typed-schema parser uses this to pick the deserializer. Multipart uploads use multipart/form-data instead.

Python end-to-end

import uuid
import requests

TOKEN = "omb_live_..."
BASE = "https://ourmemorybook.com/api/v1"

session = requests.Session()
session.headers["Authorization"] = f"Bearer {TOKEN}"

# 1. Confirm the token.
me = session.get(f"{BASE}/auth/me/").json()
print(f"Authenticated as {me['email']}")

# 2. List books.
books = session.get(f"{BASE}/books/?limit=20").json()
print(f"You can write to {len(books['items'])} books")

# 3. Submit a memory to the first one.
slug = books["items"][0]["slug"]
response = session.post(
    f"{BASE}/books/{slug}/submissions/",
    headers={
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    },
    json={
        "text": "First memory from my integration.",
        "contributor_name": "Marcus",
        "contributor_email": "[email protected]",
    },
    timeout=30,
)
response.raise_for_status()
print(f"Submission created: {response.json()['id']}")

3. Common patterns

Pagination

Every list endpoint returns {items, next_cursor, has_more}. To walk an entire collection:

def walk_all(session, url):
    while url:
        response = session.get(url).json()
        for item in response["items"]:
            yield item
        if not response["has_more"]:
            break
        url = f"{BASE}/books/?cursor={response['next_cursor']}&limit=50"

Don't try to compute a total count from has_more + accumulator , it's racy when other writers append rows during your walk. If you need a snapshot count, do a separate pin via ?updated_since= (see below).

Delta sync

Most list endpoints accept ?updated_since=<iso8601> to narrow the result to rows updated at or after the timestamp. Wired on: GET /books/, GET /books/{slug}/submissions/, GET /books/{slug}/members/, GET /books/{slug}/invitations/, GET /books/{slug}/notifications/subscriptions/.

# First sync (full list).
response = session.get(
    f"{BASE}/books/grandma-stories/submissions/?limit=100",
).headers
last_modified = response["Last-Modified"]  # RFC 7231 HTTP-date

# Convert HTTP-date to ISO 8601 (Python).
from email.utils import parsedate_to_datetime
since = parsedate_to_datetime(last_modified).isoformat()

# Next sync (only what changed).
delta = session.get(
    f"{BASE}/books/grandma-stories/submissions/?updated_since={since}",
).json()

Pin to the server's Last-Modified response header, NOT your client's clock. The header is server-authoritative; client clocks drift, browsers and mobile devices skew, and a 5-second skew is enough to miss a row that landed during the previous sync.

Idempotency keys

Every non-GET requires the Idempotency-Key header. The contract:

  • One key per logical operation. A UUIDv4 per "I want to submit this memory" intent.
  • Retry with the same key within 24h. Cached result returns without re-executing.
  • Different key for a different operation. Don't reuse keys across logically distinct operations , you'll get the wrong cached response.
  • Pair with exponential backoff. On 429 or 5xx, wait per the Retry-After header and retry with the SAME Idempotency-Key.
import time
from uuid import uuid4

def call_with_retry(session, url, payload, max_retries=3):
    key = str(uuid4())  # SAME key across retries
    for attempt in range(max_retries):
        response = session.post(
            url,
            headers={
                "Idempotency-Key": key,
                "Content-Type": "application/json",
            },
            json=payload,
        )
        if response.status_code < 500 and response.status_code != 429:
            return response
        retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
        time.sleep(retry_after)
    response.raise_for_status()

If-Match optimistic locking

Every detail GET carries an etag field and an ETag response header. Send the cached value back as If-Match on a PATCH for RFC 7232 lost-update protection.

# Read the resource. Cache the etag.
book = session.get(f"{BASE}/books/grandma-stories/").json()
etag = book["etag"]

# Patch with If-Match.
response = session.patch(
    f"{BASE}/books/grandma-stories/",
    headers={
        "Idempotency-Key": str(uuid4()),
        "Content-Type": "application/json",
        "If-Match": etag,
    },
    json={"subtitle": "A life in letters"},
)

if response.status_code == 412:
    # Another writer moved the resource forward. Refetch, merge, retry.
    current_etag = response.json()["details"]["current_etag"]
    ...
else:
    response.raise_for_status()
    # Successful PATCH stamps a new ETag header.
    new_etag = response.headers["ETag"]

If-Match is opt-in. When omitted, PATCH falls through to last-write-wins. Use it when you have concurrent writers OR a long edit window where staleness is possible.

Error handling

Every 4xx and 5xx uses the canonical error envelope:

{
  "code": "validation_failed",
  "message": "text or media required",
  "details": {"field": "text"},
  "request_id": "01HG4M3R8N2X..."
}
  • Branch on code, not on message. The message can change without a version bump; code is a stable identifier.
  • details may be null or carry structured field-level errors. Don't assume a shape.
  • request_id correlates with server-side logs. When reporting a bug, include this value.
response = session.post(...)
if not response.ok:
    body = response.json()
    code = body.get("code")
    if code == "rate_limited":
        time.sleep(int(response.headers.get("Retry-After", 60)))
        return retry(...)
    elif code == "book_grace_period":
        # Subscription lapsed; surface to user.
        raise GracePeriodError(body["message"])
    elif code == "feature_gated":
        # Plan does not include API access.
        raise PlanUpgradeRequired(body["message"])
    else:
        # Unknown error. Log request_id for support.
        log.error("api error: %s (request_id=%s)", code, body.get("request_id"))
        response.raise_for_status()

Common codes:

  • 403 feature_gated , plan does not include API access.
  • 403 insufficient_role , you're a contributor, not owner/admin.
  • 403 book_grace_period , subscription lapsed; writes blocked.
  • 412 precondition_failed , If-Match mismatch; refetch and retry.
  • 413 payload_too_large , request body too big.
  • 428 idempotency_key_required , missing the Idempotency-Key header.
  • 429 rate_limited , check Retry-After.
  • 409 export_not_ready / 410 export_failed , export polling lifecycle (see the reference for the full distinction).

Outbound webhooks

Memory Book does emit outbound webhooks (e.g. submission.created and submission.approved), signed with an X-Webhook-Signature HMAC header. In v1, webhook endpoints are configured by the Memory Book team / staff — there is no self-service /api/v1/ CRUD for managing endpoints yet. If you don't have a configured endpoint, poll the relevant list endpoint with ?updated_since= as the change-notification fallback. Self-service endpoint management is tracked in the API-first plan and will land with a separate announcement.

Next steps

  • The full integration guide covers auth modes, the error envelope, rate limits, and worked examples per domain.
  • The endpoint reference table covers all 48 routes with authz floor + idempotency + per-endpoint notes.
  • The API changelog tracks every additive and breaking change.
  • Prefer to drive Memory Book from an AI assistant (Claude Desktop, etc.)? The read-access MCP server exposes the same data over the Model Context Protocol, scoped by the same API key.
  • The live OpenAPI schema is at /api/v1/openapi.json; an interactive Swagger UI lives at /api/v1/docs.
  • Bugs and feature requests: [email protected]. Include the request_id from the error envelope so we can find your trace.

Ready to try it? Create your free memory book