UPDATED 2026-11-02 — Posts can now use an external form (cta.externalUrl, see the posts guide §1.5). On such a post POST /:id/requests returns 409 EXTERNAL_FORM_POST, and GET /:id/requests returns handledExternally: true with empty items. Open the link on the CTA tap instead of the request form. Other posts are unchanged.
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
What's new
- 2026-11-02 — Posts can now use an external form (
cta.externalUrl, see the posts guide §1.5). On such a postPOST /:id/requestsreturns 409EXTERNAL_FORM_POST, andGET /:id/requestsreturnshandledExternally: truewith emptyitems. Open the link on the CTA tap instead of the request form. Other posts are unchanged. - 2026-10-29 — Submitting again while a request is
PENDINGnow returns 409 "Request already pending". It used to overwrite the note and add a second request card to the chat. Disable the Request button onPENDING(useviewerRequestStatus); to change the note, withdraw first, then submit. Withdraw → submit is unchanged. - 2026-10-16 — Two new fields on every post object.
isExpiredistrueoncedeadlineAthas passed (find-teammates / invite-people only): other viewers' CTA becomescta.expiredLabel("Expired") withactionable: false, andPOST /api/posts/:id/requestsnow returns409 "This post has expired". The author's CTA does not change — useisExpiredfor an expired banner.unreadRequestCount(author only, 0 for everyone else) is the number of requests the author hasn't seen yet — use it for the per-post dot/number; it clears once they open that post's responses. Nothing breaks; both fields are additive. - 2026-10-05 — Authors can now reopen a declined request:
PATCH .../requests/:reqIdwith{ "action": "reopen" }works onREJECTEDrows as well asACCEPTED, flipping them back toPENDINGso the author can accept or reject again. Show a "Reopen" action on declined rows in Responses. The requester gets the samepost_request_reopenednotification and their CTA returns topendingLabel; they still can't re-submit over a decline themselves (409). If the author then accepts, the requester's chat comes back with the request card re-added as its first visible message — no empty thread (socket handling inmobile-post-request-dm-guide.md§2). Reopening aPENDING/WITHDRAWNrow is still 409. - 2026-10-01 — Corrected three stale claims: declining a request (your "Ignore" button → the
rejectaction) does clear/retire the chat thread now, it isn't a no-op on chat; the "DM tab wiring" section's client-side-merge design is retired (seemobile-post-request-dm-guide.mdfor the current design); andreopenkeepsconversationIdlinked — it never goes null like this guide previously said.
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 | I am interested | join |
INVITE_PEOPLE | Request to join | attend |
START_SOMETHING | Reach out | 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 toclustr://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; reopen an accepted or rejected one |
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 | 409 — "Request already pending". To change the note, withdraw and submit again |
ACCEPTED | 409 — already accepted |
REJECTED | 409 — requester cannot re-submit over a decline |
The requester can't undo a decline. Surface
rejectedLabel("Request Declined") and disable the CTA. Only the author can revive it, by reopening (§6) — you'll getpost_request_reopenedand the CTA reverts topendingLabel.
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 EXTERNAL_FORM_POST | the post collects responses on an external form — open cta.externalUrl instead, never call this | send the viewer to the link |
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 },
"handledExternally": false, // true ⇒ external form: items empty, counts all 0
"externalFormUrl": null // the author's form link when handledExternally
}
}
When handledExternally is true, no in-app responses exist. Show "Responses are
collected on your external form" and the link, not an empty list. The aggregate inbox
(/requests/inbox) never lists such posts. Read cta.handledExternally on the post object
for the author's post list.
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 or REJECTED rows. If your Responses UI labels the decline button "Ignore",
that's this same reject action — there is no separate ignore/no-op
endpoint.
The DM-wiring guide corrects the mechanics below for the v3 behavior shipped 2026-09-01: the conversation isn't created here, it already exists from submit (
mobile-post-request-dm-guide.md§1–2) — accept just flips itPENDING → ACCEPTED. Read that guide for the current detail; this section is kept for the request/response shape.
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.
Corrected 2026-10-01 — this used to say "no chat side-effect"; that was
wrong as of v3. Since the conversation now opens at submit (not accept),
declining a request does touch it: the server releases (clears) it for
both sides if nobody wrote past the seed message, or drops a system message
and clears your pendingPostRequest banner if the thread kept going.
Nothing is deleted — see mobile-post-request-dm-guide.md §3 for the
conversation_cleared event this produces. A decline can be undone by the author via reopen (below).
Reopen — author re-reviews an accepted or declined request
{ "action": "reopen" } flips an ACCEPTED or REJECTED row back to PENDING:
respondedAt/respondedByreset to null.conversationIdis kept, not cleared (corrected 2026-10-01 — this used to say it goes null; it doesn't) — the conversation itself is untouched and chat history (including the seed message) stays exactly as it was.- The requester gets a
post_request_reopenednotification; their CTA reverts topendingLabel("Request Sent") and the request row returns to their DM tab. (For an ACCEPTED reopen the chat never left; for a REJECTED reopen the DM row stays gone until accept — nothing to do on reopen, see below.) - A later re-accept relinks the same conversation and does not duplicate the seed message.
- Reopening a REJECTED row behaves the same, except no
post_request_reopenedline is added to the chat thread, and while the request isPENDINGagain the thread stays gone for both sides (the decline released it) and no chat event fires — don't re-add the DM row on the reopen notification. When the author accepts, the server flips the conversation toACCEPTEDand writes the request card (POST_SHARE+ note) again as a fresh message, which brings the thread back for both sides with that card as the first visible message. Wire it as inmobile-post-request-dm-guide.md§2 ("Accept after a declined request was reopened"). - Only ACCEPTED or REJECTED can be reopened — reopening a PENDING/WITHDRAWN row is 409.
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 PENDING/WITHDRAWN 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 (clustr://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) — superseded by v3
This section describes the pre-v3 (client-side merge) design and is no
longer how the DM tab works — kept for history, don't build against it.
As of 2026-09-01 the request opens a real Conversation at submit time, so
it's an ordinary row from GET /api/chat/conversations — no merge, no fake
conversation screen, no separate step 3 to "convert" it on accept. Build
against mobile-post-request-dm-guide.md §1 instead.
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".