Direct Messages API Reference
The Direct Messages API lets you read and send 1:1 messages on profiles that support DMs. Outbound sends are processed asynchronously.
A conversation between your profile and a participant is represented by a Chat. Each chat has zero or more Messages (inbound or outbound).
Platform support
Section titled “Platform support”Direct messages are supported on Facebook Messenger (facebook), Instagram (instagram), Telegram (telegram), and Bluesky (bluesky). Attempting to use the DM API on any other platform returns 422 Unprocessable Entity.
| Capability | Telegram | Bluesky | ||
|---|---|---|---|---|
| Send / receive text | Yes | Yes | Yes | Yes |
| Attachments | Yes | Yes | Yes | No |
| Reactions | Yes | Yes | No | No |
| Edit outbound message | No | No | Yes | No |
| Delivery / read receipts | Yes | Yes | No | No |
| Archive (mute) chat | No | No | No | Yes |
| Private reply to comment | Yes | Yes | No | No |
| Inbound delivery | Webhook | Webhook | Webhook | Poller (~5 min) |
Telegram and Bluesky differ from the Meta networks in several ways — see Telegram notes and Bluesky notes.
Endpoints
Section titled “Endpoints”| Method | Endpoint | Description |
|---|---|---|
GET | /api/profiles/:profile_id/chats | List chats for a profile |
POST | /api/profiles/:profile_id/chats | Find or create a chat by participant |
GET | /api/chats/:id | Get a single chat |
GET | /api/chats/:chat_id/messages | List messages in a chat |
POST | /api/chats/:chat_id/messages | Send an outbound message |
GET | /api/messages/:id | Get a single message |
PATCH | /api/messages/:id | Edit an outbound message (Telegram only) |
POST | /api/messages/:id/react | React to a message (Facebook & Instagram) |
DELETE | /api/messages/:id/unreact | Remove this account’s reaction |
POST | /api/chats/:id/archive | Archive a chat (Bluesky only) |
DELETE | /api/chats/:id/archive | Unarchive a chat (Bluesky only) |
POST | /api/posts/:post_id/comments/:id/private_reply | DM the author of a comment |
ID resolution
Section titled “ID resolution”- A chat
:idaccepts the Postproxy hashid OR the platform’sexternal_conversation_id. - A message
:idaccepts the Postproxy hashid OR the platform’sexternal_id.
Messaging window
Section titled “Messaging window”Meta only permits free-form messages to a participant within 24 hours of their last inbound message. Send freely inside that window. Sending outside the 24-hour window is not currently supported through the API.
Telegram has no messaging window — once the user has DM’d the bot, it can reply at any time. The bot can only message a user after that user has DM’d the bot at least once (see Telegram notes).
Bluesky has no messaging window either. The recipient must have DMs enabled and satisfy their own “who can message me” preference, otherwise the send fails with a Bluesky-side error.
List chats
Section titled “List chats”GET /api/profiles/:profile_id/chats
Returns a paginated list of chats for a profile, ordered by last_message_at descending (most recent first).
Query parameters
Section titled “Query parameters”| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page | integer | No | 0 | Page number (zero-indexed) |
per_page | integer | No | 20 | Items per page |
before | string | No | - | ISO 8601 timestamp — only chats with last_message_at before this |
after | string | No | - | ISO 8601 timestamp — only chats with last_message_at after this |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/profiles/PROFILE_ID/chats?per_page=20" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const chats = await client.chats.list("PROFILE_ID", { perPage: 20 });console.log(`Total chats: ${chats.total}`);for (const chat of chats.data) { console.log(`${chat.participant_username ?? chat.participant_external_id}: ${chat.last_message_at}`);}from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")chats = await client.chats.list("PROFILE_ID", per_page=20)print(f"Total chats: {chats.total}")for chat in chats.data: print(f"{chat.participant_username or chat.participant_external_id}: {chat.last_message_at}")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") perPage := 20 chats, _ := client.Chats.List(context.Background(), "PROFILE_ID", &postproxy.ChatListOptions{PerPage: &perPage}) fmt.Printf("Total chats: %d\n", chats.Total) for _, chat := range chats.Data { fmt.Printf("%s (platform: %s)\n", chat.ParticipantExternalID, chat.Platform) }}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")chats = client.chats.list("PROFILE_ID", per_page: 20)puts "Total chats: #{chats.total}"chats.data.each do |chat| puts "#{chat.participant_username || chat.participant_external_id}: #{chat.last_message_at}"enduse PostProxy\Client;
$client = new Client("YOUR_API_KEY");$chats = $client->chats()->list("PROFILE_ID", perPage: 20);echo "Total chats: {$chats->total}\n";foreach ($chats->data as $chat) { $who = $chat->participantUsername ?? $chat->participantExternalId; echo "{$who}: {$chat->lastMessageAt?->format('c')}\n";}import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();var chats = client.chats().list("PROFILE_ID");System.out.println("Total chats: " + chats.total());for (var chat : chats.data()) { System.out.println(chat.participantUsername() + ": " + chat.lastMessageAt());}using PostProxy;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var chats = await client.Chats.ListAsync("PROFILE_ID", new ListChatsParams { PerPage = 20 });Console.WriteLine($"Total chats: {chats.Total}");foreach (var chat in chats.Data){ Console.WriteLine($"{chat.ParticipantUsername ?? chat.ParticipantExternalId}: {chat.LastMessageAt}");}Response:
{ "total": 12, "page": 0, "per_page": 20, "data": [ { "id": "chat_xyz789", "profile_id": "prof_abc123", "platform": "instagram", "participant_external_id": "igsid_8675309", "participant_username": "jane_doe", "participant_name": "Jane Doe", "participant_avatar_url": "https://storage.postproxy.dev/.../chat_avatar_42.jpg", "external_conversation_id": null, "last_inbound_at": "2026-05-31T14:02:00.000Z", "last_outbound_at": "2026-05-31T15:10:00.000Z", "last_message_at": "2026-05-31T15:10:00.000Z", "metadata": { "is_verified_user": false, "is_user_follow_business": true, "is_business_follow_user": false, "follower_count": 482, "participant_fetched_at": "2026-05-31T15:10:05Z" }, "created_at": "2026-04-12T08:00:00.000Z" } ]}Create or find chat
Section titled “Create or find chat”POST /api/profiles/:profile_id/chats
Idempotently creates a chat for a (profile, participant_external_id) pair. If a chat with that participant already exists, the existing chat is returned (and any provided participant fields are updated). Use this before sending an outbound message to a participant the profile has not yet messaged.
Request body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
participant_external_id | string | Yes | Platform participant ID (Instagram-scoped user ID for IG, PSID for Facebook, DID for Bluesky) |
participant_username | string | No | Display username for the participant |
participant_name | string | No | Display name for the participant |
Example
Section titled “Example”curl -X POST "https://api.postproxy.dev/api/profiles/PROFILE_ID/chats" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "participant_external_id": "PARTICIPANT_ID", "participant_username": "jane_doe" }'import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const chat = await client.chats.create("PROFILE_ID", "PARTICIPANT_ID", { participantUsername: "jane_doe",});console.log(`Chat: ${chat.id} (platform: ${chat.platform})`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")chat = await client.chats.create( "PROFILE_ID", "PARTICIPANT_ID", participant_username="jane_doe",)print(f"Chat: {chat.id} (platform: {chat.platform})")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") username := "jane_doe" chat, _ := client.Chats.Create(context.Background(), "PROFILE_ID", "PARTICIPANT_ID", &postproxy.ChatCreateOptions{ ParticipantUsername: &username, }) fmt.Printf("Chat: %s (platform: %s)\n", chat.ID, chat.Platform)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")chat = client.chats.create("PROFILE_ID", "PARTICIPANT_ID", participant_username: "jane_doe")puts "Chat: #{chat.id} (platform: #{chat.platform})"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");$chat = $client->chats()->create( "PROFILE_ID", "PARTICIPANT_ID", participantUsername: "jane_doe",);echo "Chat: {$chat->id} (platform: {$chat->platform})\n";import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.param.CreateChatParams;
var client = PostProxy.builder("YOUR_API_KEY").build();var chat = client.chats().create("PROFILE_ID", CreateChatParams.builder("PARTICIPANT_ID") .participantUsername("jane_doe") .build());System.out.println("Chat: " + chat.id() + " (platform: " + chat.platform() + ")");using PostProxy;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var chat = await client.Chats.CreateAsync("PROFILE_ID", new CreateChatParams{ ParticipantExternalId = "PARTICIPANT_ID", ParticipantUsername = "jane_doe",});Console.WriteLine($"Chat: {chat.Id} (platform: {chat.Platform})");Response — 201 Created for a new chat, 200 OK if it already existed (idempotent):
{ "id": "chat_xyz789", "profile_id": "prof_abc123", "platform": "instagram", "participant_external_id": "igsid_8675309", "participant_username": "jane_doe", "participant_name": null, "participant_avatar_url": null, "external_conversation_id": null, "last_inbound_at": null, "last_outbound_at": null, "last_message_at": null, "metadata": null, "created_at": "2026-05-31T15:30:00.000Z"}Get chat
Section titled “Get chat”GET /api/chats/:id
Fetch a single chat by Postproxy hashid or by external_conversation_id.
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/chats/CHAT_ID" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const chat = await client.chats.get("CHAT_ID");console.log(chat);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")chat = await client.chats.get("CHAT_ID")print(chat)package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") chat, _ := client.Chats.Get(context.Background(), "CHAT_ID", nil) fmt.Printf("Chat: %s (platform: %s)\n", chat.ID, chat.Platform)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")chat = client.chats.get("CHAT_ID")puts chat.inspectuse PostProxy\Client;
$client = new Client("YOUR_API_KEY");$chat = $client->chats()->get("CHAT_ID");echo "Chat: {$chat->id} (platform: {$chat->platform})\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();var chat = client.chats().get("CHAT_ID");System.out.println("Chat: " + chat.id() + " (platform: " + chat.platform() + ")");using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var chat = await client.Chats.GetAsync("CHAT_ID");Console.WriteLine($"Chat: {chat.Id} (platform: {chat.Platform})");Response:
{ "id": "chat_xyz789", "profile_id": "prof_abc123", "platform": "instagram", "participant_external_id": "igsid_8675309", "participant_username": "jane_doe", "participant_name": "Jane Doe", "participant_avatar_url": "https://storage.postproxy.dev/.../chat_avatar_42.jpg", "external_conversation_id": null, "last_inbound_at": "2026-05-31T14:02:00.000Z", "last_outbound_at": "2026-05-31T15:10:00.000Z", "last_message_at": "2026-05-31T15:10:00.000Z", "metadata": { "is_verified_user": false, "is_user_follow_business": true, "is_business_follow_user": false, "follower_count": 482, "participant_fetched_at": "2026-05-31T15:10:05Z" }, "created_at": "2026-04-12T08:00:00.000Z"}List messages
Section titled “List messages”GET /api/chats/:chat_id/messages
Returns a paginated list of messages in a chat, ordered by external_posted_at descending (pending outbound messages without external_posted_at are ordered by created_at).
Query parameters
Section titled “Query parameters”| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
page | integer | No | 0 | Page number (zero-indexed) |
per_page | integer | No | 20 | Items per page |
direction | string | No | - | Filter by direction: inbound or outbound |
status | string | No | - | Filter by status |
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/chats/CHAT_ID/messages?direction=inbound" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const messages = await client.messages.list("CHAT_ID", { direction: "inbound" });for (const msg of messages.data) { console.log(`[${msg.direction}] ${msg.body}`); for (const att of msg.attachments) { console.log(` attachment: ${att.type} -> ${att.url}`); }}from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")messages = await client.messages.list("CHAT_ID", direction="inbound")for msg in messages.data: print(f"[{msg.direction}] {msg.body}") for att in msg.attachments: print(f" attachment: {att.type} -> {att.url}")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") dir := postproxy.MessageDirectionInbound messages, _ := client.Messages.List(context.Background(), "CHAT_ID", &postproxy.MessageListOptions{Direction: &dir}) for _, msg := range messages.Data { fmt.Printf("[%s] %v\n", msg.Direction, msg.Body) for _, att := range msg.Attachments { fmt.Printf(" attachment: %s -> %v\n", att.Type, att.URL) } }}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")messages = client.messages.list("CHAT_ID", direction: "inbound")messages.data.each do |msg| puts "[#{msg.direction}] #{msg.body}" msg.attachments.each { |att| puts " attachment: #{att.type} -> #{att.url}" }enduse PostProxy\Client;
$client = new Client("YOUR_API_KEY");$messages = $client->messages()->list("CHAT_ID", direction: "inbound");foreach ($messages->data as $msg) { echo "[{$msg->direction}] {$msg->body}\n"; foreach ($msg->attachments as $att) { echo " attachment: {$att->type} -> {$att->url}\n"; }}import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.model.MessageDirection;import dev.postproxy.sdk.param.ListMessagesParams;
var client = PostProxy.builder("YOUR_API_KEY").build();var messages = client.messages().list("CHAT_ID", ListMessagesParams.builder() .direction(MessageDirection.INBOUND) .build());for (var msg : messages.data()) { System.out.println("[" + msg.direction().getValue() + "] " + msg.body()); for (var att : msg.attachments()) { System.out.println(" attachment " + att.type() + ": " + att.url()); }}using PostProxy;using PostProxy.Models;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var messages = await client.Messages.ListAsync("CHAT_ID", new ListMessagesParams { Direction = MessageDirection.Inbound });foreach (var msg in messages.Data){ Console.WriteLine($"[{msg.Direction}] {msg.Body}"); foreach (var att in msg.Attachments) Console.WriteLine($" attachment: {att.Type} {att.Url}");}Response:
{ "total": 8, "page": 0, "per_page": 20, "data": [ { "id": "msg_111", "chat_id": "chat_xyz789", "external_id": "mid.abc123", "direction": "inbound", "body": "Hey, do you ship internationally?", "status": "received", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": "2026-05-31T14:02:00.000Z", "external_delivered_at": "2026-05-31T14:02:00.500Z", "external_read_at": "2026-05-31T14:03:12.000Z", "external_edited_at": null, "reply_to_external_id": null, "external_deleted_at": null, "reactions": [ { "sender_external_id": "psid_123", "emoji": "❤️", "reaction": "love", "at": "2026-05-31T14:04:00.000Z" } ], "attachments": [], "is_unsupported": false, "created_at": "2026-05-31T14:02:01.000Z" } ]}Send message
Section titled “Send message”POST /api/chats/:chat_id/messages
Creates an outbound message and queues it for delivery. The message is created immediately with status: "pending" and published asynchronously. Once published, the status updates to "published" and external_id is populated. On failure it becomes "failed_waiting_for_retry" (retried with backoff) or "failed".
Request body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
body | string | Conditional | Message text. Required when media is empty. |
media | array | Conditional | A single media attachment. See Send media. |
reply_to_external_id | string | No | Telegram only. Platform message_id to thread the reply under. |
reply_markup | object | No | Telegram only. Inline keyboard, reply keyboard, force-reply, or remove-keyboard payload. See Telegram notes. |
Each send accepts either text or a single attachment, never both.
Example
Section titled “Example”curl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/messages" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "body": "Yes, we ship worldwide!" }'import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const sent = await client.messages.send("CHAT_ID", { body: "Yes, we ship worldwide!" });console.log(`Sent message: ${sent.id} (status: ${sent.status})`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")sent = await client.messages.send("CHAT_ID", body="Yes, we ship worldwide!")print(f"Sent message: {sent.id} (status: {sent.status})")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") text := "Yes, we ship worldwide!" sent, _ := client.Messages.Send(context.Background(), "CHAT_ID", &postproxy.MessageSendOptions{Body: &text}) fmt.Printf("Sent message: %s (status: %s)\n", sent.ID, sent.Status)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")sent = client.messages.send("CHAT_ID", body: "Yes, we ship worldwide!")puts "Sent message: #{sent.id} (status: #{sent.status})"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");$sent = $client->messages()->send("CHAT_ID", body: "Yes, we ship worldwide!");echo "Sent message: {$sent->id} (status: {$sent->status})\n";import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.param.SendMessageParams;
var client = PostProxy.builder("YOUR_API_KEY").build();var sent = client.messages().send("CHAT_ID", SendMessageParams.builder() .body("Yes, we ship worldwide!") .build());System.out.println("Sent: " + sent.id() + " (status: " + sent.status().getValue() + ")");using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var sent = await client.Messages.SendAsync("CHAT_ID", "Yes, we ship worldwide!");Console.WriteLine($"Sent: {sent.Id} (status: {sent.Status})");Response (202 Accepted):
{ "id": "msg_222", "chat_id": "chat_xyz789", "external_id": null, "direction": "outbound", "body": "Yes, we ship worldwide!", "status": "pending", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": null, "attachments": [], "created_at": "2026-05-31T15:30:05.000Z"}Send media
Section titled “Send media”media accepts the same shapes as the Posts API — a multipart upload, a public URL, a base64 data URI, or a base64 hash. Currently one attachment per send (Meta Send API limit); passing more returns 422. Attachments are downloaded to durable storage before dispatch, so the returned url is stable and does not expire. Bluesky does not support attachments — media returns 422 on Bluesky chats.
# Send an image by hosted URLcurl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/messages" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "media": ["https://cdn.example.com/photo.png"] }'
# Or upload a local file (multipart)curl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/messages" \ -H "Authorization: Bearer YOUR_API_KEY" \ -F "media[]=@./photo.png"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Hosted URLconst sent = await client.messages.send("CHAT_ID", { media: ["https://cdn.example.com/photo.png"],});// Or a local file (multipart)// await client.messages.send("CHAT_ID", { mediaFiles: ["./photo.png"] });console.log(`Sent media message: ${sent.id} (status: ${sent.status})`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Hosted URLsent = await client.messages.send("CHAT_ID", media=["https://cdn.example.com/photo.png"])# Or a local file (multipart)# await client.messages.send("CHAT_ID", media_files=["./photo.png"])print(f"Sent media message: {sent.id} (status: {sent.status})")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Hosted URL sent, _ := client.Messages.Send(context.Background(), "CHAT_ID", &postproxy.MessageSendOptions{ Media: []string{"https://cdn.example.com/photo.png"}, }) // Or a local file (multipart) // client.Messages.Send(ctx, "CHAT_ID", &postproxy.MessageSendOptions{MediaFiles: []string{"./photo.png"}}) fmt.Printf("Sent media message: %s (status: %s)\n", sent.ID, sent.Status)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Hosted URLsent = client.messages.send("CHAT_ID", media: ["https://cdn.example.com/photo.png"])# Or a local file (multipart)# client.messages.send("CHAT_ID", media_files: ["./photo.png"])puts "Sent media message: #{sent.id} (status: #{sent.status})"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Hosted URL$sent = $client->messages()->send("CHAT_ID", media: ["https://cdn.example.com/photo.png"]);// Or a local file (multipart)// $client->messages()->send("CHAT_ID", mediaFiles: ["./photo.png"]);echo "Sent media message: {$sent->id} (status: {$sent->status})\n";import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.param.SendMessageParams;
import java.util.List;
var client = PostProxy.builder("YOUR_API_KEY").build();// Hosted URLvar sent = client.messages().send("CHAT_ID", SendMessageParams.builder() .media(List.of("https://cdn.example.com/photo.png")) .build());System.out.println("Sent media message: " + sent.id() + " (status: " + sent.status().getValue() + ")");using PostProxy;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Hosted URLvar sent = await client.Messages.SendAsync("CHAT_ID", new SendMessageParams{ Media = new[] { "https://cdn.example.com/photo.png" }, // MediaFiles = new[] { "./photo.png" },});Console.WriteLine($"Sent media message: {sent.Id} (status: {sent.Status})");Response (202 Accepted):
{ "id": "msg_223", "chat_id": "chat_xyz789", "external_id": null, "direction": "outbound", "body": null, "status": "pending", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": null, "attachments": [ { "id": "att_zxc987", "type": "image", "url": "https://cdn.example.com/photo.png", "status": "pending", "external_id": null } ], "created_at": "2026-05-31T15:31:00.000Z"}Error responses
Section titled “Error responses”Neither body nor media supplied (400):
{ "error": "body or media is required" }Both body and media supplied (422):
{ "error": "Direct messages support either text or attachment per send, not both" }More than one attachment (422):
{ "error": "Direct messages support one attachment per send" }Get message
Section titled “Get message”GET /api/messages/:id
Fetch a single message by Postproxy hashid or by platform external_id.
Example
Section titled “Example”curl -X GET "https://api.postproxy.dev/api/messages/MESSAGE_ID" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");const message = await client.messages.get("MESSAGE_ID");console.log(message);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")message = await client.messages.get("MESSAGE_ID")print(message)package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") message, _ := client.Messages.Get(context.Background(), "MESSAGE_ID", nil) fmt.Printf("[%s] %v\n", message.Direction, message.Body)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")message = client.messages.get("MESSAGE_ID")puts message.inspectuse PostProxy\Client;
$client = new Client("YOUR_API_KEY");$message = $client->messages()->get("MESSAGE_ID");echo "[{$message->direction}] {$message->body}\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();var message = client.messages().get("MESSAGE_ID");System.out.println("[" + message.direction().getValue() + "] " + message.body());using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();var message = await client.Messages.GetAsync("MESSAGE_ID");Console.WriteLine($"[{message.Direction}] {message.Body}");Response:
{ "id": "msg_111", "chat_id": "chat_xyz789", "external_id": "mid.abc123", "direction": "inbound", "body": "Hey, do you ship internationally?", "status": "received", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": "2026-05-31T14:02:00.000Z", "external_delivered_at": "2026-05-31T14:02:00.500Z", "external_read_at": "2026-05-31T14:03:12.000Z", "external_edited_at": null, "reply_to_external_id": null, "external_deleted_at": null, "reactions": [], "attachments": [], "is_unsupported": false, "created_at": "2026-05-31T14:02:01.000Z"}Edit message
Section titled “Edit message”PATCH /api/messages/:id
Edit a previously-sent outbound message on the platform. Telegram only — Facebook and Instagram do not expose outbound edits to bots, so calling this on a non-Telegram message returns 422. Edits update both the platform-side message and the Postproxy record; external_edited_at is set and a message.edited webhook fires.
Request body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
body | string | Conditional | New text. For media messages this becomes the new caption. At least one of body / reply_markup is required. |
reply_markup | object | Conditional | New inline keyboard (or other reply_markup). Pass an empty {} to remove the existing keyboard. |
Example
Section titled “Example”curl -X PATCH "https://api.postproxy.dev/api/messages/MESSAGE_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "body": "Updated answer: we ship to 90+ countries." }'import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Editing is Telegram-onlyconst message = await client.messages.edit("MESSAGE_ID", { body: "Updated answer: we ship to 90+ countries.",});console.log(`Edited at: ${message.external_edited_at}`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Editing is Telegram-onlymessage = await client.messages.edit( "MESSAGE_ID", body="Updated answer: we ship to 90+ countries.")print(f"Edited at: {message.external_edited_at}")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Editing is Telegram-only body := "Updated answer: we ship to 90+ countries." message, _ := client.Messages.Edit(context.Background(), "MESSAGE_ID", &postproxy.MessageEditOptions{Body: &body}) fmt.Printf("Edited at: %v\n", message.ExternalEditedAt)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Editing is Telegram-onlymessage = client.messages.edit("MESSAGE_ID", body: "Updated answer: we ship to 90+ countries.")puts "Edited at: #{message.external_edited_at}"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Editing is Telegram-only$message = $client->messages()->edit("MESSAGE_ID", body: "Updated answer: we ship to 90+ countries.");echo "Edited at: {$message->externalEditedAt?->format('c')}\n";import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.param.EditMessageParams;
var client = PostProxy.builder("YOUR_API_KEY").build();// Editing is Telegram-onlyvar message = client.messages().edit("MESSAGE_ID", EditMessageParams.builder() .body("Updated answer: we ship to 90+ countries.") .build());System.out.println("Edited at: " + message.externalEditedAt());using PostProxy;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Editing is Telegram-onlyvar message = await client.Messages.EditAsync("MESSAGE_ID", new EditMessageParams{ Body = "Updated answer: we ship to 90+ countries.",});Console.WriteLine($"Edited at: {message.ExternalEditedAt}");Response:
{ "id": "msg_222", "chat_id": "chat_tg456", "external_id": "5821", "direction": "outbound", "body": "Updated answer: we ship to 90+ countries.", "status": "published", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": "2026-05-31T15:30:10.000Z", "external_delivered_at": null, "external_read_at": null, "external_edited_at": "2026-05-31T15:45:00.000Z", "reply_to_external_id": null, "reply_markup": null, "external_deleted_at": null, "reactions": [], "attachments": [], "is_unsupported": false, "created_at": "2026-05-31T15:30:05.000Z"}Error responses
Section titled “Error responses”422—Editing messages is only supported on Telegram422—Only published outbound messages can be edited400—body or reply_markup is required422— Telegram-side errors are passed through verbatim (e.g.Bad Request: message is not modified).
React to message
Section titled “React to message”POST /api/messages/:id/react
Add a reaction from your business account to a message. The reaction appears in the recipient’s thread, is persisted on the message under your profile.external_id, and fires a reaction.received webhook. Supported on Facebook Messenger and Instagram Direct only — other networks (including Telegram) return 422.
Request body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
reaction | string | No | Named reaction, defaults to love. |
emoji | string | No | Unicode emoji. Forwarded to Instagram; on Messenger it overrides reaction as the literal value sent to Meta. |
Instagram’s outbound reactions API accepts only love (you may pass an emoji alongside). Facebook Messenger expects a unicode emoji — Postproxy translates the common named reactions before calling Meta:
reaction | Sent to Messenger as |
|---|---|
love | ❤ |
like | 👍 |
dislike | 👎 |
smile | 😆 |
wow | 😮 |
sad | 😢 |
angry | 😡 |
A supplied emoji always takes precedence over the named reaction for Messenger. A second react from the same account replaces the previous one — Meta does not stack reactions.
Example
Section titled “Example”# Instagram — react with a named reaction (love only)curl -X POST "https://api.postproxy.dev/api/messages/MESSAGE_ID/react" \ -H "Authorization: Bearer YOUR_API_KEY" \ -d 'reaction=love' \ -d 'emoji=❤️'
# Facebook Messenger — react by name (auto-translated to the emoji)# curl -X POST "https://api.postproxy.dev/api/messages/MESSAGE_ID/react" \# -H "Authorization: Bearer YOUR_API_KEY" \# -d 'reaction=smile'import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// React on Facebook & Instagramconst message = await client.messages.react("MESSAGE_ID", { reaction: "love", emoji: "❤️" });console.log(message.reactions);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# React on Facebook & Instagrammessage = await client.messages.react("MESSAGE_ID", reaction="love", emoji="❤️")print(message.reactions)package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // React on Facebook & Instagram reaction := "love" emoji := "❤️" message, _ := client.Messages.React(context.Background(), "MESSAGE_ID", &postproxy.MessageReactOptions{ Reaction: &reaction, Emoji: &emoji, }) fmt.Printf("Reactions: %d\n", len(message.Reactions))}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# React on Facebook & Instagrammessage = client.messages.react("MESSAGE_ID", reaction: "love", emoji: "❤️")puts message.reactions.inspectuse PostProxy\Client;
$client = new Client("YOUR_API_KEY");// React on Facebook & Instagram$message = $client->messages()->react("MESSAGE_ID", reaction: "love", emoji: "❤️");echo count($message->reactions) . " reaction(s)\n";import dev.postproxy.sdk.PostProxy;import dev.postproxy.sdk.param.ReactParams;
var client = PostProxy.builder("YOUR_API_KEY").build();// React on Facebook & Instagramvar message = client.messages().react("MESSAGE_ID", ReactParams.builder() .reaction("love") .emoji("❤️") .build());System.out.println(message.reactions());using PostProxy;using PostProxy.Parameters;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// React on Facebook & Instagramvar message = await client.Messages.ReactAsync("MESSAGE_ID", new ReactParams { Reaction = "love", Emoji = "❤️" });Console.WriteLine($"{message.Reactions.Count} reaction(s)");Response:
{ "id": "msg_111", "chat_id": "chat_xyz789", "external_id": "mid.abc123", "direction": "inbound", "body": "Hey, do you ship internationally?", "status": "received", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": "2026-05-31T14:02:00.000Z", "external_delivered_at": "2026-05-31T14:02:00.500Z", "external_read_at": "2026-05-31T14:03:12.000Z", "external_edited_at": null, "reply_to_external_id": null, "external_deleted_at": null, "reactions": [ { "sender_external_id": "prof_external_42", "emoji": "❤️", "reaction": "love", "at": "2026-05-31T15:50:00.000Z" } ], "attachments": [], "is_unsupported": false, "created_at": "2026-05-31T14:02:01.000Z"}Remove reaction
Section titled “Remove reaction”DELETE /api/messages/:id/unreact
Remove the business account’s reaction from a message. Acts on whichever reaction the current account previously set; does not affect reactions from other senders. Returns the message with the account’s entry stripped from reactions, and fires a reaction.received webhook with action: "unreact".
Example
Section titled “Example”curl -X DELETE "https://api.postproxy.dev/api/messages/MESSAGE_ID/unreact" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Removes this business account's reactionconst message = await client.messages.unreact("MESSAGE_ID");console.log(message.reactions);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Removes this business account's reactionmessage = await client.messages.unreact("MESSAGE_ID")print(message.reactions)package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Removes this business account's reaction message, _ := client.Messages.Unreact(context.Background(), "MESSAGE_ID", nil) fmt.Printf("Reactions: %d\n", len(message.Reactions))}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Removes this business account's reactionmessage = client.messages.unreact("MESSAGE_ID")puts message.reactions.inspectuse PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Removes this business account's reaction$message = $client->messages()->unreact("MESSAGE_ID");echo count($message->reactions) . " reaction(s)\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();// Removes this business account's reactionvar message = client.messages().unreact("MESSAGE_ID");System.out.println(message.reactions());using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Removes this business account's reactionvar message = await client.Messages.UnreactAsync("MESSAGE_ID");Console.WriteLine($"{message.Reactions.Count} reaction(s)");Response:
{ "id": "msg_111", "chat_id": "chat_xyz789", "external_id": "mid.abc123", "direction": "inbound", "body": "Hey, do you ship internationally?", "status": "received", "external_comment_id": null, "error_message": null, "platform_data": null, "external_posted_at": "2026-05-31T14:02:00.000Z", "external_delivered_at": "2026-05-31T14:02:00.500Z", "external_read_at": "2026-05-31T14:03:12.000Z", "external_edited_at": null, "reply_to_external_id": null, "external_deleted_at": null, "reactions": [], "attachments": [], "is_unsupported": false, "created_at": "2026-05-31T14:02:01.000Z"}Archive chat
Section titled “Archive chat”POST /api/chats/:id/archive
Bluesky only. Archives (mutes) a conversation via chat.bsky.convo.muteConvo. The new state is reflected in the chat’s metadata.muted and the top-level archived boolean.
Example
Section titled “Example”curl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/archive" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Archive (mute) a Bluesky chatconst chat = await client.chats.archive("CHAT_ID");console.log(`Archived: ${chat.archived}`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Archive (mute) a Bluesky chatchat = await client.chats.archive("CHAT_ID")print(f"Archived: {chat.archived}")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Archive (mute) a Bluesky chat chat, _ := client.Chats.Archive(context.Background(), "CHAT_ID", nil) fmt.Printf("Archived: %v\n", chat.Archived)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Archive (mute) a Bluesky chatchat = client.chats.archive("CHAT_ID")puts "Archived: #{chat.archived}"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Archive (mute) a Bluesky chat$chat = $client->chats()->archive("CHAT_ID");echo "Archived: " . var_export($chat->archived, true) . "\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();// Archive (mute) a Bluesky chatvar chat = client.chats().archive("CHAT_ID");System.out.println("Archived: " + chat.archived());using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Archive (mute) a Bluesky chatvar chat = await client.Chats.ArchiveAsync("CHAT_ID");Console.WriteLine($"Archived: {chat.Archived}");Response:
{ "id": "chat_bsky01", "profile_id": "prof_bsky99", "platform": "bluesky", "participant_external_id": "did:plc:examplez7y6x5w4v3u2", "participant_username": "jane.bsky.social", "participant_name": "Jane", "participant_avatar_url": null, "external_conversation_id": "3labcconvoid", "last_inbound_at": "2026-05-31T14:02:00.000Z", "last_outbound_at": "2026-05-31T15:10:00.000Z", "last_message_at": "2026-05-31T15:10:00.000Z", "metadata": { "muted": true }, "archived": true, "created_at": "2026-04-12T08:00:00.000Z"}Unarchive chat
Section titled “Unarchive chat”DELETE /api/chats/:id/archive
Bluesky only. Unarchives (unmutes) a conversation via chat.bsky.convo.unmuteConvo.
Example
Section titled “Example”curl -X DELETE "https://api.postproxy.dev/api/chats/CHAT_ID/archive" \ -H "Authorization: Bearer YOUR_API_KEY"import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Unarchive (unmute) a Bluesky chatconst chat = await client.chats.unarchive("CHAT_ID");console.log(`Archived: ${chat.archived}`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Unarchive (unmute) a Bluesky chatchat = await client.chats.unarchive("CHAT_ID")print(f"Archived: {chat.archived}")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Unarchive (unmute) a Bluesky chat chat, _ := client.Chats.Unarchive(context.Background(), "CHAT_ID", nil) fmt.Printf("Archived: %v\n", chat.Archived)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Unarchive (unmute) a Bluesky chatchat = client.chats.unarchive("CHAT_ID")puts "Archived: #{chat.archived}"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Unarchive (unmute) a Bluesky chat$chat = $client->chats()->unarchive("CHAT_ID");echo "Archived: " . var_export($chat->archived, true) . "\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();// Unarchive (unmute) a Bluesky chatvar chat = client.chats().unarchive("CHAT_ID");System.out.println("Archived: " + chat.archived());using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Unarchive (unmute) a Bluesky chatvar chat = await client.Chats.UnarchiveAsync("CHAT_ID");Console.WriteLine($"Archived: {chat.Archived}");Response:
{ "id": "chat_bsky01", "profile_id": "prof_bsky99", "platform": "bluesky", "participant_external_id": "did:plc:examplez7y6x5w4v3u2", "participant_username": "jane.bsky.social", "participant_name": "Jane", "participant_avatar_url": null, "external_conversation_id": "3labcconvoid", "last_inbound_at": "2026-05-31T14:02:00.000Z", "last_outbound_at": "2026-05-31T15:10:00.000Z", "last_message_at": "2026-05-31T15:10:00.000Z", "metadata": { "muted": false }, "archived": false, "created_at": "2026-04-12T08:00:00.000Z"}Private reply to comment
Section titled “Private reply to comment”POST /api/posts/:post_id/comments/:id/private_reply
Sends a DM to the author of a comment, in response to that specific comment, using Meta’s “Private Replies” mechanism. This:
- Bypasses the 24h messaging window — you can private-reply to comments up to 7 days old (Meta-enforced).
- Does not require an existing chat — one is created (or reused) automatically, keyed by the comment’s author.
- Allows one private reply per comment, ever (Meta limit).
- Is available on Instagram and Facebook Page comments only.
Query parameters
Section titled “Query parameters”| Parameter | Type | Required | Description |
|---|---|---|---|
profile_id | string | Yes | Profile hashid (Instagram or Facebook) |
Request body
Section titled “Request body”| Parameter | Type | Required | Description |
|---|---|---|---|
text | string | Yes | DM text |
Example
Section titled “Example”curl -X POST "https://api.postproxy.dev/api/posts/POST_ID/comments/COMMENT_ID/private_reply?profile_id=PROFILE_ID" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Thanks for your comment — DM-ing you the details." }'import PostProxy from "postproxy-sdk";
const client = new PostProxy("YOUR_API_KEY");// Returns a Message (the DM queued to the comment author)const reply = await client.comments.privateReply( "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.",);console.log(`Private reply queued: ${reply.id} (chat: ${reply.chat_id})`);from postproxy import PostProxy
client = PostProxy("YOUR_API_KEY")# Returns a Message (the DM queued to the comment author)reply = await client.comments.private_reply( "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.")print(f"Private reply queued: {reply.id} (chat: {reply.chat_id})")package main
import ( "context" "fmt" postproxy "github.com/postproxy/postproxy-go")
func main() { client := postproxy.NewClient("YOUR_API_KEY") // Returns a Message (the DM queued to the comment author) reply, _ := client.Comments.PrivateReply(context.Background(), "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.") fmt.Printf("Private reply queued: %s (chat: %s)\n", reply.ID, reply.ChatID)}require "postproxy"
client = PostProxy::Client.new("YOUR_API_KEY")# Returns a Message (the DM queued to the comment author)reply = client.comments.private_reply( "POST_ID", "COMMENT_ID", profile_id: "PROFILE_ID", text: "Thanks for your comment — DM-ing you the details.")puts "Private reply queued: #{reply.id} (chat: #{reply.chat_id})"use PostProxy\Client;
$client = new Client("YOUR_API_KEY");// Returns a Message (the DM queued to the comment author)$reply = $client->comments()->privateReply( "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.",);echo "Private reply queued: {$reply->id} (chat: {$reply->chatId})\n";import dev.postproxy.sdk.PostProxy;
var client = PostProxy.builder("YOUR_API_KEY").build();// Returns a Message (the DM queued to the comment author)var reply = client.comments().privateReply( "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.");System.out.println("Private reply queued: " + reply.id() + " (chat: " + reply.chatId() + ")");using PostProxy;
var client = PostProxyClient.Builder("YOUR_API_KEY").Build();// Returns a Message (the DM queued to the comment author)var reply = await client.Comments.PrivateReplyAsync( "POST_ID", "COMMENT_ID", "PROFILE_ID", "Thanks for your comment — DM-ing you the details.");Console.WriteLine($"Private reply queued: {reply.Id} (chat: {reply.ChatId})");Response (202 Accepted) — note the returned object is a Message, with chat_id pointing at the chat for the comment’s author:
{ "id": "msg_333", "chat_id": "chat_xyz789", "external_id": null, "direction": "outbound", "body": "Thanks for your comment — DM-ing you the details.", "status": "pending", "external_comment_id": "17858893269123456", "error_message": null, "platform_data": null, "external_posted_at": null, "created_at": "2026-05-31T15:35:00.000Z"}Telegram notes
Section titled “Telegram notes”Telegram DMs use the Bot API and offer richer capabilities than Meta in some areas, but work differently in others.
- Inbound DMs, edits, and media. Photos, videos, documents, animations (GIFs), audio, voice notes, and stickers all arrive via webhook and are mirrored into durable storage. Inbound edits fire
message.edited. - Outbound edits.
PATCH /api/messages/:idupdates the platform-side message — see Edit message. - Reply threading. Pass
reply_to_external_idon send (orreply_markup: { force_reply: true }) to thread under a specific message. - Keyboards.
reply_markupis passed through to Telegram unchanged — inline keyboards (inline_keyboard), custom reply keyboards (keyboard, withone_time_keyboard/resize_keyboard),force_reply, and{ "remove_keyboard": true }. - Callback queries. When a user taps an inline-keyboard button with
callback_data, Postproxy auto-acks and creates an inbound message withbodyset to the callback data andplatform_data.kind == "callback_query". Subscribe tomessage.receivedto react.
Differences from Meta:
- Bot must be DM’d first. A bot can only message a user after that user has DM’d it at least once (typically via
/start). Sending otherwise returns422with Telegram’sForbidden: bot can't initiate conversation with a user. - No reactions, no private-reply-to-comment. Both return
422on Telegram chats. - No delivery / read receipts and no deletion events.
external_delivered_at/external_read_atstaynull;message.delivered/message.read/message.deletednever fire for Telegram.
Bluesky notes
Section titled “Bluesky notes”Bluesky DMs go through the AT Protocol chat service (chat.bsky.convo.*). They behave differently from the other platforms:
- No webhooks. Inbound messages arrive via a per-profile poller that runs every 5 minutes and walks
chat.bsky.convo.getLog. New inbound messages still firemessage.received, with up to ~5 minutes of latency. - No attachments, reactions, or edits.
media, the React / Unreact endpoints, andPATCH /api/messages/:idall return422on Bluesky chats. - Archive = mute. Archive / unarchive map to
muteConvo/unmuteConvo; the chat response carries anarchivedboolean. - Starting a chat by DID. Pass the recipient’s DID as
participant_external_id. The Bluesky convo id is resolved lazily on first send and stored asexternal_conversation_id. - No delivery / read receipts. Message deletions are best-effort (
external_deleted_atis stamped when the participant deletes for self).
Participant metadata
Section titled “Participant metadata”A chat’s metadata object carries platform-specific information about the participant, fetched asynchronously when the chat is first created and refreshed at most once per hour on inbound messages. Keys are only present when the platform returns a value — treat missing keys as unknown, not false.
| Key | Platform | Type | Description |
|---|---|---|---|
page_id | string | Page ID this chat is owned by (required for sending) | |
participant_fetched_at | all | string | ISO 8601 timestamp of the last recipient-info refresh |
language_code | telegram | string | IETF language tag reported by Telegram for the user |
muted | bluesky | boolean | Whether the conversation is muted/archived |
is_verified_user | instagram, facebook | boolean | Participant has a verified account |
is_user_follow_business | instagram, facebook | boolean | Participant follows the business profile |
is_business_follow_user | instagram, facebook | boolean | The business profile follows the participant |
follower_count | integer | Participant’s follower count | |
is_payment_enabled | boolean | Participant has Messenger payments enabled | |
is_subscribed_to_messenger | boolean | Participant is subscribed to the Page via Messenger |
Chat object fields
Section titled “Chat object fields”| Field | Type | Description |
|---|---|---|
id | string | Chat hashid |
profile_id | string | Profile hashid this chat belongs to |
platform | string | Network ID (facebook, instagram, telegram, bluesky) |
participant_external_id | string | Platform-native participant ID |
participant_username | string|null | Participant’s username (when known) |
participant_name | string|null | Participant’s display name (when known) |
participant_avatar_url | string|null | Stable storage URL of the participant’s profile picture (null until fetched) |
external_conversation_id | string|null | Platform’s native conversation ID (when known) |
last_inbound_at | string|null | ISO 8601 timestamp of last inbound message |
last_outbound_at | string|null | ISO 8601 timestamp of last outbound message |
last_message_at | string|null | ISO 8601 timestamp of last message in either direction |
archived | boolean | Bluesky only — whether the chat is muted/archived |
metadata | object|null | Platform-specific metadata. See Participant metadata. |
created_at | string | ISO 8601 timestamp of chat creation |
Message object fields
Section titled “Message object fields”| Field | Type | Description |
|---|---|---|
id | string | Message hashid |
chat_id | string | Chat hashid |
external_id | string|null | Platform-native message ID (null while pending) |
direction | string | inbound or outbound |
body | string|null | Message text |
status | string | See Message statuses |
external_comment_id | string|null | Set when the message was sent as a private reply to a comment |
error_message | string|null | Last error from the platform (when failed) |
platform_data | object|null | Platform-specific payload |
external_posted_at | string|null | ISO 8601 timestamp from the platform |
external_delivered_at | string|null | When the platform confirmed delivery (outbound; FB & IG only) |
external_read_at | string|null | When the recipient read the message (outbound; FB & IG only) |
external_edited_at | string|null | ISO 8601 timestamp of the most recent edit; body reflects the latest edit |
reply_to_external_id | string|null | Platform message ID this message replies to (inbound replies, outbound reply_to_external_id, or a Telegram callback query’s source) |
reply_markup | object|null | Telegram only — the reply_markup attached to the message |
external_deleted_at | string|null | When the message was deleted/unsent on the platform (treat as redacted) |
reactions | array | Reactions on the message — each entry: sender_external_id, emoji, reaction, at. Reflects the live state. See React to message. |
attachments | array | Media attached to the message. See Media attachments. |
is_unsupported | boolean | true when the platform marked the message as unsupported (e.g. an unprocessable voice clip) |
created_at | string | ISO 8601 timestamp of record creation |
Media attachments
Section titled “Media attachments”Both inbound and outbound messages can carry attachments. Inbound media is mirrored from the platform CDN to durable storage shortly after the webhook arrives; outbound files supplied via media are uploaded before the send. In both cases the url is stable — it does not expire.
| Field | Type | Description |
|---|---|---|
id | string | Attachment hashid |
type | string | image, video, audio, sticker, file |
url | string|null | Stable storage URL. While status is pending this may temporarily fall back to the source URL. |
status | string | pending, processed, or failed |
external_id | string|null | Platform-side identifier (e.g. a Messenger sticker ID), when provided |
Message statuses
Section titled “Message statuses”| Status | Direction | Description |
|---|---|---|
pending | outbound | Queued, not yet delivered to the platform |
published | outbound | Successfully delivered to the platform |
failed_waiting_for_retry | outbound | Send failed; will be retried with backoff |
failed | outbound | Send failed permanently (retries exhausted) |
received | inbound | Received from the platform |
Webhooks
Section titled “Webhooks”Subscribe to message and reaction events with the Webhooks API. Relevant event types include message.received, message.sent, message.delivered, message.read, message.edited, message.deleted, message.failed_waiting_for_retry, message.failed, and reaction.received. See the Webhooks reference for payload shapes and per-platform firing rules.