Our Memory Book exposes a small, owner-scoped REST API for programmatic book and submission management. The API is positioned for owner / integrator workflows, not for contributors. If you want to let people submit memories through your own app or website, the API is the right tool. If you're a contributor sending one memory, use the web submission form instead.

Status: pre-release. As of 2026-05-17 the API is not publicly released and has no live integrators. The endpoints in this guide are stable, but the broader API surface (collaboration, exports, account management) is still being built out per the API-first plan. Reach out before integrating in production.

Companion docs:

  • API getting started , copy-pasteable first-call walk-through (token bootstrap, end-to-end round-trip, common patterns).
  • API endpoint reference , per-endpoint table covering all 48 routes with authz floor + idempotency + notes.
  • API changelog , every additive and breaking change with the date it shipped.

Base URL and versioning

  • Base URL: https://ourmemorybook.com/api/v1/
  • Versioning: the major version is in the URL (/v1/). Additive changes happen within a major version; breaking changes get a new prefix with a deprecation window.
  • Schema: the live OpenAPI schema is at /api/v1/openapi.json and an interactive Swagger UI lives at /api/v1/docs. Both are auto-generated from the type-annotated route definitions, so they always match production behaviour.

Authentication

Two auth modes are supported:

API key (recommended for integrations)

Each key is bound to a specific user account. Pass it as a bearer token:

Authorization: Bearer omb_live_<your-key>

API keys carry the same permissions as the user who owns them. Keys can be revoked at any time without affecting the user's session-based access.

Self-service issuance is live at POST /api/v1/auth/token/. The bootstrap requirement is that you must call it 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, and we will not accept raw username + password over the API surface). If you need a token for a non-browser context (CI, headless server), email [email protected] and we will provision one for you.

Bootstrap flow, Python

import uuid, requests

# 1. Log in via the web form to seed a session cookie.
session = requests.Session()
login_page = 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. Idempotency-Key is required on every non-GET.
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"},
)
token = response.json()["plaintext"]  # store this; it is shown ONCE

# 3. Subsequent calls use the bearer token; no more session needed.
requests.get(
    "https://ourmemorybook.com/api/v1/books/",
    headers={"Authorization": f"Bearer {token}"},
).json()

Other auth-related routes on /api/v1/:

  • GET /api/v1/auth/me/: confirm which user / token your current credentials resolve to.
  • DELETE /api/v1/auth/token/: revoke the token you are currently using. Must be called with that token as the bearer credential.
  • POST /api/v1/me/sessions/revoke/: stolen-device cascade. Revokes all active tokens for your user. Session login is unaffected.

Session (browser-based)

If you're calling the API from a logged-in browser session (e.g. an SPA on the same origin), session cookies + the standard Django CSRF token are accepted. This mode is for first-party clients only.

Authorisation surface

The /api/v1/ surface is owner-scoped:

  • Every protected endpoint requires the calling user to be an owner or admin on the book in question.
  • Contributor-role memberships do not have API access. Contributors submit through the public web flow.
  • The book's plan must include the API access feature. Plans without it return 403 on every /api/v1/ request.

Request and response shape

Idempotency

Every non-GET request on /api/v1/ requires an Idempotency-Key header. Missing or empty header returns 428 Precondition Required so clients can tell a forgotten header from a generic validation error.

  • Use a UUIDv4 (or any string up to 200 characters) per logical operation.
  • If you retry a request with the same key within 24 hours, the server returns the result of the original call (success or failure) without re-executing the side effect.
  • Pair retries with exponential backoff on 429 and 5xx; the Retry-After response header advises the minimum wait.

Error envelope

All 4xx and 5xx responses use a canonical envelope:

{
  "code": "book_grace_period",
  "message": "This book is in grace period and cannot accept new submissions.",
  "details": null,
  "request_id": "01HG4M3R8N2X..."
}
  • code: stable machine-readable identifier. Branch on this in your code, not on message.
  • message: human-readable summary suitable for surfacing to end users.
  • details: optional field-level errors or extra context. null when there's nothing structured to convey.
  • request_id: correlate with server-side logs when reporting a bug.

