# Stories API — a guide with curl you can paste

Base URL in production: `https://stories.cstsolution.com/api/v1`
Locally: `http://127.0.0.1:1471/api/v1`

The machine-readable spec is [`openapi.yaml`](openapi.yaml) next to this file.
This page is the version you actually copy from.

```bash
export API=https://stories.cstsolution.com/api/v1
```

---

## Two ways to authenticate

| | Header | Use for | Cannot |
|---|---|---|---|
| **Session** | `Authorization: Bearer <session token>` | the website, signing in as a person | — |
| **API key** | `X-API-Key: cstk_…` | upload scripts, bulk publishing, CI | create another key, change a password |

An API key is a long-lived credential for a machine. It deliberately cannot
take over the account it belongs to: a leaked upload key should cost an author
their key, not their login.

---

## 1. Get an account and a key

```bash
# Register as an author. `role` may be "reader" or "author"; "admin" is not a
# signup field and never will be.
curl -s -X POST "$API/auth/register" \
  -H 'content-type: application/json' \
  -d '{
        "email": "nadia@example.com",
        "password": "a-real-passphrase-please",
        "displayName": "Nadia Ferrant",
        "role": "author"
      }'
```

```json
{
  "token": "aQ7…",
  "expiresIn": 2592000,
  "member": { "id": "…", "email": "nadia@example.com", "role": "author" }
}
```

```bash
export SESSION=aQ7…

# Create an API key. Shown exactly once — store it now.
curl -s -X POST "$API/auth/keys" \
  -H "authorization: Bearer $SESSION" \
  -H 'content-type: application/json' \
  -d '{"label": "laptop upload script", "scopes": ["stories:read","stories:write","stats:read"]}'
```

```json
{
  "id": "…",
  "label": "laptop upload script",
  "preview": "cstk_3AmlNgSb…",
  "scopes": ["stories:read", "stories:write", "stats:read"],
  "key": "cstk_3AmlNgSbpxlC7NG_IYT5-VlAsIYeQ7pp4r36ji6ERaw",
  "warning": "This is the only time the key is shown. Store it now."
}
```

```bash
export KEY=cstk_3AmlNgSbpxlC7NG_IYT5-VlAsIYeQ7pp4r36ji6ERaw

curl -s "$API/auth/keys" -H "authorization: Bearer $SESSION"          # list
curl -s -X DELETE "$API/auth/keys/<id>" -H "authorization: Bearer $SESSION"  # revoke
```

---

## 2. Create a story

```bash
curl -s -X POST "$API/author/stories" \
  -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{
        "title": "The Ledger of Ost",
        "synopsis": "A harbour clerk discovers that the ledger she keeps is not a record of what has happened, but a manifest of what is owed.",
        "language": "en",
        "genre": ["fantasy", "literary"],
        "tags": ["sea", "folk horror"],
        "maturity": "teen",
        "isAiDisclosed": false
      }'
```

`isAiDisclosed` is **required** and has no default. The site is ad-funded and
undisclosed generated content is a policy problem for every author here, not
just the one who posted it. `"unknown"` is not a state anyone wants to
reconstruct later.

The slug is generated from the title and made unique; it is what the reader URL
uses. A story is created as a `draft`.

### Imported work: the `provenance` block

A story the platform did not commission — a public-domain novel, an
open-licensed text — carries a `provenance` block at creation. It is optional,
and once given it changes two things permanently.

```bash
curl -s -X POST "$API/author/stories" \
  -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{
        "title": "Pride and Prejudice",
        "language": "en",
        "genre": ["romance", "literary"],
        "maturity": "general",
        "isAiDisclosed": false,
        "provenance": {
          "source": "gutenberg",
          "sourceId": "1342",
          "sourceUrl": "https://www.gutenberg.org/ebooks/1342",
          "licence": "pd-us",
          "attribution": "Pride and Prejudice, by Jane Austen. Source: Project Gutenberg (https://www.gutenberg.org/ebooks/1342). Public domain in the United States.",
          "rightsNote": "dcterms:rights = \"Public domain in the USA.\"; Jane Austen died 1817"
        }
      }'
```

The two things it changes:

