Skip to content

Developers

The Sproker API

Read your own stories, chapter by chapter, and generate insights grounded in them. Six read-only endpoints, bearer authentication, and a sandbox that costs nothing so you can build before you decide.

Machine-readable spec: openapi.json

Getting a key

Create one at your developer settings. Pick the scopes it needs and whether it is a sandbox or a live key. The secret is shown once and stored only as a hash: it cannot be recovered, only replaced. Keys expire after a year unless you revoke them sooner.

Authorization: Bearer sk_test_...

Sandbox and live

A sk_test_ key authenticates exactly like a live one, is rate limited the same way, and answers in the same shape - so a client written against it works unchanged when you swap the key. What it does not do is read your real stories or spend any credits: it returns fixed example data and reports credits_charged: 0.

Because it is fixed, a sandbox response is safe to commit to a test suite and safe to share with a teammate. A sk_live_ key reads your real work and, on the AI endpoint, spends your AI credits.

Scopes

stories.readRead your own stories and their chapters, including the text.
ai.generateGenerate insights grounded in your stories. Spends credits on a live key.
account.readRead your account, plan and credit balance.

Endpoints

GET /v1/meWho this key belongs to and what it may do.
GET /v1/storiesYour stories, newest first.
GET /v1/stories/{id}One story.
GET /v1/stories/{id}/chaptersIts chapters, in reading order.
GET /v1/chapters/{id}One chapter, with its text.
POST /v1/insightsIdeas grounded in one of your stories.

Everything reachable through the API belongs to a story you own. A chapter you wrote inside somebody else's story is theirs to expose, not ours, so it is not listed here and not fetchable by id either - one rule, in both places, so the endpoints never disagree about what exists.

The API is read-only. Nothing here creates, edits or deletes anything, which also means a leaked key cannot destroy your work - it can read it, and on /v1/insights spend credits up to that key's daily cap.

GET /v1/me

Call this first. It is the only way to check a key without making a request that might cost money, and the only way to read a key's scopes and limits instead of inferring them from a 403. It answers with your real account on a sandbox key too - a fixture identity would defeat the point - and it spends nothing either way.

curl -s https://app.sproker.com/api/v1/me \
  -H "Authorization: Bearer $SPROKER_KEY"

{
  "user": { "id": "...", "username": "rik", "plan": "pro", "xp": 1240 },
  "key": {
    "name": "Export tool",
    "mode": "test",
    "scopes": ["stories.read", "account.read"],
    "rate_limit_per_min": 60,
    "daily_spend_cap_microcents": 5000000
  },
  "credits": {
    "balance_microcents": 4212000,
    "spent_today_microcents": 0,
    "calls_today": 3
  }
}

All money is in microcents: 1 000 000 = EUR 1, the same unit credits_charged uses. spent_today_microcents is null, never 0, when we could not read it - a zero is a number you would happily pace against.

GET /v1/stories

Your stories, newest first. Paged by cursor rather than page number, so a story added mid-walk cannot make you skip or repeat a row. Pass meta.next_cursor back verbatim and stop when meta.has_more is false.

curl -s https://app.sproker.com/api/v1/stories?limit=2 \
  -H "Authorization: Bearer $SPROKER_KEY"

{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "title": "The Lighthouse at Vondel",
      "logline": "A keeper who has not seen the sea in thirty years...",
      "status": "open",
      "format": "novel",
      "genre": "literary",
      "writing_mode": "solo",
      "ai_usage": "NONE",
      "contribution_count": 4,
      "vote_count": 0,
      "view_count": 128,
      "created_at": "2026-01-04T09:00:00.000000+00:00",
      "updated_at": "2026-03-18T14:32:11.000000+00:00"
    }
  ],
  "meta": { "returned": 1, "limit": 2, "has_more": true, "next_cursor": "..." }
}