Rate limits

  • Submission ingest is capped at 30 requests per hour per (user, IP) pair.
  • Burst traffic gets 429 Too Many Requests with a Retry-After header.
  • Other endpoints have their own per-route limits; see the OpenAPI schema for the exact ceilings.

Mobile-friendly conventions

The API surface is designed for offline-friendly mobile clients (intermittent connectivity, background sync, retry storms after a tower handoff). Two conventions cover the chaos modes that show up in production:

Delta sync via ?updated_since=

Every list endpoint accepts a ?updated_since=<iso8601> query parameter that narrows the result set to rows updated at or after that timestamp. This is the cheap path for clients that already hold most of the data and only want the delta since their last successful sync.

  • Wired on: GET /books/, GET /books/{slug}/submissions/, GET /books/{slug}/members/, GET /books/{slug}/invitations/, GET /books/{slug}/notifications/subscriptions/.
  • Composes with cursor pagination. The ?updated_since= filter narrows the candidate set; ?cursor= still drives page boundaries. Both query parameters work in the same request.
  • Malformed timestamps return 400 query_param_invalid with details: {"param": "updated_since", "value": "<raw>"}.
  • The response stamps a Last-Modified header in RFC 7231 HTTP-date format. Pin your next request's ?updated_since= to this value, not your client's clock - that protects you against client / server clock skew.

Worked example:

# First sync (full list).
curl -H "Authorization: Bearer omb_live_<your-key>" \
     -D - \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/?limit=100"
# Response carries: Last-Modified: Sat, 17 May 2026 12:34:56 GMT

# Convert that header to an ISO 8601 value and use it as the next
# request's pin. Clients running in JavaScript can do:
#   new Date(response.headers.get("Last-Modified")).toISOString()
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/?updated_since=2026-05-17T12:34:56Z&limit=100"

Optimistic locking via If-Match + ETag

Every detail resource (GET /books/{slug}/, GET /me/, GET /books/{slug}/submissions/{id}/) carries an etag field in the JSON response and an ETag response header. The etag is a quoted ISO 8601 timestamp derived from updated_at - opaque to clients, but cheap to compare for equality.

  • To get RFC 7232 lost-update protection on a PATCH, echo the cached etag back in the If-Match request header.
  • If the resource has moved on since you read it (another writer bumped updated_at), the server returns 412 precondition_failed with the current etag in details.current_etag. Refetch, merge, and retry.
  • If you omit If-Match, the PATCH falls through to last-write-wins. The header is opt-in; choose it when you need to protect against concurrent writers.
  • Successful PATCHes stamp the new ETag header on the response so you always have the freshest value for the next round-trip.
  • Wired on: PATCH /books/{slug}/, PATCH /me/, PATCH /books/{slug}/submissions/{id}/.

Worked example:

# 1. Read the resource. Cache the etag.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     -D - \
     https://ourmemorybook.com/api/v1/books/grandma-stories/
# Response includes both:
#   ETag: "2026-05-17T12:00:00.123456+00:00"   (header)
#   {"...","etag": "\"2026-05-17T12:00:00.123456+00:00\""}   (body)

# 2. PATCH with If-Match. Send the body etag value verbatim.
curl -X PATCH \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -H 'If-Match: "2026-05-17T12:00:00.123456+00:00"' \
     -d '{"subtitle": "A life in letters"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/
# On success: 200 + new ETag header. On stale etag: 412 with
# {"code": "precondition_failed", "details": {"current_etag": "..."}}

Worked examples

Books

The books resource is the integrator entry point. The collection paginates via opaque cursors. Pass pass the previous response's next_cursor back to fetch the next page; has_more: false means you've reached the end.

# List + read
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/?limit=20"
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/

# Create (quota-only). 402 payment_required when no slot is free.
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"title": "Grandma stories"}' \
     https://ourmemorybook.com/api/v1/books/

# Update (partial)
curl -X PATCH \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"subtitle": "A life in letters"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/

