Munthu Integration guide
API https://munthu-api.kaizenflow.app/api/v1

Stories and poetry

A social API for stories and poetry.

The Munthu app calls https://munthu-api.kaizenflow.app/api/v1. Every path on this page is added to that base. This is the contract for version 1. A story and a poem use the same record. A poem belongs in the Poetry category.

Write

Stories and poems have a title, a body, a category, hashtags, and images. Drafts stay private until they are published.

Read

The feed shows people you follow. Likes, comments, bookmarks, and shares are available on published stories and poems.

Write with someone

Tag a reader in a story or a poem, or invite a co-author. An accepted co-author can edit. Only the owner can delete.

Pass it on

Search for a person, a story, a poem, a category, or a book to cite. Send a piece in a direct message.

Integrating

The app and the API are separate. The API does not set a cookie and does not serve the screens. The app keeps the token and renders each story and poem.

Where to send requests

https://munthu-api.kaizenflow.app/api/v1

A story list is GET https://munthu-api.kaizenflow.app/api/v1/stories. Cross-origin calls to /api/* are allowed. Authenticate with the Authorization header.

Headers

Accept: application/json
Content-Type: application/json
Authorization: Bearer {token}

Send Accept on every request. Send Content-Type when the body is JSON. Send Authorization after register or login. For a file upload, use multipart form data and leave Content-Type unset so the browser can add the boundary. A story image field is images[]. A profile photo field is avatar.

The token

Register answers 201. Login answers 200. Both use this shape, and neither wraps it in data:

{
  "token": "1|plain-text-token",
  "token_type": "Bearer",
  "user": { "id": 1, "username": "ada_banda", "email": "ada@example.com" }
}

Save token in the app and send it as Authorization: Bearer {token}. Logout deletes that token only. Other devices keep theirs. There is no refresh token. A 401 means the saved token is gone: drop it and show sign-in.

Reading a response

One record, and a list, are wrapped in data. Logout, forgot-password, and reset-password return message at the top level. Search returns query and a data object with users, stories, and categories. Starting a conversation returns data for the thread and sent for the message just written.

A list also has links and meta. Follow links.next when it is not null. meta.current_page, meta.last_page, meta.per_page, and meta.total describe the whole result.

{
  "data": [],
  "links": { "next": "https://munthu-api.kaizenflow.app/api/v1/stories?page=2" },
  "meta": { "current_page": 1, "last_page": 4, "per_page": 15, "total": 48 }
}

When the person is signed in, send the token on public reads too. Stories then include liked, bookmarked, and shared. Profiles then include is_following and blocked.

When something is wrong

  • 401 means the token is missing or no longer valid.
  • 403 means this account can see the record but cannot change it.
  • 404 means it is missing, still a draft, or hidden from this account. Use the same empty state for all three.
  • 422 means the body failed validation. Show errors[field] beside that input.
  • 429 means the request was made too often. Sign-in and password reset allow 10 requests a minute. Other routes allow 60.
{
  "message": "These credentials do not match our records.",
  "errors": {
    "login": ["These credentials do not match our records."]
  }
}

Password reset

The email opens the app at /reset-password?token={token}&email={email}. Read token and email from the query string, ask for a new password, and POST them to /api/v1/reset-password. Then go to sign-in. Forgot-password always answers the same sentence, so the screen should not say whether that email is registered.

Pictures

Profile photos and story images are files. Field names, size limits, and how to remove a file are in Images. The URLs in the response are absolute and already point at storage.

A person

{
  "id": 1,
  "name": "Ada",
  "surname": "Banda",
  "username": "ada_banda",
  "email": "ada@example.com",
  "bio": "Writer from Blantyre",
  "avatar_url": "https://munthu.fra1.digitaloceanspaces.com/avatars/1/face.jpg",
  "followers_count": 12,
  "following_count": 4,
  "stories_count": 3,
  "is_following": false,
  "blocked": false
}

email is returned only for the signed-in account. stories_count counts published stories and poems. blocked is true when you have blocked that person. Both is_following and blocked are left off your own account.

A story or a poem

{
  "id": 10,
  "title": "Rain over Lilongwe",
  "slug": "rain-over-lilongwe",
  "excerpt": "A short piece about the first storm.",
  "body": "The city smells like rain.",
  "status": "published",
  "published_at": "2026-11-01T10:00:00.000000Z",
  "author": { "username": "ada_banda" },
  "category": { "name": "Poetry", "slug": "poetry" },
  "hashtags": [{ "name": "malawi" }],
  "tagged_users": [{ "username": "thandiwe" }],
  "coauthors": [{ "status": "accepted", "user": { "username": "thandiwe" } }],
  "images": [{ "id": 3, "url": "https://munthu.fra1.digitaloceanspaces.com/stories/10/cover.jpg", "alt": null, "position": 0 }],
  "likes_count": 4,
  "comments_count": 2,
  "shares_count": 1,
  "bookmarks_count": 6
}

