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 inmobile-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.buttonLabel | verb |
|---|---|---|
FIND_TEAMMATES | Request to Join | join |
INVITE_PEOPLE | Request to Attend | attend |
START_SOMETHING | Request to Collaborate | collaborate |
SHARE_OPPORTUNITY, ANYTHING, REPOST have no CTA (cta.enabled = false).
Do not hardcode button labels. Read them off the post object's
ctablock (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 seesauthorLabel("View Responses").
"Open chat" navigation. When
viewerRequestStatusisACCEPTED, the post object also carriesviewerRequestConversationId(string | null) — navigate toksn://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
| Method | Path | Who | Use |
|---|---|---|---|
POST | /api/posts/:id/requests | viewer | submit / re-submit a request |
DELETE | /api/posts/:id/requests/me | viewer | withdraw own pending request |
GET | /api/posts/requests/mine | viewer | "My Requests" tab (outgoing, all posts) |
GET | /api/posts/:id/requests | author | inbox for one post (searchable by requester via q) |
GET | /api/posts/requests/inbox | author | aggregate inbox (all my posts; searchable by post title via q) |
PATCH | /api/posts/:id/requests/:reqId | author | accept / 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 state | POST result |
|---|---|
| no row yet | creates PENDING, notifies author |
WITHDRAWN | flips back to PENDING, re-notifies author |
PENDING | refreshes selectedOptions / message, no re-notify |
ACCEPTED | 409 — already accepted |
REJECTED | 409 — 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
| Code | When | UI |
|---|---|---|
400 | post type has no CTA / option not in requestOptions / requesting your own post | block before send; shouldn't reach if CTA hidden on own posts |
404 | post not found, or not visible to viewer | treat as gone |
409 | already ACCEPTED or REJECTED | refresh post, show accepted/declined label |
429 | rate 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
| Code | When |
|---|---|
404 | viewer has no request on this post |
409 | request 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
| Param | Default | Notes |
|---|---|---|
status | (all) | PENDING | ACCEPTED | REJECTED | WITHDRAWN |
limit | 20 | 1–50 |
cursor | — | from 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).
| Param | Notes |
|---|---|
q | Optional. Trimmed; applied only when ≥ 2 chars (shorter/empty ignored — full list, no error). Narrows items only — counts / 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 == nullin the fetched items → highlight the row as new.- The next fetch returns those rows with
authorReadAtset → no highlight. - Unfetched pages stay unread (pagination-correct) —
unreadonly drops as pages are actually delivered.
Errors
| Code | When |
|---|---|
403 | viewer is not the post author |
404 | post 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.
| Param | Notes |
|---|---|
q | Optional — searches by post title (case-insensitive). Trimmed; applied only when ≥ 2 chars (shorter/empty ignored). Narrows items only — counts / 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:
- 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 toACCEPTED. - The DM is seeded with one
POST_SHAREmessage from the requester: the post as a live card (sharedPostId) + the request's note ascontent. Render it exactly like any shared post — seemobile-post-share-guide.md. Accepting more requests from the same person (other posts) appends more seed messages into the same DM. conversationIdis written on the PostRequest row, returned in this response, and included in thepost_request_acceptednotification. Navigate straight to/chat/:conversationId.
⚠
ACCEPTEDwithconversationId: nullis 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; whenconversationIdis 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/respondedByreset to null;conversationIdbecomes null on the row. The conversation itself is untouched — chat history (including the seed message) stays.- The requester gets a
post_request_reopenednotification; their CTA reverts topendingLabel("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
| Code | When |
|---|---|
403 | viewer is not the post author |
404 | post or request not found, or reqId doesn't belong to :id |
409 | accept/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,datacarriesconversationIdand 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:
- 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. - Real conversations keep coming from
GET /api/chat/conversations(seemobile-chat-guide.md). - On
post_request_accepted(push or refetch), the row becomes a real conversation — navigate viaconversationIdand drop the request row (the DM now contains the seeded POST_SHARE opener). - 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
- Render the CTA button from the post's
ctablock; swap label by the viewer's request state (pendingLabel/acceptedLabel/rejectedLabel). - Hide the CTA on the viewer's own posts; show "View Responses" (
authorLabel) instead, linking to section 4. - CTA tap → option sheet (post's
requestPrompt+requestOptions) + note →POST(section 1). On 409, refresh the post to pick up the real state. - "My Requests" tab → section 3. "Requests" inbox tab → section 5; per-post
"View Responses" → section 4. Both author-inbox screens take a
qsearch box (section 5 by post title, section 4 by requester); ≥ 2 chars, debounce ~250ms, and remembercountsstay whole-inbox totals while filtering. - Author accept/decline → section 6, then optimistically move the row between
countsbuckets. - Allow withdraw (section 2) only while
status === "PENDING".