# Lifecycle: archive / restore / delete
curl -X POST   -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" https://ourmemorybook.com/api/v1/books/grandma-stories/archive/
curl -X POST   -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" https://ourmemorybook.com/api/v1/books/grandma-stories/restore/
curl -X DELETE -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" https://ourmemorybook.com/api/v1/books/grandma-stories/

Stripe checkout is not initiated from the API. If you don't already hold a quota slot at the plan tier you request, POST /books/ returns 402 payment_required with a hint URL pointing at the web checkout. Acquire slots via admin grant or a Package purchase in the web UI, then the API will create the book directly.

Delete is a two-step contract when the off-provider backup runner is unreachable. The route returns 202 deletion_queued; poll GET /api/v1/books/{slug}/ until it 404s.

User profile

Read your own profile, including channel identifiers and staff flag:

curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/me/

Read your quotas (storage usage + per-plan book-slot info):

curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/me/quotas/

Find out what you can do on a specific book before you hit a 403. This is the "what can I do here" pattern: returns your role, the book's feature flags, and the permitted action set:

curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/permissions/

Use PATCH /api/v1/me/ with first_name, last_name, or phone_number (E.164) to update your profile. Email and channel identifiers can't be changed via the API; use the web UI or the relevant channel /link command.

Create a submission

POST a text memory to a book identified by its slug:

curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -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/

Python equivalent:

import uuid, requests

response = requests.post(
    "https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/",
    headers={
        "Authorization": "Bearer omb_live_<your-key>",
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    },
    json={
        "text": "Visiting Grandma in 2003 was when I learned how to make her bread.",
        "contributor_name": "Marcus",
        "contributor_email": "[email protected]",
    },
    timeout=30,
)
response.raise_for_status()
submission = response.json()

Submissions: list, read, edit, moderate

The submissions sub-resource carries the full integrator surface: list with cursor + delta-sync, read individual rows, PATCH content, and moderate (approve, reject, pin, unpin, delete). Each response carries an etag field that you can send back as If-Match on a subsequent PATCH to get RFC 7232 optimistic-lock semantics - if another writer moved the resource forward, the server returns 412 precondition_failed instead of overwriting their change.

# List (cursor-paginated; supports ?updated_since= for delta sync)
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/?limit=20"
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/?updated_since=2026-05-01T00:00:00Z"

# Read a single submission
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/

# Edit (partial). If-Match is optional - when supplied, you get 412 on mismatch.
curl -X PATCH \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H 'If-Match: "2026-05-17T12:00:00.123456+00:00"' \
     -H "Content-Type: application/json" \
     -d '{"text_content": "Polished version of the story."}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/

# Moderation actions - owner/admin only.
curl -X POST -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/approve/
curl -X POST -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Content-Type: application/json" -d '{"reason": "off-topic"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/reject/
curl -X POST -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/pin/
curl -X POST -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/unpin/

# Permanent delete (owner/admin only - irreversible).
curl -X DELETE -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/

Per-resource PATCH policy. PATCH /submissions/{id}/ is the one route in this domain that owners, admins, AND the original submitter can call: a contributor-role member who authored the submission can edit their own row. Moderation actions (approve / reject / pin / unpin / delete) stay owner/admin only.

Delta-sync clients can poll with ?updated_since=<iso8601> to fetch only rows updated at or after that timestamp. Combine with ?cursor= to paginate the narrowed result; both query params compose. The server returns a Last-Modified response header you can pin your next request to instead of trusting the client clock.

Media (upload / read / delete)