1. **The story can never earn a payout.** The payout sweep in `lib/payouts.js`
   refuses to claim a qualified view whose story has a provenance row. There is
   no `payoutEligible` flag to set — the provenance *is* the flag. Every story
   response reports `"payoutEligible": false` so a caller can see it.
2. **`attribution` is required and must be non-empty**, enforced by the column.
   For several of these licences, credit is the condition on which the right to
   publish rests, and a page that renders the text without it is infringing
   while looking perfectly normal. It renders on the story page and at the foot
   of every chapter.

`licence` must be a code from `lib/provenance.js`. **A NonCommercial licence is
refused with a 422**, because this site serves advertising:

```json
{ "error": "licence \"cc-by-nc-4.0\" (Creative Commons Attribution-NonCommercial 4.0) does not permit commercial use, and Pagelark serves advertising against every chapter. …" }
```

`(source, sourceId)` is unique across the catalogue. Posting a duplicate
returns **409** with the story that already holds it, so an importer can carry
on rather than stop:

```json
{ "error": "gutenberg:1342 is already imported as \"Pride and Prejudice\"",
  "storyId": "…", "slug": "pride-and-prejudice" }
```

To look one up before creating it:

```bash
curl -s "$API/author/stories?source=gutenberg&sourceId=1342" -H "x-api-key: $KEY"
# 200 with { story }, or 404 if this account has not imported it
```

See [`content-sources.md`](content-sources.md) for which sources and licences
are usable at all, and `server/import.mjs` for the importer that drives this.

---

## 3. Upload a whole novel from Markdown files — the worked example

This is what the API exists for. An author with a finished novel has forty
Markdown files and no interest in forty web forms.

Assume a directory like this, named so it sorts in reading order:

```
the-ledger-of-ost/
  01-the-ledger.md
  02-what-the-water-kept.md
  03-forty-one-at-dawn.md
  …
```

Each file's first `# Heading` (if there is one) is the chapter title; the rest
is the body.

### The script

```bash
#!/usr/bin/env bash
# upload-novel.sh — publish a directory of Markdown files as one story.
#
#   ./upload-novel.sh <story-id> <directory>
#
# Needs: curl, and node (any version with JSON.stringify, so: any).
set -euo pipefail

STORY="$1"
DIR="$2"
: "${API:?set API}"
: "${KEY:?set KEY}"

# Build the JSON body with node, not with shell string concatenation. Chapter
# bodies contain quotes, newlines, backslashes and em-dashes, and every one of
# those breaks a hand-rolled here-doc in a way that is not obvious until
# chapter 23 renders as one line.
BODY=$(node -e '
  const fs = require("fs"), path = require("path");
  const dir = process.argv[1];
  const files = fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort();
  const chapters = files.map((f, i) => {
    const raw = fs.readFileSync(path.join(dir, f), "utf8");
    const m = /^#\s+(.+)$/m.exec(raw);
    return {
      number: i + 1,
      title: m ? m[1].trim() : path.basename(f, ".md").replace(/^\d+[-_]/, "").replace(/-/g, " "),
      body: m ? raw.replace(m[0], "").trim() : raw.trim(),
      status: "published",
    };
  });
  process.stdout.write(JSON.stringify({ chapters, publish: true }));
' "$DIR")

curl -s -X POST "$API/author/stories/$STORY/chapters/bulk" \
  -H "x-api-key: $KEY" \
  -H 'content-type: application/json' \
  --data-binary "$BODY"
```

### Running it

```bash
export API=https://stories.cstsolution.com/api/v1
export KEY=cstk_…

STORY=$(curl -s -X POST "$API/author/stories" \
  -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"title":"The Ledger of Ost","isAiDisclosed":false,"genre":["fantasy"]}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).story.id))')

./upload-novel.sh "$STORY" ./the-ledger-of-ost
```

```json
{
  "written": 3,
  "published": 3,
  "totalWords": 1607,
  "chapters": [
    { "id": "…", "number": 1, "title": "The Ledger", "wordCount": 189, "status": "published" },
    { "id": "…", "number": 2, "title": "What the Water Kept", "wordCount": 225, "status": "published" },
    { "id": "…", "number": 3, "title": "Forty-One at Dawn", "wordCount": 236, "status": "published" }
  ]
}
```

