Messaging Overview

The Messaging API is available by request. To explore availability for your integration, please contact our team.

Use the Messaging API to send SMS messages to customers and prospects, track delivery, and read the conversation history a property has with a resident or applicant.

Key Concepts

Conversations and Messages

A conversation is the running thread between one property and one recipient. A message is a single SMS within that thread. You never create a conversation directly — Fortress opens one the first time a property and a recipient exchange a message, and every message after that is appended to it.

A conversation follows the person, not their record. Someone who texts a property as a prospect and later becomes a resident keeps the same conversation, which is why recipients can hold both a prospectId and a customerId over the thread’s lifetime. The recipient object identifies the party currently being messaged.

Recipients

Every send targets either a customerId or a prospectId. Fortress resolves that id to a phone number and to the property phone number the message should come from, so you never supply phone numbers yourself.

Idempotency

idempotencyId is required on every send and must uniquely identify the request. If a send is retried with the same key — after a network timeout, for example — Fortress returns the message created by the original request rather than sending the recipient a duplicate.

Choose keys that are stable across retries of the same logical send and distinct across different sends. A UUID generated once, before the first attempt, is the usual choice.

You can also look a message up by its key later with GET /messages?idempotencyId=..., which is the reliable way to determine whether a send you lost the response to was actually recorded.

Delivery Is Asynchronous

A 201 from POST /messages means Fortress accepted the message and handed it to the carrier. It does not mean the recipient received it.

The returned messageStatus is PROCESSING while delivery is in flight. Poll GET /messages/{messageId} until it reaches one of the terminal statuses:

StatusMeaning
DELIVEREDThe carrier confirmed delivery to the handset.
UNDELIVEREDThe carrier accepted the message but could not deliver it.
FAILEDThe message could not be sent. messageStatusReason explains why.

Statuses reported by the carrier are passed through verbatim, so treat messageStatus as an open set of strings rather than a fixed enumeration, and match on the terminal values above rather than assuming the intermediate ones.

Message Limits

content is limited to 1600 characters. Longer messages are rejected with a 400.

Attachment url values on a message are generated per request and expire 15 minutes after the response is issued. Download promptly, or re-read the message to get a fresh URL.

Needs Help

A needs-help flag marks a single message as requiring a human to look at it — Leslie, our AI agent, raises one when unable to answer a resident’s question, and a Fortress staff member can raise or resolve one too. PATCH /messages/{messageId}/needsHelp handles both directions: isFlagged: true raises the flag, isFlagged: false resolves it.

This is a different signal from conversationStatus: UNRESOLVED. UNRESOLVED is thread-level and set by a human working the conversation as a whole; a needs-help flag is per-message and is typically raised by the agent itself, at the exact point it gave up on a specific question. The two coexist: a conversation can be UNRESOLVED with no flags, or flagged while the underlying workflow is anywhere in its own lifecycle.

While any message on a conversation has an open flag, GET /conversations and GET /messages/{messageId} report conversationStatus: NEEDS_HELP regardless of what the workflow status underneath it is doing. NEEDS_HELP prevails: it does not clear just because a new message arrives or a bot closes the thread. Only resolving every open flag clears it, and when that happens the conversation falls through to whatever status its current workflow calls for — not whatever status was in effect when the first flag was raised.

The conversation payload carries the ids of every currently-flagged message under needsHelpMessageIds, so you can jump straight to the messages that need attention without scanning the whole thread:

1{
2 "id": "68a1f4c29b3e7a0012d4e87f",
3 "conversationStatus": "NEEDS_HELP",
4 "needsHelpMessageIds": ["68a1f4c29b3e7a0012d4e8a3"]
5}

needsHelpMessageIds is empty whenever nothing is flagged. On the message itself, GET /messages/{messageId} returns needsHelp as a plain boolean alongside a needsHelpDetails object holding the reason, who flagged it and when, and who resolved it and when. needsHelpDetails outlives the flag: after a resolution needsHelp returns to false but the details still describe who asked for help and why.

$curl --location --request PATCH 'https://api.fortresstech.io/v1/messages/68a1f4c29b3e7a0012d4e8a3/needsHelp' \
>--header 'x-api-key: YOUR_API_KEY' \
>--header 'Content-Type: application/json' \
>--data '{
> "isFlagged": true,
> "reason": "Resident asked about a lease clause that needs a human to verify"
>}'

The request is idempotent: flagging an already-flagged message, or resolving one that isn’t flagged, returns 200 with the current state rather than an error.

Permissions

Messaging spans two permission scopes, messages and conversations, which are granted independently on your API key. A key that can send messages cannot necessarily list conversations. Request both if your integration needs the full surface.