Each submission can carry image, audio, and video attachments. The media sub-resource has three routes: upload (multipart), read (returns a download_url resolved via the server's resolve_delivery SoT and signed via issue_media_signed_url for a 15-minute TTL, plus a thumbnail_url poster for video with the same TTL), and delete (detach + queue storage cleanup). Uploads route through edit_submission(media_will_change=True) on the server so adding or removing media on an already-approved submission resets moderation to pending and unpins the submission before the new bytes become eligible to render publicly.

Signed download URLs expire in 15 minutes. Production R2 storage signs each download_url with X-Amz-Expires=900. Clients MUST NOT cache download_url , refetch via the GET media endpoint when the URL expires. If the storage backend refuses to sign for the file (rare, fail-closed), the response carries download_url: null and download_status: "unavailable" , poll until it becomes available rather than retrying client-side.

# Upload (multipart). Accepts image/*, audio/*, video/*.
# Owner / admin OR the submission's contributor_user can call this.
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -F "[email protected];type=image/jpeg" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<id>/media/

# Read. Response includes download_url, processing_status, mime_type.
# Poll until processing_status == "complete" before surfacing the URL.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<sid>/media/<mid>/

# Delete (owner/admin only).
curl -X DELETE -H "Idempotency-Key: $(uuidgen)" -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/submissions/<sid>/media/<mid>/

Upload size limits (per kind): photos up to 20 MB, audio up to 50 MB, video up to your plan's limit; the multipart request ceiling is 90 MB. Either case returns 413 payload_too_large before any bytes are persisted. Audio and video files write scan_result: "skipped_by_policy" (not "clean"), this is by design; the server's resolve_delivery path serves them anyway, so clients should never gate playback on scan_result == "clean" alone.

Plan feature gates: audio uploads require the audio feature on the book's plan; video uploads require video. Plans without the feature return 400 validation_failed on upload with a message naming the gated media type.

Members and invitations

The collaboration sub-resource manages who has access to a book. Six routes split across two collections: members (accepted roster) and invitations (pending / accepted / expired / revoked). Both list endpoints are cursor-paginated and accept ?updated_since=<iso8601> for delta-sync.

# List members. Cursor-paginated, optionally filtered with ?updated_since=.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/members/?limit=50"

# Add a member by email OR user_id (exactly one). role defaults to "admin".
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"email": "[email protected]", "role": "admin"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/members/

# Remove a member (owner only). Returns 204; idempotent on replay.
# Removing the owner returns 400 cannot_remove_owner.
curl -X DELETE \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/members/<user-id>/

# List invitations. ?status= narrows to pending / accepted / expired / revoked.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/invitations/?status=pending"

# Create an invitation. identifier_type: email | phone | telegram.
# Email-type sends a link; phone/telegram-type activate on first inbound message.
# If the identifier matches an existing user, the membership is created eagerly
# and the returned row carries status="accepted".
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"identifier_type": "email", "identifier": "[email protected]", "role": "contributor"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/invitations/

# Revoke a pending invitation (soft-delete). Idempotent: re-revoke returns 200
# with `already_revoked: true`. Audit row survives the call.
curl -X DELETE \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/invitations/<invitation-id>/

Authorization. Every route under /members/ and /invitations/ requires owner or admin role (the manage_members action) , contributors cannot list, invite, or revoke. Removing a member is owner-only (the delete action floor). Book ownership transfer is not exposed via this surface; role on add-member / create-invitation must be "admin" or "contributor".

Member cap. When the book's plan defines max_members_per_book and the cap has been reached, both POST /members/ and the eager-Membership branch of POST /invitations/ return 402 member_limit_exceeded. The error envelope's message names the cap. Upgrade the plan or remove a member before retrying.

Invitation routing. Each identifier type has its own activation path: email sends a link via the standard invitation email (deferred via on_commit); phone waits for the first inbound SMS matching the E.164 identifier; telegram waits for the first inbound Telegram message matching the username. There is no API call to "send the invite" - the create endpoint owns both creation and dispatch.

Exports (request / status / artifact)

Exports are async. POST /exports/ queues a job and returns 202 Accepted with the job row; the server's generate_export Celery task renders the PDF / JSON / ZIP out of band. Clients poll GET /exports/{id}/ until status flips to complete or failed, then call GET /exports/{id}/artifact/ to get a short-TTL (15-minute) download URL.

# Request an export. `theme` is required (one of the v2 themes);
# `intent` defaults to "screen" and resolves to (bleed_mm, gutter_mm)
# server-side: screen → (0,0), home → (0,10), shop → (3,15).
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"theme": "elegant_classic", "intent": "screen", "export_format": "pdf"}' \
     https://ourmemorybook.com/api/v1/books/grandma-stories/exports/

