Skip to main content
Version: Latest

Mobile Post Requests (CTA) — Integration Guide

The CTA flow on a post: a viewer taps the post's call-to-action, picks prompt options + writes a quick note, and submits a request. The post author reviews an inbox and accepts / declines. Backend already shipped. Companion to mobile-posts-guide.md — read that first for the post-object shape, post types, and auth headers. The DM integration (requests in the DM tab, accept opening a chat, "new" badges) is wired separately in mobile-post-request-dm-guide.md.

  • Base URL: http://localhost:3001 (dev) — production URL from backend team
  • Auth: Authorization: Bearer <accessToken> on every endpoint
  • Headers: Content-Type: application/json, X-Client-Type: mobile

Concept — one flow, label varies per post type

There is one request mechanism. Only the button copy and the verb in notifications change per post type. Three post types enable the CTA:

Post type (type)cta.buttonLabelverb
FIND_TEAMMATESRequest to Joinjoin
INVITE_PEOPLERequest to Attendattend
START_SOMETHINGRequest to Collaboratecollaborate

SHARE_OPPORTUNITY, ANYTHING, REPOST have no CTA (cta.enabled = false).

Do not hardcode button labels. Read them off the post object's cta block (served per-post). The viewer-facing label swaps with the viewer's own request state: buttonLabel (none yet) → pendingLabel ("Request Sent") → acceptedLabel ("Open chat") → rejectedLabel ("Request Declined"). The author always sees authorLabel ("View Responses").

"Open chat" navigation. When viewerRequestStatus is ACCEPTED, the post object also carries viewerRequestConversationId (string | null) — navigate to ksn://chat/{viewerRequestConversationId} on tap. Null while ACCEPTED is a valid state (blocked pair / linkage failure) → keep the label but open the post's request detail instead. Null for every other status.

The request payload is identical across all three types: a subset of the post's requestOptions plus an optional free-text note. You never branch on post type in the request code — the server validates against that post's own requestOptions.


At-a-glance endpoint table

MethodPathWhoUse
POST/api/posts/:id/requestsviewersubmit / re-submit a request
DELETE/api/posts/:id/requests/meviewerwithdraw own pending request
GET/api/posts/requests/mineviewer"My Requests" tab (outgoing, all posts)
GET/api/posts/:id/requestsauthorinbox for one post (searchable by requester via q)
GET/api/posts/requests/inboxauthoraggregate inbox (all my posts; searchable by post title via q)
PATCH/api/posts/:id/requests/:reqIdauthoraccept / reject a pending request

PostRequest object

Returned by every endpoint below (inside data, or data.items[] for lists).

{
"id": "preq_abc123",
"postId": "pst_xyz789",
"requesterId": "usr_aaa",
"status": "PENDING", // PENDING | ACCEPTED | REJECTED | WITHDRAWN
"selectedOptions": ["Frontend", "Design"],
"message": "I've shipped two React Native apps.", // null if omitted, ≤500 chars
"details": null, // optional free-form object, usually null
"respondedAt": null, // ISO time when author decided; null while PENDING
"respondedBy": null, // author userId once decided; null while PENDING
"conversationId": null, // DM opened on accept — see section 6. ⚠ ACCEPTED
// with null conversationId is a VALID state
"authorReadAt": null, // author-side read marker — see "New badge" note
// in sections 4/5; meaningless to the requester
"createdAt": "2026-06-30T10:00:00Z",
"updatedAt": "2026-06-30T10:00:00Z",
"requester": { // always included
"id": "usr_aaa",
"username": "alice",
"profile": { "displayName": "Alice", "avatarUrl": "https://..." } // profile may be null
},
"post": { /* post summary — only on /requests/mine and /requests/inbox */ }
}

post summary shape (only on the two cross-post list endpoints):

{
"id": "pst_xyz789",
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th",
"content": "Hackathon team forming...",
"authorId": "usr_bbb",
"eventAt": null,
"createdAt": "2026-06-29T09:00:00Z"
}

1 — Submit a request (viewer)

POST /api/posts/:id/requests

The CTA action. Creates the request, or updates the viewer's existing row in place — there is exactly one request per (post, viewer). Idempotent: tapping twice does not create duplicates.

Request body

{
"selectedOptions": ["Frontend", "Design"], // optional; must be ⊆ post.requestOptions
"message": "I've shipped two React Native apps.", // optional; ≤500 chars
"details": { "portfolio": "https://..." } // optional; free-form object, usually omit
}

All three fields optional — {} is a valid submit (note + options are "Add a quick note (Optional)"). Validate selectedOptions client-side against the post's requestOptions before sending; server rejects unknown options with 400.