Then submit it for moderation:

```bash
curl -s -X PATCH "$API/author/stories/$STORY" \
  -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"status": "pending"}'
```

### Things worth knowing about bulk upload

- **It is one transaction.** Either the whole novel lands or none of it does.
  A partial upload never leaves a story with chapters 1–17 and a 502.
- **It is an upsert on `(story, number)`.** Re-running it after fixing a typo
  in chapter 12 replaces chapter 12 and leaves the rest alone.
- **`published_at` is set on first publish and never reset.** Re-uploading an
  edit does not push a finished story back to the top of "new".
- **`wordCount` is computed by the server** and any value you send is ignored.
  It sets the minimum dwell time a view has to clear, so an author who could
  set it could set their own fraud threshold to zero.
- Limits: 200 chapters per request, 400 KB per chapter body, 8 MB per request,
  10 bulk requests per hour.

### Markdown: what is supported

Headings (rendered from `<h3>` down, so a chapter cannot outrank the site),
paragraphs, `**bold**`, `*italic*`, `_italic_`, `~~strike~~`, `` `code` ``,
`> blockquote`, ordered and unordered lists, `---` scene breaks, links and
images with `http(s)` URLs.

**Raw HTML is escaped, not rendered.** `<script>` in a chapter body appears on
the page as the literal text `<script>`. `javascript:` and `data:` URLs in
links are dropped and the link text is kept. This is not configurable.

---

## 4. Individual chapters

```bash
# Create or replace one chapter
curl -s -X PUT "$API/author/stories/$STORY/chapters/4" \
  -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"title": "The Second Ledger", "body": "It began again in the spring.\n\nShe had...", "status": "draft"}'

# Publish it
curl -s -X POST "$API/author/stories/$STORY/chapters/4/publish" -H "x-api-key: $KEY"

# Delete it
curl -s -X DELETE "$API/author/stories/$STORY/chapters/4" -H "x-api-key: $KEY"
```

Publishing a chapter of a `draft` story moves the story to `pending`. **An
author cannot set a story to `published`** — that is a moderation decision, and
it is the only thing standing between the catalogue and whatever an API key
decides to post.

---

## 5. Your numbers

```bash
curl -s "$API/author/stories" -H "x-api-key: $KEY"
curl -s "$API/author/stories/$STORY/stats" -H "x-api-key: $KEY"
curl -s "$API/author/earnings" -H "x-api-key: $KEY"
```

```json
{
  "rawViews": 1483,
  "rejectedViews": 402,
  "qualifiedViews": 1081,
  "qualifiedViews30d": 1081,
  "estimatedEarnings": {
    "currency": "USD",
    "micros": 1621500,
    "amount": 1.6215,
    "ratePer1kMicros": 1500000,
    "note": "Estimate at the current rate, before review. Not a payable balance."
  },
  "flagBreakdown": [
    { "flag": "dwell_too_short", "n": 210 },
    { "flag": "duplicate_24h", "n": 141 },
    { "flag": "no_scroll", "n": 51 }
  ]
}
```

Both counts are always shown. If your raw count is many times your qualified
count, something is sending you traffic that is not being paid for — see
[view-counting.md](view-counting.md) for what each flag means.

`estimatedEarnings` is an estimate at the current rate before review. It is
not a payable balance; `/author/earnings` shows the actual payout lines and
their status.

---

## 6. Reading (no account needed)

```bash
# Browse. Filters: q, genre, tag, language, maturity, author, sort, limit, offset
curl -s "$API/stories?sort=popular&genre=fantasy&limit=10"

# One story with its chapter list
curl -s "$API/stories/the-ledger-of-ost"

# One chapter — returns rendered HTML and a read token
curl -s "$API/stories/the-ledger-of-ost/chapters/1"
```

```json
{
  "story": { "…": "…" },
  "chapter": { "id": "…", "number": 1, "title": "The Ledger", "html": "<p>The tide went out…</p>" },
  "nav": { "prev": null, "next": 2 },
  "read": {
    "token": "v1.eyJjIjoi….sig",
    "minDwellMs": 18900,
    "heartbeatUrl": "/api/v1/read/heartbeat"
  }
}
```