Open a story by its numeric id or its slug. Write routes accept either one in {story}. {username} is the username. {conversation}, {comment}, and {image} are numeric ids. status is draft or published.

Accounts

Registration asks for a given name, a surname, a username, an email, and a password. Sign-in accepts either the email or the username in the login field. Usernames are stored in lowercase and may use letters, numbers, and underscores, from 3 to 30 characters. admin, api, help, me, munthu, and support are reserved.

POST /api/v1/register Public

Create an account and receive a token. password must be at least 8 characters and confirmed.

{
  "name": "Ada",
  "surname": "Banda",
  "username": "ada_banda",
  "email": "ada@example.com",
  "password": "secret-password",
  "password_confirmation": "secret-password"
}

The response is 201 with token, token_type (Bearer), and user.

POST /api/v1/login Public

Sign in with an email or a username. A wrong password returns 422 on login.

{ "login": "ada_banda", "password": "secret-password" }
POST /api/v1/logout Token

Revoke the token used for this request. Other devices keep their own tokens.

POST /api/v1/forgot-password Public

Send { "email": "ada@example.com" }. The reply is always { "message": "If that email is registered, a reset link is on its way." }. The link in the email opens the app, not the API.

POST /api/v1/reset-password Public

Set a new password with the token from the email. Existing API tokens for that account are revoked.

{
  "email": "ada@example.com",
  "token": "{token from the email}",
  "password": "new-password",
  "password_confirmation": "new-password"
}
GET /api/v1/me Token

The signed-in profile, including email.

PATCH POST /api/v1/me Token

Update any of name, surname, username, and bio as JSON. A profile photo uses POST and the avatar file field. See Images.

Images

Profile photos and story images are files, not JSON. Send multipart/form-data and leave Content-Type unset so the boundary is added for you. Use POST whenever the request includes a file. PATCH is for a JSON body only.

Each file must be jpeg, png, webp, or gif, and at most 5 MB. Anything else returns 422 on avatar or images.0. Files are stored on DigitalOcean Spaces. avatar_url and each story image url are absolute. Use them as the image source. Do not prefix them with the API host. No photo is null.

Profile photo

POST /api/v1/me with a file field named avatar. Text fields such as bio can ride along in the same form. The response is the profile, and data.avatar_url is the new photo. Sending another file replaces it, and the previous file is deleted.

To clear the photo, POST /api/v1/me with remove_avatar set to true and no file. avatar_url comes back null. The same field works in a JSON body on PATCH /api/v1/me.

avatar_url is on every person: the signed-in profile, a public profile, a story author, a comment, a tag, a co-author, and the other person in a message.

Story images

A story or a poem holds at most 10 images. Send the files as images[]:

  • POST /api/v1/stories while creating the story. The title and body stay in the same form.
  • POST /api/v1/stories/{story} while editing. New files are added. Images already on the story stay.
  • POST /api/v1/stories/{story}/images to add files without changing the text.

An eleventh image returns 422 on images. The owner and an accepted co-author can add or remove images. Anyone else who can see the story gets 403. A story they cannot see returns 404.

Each item in data.images has id, url, alt, and position. position starts at 0 in the order the files were added. alt is null. There is no request to reorder images or to set alt text.

DELETE /api/v1/stories/{story}/images/{image} removes that image and deletes the file. {image} is the numeric id. The response is the story.

People

Profiles are addressed by username. Following someone puts their published stories, accepted co-authored stories, and shares on your feed. Blocking someone hides your profile, stories, comments, and messages from them.

GET /api/v1/users/{username} Public, richer with a token

Public profile. Usernames are matched without case sensitivity.

GET /api/v1/users/{username}/stories Public, richer with a token

Published stories they wrote, plus stories they co-authored and accepted.

GET /api/v1/users/{username}/followers Public, richer with a token

People who follow this account.

GET /api/v1/users/{username}/following Public, richer with a token

People this account follows.

POST /api/v1/users/{username}/follow Token

Follow this person. Following yourself returns 422. Repeating the request is safe.

DELETE /api/v1/users/{username}/follow Token

Unfollow. The response is the profile, with is_following set to false.

POST /api/v1/users/{username}/block Token

Block this person. They can no longer see your profile, your stories, your comments, or a conversation with you. You both unfollow each other, and co-author links between you are removed. Their name is also left off stories other people wrote, including tags. Blocking yourself returns 422. Repeating the request is safe. A direct request for a hidden profile or story returns 404.