# Poll status. Status is one of: pending, processing, complete, failed.
# `completed_at` and `error` are nullable.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/exports/<job-id>/

# Get the signed download URL (15-minute TTL).
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/exports/<job-id>/artifact/

Themes. Themes come from the full v2 enum: elegant_classic, modern_clean, storybook, memorial_service, wedding_album, baby_first_year, daily_journal, retirement_tribute, botanical_luxe, heirloom_gallery, editorial_noir. The API always uses the v2 render path regardless of the export_themes_v2 Waffle switch state. Unknown themes return 400 validation_failed with the offered list in details.offered.

Export formats. pdf (default), json, or zip. Each format requires a matching plan feature (pdf_export, json_export, zip_export); plans without the feature get a 400 validation_failed when full_clean rejects the model.

Artifact 4xx codes. 409 export_not_ready when status is still pending or processing (poll the status endpoint instead). 410 export_failed when the job failed or the output file is no longer available (request a new export). 404 is reserved for unknown job ids , distinguish from 409/410 so retry logic does the right thing.

Artifact 5xx codes. 502 storage_unavailable on the artifact route (both /books/{slug}/exports/{id}/artifact/ and /me/export/{id}/) means the file is still on the server but minting the signed URL failed transiently , storage outage, credential rotation, region mismatch. The artifact has NOT been lost. Retry with exponential backoff (1s, 2s, 4s, 8s, cap at 60s). Distinct from 410, which is terminal.

Account & GDPR self-service

Four routes under /api/v1/me/ cover the right-to-export and right-to-be-forgotten flows plus per-stream email unsubscribe (authenticated + RFC 8058 one-click). The export route is async (the JSON blob can run into megabytes for active owners); poll the job status endpoint until status flips to complete, then read the download_url (60-minute signed-URL TTL , each poll re-issues a fresh TTL).

# Queue a GDPR data export. Returns 202 + pending job row.
# Rate-limited 1 per day per user (429 rate_limited on the second
# request within 24h, even with a different Idempotency-Key).
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{}' \
     https://ourmemorybook.com/api/v1/me/export/

# Poll the job. When status flips to "complete", download_url is a
# 60-minute signed URL pointing at a gzip-compressed JSON blob.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/me/export/<job-id>/

# Delete your account. Requires step-up reauthentication: first mint a
# single-use 5-minute reauth grant with a FRESH credential (your account
# password, or a delete-purpose OTP requested via
# POST /me/delete/reauth/otp/), then spend the grant on the delete.
# A bearer credential alone is rejected with 403 reauth_required.
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Content-Type: application/json" \
     -d '{"password": "your-account-password"}' \
     https://ourmemorybook.com/api/v1/me/delete/reauth/
# → {"reauth_token": "…", "expires_in": 300}

curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{"reauth_token": "<grant-from-above>"}' \
     https://ourmemorybook.com/api/v1/me/delete/

# Unsubscribe from a per-stream email flow. Supported streams:
# onboarding (post-signup reminders), digests (per-book digests on
# books you own), activity_reminders (inactivity nudges on books you
# own). Unknown streams return 400 unknown_stream with the supported
# list in details.offered.
#
# AUTHENTICATED PATH (Bearer token or session) , covers all three streams.
# Same auth contract as every other /me/ route.
curl -X POST \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     -H "Content-Type: application/json" \
     -d '{}' \
     https://ourmemorybook.com/api/v1/me/unsubscribe/onboarding/

# RFC 8058 ONE-CLICK PATH (unauthenticated) , separate route at
# /me/unsubscribe/{stream}/token/. The unsubscribe links in our
# onboarding emails work without auth via ?token=<uuid>. Supports
# the onboarding stream ONLY (other streams have no RFC 8058 token);
# requesting another stream on this path returns 400
# stream_not_token_supported.
curl -X POST \
     "https://ourmemorybook.com/api/v1/me/unsubscribe/onboarding/token/?token=<uuid>"

