Skip to main content
Version: Latest
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.
- **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.- **2026-10-29** — **Submitting again while a request is `PENDING` now returns 409 "Request already pending".** It used to overwrite the note and add a second request card to the chat. Disable the Request button on `PENDING` (use `viewerRequestStatus`); to change the note, withdraw first, then submit. Withdraw → submit is unchanged.
- **2026-10-01** — Corrected three stale claims: declining a request (your "Ignore" button → the `reject` action) **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 (see `mobile-post-request-dm-guide.md` for the current design); and `reopen` keeps `conversationId` linked — it never goes null like this guide previously said.- **2026-09-10** — Notification deep links now use the `clustr://` scheme instead of `ksn://` (e.g. `clustr://posts/:id`). Paths and query params are unchanged, so only your URL handler's scheme check moves. The API no longer sends `ksn://`.
| `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 |
"nextCursor": null, "counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 } "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 arecollected 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 objectfor the author's post list. `counts` is always totals across **all** statuses for the post, regardless of

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

What's new​

  • 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.
  • 2026-10-29 — Submitting again while a request is PENDING now returns 409 "Request already pending". It used to overwrite the note and add a second request card to the chat. Disable the Request button on PENDING (use viewerRequestStatus); to change the note, withdraw first, then submit. Withdraw → submit is unchanged.
  • 2026-10-16 — Two new fields on every post object. isExpired is true once deadlineAt has passed (find-teammates / invite-people only): other viewers' CTA becomes cta.expiredLabel ("Expired") with actionable: false, and POST /api/posts/:id/requests now returns 409 "This post has expired". The author's CTA does not change — use isExpired for 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/:reqId with { "action": "reopen" } works on REJECTED rows as well as ACCEPTED, flipping them back to PENDING so the author can accept or reject again. Show a "Reopen" action on declined rows in Responses. The requester gets the same post_request_reopened notification and their CTA returns to pendingLabel; 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 in mobile-post-request-dm-guide.md §2). Reopening a PENDING/WITHDRAWN row is still 409.
  • 2026-10-01 — Corrected three stale claims: declining a request (your "Ignore" button → the reject action) 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 (see mobile-post-request-dm-guide.md for the current design); and reopen keeps conversationId linked — 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.buttonLabelverb
FIND_TEAMMATESI am interestedjoin
INVITE_PEOPLERequest to joinattend
START_SOMETHINGReach outcollaborate

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 clustr://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; 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 statePOST result
no row yetcreates PENDING, notifies author
WITHDRAWNflips back to PENDING, re-notifies author
PENDING409 — "Request already pending". To change the note, withdraw and submit again
ACCEPTED409 — already accepted
REJECTED409 — 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 get post_request_reopened and the CTA reverts to pendingLabel.

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
409 EXTERNAL_FORM_POSTthe post collects responses on an external form — open cta.externalUrl instead, never call thissend the viewer to the link
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
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).

ParamNotes
qOptional. 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 == 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 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 it PENDING → 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:

  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.

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 / respondedBy reset to null. conversationId is 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_reopened notification; their CTA reverts to pendingLabel ("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_reopened line is added to the chat thread, and while the request is PENDING again 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 to ACCEPTED and 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 in mobile-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​

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 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, data carries conversationId and 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​

  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