Journey. As a developer, I want my app's users to open conversations with
each other, exchange messages, see delivery/read state, and edit or remove
what they sent — while the platform moderates content and translates
messages for each reader automatically.
The Messaging platform is a CQRS trio: Messaging Command (MSC) owns every write, Messaging Query (MSQ) owns every read, and Messaging Workflow (MSW) runs the pipeline in the background — a civility check via Evergreen AI, sentiment analysis, then promotion to the published view (with an SSE MessagePublished fan-out via the Notifications service so clients can update in realtime). This guide walks create conversation → send → read/poll → edit → react/receipts → delete, verified against the local stack (MSC :40163, MSQ :40164); the shared dev subdomains behave identically.
Prerequisites
- Messaging Command, Messaging Query and Messaging Workflow are reachable
(local stack :40163/:40164 + workflow running, or the shared dev subdomains). The workflow is what publishes messages — without it, writes succeed but recipients never see them.
- The Notifications service must be reachable by the workflow (it carries the
internal publish + SSE subscription); Evergreen AI must be able to serve the moderation model (on macOS it resolves via Ollama).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEY, and user JWTs
(obtain via guide G-01; the snippets below fetch fresh users via the QA sign-in shortcut — messaging is inherently multi-actor).
curlpluspython3for JSON handling (any HTTP client works; the same
calls translate directly).
Concepts (only what this journey needs)
- Everything is camelCase — request bodies (
conversationId,otherParticipants,
messageId, mediaUrls) and response fields (senderId, publishedAt). The one exception is the pagination query string: page and page_size (snake_case — ?page=1&page_size=20, defaults 1 / 20, max 100).
- The sender comes from the JWT. There is no "on behalf of" parameter:
the sender is the JWT's sub, the tenant is the JWT's tenant_id. Participants are referenced by user id (the Users service id, equal to the JWT sub) — never by username.
- Sending is async.
POST /v3/messaging/messagesreturns201
immediately with content: null and publishedAt: null — the message is pending moderation. The workflow promotes it seconds later; only then do other participants see it. The author always sees their own pending, published, and quarantined messages; everyone else sees published only.
- Reactions and receipts target published messages. A sentiment or receipt
posted against a still-pending message is rejected as 404 — clients should react only to messages the user can actually see.
- Reads are per-reader localized.
content/contentLocale/isOriginal
in responses are the caller's localized version: exact locale → language fallback → AI translation via Linga (cached). isOriginal: true means the message was written in your locale.
- Errors are often bare status codes. Several endpoints answer
401/
403 / 404 with an empty body rather than a JSON error document — check the status code first.
- Content is encrypted at rest per-tenant (AES-256); you always send and
receive plaintext.
Base URLs used below
| Service | Local | Variable |
|---|---|---|
| Auth (QA sign-in) | http://127.0.0.1:40004 | AUTH_URL |
| Messaging Command (writes) | http://127.0.0.1:40163 | MSC_URL |
| Messaging Query (reads) | http://127.0.0.1:40164 | MSQ_URL |
All messaging endpoints are prefixed /v3/messaging and require the tenant key pair headers plus Authorization: Bearer <JWT>.
Step 1 — Create a conversation
A conversation is a real entity with a participant list (roles Owner / ConversationManager / Member, max 100). The creator becomes Owner; otherParticipants takes the user ids of everyone else. Creating again with the same title + participant set is idempotent — you get the existing conversation back:
login() { # username -> JWT
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() { # username -> user id
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); BOB_T=$(login g41-bob); CAROL_T=$(login g41-carol)
ALICE_U=$(user_id g41-alice); BOB_U=$(user_id g41-bob)
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 demo"}' "$BOB_U")
RESP=$(curl -s -w '\n%{http_code}' -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY")
CODE=$(tail -n1 <<< "$RESP"); echo "create -> HTTP ${CODE}"
test "$CODE" = 201 || { echo "expected 201"; exit 1; }
CONV=$(sed '$d' <<< "$RESP")
echo "$CONV" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("conversation id:", d["id"]); print("createdById:", d["createdById"])'
CID=$(echo "$CONV" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# Idempotent: same title + participants returns the same conversation:
CID2=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "re-create returns same id: $([ "$CID" = "$CID2" ] && echo yes || echo NO)"
test "$CID" = "$CID2" || exit 1
# Non-participant carol cannot open the conversation:
CODE=$(curl -s -o /dev/null -w '%{http_code}' "${MSQ_URL}/v3/messaging/conversations/${CID}" \
-H "Authorization: Bearer ${CAROL_T}" "${KEYS[@]}")
echo "carol (non-participant) reads -> HTTP ${CODE}"
test "$CODE" -ge 400 || { echo "expected 4xx"; exit 1; }Expected response — create → 201 with the Conversation JSON (id, title, createdById = alice, latestMessageId: null); re-create → the same id; carol → 4xx with an empty body.
Step 2 — Send a message
The sender is whoever the JWT belongs to. The 201 response is the stored message before moderation — note content: null and publishedAt: null:
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); CAROL_T=$(login g41-carol)
BOB_U=$(user_id g41-bob)
ALICE_U=$(user_id g41-alice)
# Fresh conversation for this step:
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 step2"}' "$BOB_U")
CID=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
MSG_BODY=$(printf '{"conversationId": "%s", "content": "Morning! Watering the tomatoes before it gets hot.", "mediaUrls": []}' "$CID")
MSG=$(curl -s -w '\n%{http_code}' -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$MSG_BODY")
CODE=$(tail -n1 <<< "$MSG"); echo "send -> HTTP ${CODE}"
test "$CODE" = 201 || { echo "expected 201"; exit 1; }
echo "$MSG" | sed '$d' | python3 -c '
import json,sys
d = json.load(sys.stdin)
print("id:", d["id"])
print("senderId:", d["senderId"])
print("content (pending):", d["content"], "| publishedAt:", d["publishedAt"])
assert d["content"] is None and d["publishedAt"] is None, "new message must be unpublished"'
# Non-participant carol cannot send into the conversation:
CAROL_BODY=$(printf '{"conversationId": "%s", "content": "intrusion", "mediaUrls": []}' "$CID")
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${CAROL_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$CAROL_BODY")
echo "carol (non-participant) sends -> HTTP ${CODE}"
test "$CODE" = 401 || { echo "expected 401"; exit 1; }Expected response — alice → 201, the message JSON with senderId = her user id, content: null, publishedAt: null (it appears once the workflow publishes it); carol → bare 401.
Step 3 — Read the thread, poll for new messages
Recipients read via Messaging Query. Messages come back newest first, with the caller's localized content filled in once published. Poll the list until your message appears, then use since to fetch only newer messages on subsequent polls:
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); BOB_T=$(login g41-bob)
BOB_U=$(user_id g41-bob); ALICE_U=$(user_id g41-alice)
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 step3"}' "$BOB_U")
CID=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
MSG1_BODY=$(printf '{"conversationId": "%s", "content": "First: moved the cows to the north pasture.", "mediaUrls": []}' "$CID")
MID1=$(curl -s -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$MSG1_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# Poll as bob until the workflow publishes it (seconds, not minutes):
FOUND=""
for i in $(seq 1 24); do
FOUND=$(curl -s "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}" \
| python3 -c 'import json,sys
ms = json.load(sys.stdin)
m = next((m for m in ms if m["id"] == "'"$MID1"'"), None)
print(m["content"] or "" if m else "")')
[ -n "$FOUND" ] && break
sleep 5
done
echo "bob sees: $FOUND"
grep -q "north pasture" <<< "$FOUND" || { echo "message never published"; exit 1; }
# A second message; bob's client then polls with `since` (ISO 8601).
# `since` filters on created_at — capture the watermark BEFORE sending:
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
sleep 1
MSG2_BODY=$(printf '{"conversationId": "%s", "content": "Second: fence charger reads 7.2 kV.", "mediaUrls": []}' "$CID")
MID2=$(curl -s -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$MSG2_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# Poll the incremental view until the new message is published:
for i in $(seq 1 24); do
NEWS=$(curl -s "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=10&since=${SINCE}" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}")
if echo "$NEWS" | grep -q "fence charger"; then break; fi
sleep 5
done
echo "since ${SINCE} -> $(echo "$NEWS" | python3 -c 'import json,sys
ms = json.load(sys.stdin)
print(len(ms), "message(s):", [ (m["content"] or "")[:30] for m in ms ])')"
echo "$NEWS" | python3 -c 'import json,sys
ms = json.load(sys.stdin)
assert any("fence charger" in (m["content"] or "") for m in ms), "since-poll should include the new message"
assert all("north pasture" not in (m["content"] or "") for m in ms), "since-poll should exclude the older message"'Expected response — bob's poll eventually returns the message with its plaintext content and a publishedAt timestamp; the since poll returns only the newer message. Newest-first ordering: when both messages are present, the fence-charger message (created later) comes before the pasture message.
Step 4 — Edit a message (author only)
PUT /v3/messaging/messages replaces the content. Non-authors get 403; unknown messages 404. The edit re-enters the moderation pipeline — the recipient sees the updated content only after the workflow re-publishes:
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); BOB_T=$(login g41-bob)
BOB_U=$(user_id g41-bob)
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 step4"}' "$BOB_U")
CID=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
M_BODY=$(printf '{"conversationId": "%s", "content": "Eggs collected, 14 today.", "mediaUrls": []}' "$CID")
MID=$(curl -s -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$M_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# Bob (non-author) tries to edit -> 403:
EDIT_BODY=$(printf '{"messageId": "%s", "content": "tampered"}' "$MID")
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$EDIT_BODY")
echo "bob edits alice's message -> HTTP ${CODE}"
test "$CODE" = 403 || { echo "expected 403"; exit 1; }
# Unknown message -> 404:
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' \
-d '{"messageId": "no-such-message", "content": "x"}')
echo "alice edits unknown message -> HTTP ${CODE}"
test "$CODE" = 404 || { echo "expected 404"; exit 1; }
# Alice edits her own -> 200. The response is transitional (content null
# while the workflow re-runs translation/moderation); assert id + status:
EDIT_BODY=$(printf '{"messageId": "%s", "content": "Eggs collected, 16 today — found a second nest."}' "$MID")
EDITED=$(curl -s -w '\n%{http_code}' -X PUT "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$EDIT_BODY")
CODE=$(tail -n1 <<< "$EDITED"); echo "alice edits her own -> HTTP ${CODE}"
test "$CODE" = 200 || { echo "expected 200"; exit 1; }
echo "$EDITED" | sed '$d' | python3 -c 'import json,sys
d = json.load(sys.stdin)
assert d["id"] == "'"$MID"'", "response carries the edited message id"
print("edit accepted for message", d["id"], "| content (transitional):", d["content"])'
# Recipient sees it after the workflow re-publishes the edit:
SEEN=""
for i in $(seq 1 24); do
SEEN=$(curl -s "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}" \
| python3 -c 'import json,sys
ms = json.load(sys.stdin)
m = next((m for m in ms if m["id"] == "'"$MID"'"), None)
print((m["content"] or "") if m else "")')
grep -q "second nest" <<< "$SEEN" && break
sleep 5
done
echo "bob eventually sees: $SEEN"
grep -q "second nest" <<< "$SEEN" || { echo "edit never published"; exit 1; }Expected response — bob → 403; unknown id → 404; alice → 200 carrying the edited message id (the response content is transitional — null until the workflow re-runs translation); bob's poll converges on the new content.
Step 5 — React with sentiment and track receipts
Users attach a sentiment (Positive/Negative/Neutral/Mixed + intensity 0.0–1.0) to any message they can see, and post delivery/read receipts for themselves. Receipts are per-user state you read back per message:
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); BOB_T=$(login g41-bob)
BOB_U=$(user_id g41-bob)
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 step5"}' "$BOB_U")
CID=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
M_BODY=$(printf '{"conversationId": "%s", "content": "The well pump is holding steady at 42 psi.", "mediaUrls": []}' "$CID")
MID=$(curl -s -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$M_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
# Reactions target *visible* messages — wait until the workflow publishes
# THIS message (match by id: the idempotent conversation may hold older ones):
for i in $(seq 1 24); do
VISIBLE=$(curl -s "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=10" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}" \
| python3 -c 'import json,sys
ms = json.load(sys.stdin)
print("yes" if any(m["id"] == "'"$MID"'" for m in ms) else "")')
[ "$VISIBLE" = yes ] && { echo "published"; break; }
sleep 5
done
test "$VISIBLE" = yes || { echo "message never published"; exit 1; }
# Bob reacts with a sentiment:
SENT_BODY=$(printf '{"messageId": "%s", "sentiment": "Positive", "intensity": 0.8}' "$MID")
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${MSC_URL}/v3/messaging/messages/sentiment" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$SENT_BODY")
echo "bob reacts -> HTTP ${CODE}"
test "$CODE" = 200 || { echo "expected 200"; exit 1; }
# Read it back (per user):
curl -s "${MSQ_URL}/v3/messaging/messages/${MID}/sentiment/${BOB_U}" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
| python3 -c 'import json,sys
d = json.load(sys.stdin)
print("sentiment:", d["sentiment"], "| intensity:", d["intensity"], "| userId:", d["userId"])
assert d["sentiment"] == "Positive" and d["userId"]'
# Bob posts a delivery receipt, then a read receipt:
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${MSC_URL}/v3/messaging/messages/${MID}/delivery-receipts" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}")
echo "delivery receipt -> HTTP ${CODE}"
test "$CODE" = 200 || { echo "expected 200"; exit 1; }
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${MSC_URL}/v3/messaging/messages/${MID}/read-receipts" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}")
echo "read receipt -> HTTP ${CODE}"
test "$CODE" = 200 || { echo "expected 200"; exit 1; }
# Read the receipt state back:
curl -s "${MSQ_URL}/v3/messaging/messages/${MID}/receipts?page=1&page_size=10" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
| python3 -c 'import json,sys
rs = json.load(sys.stdin)
for r in rs:
print("receipt:", r["userId"][:12], "->", r["status"])
assert any(r["status"] == "DeliveredAndRead" for r in rs), "bob read state should be visible"'Expected response — sentiment and both receipts → 200 (empty body); the sentiment read-back returns the UserMessageSentiment JSON; the receipts list shows bob's message as DeliveredAndRead (the final status supersedes Delivered).
Step 6 — Delete a message (author only, permanent)
DELETE removes the message for everyone. Author (or an admin role) only, 204 on success, 404 once already gone:
login() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
user_id() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g41-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
ALICE_T=$(login g41-alice); BOB_T=$(login g41-bob)
BOB_U=$(user_id g41-bob)
BODY=$(printf '{"otherParticipants": ["%s"], "title": "G-41 step6"}' "$BOB_U")
CID=$(curl -s -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
M_BODY=$(printf '{"conversationId": "%s", "content": "Scratch note, deleting in a moment.", "mediaUrls": []}' "$CID")
MID=$(curl -s -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$M_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "${MSC_URL}/v3/messaging/messages/${MID}" \
-H "Authorization: Bearer ${BOB_T}" "${KEYS[@]}")
echo "bob (non-author) deletes -> HTTP ${CODE}"
test "$CODE" = 403 || { echo "expected 403"; exit 1; }
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "${MSC_URL}/v3/messaging/messages/${MID}" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}")
echo "alice (author) deletes -> HTTP ${CODE}"
test "$CODE" = 204 || { echo "expected 204"; exit 1; }
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE "${MSC_URL}/v3/messaging/messages/${MID}" \
-H "Authorization: Bearer ${ALICE_T}" "${KEYS[@]}")
echo "delete again -> HTTP ${CODE}"
test "$CODE" -ge 400 || { echo "expected 4xx on repeat"; exit 1; }Expected response — bob → 403; alice → 204; repeat → 404 (bare code, empty body).
Full example — one condensed pass
set -e
KEYS=(-H "x-api-key: ${INAYA_API_KEY}" -H "x-client-app-key: ${INAYA_APP_KEY}")
jwt() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' "${KEYS[@]}" \
-d "{\"device_id\": \"g41-full-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])'
}
uid() {
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H 'Content-Type: application/json' "${KEYS[@]}" \
-d "{\"device_id\": \"g41-full-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"$1\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user"]["id"])'
}
ALICE=$(jwt g41-full-alice); BOB=$(jwt g41-full-bob); BOB_U=$(uid g41-full-bob)
# 1. Alice opens a conversation with bob.
C_BODY=$(printf '{"otherParticipants": ["%s"], "title": "Weekly plan"}' "$BOB_U")
CID=$(curl -sf -X POST "${MSC_URL}/v3/messaging/conversations" \
-H "Authorization: Bearer ${ALICE}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$C_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "conversation: $CID"
# 2. Alice sends; bob polls until THIS message publishes (match by id).
M_BODY=$(printf '{"conversationId": "%s", "content": "Rotating the flock to the east paddock on Thursday.", "mediaUrls": []}' "$CID")
MID=$(curl -sf -X POST "${MSC_URL}/v3/messaging/messages" \
-H "Authorization: Bearer ${ALICE}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$M_BODY" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
for i in $(seq 1 24); do
VISIBLE=$(curl -sf "${MSQ_URL}/v3/messaging/conversations/${CID}/messages?page=1&page_size=5" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
| python3 -c 'import json,sys
ms = json.load(sys.stdin)
print("yes" if any(m["id"] == "'"$MID"'" for m in ms) else "")')
[ "$VISIBLE" = yes ] && { echo "published"; break; }
sleep 5
done
test "$VISIBLE" = yes || { echo "message never published"; exit 1; }
# 3. Bob reacts and reads.
S_BODY=$(printf '{"messageId": "%s", "sentiment": "Positive", "intensity": 0.6}' "$MID")
curl -sf -o /dev/null -X POST "${MSC_URL}/v3/messaging/messages/sentiment" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" \
-H 'Content-Type: application/json' -d "$S_BODY" && echo "reacted"
curl -sf -o /dev/null -X POST "${MSC_URL}/v3/messaging/messages/${MID}/read-receipts" \
-H "Authorization: Bearer ${BOB}" "${KEYS[@]}" && echo "read receipt"
# 4. Alice deletes the message again.
curl -sf -o /dev/null -X DELETE "${MSC_URL}/v3/messaging/messages/${MID}" \
-H "Authorization: Bearer ${ALICE}" "${KEYS[@]}" && echo "deleted"(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; MSC_URL / MSQ_URL are the Messaging Command / Query base URLs — http://127.0.0.1:40163 / http://127.0.0.1:40164 locally; TENANT_ID is your tenant's id.)
Try it live
Open the Playground → Communication — the Messaging console exercises the same create → send → read cycle against the live Messaging services.
Copy agent prompt
You are working with the Inaya Labs Messaging platform. Messaging Command
(writes) is at MSC_URL and Messaging Query (reads) at MSQ_URL; the Auth
Service is at AUTH_URL; the tenant key pair is in INAYA_API_KEY /
INAYA_APP_KEY and TENANT_ID. Obtain three user JWTs via POST
/v3/auth/qa/login {"device_id","locale","tenant_id","username"} with usernames
"g41-agent-a", "g41-agent-b", "g41-agent-c" if tokens are not already set, and
capture each user's id from the login response's user object. Steps: (1) As
agent-a, POST MSC /v3/messaging/conversations {"otherParticipants":
["<agent-b id>"], "title": "agent-demo"} and report the conversation id.
(2) As agent-a, POST MSC /v3/messaging/messages {"conversationId", "content",
"mediaUrls": []} and report the 201 with content null / publishedAt null.
(3) As agent-b, poll MSQ GET /v3/messaging/conversations/{id}/messages
?page=1&page_size=10 every 5 seconds until the message appears, then report
its content and publishedAt. (4) As agent-c (not a participant), POST a message
into the conversation and report the bare 401; also GET the conversation and
report the 4xx. (5) As agent-b, PUT MSC /v3/messaging/messages {"messageId",
"content"} on agent-a's message and report the 403; then as agent-a PUT new
content and report the 200. (6) As agent-b, POST MSC /v3/messaging/messages/
sentiment {"messageId","sentiment":"Positive","intensity":0.7} (200), POST
delivery-receipts then read-receipts (200 each), and read back MSQ GET
/v3/messaging/messages/{id}/receipts, reporting agent-b's status as
DeliveredAndRead. (7) As agent-a, DELETE MSC /v3/messaging/messages/{id} and
report the 204, then repeat and report the 404. Never log the JWTs; report
status codes per step.API reference
Next journeys
- Publishing and reading a feed (G-40) — the public, moderated broadcast
counterpart to these private conversations.
- Managing users & profiles (G-30) — the user records every participant
id maps back to.
- Authentication & sessions (G-01) — the token lifecycle that every call
in this guide depends on.