### The heartbeat

```bash
curl -s -X POST "$API/read/heartbeat" \
  -H 'content-type: application/json' \
  -H "referer: https://stories.cstsolution.com/read/" \
  -d '{
        "chapterId": "…",
        "readToken": "v1.eyJjIjoi….sig",
        "dwellMs": 21000,
        "scrollDepth": 0.94,
        "interactions": 17,
        "interactionIntervals": [2400, 18900, 6100, 31500, 4200],
        "client": "web"
      }'
```

```json
{ "counted": true }
```

`counted: false` is the normal answer for most of the conditions in
[view-counting.md](view-counting.md) and is **not an error** — the response is
always `200`. Reasons are returned in a `flags` array only when the server runs
with `NODE_ENV=development`; in production they are recorded and not disclosed,
because telling a farm which knob to turn is the one thing this endpoint must
not do.

A client that wants views to count must send the heartbeat **after**
`minDwellMs` has genuinely elapsed, with real scroll and interaction data. The
server checks the dwell against the token's own age, so posting a large
`dwellMs` immediately fails twice over.

---

## 7. Library and reading position

```bash
curl -s "$API/me/library" -H "authorization: Bearer $SESSION"

curl -s -X PUT "$API/me/bookmarks/$STORY" \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' -d '{}'

curl -s -X DELETE "$API/me/bookmarks/$STORY" -H "authorization: Bearer $SESSION"

curl -s "$API/me/progress/$STORY" -H "authorization: Bearer $SESSION"

curl -s -X PUT "$API/me/progress/$STORY" \
  -H "authorization: Bearer $SESSION" -H 'content-type: application/json' \
  -d '{"chapterId": "…", "position": 0.42, "explicit": false}'
```

Progress never moves backwards unless `explicit` is true. Opening chapter 3 to
check a name should not lose your place in chapter 20.

Saving a place is free and earns nobody anything — it is completely separate
from view counting.

---

## 8. Admin

Every admin route requires `X-Admin-Token`, matching `ADMIN_TOKEN` in the
service's `.env`. Unset it and the routes 404. A wrong token also 404s rather
than 403s, so the routes' existence is not confirmed to somebody probing.

```bash
export ADMIN=…

# The moderation queue
curl -s "$API/admin/moderation?status=pending" -H "x-admin-token: $ADMIN"
curl -s -X POST "$API/admin/stories/<id>/approve" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"note":"reads fine"}'
curl -s -X POST "$API/admin/stories/<id>/hide" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"note":"plagiarism report"}'

# The fraud queue — read this before approving any payout
curl -s "$API/admin/fraud?hours=168" -H "x-admin-token: $ADMIN"

# Payouts
curl -s -X POST "$API/admin/payouts" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"startsAt":"2026-09-01T00:00:00Z","endsAt":"2026-10-01T00:00:00Z","ratePer1kMicros":1500000}'

curl -s -X POST "$API/admin/payouts/<period-id>/run" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{}'

curl -s "$API/admin/payouts/<period-id>/lines?status=held_for_review" -H "x-admin-token: $ADMIN"

curl -s -X POST "$API/admin/payout-lines/<line-id>/decide" -H "x-admin-token: $ADMIN" \
  -H "x-admin-user: son" -H 'content-type: application/json' \
  -d '{"decision":"approved"}'

# Put an author under review — every line of theirs is then held, at any amount
curl -s -X POST "$API/admin/members/<member-id>/review" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"note":"87% rejection rate in September","status":"active"}'
```

Running a period twice without `{"recalculate": true}` returns `409`. Every
qualified view a period sweeps is stamped with the period id and can never be
swept again, so a re-run is safe and a double payout is not possible.
Recalculating leaves `approved` and `rejected` lines alone — a human decision
is not recomputed out from under whoever made it.

---

## 9. Reporting, blocking and account deletion

These exist because the app stores require them, not as extras. Without them
the iOS app cannot ship. See `moderation.md` for the rules and the reviewer
workflow.

**Reporting works without an account.** A reader who has not signed up is still
a reader, and making somebody register before they can flag something
objectionable is both hostile and a review risk.