Delete flow: Phase 1 (synchronous PII scrub on the user row) runs inside the request; Phase 2 (heavy cascade , owned books, history rows, off-provider backup erasure publish) runs as a Celery task on commit. If the off-provider erasure runner is unreachable, the route returns 202 deletion_queued; retry the same Idempotency-Key after a backoff window. Idempotent replay works even after PII scrub , the cached 202 body comes back without re-running the destructive scrub.

Export retention: generated JSON blobs are deleted from R2 after 30 days. If you need to re-download an old export, request a new one.

Messaging and notifications

The messaging sub-resource exposes the channel-status snapshot the owner sees on the Channels page plus the list of notification subscriptions on the book. Subscription writes (signup / verification) remain web-only because they require an out-of-band verification step; the API surface covers read and unsubscribe only.

# List channel status. Returns one row per channel (web, email, sms, rcs,
# imessage, whatsapp, telegram) with enabled / plan_allowed /
# platform_configured flags plus the contributor-facing address and deep
# link. Sensitive provider config (API keys, webhook secrets) is never
# exposed.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/channels/

# List notification subscriptions. Cursor-paginated; supports ?updated_since=
# for delta-sync. Both active and inactive rows are returned so the caller
# can audit recent unsubscribes.
curl -H "Authorization: Bearer omb_live_<your-key>" \
     "https://ourmemorybook.com/api/v1/books/grandma-stories/notifications/subscriptions/?limit=50"

# Unsubscribe (soft-delete) a subscription. Idempotent: re-delete returns
# 200 with `already_inactive: true`. The subscription owner can always
# delete their own row regardless of book membership.
curl -X DELETE \
     -H "Authorization: Bearer omb_live_<your-key>" \
     -H "Idempotency-Key: $(uuidgen)" \
     https://ourmemorybook.com/api/v1/books/grandma-stories/notifications/subscriptions/<subscription-id>/

Authorization. GET /channels/ and GET /notifications/subscriptions/ require owner or admin role (the manage_members action) , contributors cannot list either surface. DELETE /notifications/subscriptions/{id}/ accepts owner / admin OR the subscription's own user so a subscriber can always cancel their own notifications without holding a Membership row.

No subscription create endpoint. Subscriptions require a contact-verification step (an out-of-band email link click) before they receive notifications. The web form at /notifications/subscribe/ owns this flow today; exposing it via the API would require a verification-token model the API does not yet ship. If you need integrator-managed subscriptions, get in touch.

Common error codes

StatusCodeMeaning
403feature_gatedThe book's plan does not include API access.
403insufficient_roleYou are a contributor on this book, not an owner or admin.
403book_grace_periodThe book's subscription lapsed. Writes are blocked until it's resolved.
413payload_too_largeRequest body exceeds the per-endpoint size limit.
428idempotency_key_requiredYou omitted the Idempotency-Key header on a non-GET route.
429rate_limitedToo many requests. Check Retry-After for the cool-down window.

What's next

Endpoints currently exposed at /api/v1/ are auth + identity (token issuance, profile + quotas, permissions), book CRUD + lifecycle (list, create, read, update, archive, restore, delete), billing read, submissions full CRUD + moderation (list with cursor + delta-sync, read, edit with optional If-Match, delete, approve, reject, pin, unpin), media upload / read / delete (multipart upload, signed download URL via resolve_delivery, owner/admin-gated delete with moderation reset), exports (request a v2 export, poll status, fetch a 15-minute signed artifact URL), members + invitations (list / add / remove members, list / create / revoke invitations via email, phone, or Telegram), messaging + notifications (read channel status, list and unsubscribe notification subscriptions), and account / GDPR self-service (async data export, confirmation-token account deletion, per-stream email unsubscribe with optional RFC 8058 token path). Domain 7 closes Phase 2's user-facing surface; remaining work in the API-first plan is staff/admin-scoped (internal tooling). If your integration needs a specific endpoint before it's available, get in touch and we'll prioritise it.

Reporting bugs

Email [email protected] and include the request_id from the error envelope. That value lets us find the exact server-side trace for your call.

Ready to try it? Create your free memory book