Response 200

The PostRequest object, status: "PENDING".

{ "success": true, "data": { /* PostRequest, status PENDING */ } }

Re-submit semantics

Current statePOST result
no row yetcreates PENDING, notifies author
WITHDRAWNflips back to PENDING, re-notifies author
PENDINGrefreshes selectedOptions / message, no re-notify
ACCEPTED409 — already accepted
REJECTED409 — declined is final, cannot re-submit

Reject is sticky. Once an author declines, the requester cannot re-request. Surface rejectedLabel ("Request Declined") and disable the CTA.

Errors

CodeWhenUI
400post type has no CTA / option not in requestOptions / requesting your own postblock before send; shouldn't reach if CTA hidden on own posts
404post not found, or not visible to viewertreat as gone
409already ACCEPTED or REJECTEDrefresh post, show accepted/declined label
429rate limit — 100 submits per viewer / rolling 24h (across all posts)toast "Try again later"

2 — Withdraw own request (viewer)

DELETE /api/posts/:id/requests/me

Soft-cancel. Sets the viewer's row to WITHDRAWN. No body.

Response 200

{ "success": true, "data": { /* PostRequest, status WITHDRAWN */ } }

A withdrawn request can be re-submitted later (back to section 1).

Errors

CodeWhen
404viewer has no request on this post
409request is not PENDING (already accepted/rejected/withdrawn) — only pending can be withdrawn

3 — My outgoing requests (viewer)

GET /api/posts/requests/mine

Drives a "My Requests" tab — every request this viewer has sent, across all posts. Each item carries the post summary so you can render context.

Query params

ParamDefaultNotes
status(all)PENDING | ACCEPTED | REJECTED | WITHDRAWN
limit201–50
cursorfrom previous nextCursor

Response 200

{
"success": true,
"data": {
"items": [ /* PostRequest[], each with embedded `post` */ ],
"nextCursor": "eyJjcmVhdGVkQXQiOi..." // null when no more pages
}
}

4 — Author inbox for one post

GET /api/posts/:id/requests

Author-only. The "View Responses" screen for a single post. Cursor-paginated, with a counts object for tab badges.

Query params

Section 3's status / limit / cursor, plus q — searches this post's requests by requester username / displayName (case-insensitive).

ParamNotes
qOptional. Trimmed; applied only when ≥ 2 chars (shorter/empty ignored — full list, no error). Narrows items onlycounts / unread stay whole-post totals, so the status-tab badges don't move while you search.

Back a search box on the responses screen with it (debounce ~250ms).

Response 200

{
"success": true,
"data": {
"items": [ /* PostRequest[], each with `requester` */ ],
"nextCursor": null,
"counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 }
}
}

counts is always totals across all statuses for the post, regardless of the status filter — use it for tab/badge numbers.

New badge — fetch marks read

counts.unread = rows the author has never been served by an inbox endpoint (any status). Fetching this endpoint marks exactly the returned rows as read server-side — there is no explicit mark-read call. The payload you receive still carries the pre-mark value, so:

  • authorReadAt == null in the fetched items → highlight the row as new.
  • The next fetch returns those rows with authorReadAt set → no highlight.
  • Unfetched pages stay unread (pagination-correct) — unread only drops as pages are actually delivered.

Errors

CodeWhen
403viewer is not the post author
404post not found

5 — Aggregate inbox (all my posts)

GET /api/posts/requests/inbox

Author-only. One feed of incoming requests across every post the author created — drives a home-screen "Requests" tab. Each item carries both requester and post.

Query params

Section 3's status / limit / cursor, plus q. Difference: when status is omitted it defaults to PENDING (the actionable set), not all.

ParamNotes
qOptional — searches by post title (case-insensitive). Trimmed; applied only when ≥ 2 chars (shorter/empty ignored). Narrows items onlycounts / unread stay whole-inbox totals. Titleless posts (title is optional) never match a title search.

Use it to let an author jump to requests for one specific post when the aggregate inbox spans many posts.

Response 200

Same shape as section 4 (items + nextCursor + counts). counts totals span all the author's posts. Fetch-marks-read applies here too (see section 4's "New badge" note) — counts.unread spans all the author's posts.


6 — Accept / reject / reopen a request (author)

PATCH /api/posts/:id/requests/:reqId

Author-only. accept / reject act on PENDING rows; reopen acts on ACCEPTED rows.

Request body

{ "action": "accept" } // or "reject" or "reopen"

Response 200

{ "success": true, "data": { /* PostRequest, status ACCEPTED or REJECTED, respondedAt/By set,
conversationId set on accept (see below) */ } }