Within the messages scope, the grant is by action rather than by endpoint. Flagging or resolving a needs-help flag requires the update action — a key row needs messages: ['update'], or the request 403s before it reaches the backend. That grant is service-wide: it authorizes every write under /v1/messages/*, not only this endpoint, so a key with messages: ['update'] today also gets any future PUT/PATCH added under that path.

Sending a Message

Step 1: Send the Message

$curl --location 'https://api.fortresstech.io/v1/messages' \
>--header 'x-api-key: YOUR_API_KEY' \
>--header 'Content-Type: application/json' \
>--data '{
> "customerId": "6e46a1d7-503a-42fa-a55b-047ee04b5279",
> "content": "Your maintenance request has been scheduled for Thursday between 9am and noon.",
> "idempotencyId": "049665d5-2a23-4f37-9c5e-da5dd30f5364"
>}'

The response is the created message:

1{
2 "id": "68a1f4c29b3e7a0012d4e881",
3 "conversationId": "68a1f4c29b3e7a0012d4e87f",
4 "type": "OUTBOUND",
5 "content": "Your maintenance request has been scheduled for Thursday between 9am and noon.",
6 "messageStatus": "PROCESSING",
7 "messageSid": "SM7f3a1c2d4e5b6a7089c1d2e3f4a5b6c7",
8 "idempotencyId": "049665d5-2a23-4f37-9c5e-da5dd30f5364",
9 "customerId": "6e46a1d7-503a-42fa-a55b-047ee04b5279",
10 "organizationId": "123e4567-e89b-12d3-a456-426614174000",
11 "metadata": {
12 "sentAt": "2026-08-02T14:32:07.184Z",
13 "sentBy": {
14 "id": "9c8b7a6d-5e4f-4321-8765-0fedcba98765",
15 "name": "Dana Whitfield"
16 }
17 },
18 "createdAt": "2026-08-02T14:32:07.184Z",
19 "updatedAt": "2026-08-02T14:32:07.184Z"
20}

Important Notes:

  • Supply exactly one of customerId or prospectId. Supplying both is not rejected: customerId takes precedence and the prospectId you sent is ignored.
  • messageSid is the carrier’s identifier. It is absent until the carrier accepts the message.
  • metadata.sentBy records the Fortress user the message was sent on behalf of. Any metadata you supply is preserved alongside it and returned unchanged on reads.

Step 2: Check Delivery Status

$curl --location 'https://api.fortresstech.io/v1/messages/68a1f4c29b3e7a0012d4e881' \
>--header 'x-api-key: YOUR_API_KEY'

When delivery fails, messageStatusReason carries the explanation:

1{
2 "id": "68a1f4c29b3e7a0012d4e881",
3 "messageStatus": "FAILED",
4 "messageStatusReason": "The 'To' number is not a valid mobile number",
5 "updatedAt": "2026-08-02T14:32:11.902Z"
6}

Step 3: Read the Conversation

To see the full thread, list the conversations for the recipient and then read the messages on the conversation you want:

$curl --location 'https://api.fortresstech.io/v1/conversations?recipientId=6e46a1d7-503a-42fa-a55b-047ee04b5279' \
>--header 'x-api-key: YOUR_API_KEY'
1[
2 {
3 "id": "68a1f4c29b3e7a0012d4e87f",
4 "conversationStatus": "NEW",
5 "messageType": "SMS",
6 "propertyId": "123e4567-e89b-12d3-a456-426614174000",
7 "recipient": {
8 "customerId": "6e46a1d7-503a-42fa-a55b-047ee04b5279",
9 "lastUsedPhoneNumberId": "68a1f4c29b3e7a0012d4e7c1"
10 },
11 "recipients": [
12 { "customerId": "6e46a1d7-503a-42fa-a55b-047ee04b5279" }
13 ],
14 "lastReceivedMessageId": "68a1f4c29b3e7a0012d4e8a3",
15 "lastReceivedMessage": {
16 "id": "68a1f4c29b3e7a0012d4e8a3",
17 "type": "INBOUND",
18 "content": "Thursday morning works, thanks!",
19 "messageStatus": "RECEIVED",
20 "createdAt": "2026-08-02T15:07:44.310Z"
21 },
22 "createdAt": "2026-07-28T18:02:15.007Z",
23 "updatedAt": "2026-08-02T15:07:44.310Z"
24 }
25]
$curl --location 'https://api.fortresstech.io/v1/messages?conversationId=68a1f4c29b3e7a0012d4e87f' \
>--header 'x-api-key: YOUR_API_KEY'

A conversationStatus of NEW means an inbound message is waiting on a response. UNRESOLVED means someone is working the thread, and RESOLVED means it has been closed.

Confirming a Send You Lost the Response To

If a send times out and you are unsure whether it was recorded, look it up by the key you used rather than retrying blindly:

$curl --location 'https://api.fortresstech.io/v1/messages?idempotencyId=049665d5-2a23-4f37-9c5e-da5dd30f5364' \
>--header 'x-api-key: YOUR_API_KEY'

An empty array means the send never landed and is safe to reissue. Retrying with the same idempotencyId is also safe — it returns the original message instead of sending a second one.

Available Endpoints

EndpointSummaryMethod
/conversationsList ConversationsGET
/messagesList MessagesGET
/messages/{messageId}Read MessageGET
/messagesSend MessagePOST
/messages/{messageId}/needsHelpUpdate Message Needs HelpPATCH