```bash
# Report a story, a chapter or a member. No token needed.
curl -s -X POST "$API/reports" -H 'content-type: application/json' \
  -d '{"targetType":"story","targetId":"<id>","reason":"sexual_content",
       "detail":"chapter 4 is explicit and the story is rated teen"}'

# Block an author. Their stories vanish from your browse, story and library
# views. Your bookmark is kept, so unblocking gives your library back.
curl -s -X POST "$API/me/blocks" -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"memberId":"<id>"}'

curl -s "$API/me/blocks" -H "authorization: Bearer $TOKEN"
curl -s -X DELETE "$API/me/blocks/<member-id>" -H "authorization: Bearer $TOKEN"
```

Account deletion has two possible outcomes, and which one a member gets is
decided by their data rather than by a setting. Ask first:

```bash
# What would deletion actually do to this account?
curl -s "$API/auth/account/impact" -H "authorization: Bearer $TOKEN"
```

A reader, and any author who has never been paid, gets a **purge**: the row is
deleted, everything cascades, and it is a real and complete deletion. An author
whose payout history would be destroyed gets an **anonymise** instead: the row
survives stripped of every identifying field, and published stories stay
published under a placeholder byline.

The impact endpoint returns which outcome applies and why, so the member is
told before they confirm rather than after.

```bash
curl -s -X DELETE "$API/auth/account" -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"password":"…"}'
```

The password is required even though the session token already proves identity.
Deletion is not reversible and a borrowed phone should not be enough.

### The report queue, for whoever is on moderation duty

```bash
# The queue, with an overdue count measured against the 24h commitment
curl -s "$API/admin/reports?status=open" -H "x-admin-token: $ADMIN"

# One report, returned together with the content it is about
curl -s "$API/admin/reports/<id>" -H "x-admin-token: $ADMIN"

# Uphold or reject, with a note. Recorded with who and when.
curl -s -X POST "$API/admin/reports/<id>/resolve" -H "x-admin-token: $ADMIN" \
  -H "x-admin-user: son" -H 'content-type: application/json' \
  -d '{"decision":"upheld","note":"explicit content in a teen-rated story"}'

# Consequences
curl -s -X POST "$API/admin/stories/<id>/hide" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"note":"report #: upheld"}'
curl -s -X POST "$API/admin/stories/<id>/restore" -H "x-admin-token: $ADMIN"
curl -s -X POST "$API/admin/members/<id>/suspend" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"suspended":true,"note":"repeat"}'
```

A moderator hide is not the same as an author withdrawing their own work. It
stamps who did it and when, and the author cannot lift it. Suspending a member
kills every session they hold immediately rather than letting existing ones run
until they expire.

---

## 10. Notice and takedown — 17 U.S.C. 512

**Read [`dmca.md`](dmca.md) before using any of this.** It is the operating
procedure; this section is only the calls. The public policy page a rights
holder is sent to is `/dmca/`.

A DMCA notice is a specific, higher-stakes kind of report: it carries a legal
deadline, it names somebody who has sworn a statement under penalty of perjury,
and acting on it has consequences for the person who uploaded the material. So
it extends the moderation system rather than sitting beside it — a takedown
stamps `moderated_at` / `moderated_by` on the story exactly as a hide from the
report queue does, and shows up in the same log.

### Recording and actioning a notice

```bash
# 1. Log it the day it arrives, in whatever state it arrived in. Nothing is
#    refused: a notice turned away for being incomplete is a notice with no
#    record of having arrived, and the record is the point.
curl -s -X POST "$API/admin/dmca/notices" -H "x-admin-token: $ADMIN" \
  -H "x-admin-user: son" -H 'content-type: application/json' -d '{
    "receivedAt": "2026-09-10T09:14:00Z",
    "receivedVia": "email",
    "complainantName": "Halden Press Ltd",
    "complainantEmail": "rights@haldenpress.example",
    "complainantPhone": "+44 20 7946 0000",
    "complainantAddress": "4 Gower Mews, London WC1E",
    "signature": "/s/ A. Halden",
    "workDescription": "The Ledger of Ost, a novel by …, Halden Press 2021",
    "materialDescription": "The complete text posted under the same title",
    "materialUrls": ["/story/?slug=the-ledger-of-ost"],
    "statesGoodFaith": true,
    "statesAccuracyAndAuthority": true,
    "rawText": "…the notice, verbatim…"
  }'
```