DELETE /api/v1/users/{username}/block Token

Remove the block. Follows and co-author roles are not restored.

GET /api/v1/me/blocks Token

People you have blocked.

Stories and poetry

A story or a poem is one piece of writing, and both use this resource. Publish a poem with the Poetry category. Create either as JSON, or as multipart when attaching images[]. File types, the 10-image limit, and how to delete one are in Images. Hashtags are the hashtags array plus any #tag in the body. A tag uses letters, numbers, and underscores, 2 to 50 characters.

GET /api/v1/stories Public, richer with a token

Published stories, newest first. Filter with category (slug), hashtag, or author (username).

POST /api/v1/stories Token

Create a story or a poem. status defaults to draft. coauthor_usernames sends invitations. Unknown usernames return 422. Use the Poetry category for a poem.

{
  "title": "Rain over Lilongwe",
  "excerpt": "A short piece about the first storm.",
  "body": "The city smells like rain. #malawi",
  "category_id": 2,
  "hashtags": ["poetry"],
  "tagged_usernames": ["thandiwe"],
  "coauthor_usernames": ["thandiwe"],
  "status": "published"
}
GET /api/v1/stories/{id-or-slug} Public, richer with a token

Read one story or poem. A draft returns 404 unless you are the author or an invited co-author. books lists the published works this piece cites.

PATCH POST /api/v1/stories/{story} Token

Update any of the fields from create, except co-author invitations, which have their own route. The owner and accepted co-authors can edit. Use POST when attaching more images[]; those files are added to the ones already stored. Publishing sets published_at the first time.

DELETE /api/v1/stories/{story} Token

Delete a story. Only the owner can do this. The story disappears from the feed, search, and messages.

GET /api/v1/me/stories Token

Stories you wrote or were invited to co-author, including drafts. Optional status=draft or status=published.

Writing together

Tags name people in a published story or poem. Co-authors can work on the draft. A pending co-author can read the draft. After they accept, they can edit. Declining keeps the draft private from them again.

POST /api/v1/stories/{story}/images Token

Add images without changing the text. Send multipart images[]. The story can hold 10 in total. See Images.

DELETE /api/v1/stories/{story}/images/{image} Token

Remove one image. The file is deleted from storage. The response is the story.

PUT /api/v1/stories/{story}/tags Token

Replace the tagged people. Send an empty array to clear them.

{ "tagged_usernames": ["thandiwe", "ada_banda"] }
POST /api/v1/stories/{story}/coauthors Token

Invite a co-author. Only the owner can invite. The primary author cannot be invited.

{ "username": "thandiwe" }
GET /api/v1/me/coauthor-invites Token

Stories waiting for you to accept.

POST /api/v1/stories/{story}/coauthor-invite/accept Token

Accept your invitation. You can then edit the story.

POST /api/v1/stories/{story}/coauthor-invite/decline Token

Decline your invitation.

DELETE /api/v1/stories/{story}/coauthors/{username} Token

Remove a co-author. Owner only.

Reading

Likes, bookmarks, comments, and shares apply to published stories and poems. Repeating a like, bookmark, or share does not create a second one.

GET /api/v1/feed Token

Published stories and poems from you and the people you follow, including pieces they accepted as co-authors and pieces they shared. If you follow nobody yet, this is the latest published work, so the first visit is not empty.

POST /api/v1/stories/{story}/like Token

Like a story. The response is { "liked": true, "likes_count": 1 }.

DELETE /api/v1/stories/{story}/like Token

Remove your like.

POST /api/v1/stories/{story}/bookmark Token

Save a story for later. The response uses bookmarked and bookmarks_count.

DELETE /api/v1/stories/{story}/bookmark Token

Remove a bookmark.

GET /api/v1/me/bookmarks Token

Your bookmarks, most recently saved first.

POST /api/v1/stories/{story}/share Token

Share a story onto the feeds of people who follow you. commentary is optional and limited to 500 characters. Sending it again updates the commentary.

{ "commentary": "Worth sitting with." }
DELETE /api/v1/stories/{story}/share Token

Remove your share.

GET /api/v1/stories/{story}/comments Public, richer with a token

Comments on a story, oldest first. A reply has parent_id.

POST /api/v1/stories/{story}/comments Token

Comment, or reply by including parent_id of a comment on the same story.

{ "body": "This stayed with me.", "parent_id": null }
PATCH /api/v1/comments/{comment} Token

Edit your own comment. Send { "body": "A clearer line." }.

DELETE /api/v1/comments/{comment} Token

Delete your comment. The story's owner can delete any comment on it.

Discovery

