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 fromPOST /auth/token/; the prefixomb_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 get428 idempotency_key_required.Content-Type: application/json, Ninja's typed-schema parser uses this to pick the deserializer. Multipart uploads usemultipart/form-datainstead.
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-Afterheader 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 onmessage. Themessagecan change without a version bump;codeis a stable identifier. detailsmay benullor carry structured field-level errors. Don't assume a shape.request_idcorrelates 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-Matchmismatch; refetch and retry.413 payload_too_large, request body too big.428 idempotency_key_required, missing theIdempotency-Keyheader.429 rate_limited, checkRetry-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_idfrom the error envelope so we can find your trace.
Ready to try it? Create your free memory book