The response carries a reference (`DMCA-2026-0001`) and an `elements` block
checking the notice against the six things § 512(c)(3)(A) requires. Two fields
on it matter:

* `complete` — all six present.
* `startsTheClock` — the work, the material and the contact details are all
  present. **This is the one that decides what to do.** False means the notice
  is defective under § 512(c)(3)(B) and is not considered in deciding whether we
  had knowledge; ask for the missing part before acting on it. True with
  `complete: false` means § 512(c)(3)(B)(ii) applies: we must take reasonable
  steps to get the missing element rather than ignore the notice.

```bash
# 2. Point it at the material. A separate step, because working out which story
#    a notice means is usually the slowest part of handling one.
curl -s -X POST "$API/admin/dmca/notices/<id>/match" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"targetType":"story","targetId":"<story id>"}'   # or "chapter"

# 3. Disable it. One transaction: the material is hidden, the takedown is
#    logged with the status it had beforehand, and the uploader is struck.
curl -s -X POST "$API/admin/dmca/notices/<id>/takedown" -H "x-admin-token: $ADMIN" \
  -H "x-admin-user: son" -H 'content-type: application/json' \
  -d '{"note":"disabled on receipt","strike":true}'

# 4. § 512(g)(2)(A) — record that the uploader was told. There is no SMTP on
#    this box, so somebody sends it by hand and records that they did.
curl -s -X POST "$API/admin/dmca/notices/<id>/notified" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"method":"email + in-app","note":"sent from the agent address"}'

# Or: it is not a notice we act on. Requires a note, and keeps the record.
curl -s -X POST "$API/admin/dmca/notices/<id>/status" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"status":"rejected","note":"public domain in the US; author died 1931"}'
```

`GET /admin/dmca/notices` is the queue. Its `queue` block carries two numbers
that catch a failure leaving no other trace — the material is down, everything
looks handled, and a statutory step was skipped:

```json
{ "uploaderNotTold": 1, "counterNoticesNotForwarded": 0 }
```

`GET /admin/dmca/notices/<id>` returns the notice, the material it names, the
takedowns it caused, every counter notice, and **where the uploader stands
under the repeat infringer policy** — because "this is their third notice in a
year" is the most important fact on the page and should not be a click away.

### What the reader sees

A story or chapter under a live takedown answers **451 Unavailable For Legal
Reasons**, not 404:

```bash
curl -s "$API/stories/the-ledger-of-ost"
```
```json
{ "error": "This story was removed after the site received a copyright notice about it.",
  "removed": { "removed": true, "reason": "copyright", "reference": "DMCA-2026-0007",
               "scope": "story", "removedAt": "…", "statute": "17 U.S.C. 512(c)",
               "counterNoticeHref": "/dmca/#counter-notice", "policyHref": "/dmca/" } }
```

It names nobody, on either side. The complainant enforced a copyright and does
not thereby publish their address; the uploader has been accused and not found
to have done anything. The reference is what either of them corresponds about.

### The counter notice, and the clock

The subscriber files their own, with a **session** — not an API key. A leaked
upload key must not be able to swear a statement under penalty of perjury in
its owner's name.

```bash
# What is on my account, and where I stand
curl -s "$API/me/dmca" -H "authorization: Bearer $TOKEN"

# § 512(g)(3). All four elements, or it does not start a clock — there is no
# "substantially" in this subsection.
curl -s -X POST "$API/me/dmca/notices/<notice id>/counter" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{
    "signature": "/s/ Nadia Ferrant",
    "materialDescription": "The complete text of The Ledger of Ost",
    "materialLocation": "/story/?slug=the-ledger-of-ost",
    "statesMistakeUnderPenalty": true,
    "contactName": "Nadia Ferrant",
    "contactAddress": "12 Rue Blanche, Lyon, France",
    "contactPhone": "+33 4 72 00 00 00",
    "consentsToJurisdiction": true,
    "acceptsServiceOfProcess": true
  }'
```