There is no total count, on purpose: once a cursor is applied an exact count would count the remainder rather than the whole, which is worse than no number at all. has_more answers what a pager actually asks.

Chapters

This is where the actual writing lives. GET /v1/stories/{id}/chapters lists them in reading order - keyset paging on (chapter_number, id) ascending, the opposite direction to /v1/stories. Chapter numbers are not unique: in a chain story several chapters compete for the same number until one wins the vote, which is why the id is part of the cursor.

Every chapter comes back whatever its status, drafts included, because that is already what you can see in the app as the story's author. An empty data means the story has no chapters yet, not that it is missing - that would be a 404.

curl -s "https://app.sproker.com/api/v1/stories/$STORY/chapters?limit=2" \
  -H "Authorization: Bearer $SPROKER_KEY"

{
  "data": [
    {
      "id": "00000000-0000-4000-8000-0000000000a1",
      "story_id": "00000000-0000-4000-8000-000000000001",
      "author_id": "00000000-0000-4000-8000-0000000000f1",
      "chapter_number": 1,
      "title": "The Light Goes On Regardless",
      "status": "approved",
      "word_count": 1840,
      "vote_count": 0,
      "comment_count": 3,
      "ai_assisted": false,
      "language": "en",
      "content_preview": "Thirty years is long enough to forget the sound of a thing...",
      "created_at": "2026-01-04T09:12:00.000000+00:00"
    }
  ],
  "meta": { "returned": 1, "limit": 2, "has_more": true, "next_cursor": "1|00000000-0000-4000-8000-0000000000a1" }
}

The list carries no content, on purpose: a long novel is megabytes of HTML, and a list you can page through freely is worth more than one you have to be careful with. Fetch the body one chapter at a time:

curl -s https://app.sproker.com/api/v1/chapters/$CHAPTER \
  -H "Authorization: Bearer $SPROKER_KEY"

{
  "data": {
    "id": "00000000-0000-4000-8000-0000000000a1",
    "chapter_number": 1,
    "title": "The Light Goes On Regardless",
    "content": "<p>Thirty years is long enough to forget the sound of a thing and still know when it stops.</p>",
    "word_count": 1840,
    "status": "approved"
  }
}

content is HTML: the string the writer produced, with their emphasis, scene breaks and highlight marks intact. Strip the tags if you want plain text - a client handed plain text cannot invent the formatting back.

POST /v1/insights

Ideas grounded in one of your stories. Pass story_id, prompt, or both - neither is a 400, because ungrounded output is the one thing this endpoint is not for. kind is insight or plot_twist; count is 1-5.

curl -s https://app.sproker.com/api/v1/insights \
  -H "Authorization: Bearer $SPROKER_KEY" \
  -H "content-type: application/json" \
  -d '{"story_id":"...","kind":"plot_twist","count":2}'

{
  "kind": "plot_twist",
  "ideas": ["...", "..."],
  "text": "...",
  "credits_charged": 0,
  "credits_remaining": null
}

Rate limits

Every response carries your budget, so you can pace yourself instead of discovering the ceiling by failing. On a 429, wait for Retry-After.

X-RateLimit-LimitRequests allowed per minute for this key.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetUnix seconds at which the window resets.

Live AI calls are additionally bounded by a daily spend cap on each key, so a runaway loop cannot empty your wallet. Reaching it is also a 429.

Errors

Every failure is { "error": "..." } with a meaningful status. Branch on the status code - the sentence is written for a human reading a log and may be reworded.

400Malformed cursor, or an invalid body.
401Missing, unknown, revoked or expired key.
403The key lacks the scope, or your plan does not include it.
404That story is not yours, or does not exist.
429Rate limit or daily spend cap reached. See Retry-After.
500 / 503 / 504Our side. Nothing was charged for a failed or timed-out generation.

Versioning

The path carries the version. Within /v1 we may add fields and endpoints; we will not remove a field or change the meaning of one. Treat unknown fields as ignorable rather than as an error.