Categories are created by people. Poetry is one of the seeded categories. Hashtags appear when a story or a poem uses them. Search needs at least two characters.

GET /api/v1/categories Public, richer with a token

Every category, ordered by name.

POST /api/v1/categories Token

Create a category. The slug comes from the name, and a second category with the same slug is rejected.

{ "name": "Field notes", "description": "Places, walks, and what they left behind." }
GET /api/v1/categories/{slug} Public, richer with a token

One category.

GET /api/v1/categories/{slug}/stories Public, richer with a token

Published stories in that category.

GET /api/v1/hashtags/{name} Public, richer with a token

One hashtag. The name is matched without the leading # and without case sensitivity.

GET /api/v1/hashtags/{name}/stories Public, richer with a token

Published stories that use the hashtag.

GET /api/v1/search?q={query}&type={type} Public, richer with a token

type is users, stories, categories, or all. Stories in the result are published. The response is grouped:

{
  "query": "rain",
  "data": {
    "users": [],
    "stories": [],
    "categories": []
  }
}

Books

Search is for finding a published book to cite on a story or a poem. Catalogs are tried in order, and the next one is called only when the current one returns nothing or fails. The first hit is the whole result. Saving that hit stores it here, so later stories and poems can cite the same record without searching again.

The order is Open Library, Apple Books, the Internet Archive, Inventaire, Project Gutenberg (Gutendex), LibriVox, OpenAlex, Crossref, then AniList novels. OpenAlex, Crossref, and the keyed scholarly catalogs only count when the title or an author matches the query, so a loose academic hit does not hide the next catalog. Google Books, Europeana, ISBNdb, and Semantic Scholar are added at the end when their API keys are set.

GET /api/v1/books/search?q={query}&limit={limit} Public, richer with a token

q needs at least two characters. limit defaults to 8 and cannot be higher than 10. source is the catalog that answered. sources_tried is every catalog actually called. A saved copy includes id; a new hit has id: null.

{
  "query": "The Name of the Wind",
  "source": "open_library",
  "sources_tried": ["open_library"],
  "data": [
    {
      "id": null,
      "source": "open_library",
      "external_id": "/works/OL27448W",
      "title": "The Name of the Wind",
      "authors": ["Patrick Rothfuss"],
      "published_year": 2007,
      "description": null,
      "cover_url": "https://covers.openlibrary.org/b/id/123-M.jpg",
      "isbn": "9780756404079",
      "language": "eng",
      "url": "https://openlibrary.org/works/OL27448W"
    }
  ]
}
POST /api/v1/books Token

Save a hit from search. The same source and external_id update the existing row instead of creating a second one. Send the fields the search result already gave you.

{
  "source": "open_library",
  "external_id": "/works/OL27448W",
  "title": "The Name of the Wind",
  "authors": ["Patrick Rothfuss"],
  "published_year": 2007,
  "description": null,
  "cover_url": "https://covers.openlibrary.org/b/id/123-M.jpg",
  "isbn": "9780756404079",
  "language": "eng",
  "url": "https://openlibrary.org/works/OL27448W"
}
GET /api/v1/books/{book} Public, richer with a token

Read one saved book by its Munthu id.

PUT /api/v1/stories/{story}/books Token

Replace the books a story or poem cites. The owner and accepted co-authors can do this. The list keeps the order you send, up to 10 books, and each id must already be saved. An empty list clears the citations.

{ "book_ids": [1, 4] }

Messages

Direct messages are between two people. A message can be text, a published story, or both. Sending to the same person again reuses the existing conversation. You cannot message yourself, and you cannot read a conversation you are not in. Search looks through your own conversations only.

GET /api/v1/conversations Token

Your conversations, newest activity first. Each one includes with, latest_message, and unread_count.

GET /api/v1/conversations/search?q={query}&type={type} Token

type is people, messages, or all. q needs at least two characters. People matches the other person's name, surname, or username. Messages match the text. Each group returns at most 20. A person who blocked you is left out.

{
  "query": "rain",
  "data": {
    "people": [],
    "messages": []
  }
}

A person result is a conversation, so it can be opened the same way as a row in the inbox. A message result includes conversation_id and with.

POST /api/v1/conversations Token

Open a conversation and send the first message. body is required unless story_id is a published story.

{
  "username": "thandiwe",
  "body": "Read this when you can.",
  "story_id": 10
}

The 201 response has data for the conversation and sent for the message. If the story was deleted later, story_id remains and story is null.

GET /api/v1/conversations/{conversation}/messages Token

Messages in one conversation, newest first. Opening the list marks it read.

POST /api/v1/conversations/{conversation}/messages Token

Reply in a conversation you belong to.

{ "body": "Reading it now." }