An incomplete counter notice is **recorded, not refused**, and comes back with
`status: "incomplete"` and `restoration.state: "ineffective"` — there is no
window, as opposed to a window that has not opened yet.

A complete one comes back with the clock on it:

```json
{ "restoration": { "state": "waiting", "mayRestore": false,
                   "earliestAt": "2026-09-24T09:14:00.000Z",
                   "latestAt":   "2026-09-30T09:14:00.000Z" } }
```

Ten and fourteen **business days from receipt of the counter notice** —
§ 512(g)(2)(C) — counting Monday to Friday and excluding the eleven US federal
holidays as observed under 5 U.S.C. § 6103(b). Not from the takedown, and not
from when it was forwarded.

```bash
# § 512(g)(2)(B): a copy goes to the complainant, promptly. Record it.
curl -s -X POST "$API/admin/dmca/counter-notices/<id>/forward" -H "x-admin-token: $ADMIN"

# § 512(g)(2)(C)'s escape hatch: they told us they have filed an action.
# Recorded, the material stays down, and nothing expires.
curl -s -X POST "$API/admin/dmca/counter-notices/<id>/suit" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"note":"EDNY 1:26-cv-…, told 2026-09-22"}'

# Every clock that is running, with its state
curl -s "$API/admin/dmca/counter-notices" -H "x-admin-token: $ADMIN"

# Put it back. Refused with 409 before the tenth business day, and the refusal
# carries the dates it refused against.
curl -s -X POST "$API/admin/dmca/takedowns/<id>/restore" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' \
  -d '{"reason":"counter_notice","note":"window ran, no court action"}'
```

`reason` is one of `counter_notice`, `notice_withdrawn` or `our_error`. All
three **expunge the strike** — nobody is penalised for a claim that did not
stand up. The last two have no statutory window and are not refused early.

### The repeat infringer policy — § 512(i)(1)(A)

The thresholds are published at `/dmca/`, defined once in `STRIKE_POLICY`
(`server/lib/dmca.js`), and a unit test asserts the code counts the numbers the
page publishes. Every safe harbour in section 512 depends on this policy being
*reasonably implemented*, which is why each rung is something the API enforces:

```bash
# Strike history and standing
curl -s "$API/admin/dmca/members/<member id>" -H "x-admin-token: $ADMIN"

# Rung 2: 30 days without publishing. Drafts still work.
curl -s -X POST "$API/admin/dmca/members/<id>/restrict" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"days":30,"reason":"second upheld notice"}'
curl -s -X POST "$API/admin/dmca/members/<id>/restrict" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"lift":true}'

# Rung 3: sessions and API keys revoked, everything published taken off the site
curl -s -X POST "$API/admin/dmca/members/<id>/terminate" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"reason":"notices DMCA-2026-0004, -0009, -0017"}'

# Take a strike off the count. Requires a reason; the row stays.
curl -s -X POST "$API/admin/dmca/strikes/<id>/expunge" -H "x-admin-token: $ADMIN" \
  -H 'content-type: application/json' -d '{"reason":"edition was public domain after all"}'
```

While a member is restricted, publishing paths answer **403** with the policy
in the message rather than a bare "forbidden". A chapter under a live takedown
answers **409** on every write that would put it back — `PUT` over it, a bulk
re-upload of the whole novel, and the publish route. A takedown has to survive
the person who uploaded the material.

---

## Errors

| Status | Meaning |
|---|---|
| 400 | validation — the message says what |
| 401 | no credential, or it expired |
| 403 | wrong role, missing scope, or an operation an API key may not do |
| 404 | not found — also what a bad admin token gets |
| 409 | conflict (duplicate email, period already calculated, material under a live takedown, restoration attempted before the tenth business day) |
| 413 | body too large |
| 429 | rate limited |
| 451 | the material was removed after a copyright notice — the body carries the tombstone. See §10 |
| 5xx | our problem; the body says `internal error` and the detail is in the container log |

Every write endpoint is rate limited. Signing in is limited on **failures
only**, so a person who signs in often is never told "too many attempts".