Accept opens the DM

Accepting flows the request into chat:

  1. The server find-or-creates the direct DM between author and requester (created with status: "ACCEPTED" — the author's accept is the consent, no chat-request tray step). If a PENDING chat request already existed between the pair, it flips to ACCEPTED.
  2. The DM is seeded with one POST_SHARE message from the requester: the post as a live card (sharedPostId) + the request's note as content. Render it exactly like any shared post — see mobile-post-share-guide.md. Accepting more requests from the same person (other posts) appends more seed messages into the same DM.
  3. conversationId is written on the PostRequest row, returned in this response, and included in the post_request_accepted notification. Navigate straight to /chat/:conversationId.

ACCEPTED with conversationId: null is a VALID state. Chat linkage is a side-effect — it is skipped when the pair has a block, and can fail independently of the accept. Never assume ACCEPTED ⇒ conversation exists; when conversationId is null, fall back to linking to the post.

If the post is later deleted, the seed message survives but its sharedPostId becomes null (hard delete + SetNull) → render the standard "post unavailable" placeholder, exactly like any other POST_SHARE whose post is gone (see mobile-post-share-guide.md). A post the viewer merely loses access to resolves live as available: false instead.

Rejecting has no chat side-effect and is final (reject is sticky).

Reopen — author re-reviews an accepted request

{ "action": "reopen" } flips an ACCEPTED row back to PENDING:

  • respondedAt / respondedBy reset to null; conversationId becomes null on the row. The conversation itself is untouched — chat history (including the seed message) stays.
  • The requester gets a post_request_reopened notification; their CTA reverts to pendingLabel ("Request Sent") and the request row returns to their DM tab.
  • A later re-accept relinks the same conversation and does not duplicate the seed message.
  • Only ACCEPTED can be reopened — reopening a PENDING/REJECTED/WITHDRAWN row is 409. Rejected stays final.

Errors

CodeWhen
403viewer is not the post author
404post or request not found, or reqId doesn't belong to :id
409accept/reject on a non-PENDING row, or reopen on a non-ACCEPTED row — refresh the inbox

Notifications

Both sides get push/in-app notifications (handled by the notifications system, see mobile-posts-guide.md for the notification envelope):

  • Author on new/re-opened request → type post_request, title "{name} wants to {verb} your post", data.webPath = /posts/:id/requests.
  • Requester on decision → type post_request_accepted / post_request_rejected. On accept with a linked DM, data carries conversationId and deep-links to the chat (ksn://chat/:conversationId); otherwise (reject, or accept without a DM) it falls back to the post (data.webPath = /posts/:id).
  • Requester on reopen → type post_request_reopened, title "Your request to {verb} is being reviewed again", links to the post.

data carries requestId, postId (entityId), postType, actorId — use to deep-link straight to the relevant inbox row, chat, or post.


DM tab wiring (requester side)

Product decision: the requester sees their pending requests inside the DM tab, as rows above/alongside real conversations. This is a client-side merge — there is no combined endpoint:

  1. Fetch GET /api/posts/requests/mine?status=PENDING (section 3) and render each row in the DM list: post title + "Waiting for author…" state. Tapping it can open the post or a request detail sheet.
  2. Real conversations keep coming from GET /api/chat/conversations (see mobile-chat-guide.md).
  3. On post_request_accepted (push or refetch), the row becomes a real conversation — navigate via conversationId and drop the request row (the DM now contains the seeded POST_SHARE opener).
  4. Withdrawn/rejected rows leave the DM tab (they remain visible in a "My Requests" history screen if you build one — section 3 without the status filter).

Remember the valid-state caveat from section 6: an ACCEPTED row with conversationId: null cannot navigate to chat — link to the post instead.


Wiring checklist

  1. Render the CTA button from the post's cta block; swap label by the viewer's request state (pendingLabel / acceptedLabel / rejectedLabel).
  2. Hide the CTA on the viewer's own posts; show "View Responses" (authorLabel) instead, linking to section 4.
  3. CTA tap → option sheet (post's requestPrompt + requestOptions) + note → POST (section 1). On 409, refresh the post to pick up the real state.
  4. "My Requests" tab → section 3. "Requests" inbox tab → section 5; per-post "View Responses" → section 4. Both author-inbox screens take a q search box (section 5 by post title, section 4 by requester); ≥ 2 chars, debounce ~250ms, and remember counts stay whole-inbox totals while filtering.
  5. Author accept/decline → section 6, then optimistically move the row between counts buckets.
  6. Allow withdraw (section 2) only while status === "PENDING".
⤓ Download .md