{
  "openapi": "3.0.3",
  "info": {
    "title": "Visit Messages API",
    "version": "1.0.0",
    "contact": {
      "email": "admin@vsee.com"
    },
    "description": "Asynchronous messaging on an eConsult visit: patients send and read messages through the API while providers reply from the VSee Clinic dashboard. Every new message fires a `visit.message.created` webhook so an integrator's backend can notify its users without polling.\n\nAuthorization is participant-scoped — a user can only read or write messages on visits they are part of (patient, assigned provider, or host).\n\n## Webhooks\n\nThis API emits one event. Its payload, delivery contract and handling steps are documented on its own page — see **`visit.message.created`** in the sidebar.\n\n| Event | Fires when |\n|-------|------------|\n| `visit.message.created` | Any new message is added to a visit thread, in either direction |"
  },
  "servers": [
    {
      "url": "https://api.vseepreview.com",
      "description": "Sandbox (vcpreview) — live"
    },
    {
      "url": "https://api-ms.vsee.me",
      "description": "Production — pending account enablement; identical contract"
    }
  ],
  "x-webhooks": [
    {
      "name": "visit.message.created",
      "tag": "Visit Messages",
      "summary": "New message on the visit thread",
      "description": "Fires for **every** new message on the visit, in either direction — patient messages sent through this API and provider replies sent from the VSee Clinic dashboard. Filter on `data.actor_type` if you only want one side.\n\nThe payload is a thin pointer: it deliberately carries **no message content and no PHI**. Treat it as a signal to fetch, then read the thread with `GET /api/visits/{visit_id}/messages`.\n\n## Delivery\n\nConfigure the destination URL per clinic account in Admin settings. There is no signature header — restrict your endpoint by network or use an unguessable path.\n\n| Behaviour | Contract |\n|-----------|----------|\n| Acknowledgement | Return any `2xx` within 30 seconds |\n| Retries | Up to 5 attempts, roughly 60 seconds apart |\n| De-duplication | Retries repeat a delivery — de-duplicate on `data.message_id` |\n| Ordering | Not guaranteed; use `data.ts` to order |\n| Latency | Seconds for messages sent through this API, up to ~2 minutes for provider replies |\n\n## Handling\n\n1. Acknowledge immediately — do the work after responding, not before.\n2. Skip the event if you have already seen its `data.message_id`.\n3. Call `GET /api/visits/{visit_id}/messages?skip=<count you hold>` to pull only what is new.",
      "payload": {
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/VisitMessageCreatedEvent"
            },
            "example": {
              "id": "6a47ea02-4809-4af6-b8f6-c56bc2bd2adb",
              "type": "visit.message.created",
              "created": 1786755119,
              "account_code": "aubrey",
              "version": "1.0",
              "data": {
                "visit_id": "1000021648",
                "message_id": "83c3b3d7-346c-4ac9-8276-f96ac8356e25",
                "actor_type": "provider",
                "ts": 1786755119
              }
            }
          }
        }
      }
    }
  ],
  "x-readme": {
    "explorer-enabled": false,
    "proxy-enabled": false,
    "samples-languages": [
      "curl",
      "python",
      "javascript",
      "java"
    ]
  },
  "tags": [
    {
      "name": "Visit Messages",
      "description": "Read and send messages on an eConsult visit"
    }
  ],
  "paths": {
    "/api/visits/{visit_id}/messages": {
      "get": {
        "tags": [
          "Visit Messages"
        ],
        "summary": "Read the conversation",
        "description": "Returns the visit's message thread. The list is append-only and chronological (oldest first by default).\n\n**Polling for new replies** — because the thread is append-only and chronological, request `?skip=<number of messages you already have>`; the response contains only what's new, and `total_count` tells you when you're caught up.\n\nPolling is only needed as a fallback: subscribe to the `visit.message.created` webhook instead and fetch the thread when it fires.",
        "operationId": "getVisitMessages",
        "parameters": [
          {
            "name": "X-ApiToken",
            "in": "header",
            "description": "API token — the same token returned by the SSO endpoint (`data.token.token`)",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "your_api_token"
          },
          {
            "name": "visit_id",
            "in": "path",
            "description": "ID of the visit whose thread is being read",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "1000021648"
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size. Default 25, maximum 200.",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 25,
              "maximum": 200
            },
            "example": 25
          },
          {
            "name": "skip",
            "in": "query",
            "description": "Offset into the list. Default 0.",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "example": 0
          },
          {
            "name": "page",
            "in": "query",
            "description": "Alternative to `skip`: skip = (page − 1) × limit.",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "example": 1
          },
          {
            "name": "sort",
            "in": "query",
            "description": "Sort field. Use `sort=created&order=desc` (or the compact `sort=created:desc`) to return latest first. Default is oldest first.",
            "required": false,
            "schema": {
              "type": "string"
            },
            "example": "created"
          },
          {
            "name": "order",
            "in": "query",
            "description": "Sort direction, used with `sort`.",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            },
            "example": "desc"
          }
        ],
        "responses": {
          "200": {
            "description": "Message thread retrieved successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "total_count"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    },
                    "total_count": {
                      "type": "integer",
                      "description": "Total number of messages on the visit, ignoring pagination",
                      "example": 7
                    }
                  }
                },
                "examples": {
                  "Conversation": {
                    "summary": "A patient message in an ongoing thread",
                    "value": {
                      "data": [
                        {
                          "id": "b505f15c-8b98-43ea-a8e8-e29188b5f757",
                          "type": "patient_message",
                          "actor": {
                            "id": "1000004436",
                            "type": 200,
                            "display_name": "An VSee"
                          },
                          "content": "Thank you doctor — what did you find?",
                          "created": 1786745961.166
                        }
                      ],
                      "total_count": 7
                    }
                  },
                  "Empty thread": {
                    "summary": "No messages yet",
                    "value": {
                      "data": [],
                      "total_count": 0
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 400,
                  "message": "Missing token"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 401,
                  "message": "Invalid token"
                }
              }
            }
          },
          "403": {
            "description": "The authenticated user is not a participant on this visit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 403,
                  "message": "Not a participant on this visit"
                }
              }
            }
          },
          "404": {
            "description": "Visit not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 404,
                  "message": "Visit not found"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Visit Messages"
        ],
        "summary": "Send a patient message",
        "description": "Posts a message to the visit thread as the authenticated user. The message appears in the provider's Visit Chat in real time.\n\nAttachments reuse the existing `POST /files` upload flow — pass the returned file ids in `attachment_ids`.\n\nA closed eConsult returns `409`: history stays readable, but a new concern should open a new eConsult.\n\nA successful post fires a `visit.message.created` webhook with `data.actor_type` set to the sender — see the webhook section on the API overview page.",
        "operationId": "createVisitMessage",
        "parameters": [
          {
            "name": "X-ApiToken",
            "in": "header",
            "description": "API token — the same token returned by the SSO endpoint (`data.token.token`)",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "your_api_token"
          },
          {
            "name": "visit_id",
            "in": "path",
            "description": "ID of the visit to post the message to",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "1000021648"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "content"
                ],
                "properties": {
                  "content": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Message text (UTF-8). Required, must be a non-empty string.",
                    "example": "Hi doctor, my molar still aches when I bite down."
                  },
                  "attachment_ids": {
                    "type": "array",
                    "description": "Optional file ids from the `POST /files` upload flow",
                    "items": {
                      "type": "string"
                    },
                    "example": []
                  }
                }
              },
              "examples": {
                "Text message": {
                  "summary": "Plain text message",
                  "value": {
                    "content": "Hi doctor, my molar still aches when I bite down.",
                    "attachment_ids": []
                  }
                },
                "With attachment": {
                  "summary": "Message carrying an uploaded file",
                  "value": {
                    "content": "Here is the photo you asked for.",
                    "attachment_ids": [
                      "f1d2c3b4-a596-4877-8be8-1c0d9e2f3a4b"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Message created successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Message"
                    }
                  }
                },
                "examples": {
                  "Created": {
                    "summary": "The created message",
                    "value": {
                      "data": {
                        "id": "83c3b3d7-346c-4ac9-8276-f96ac8356e25",
                        "type": "patient_message",
                        "actor": {
                          "id": "1000004436",
                          "type": 200,
                          "display_name": "An VSee"
                        },
                        "content": "Hi doctor, my molar still aches when I bite down.",
                        "created": 1786755119.482
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing token, or `content` missing/empty",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 400,
                  "message": "content is required"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 401,
                  "message": "Invalid token"
                }
              }
            }
          },
          "403": {
            "description": "The authenticated user is not a participant on this visit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 403,
                  "message": "Not a participant on this visit"
                }
              }
            }
          },
          "404": {
            "description": "Visit not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 404,
                  "message": "Visit not found"
                }
              }
            }
          },
          "409": {
            "description": "Visit is closed for new messages (history remains readable)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": 409,
                  "message": "Visit is closed for new messages"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Message": {
        "type": "object",
        "description": "A single message on the visit thread. Array order is authoritative for display ordering.",
        "required": [
          "id",
          "type",
          "actor",
          "content",
          "created"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable unique id (UUID). Use it for de-duplication.",
            "example": "b505f15c-8b98-43ea-a8e8-e29188b5f757"
          },
          "type": {
            "type": "string",
            "description": "Which side sent the message",
            "enum": [
              "patient_message",
              "provider_message"
            ],
            "example": "patient_message"
          },
          "actor": {
            "$ref": "#/components/schemas/MessageActor"
          },
          "content": {
            "type": "string",
            "description": "Message text (UTF-8)",
            "example": "Thank you doctor — what did you find?"
          },
          "attachment_ids": {
            "type": "array",
            "description": "Present only when the message carries attachments",
            "items": {
              "type": "string"
            }
          },
          "created": {
            "type": "number",
            "format": "double",
            "description": "Unix seconds with millisecond precision. Recover milliseconds with `Math.round(created * 1000)`.",
            "example": 1786745961.166
          }
        }
      },
      "MessageActor": {
        "type": "object",
        "description": "The sender of the message",
        "required": [
          "id",
          "type"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "VSee user id of the sender",
            "example": "1000004436"
          },
          "type": {
            "type": "integer",
            "description": "User type: 200 patient, 400 provider",
            "enum": [
              200,
              400
            ],
            "example": 200
          },
          "display_name": {
            "type": "string",
            "nullable": true,
            "description": "Sender's display name, when available",
            "example": "An VSee"
          }
        }
      },
      "VisitMessageCreatedEvent": {
        "type": "object",
        "description": "Envelope delivered to your webhook URL for `visit.message.created`",
        "required": [
          "id",
          "type",
          "created",
          "account_code",
          "version",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Unique id for this delivery. Changes on each retry — do not de-duplicate on it.",
            "example": "6a47ea02-4809-4af6-b8f6-c56bc2bd2adb"
          },
          "type": {
            "type": "string",
            "description": "Event name",
            "enum": [
              "visit.message.created"
            ],
            "example": "visit.message.created"
          },
          "created": {
            "type": "integer",
            "format": "int64",
            "description": "UNIX timestamp when the event was emitted",
            "example": 1786755119
          },
          "account_code": {
            "type": "string",
            "description": "Clinic account the visit belongs to",
            "example": "aubrey"
          },
          "version": {
            "type": "string",
            "description": "Payload version",
            "example": "1.0"
          },
          "data": {
            "$ref": "#/components/schemas/VisitMessageCreatedData"
          }
        }
      },
      "VisitMessageCreatedData": {
        "type": "object",
        "description": "Pointer to the new message. Carries no message content or PHI.",
        "required": [
          "visit_id",
          "message_id",
          "actor_type",
          "ts"
        ],
        "properties": {
          "visit_id": {
            "type": "string",
            "description": "Visit whose message thread changed",
            "example": "1000021648"
          },
          "message_id": {
            "type": "string",
            "description": "The new message. De-duplicate on this — retries repeat a delivery.",
            "example": "83c3b3d7-346c-4ac9-8276-f96ac8356e25"
          },
          "actor_type": {
            "type": "string",
            "description": "Who sent the message",
            "enum": [
              "patient",
              "provider"
            ],
            "example": "provider"
          },
          "ts": {
            "type": "integer",
            "format": "int64",
            "description": "UNIX timestamp when the message was created",
            "example": 1786755119
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "Standard VSee API error shape",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "integer",
            "example": 401
          },
          "message": {
            "type": "string",
            "example": "Invalid token"
          }
        }
      }
    }
  }
}
