Write
Stories and poems have a title, a body, a category, hashtags, and images. Drafts stay private until they are published.
https://munthu-api.kaizenflow.app/api/v1Stories 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.
Stories and poems have a title, a body, a category, hashtags, and images. Drafts stay private until they are published.
The feed shows people you follow. Likes, comments, bookmarks, and shares are available on published stories and poems.
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.
Search for a person, a story, a poem, a category, or a book to cite. Send a piece in a direct message.
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.
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.
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.
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.
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.
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."]
}
}
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.
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.
{
"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.
{
"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.
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.
/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.
/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" }
/api/v1/logout
Token
Revoke the token used for this request. Other devices keep their own tokens.
/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.
/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"
}
/api/v1/me
Token
The signed-in profile, including email.
/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.
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.
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.
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.
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.
/api/v1/users/{username}
Public, richer with a token
Public profile. Usernames are matched without case sensitivity.
/api/v1/users/{username}/stories
Public, richer with a token
Published stories they wrote, plus stories they co-authored and accepted.
/api/v1/users/{username}/followers
Public, richer with a token
People who follow this account.
/api/v1/users/{username}/following
Public, richer with a token
People this account follows.
/api/v1/users/{username}/follow
Token
Follow this person. Following yourself returns 422. Repeating the request is safe.
/api/v1/users/{username}/follow
Token
Unfollow. The response is the profile, with is_following set to false.
/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.
/api/v1/users/{username}/block
Token
Remove the block. Follows and co-author roles are not restored.
/api/v1/me/blocks
Token
People you have blocked.
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.
/api/v1/stories
Public, richer with a token
Published stories, newest first. Filter with category (slug), hashtag, or author (username).
/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"
}
/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.
/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.
/api/v1/stories/{story}
Token
Delete a story. Only the owner can do this. The story disappears from the feed, search, and messages.
/api/v1/me/stories
Token
Stories you wrote or were invited to co-author, including drafts. Optional status=draft or status=published.
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.
/api/v1/stories/{story}/images
Token
Add images without changing the text. Send multipart images[]. The story can hold 10 in total. See Images.
/api/v1/stories/{story}/images/{image}
Token
Remove one image. The file is deleted from storage. The response is the story.
/api/v1/stories/{story}/tags
Token
Replace the tagged people. Send an empty array to clear them.
{ "tagged_usernames": ["thandiwe", "ada_banda"] }
/api/v1/stories/{story}/coauthors
Token
Invite a co-author. Only the owner can invite. The primary author cannot be invited.
{ "username": "thandiwe" }
/api/v1/me/coauthor-invites
Token
Stories waiting for you to accept.
/api/v1/stories/{story}/coauthor-invite/accept
Token
Accept your invitation. You can then edit the story.
/api/v1/stories/{story}/coauthor-invite/decline
Token
Decline your invitation.
/api/v1/stories/{story}/coauthors/{username}
Token
Remove a co-author. Owner only.
Likes, bookmarks, comments, and shares apply to published stories and poems. Repeating a like, bookmark, or share does not create a second one.
/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.
/api/v1/stories/{story}/like
Token
Like a story. The response is { "liked": true, "likes_count": 1 }.
/api/v1/stories/{story}/like
Token
Remove your like.
/api/v1/stories/{story}/bookmark
Token
Save a story for later. The response uses bookmarked and bookmarks_count.
/api/v1/stories/{story}/bookmark
Token
Remove a bookmark.
/api/v1/me/bookmarks
Token
Your bookmarks, most recently saved first.
/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." }
/api/v1/stories/{story}/share
Token
Remove your share.
/api/v1/stories/{story}/comments
Public, richer with a token
Comments on a story, oldest first. A reply has parent_id.
/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 }
/api/v1/comments/{comment}
Token
Edit your own comment. Send { "body": "A clearer line." }.
/api/v1/comments/{comment}
Token
Delete your comment. The story's owner can delete any comment on it.
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.
/api/v1/categories
Public, richer with a token
Every category, ordered by name.
/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." }
/api/v1/categories/{slug}
Public, richer with a token
One category.
/api/v1/categories/{slug}/stories
Public, richer with a token
Published stories in that category.
/api/v1/hashtags/{name}
Public, richer with a token
One hashtag. The name is matched without the leading # and without case sensitivity.
/api/v1/hashtags/{name}/stories
Public, richer with a token
Published stories that use the hashtag.
/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": []
}
}
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.
/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"
}
]
}
/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"
}
/api/v1/books/{book}
Public, richer with a token
Read one saved book by its Munthu id.
/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] }
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.
/api/v1/conversations
Token
Your conversations, newest activity first. Each one includes with, latest_message, and unread_count.
/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.
/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.
/api/v1/conversations/{conversation}/messages
Token
Messages in one conversation, newest first. Opening the list marks it read.
/api/v1/conversations/{conversation}/messages
Token
Reply in a conversation you belong to.
{ "body": "Reading it now." }