Media messages
Wabery uses one message API and one normalized inbound webhook contract across WhatsApp Business, Instagram Direct, and Facebook Messenger.
The SDK and REST examples below are equivalent. Wabery resolves Meta’s
channel-specific sender identifiers and credentials server-side, so integrations
must pass Wabery channelId/channel_id values rather than Facebook Page or
Instagram account IDs.
| Operation | REST | SDK |
|---|---|---|
| Send media | POST /v1/messages |
wabery.messages.send() |
| Check send status | GET /v1/messages/{message_id} |
wabery.messages.get() |
| Read history or refresh a media URL | GET /v1/conversations/{id}/messages |
wabery.conversations.listMessages() |
| Receive media | message.received webhook |
wabery.webhooks.constructEvent() |
The complete machine-readable contract is also available as OpenAPI.
Send an attachment
Section titled “Send an attachment”There is no multipart upload endpoint. Send a public HTTPS URL that Meta can
download, together with the existing channel_id and conversation_id.
Instagram and Messenger are reply channels: the customer must have messaged that
same channel during the previous 24 hours.
The PHP and Python examples below use the reusable clients from PHP, Python, and API clients.
await wabery.messages.send({ channelId: "instagram_channel_...", conversationId: "conversation_...", idempotencyKey: "reply-media-123", media: { type: "image", link: "https://cdn.example.com/photo.jpg", },});$message = waberyRequest("POST", "/messages", [ "channel_id" => "instagram_channel_...", "conversation_id" => "conversation_...", "idempotency_key" => "reply-media-123", "media" => [ "type" => "image", "link" => "https://cdn.example.com/photo.jpg", ],]);message = wabery_request( "POST", "/messages", json={ "channel_id": "instagram_channel_...", "conversation_id": "conversation_...", "idempotency_key": "reply-media-123", "media": { "type": "image", "link": "https://cdn.example.com/photo.jpg", }, },)curl https://api.wabery.com/v1/messages \ -H "Authorization: Bearer $WABERY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-media-123" \ -d '{ "channel_id": "instagram_channel_...", "conversation_id": "conversation_...", "media": { "type": "image", "link": "https://cdn.example.com/photo.jpg" } }'202 Accepted means Wabery queued the send; it does not mean Meta delivered it.
Poll wabery.messages.get(message.id) or consume message.status webhooks to
observe sent, delivered, read, or failed.
Channel support and limits
Section titled “Channel support and limits”| Channel | Outbound types | Provider limit |
|---|---|---|
| image, document, audio, video | image 5 MB; audio/video 16 MB; document 100 MB | |
| Instagram beta | image, audio, video | image 8 MB; audio/video 25 MB |
| Messenger beta | image, document, audio, video | 25 MB per attachment |
WhatsApp accepts either a public HTTPS media.link or a previously uploaded
WhatsApp media.id. It supports caption and document filename; audio accepts
neither.
Instagram and Messenger accept media.link only. The URL must be fetchable by
Meta without custom headers. Wabery rejects media.id, caption, and filename
on those channels because Meta’s URL-attachment request cannot honor those
fields. Instagram does not accept outbound documents; send the document URL as
an ordinary text message instead.
Unsupported combinations fail before queueing:
| Error | Meaning |
|---|---|
media_id_unsupported_on_channel |
Instagram/Messenger received media.id |
public_https_media_link_required |
The channel requires a directly fetchable HTTPS URL |
media_type_unsupported_on_channel |
The selected channel does not support that type |
media_metadata_unsupported_on_channel |
The channel cannot honor caption or filename |
messaging_window_closed |
No customer message on that channel within 24 hours |
Media capability errors use HTTP 422. A closed messaging window uses HTTP 409. None of these responses queue a message.
Receive an attachment
Section titled “Receive an attachment”Instagram, Messenger, and WhatsApp use the same normalized message.received
shape. Provider-specific temporary URLs do not leak into your integration;
Wabery caches the attachment and supplies a signed download URL.
{ "event": "message.received", "api_version": "2026-06-18", "payload": { "channel_id": "instagram_channel_...", "conversation_id": "conversation_...", "contact_id": "contact_...", "from": null, "user_id": "INSTAGRAM_SCOPED_USER_ID", "message_id": "message_...", "type": "image", "text": null, "messages": [ { "id": "message_...", "type": "image", "text": null, "received_at": "2026-07-30T15:27:01.778Z", "media": { "id": "asset_...", "message_id": "message_...", "type": "image", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "expires_at": "2026-08-06T15:27:01.778Z", "mime_type": "image/jpeg", "file_name": "photo.jpg", "file_size": 248913, "provider": "instagram", "provider_media_id": null, "status": "available", "error_code": null }, "location": null, "interactive": null, "replied_to": null } ], "assets": [ { "id": "asset_...", "message_id": "message_...", "type": "image", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "expires_at": "2026-08-06T15:27:01.778Z", "mime_type": "image/jpeg", "file_name": "photo.jpg", "file_size": 248913, "provider": "instagram", "provider_media_id": null, "status": "available", "error_code": null } ], "media": { "id": "asset_...", "url": "https://storage.wabery.com/assets/inbound/asset_...?token=...", "provider": "instagram", "status": "available" } }, "sentAt": "2026-07-30T15:27:04.000Z"}Messenger uses the identical fields with provider: "messenger". A received PDF
has type: "document" and normally mime_type: "application/pdf".
provider_media_id may be null when Meta supplies only a temporary source URL.
file_name preserves the provider filename when Meta supplies it. Otherwise
Wabery uses the response Content-Disposition, the source URL basename, or a
generated safe name. Do not assume that the sender’s original local filename is
always available.
On WhatsApp, from is the sender’s E.164 number. On Instagram and Messenger it
is null; use user_id for the Instagram-scoped or Page-scoped sender ID.
Download authentication and expiry
Section titled “Download authentication and expiry”The normalized media.url contains its authorization in the token query
parameter. Download it directly—do not send the Wabery API key or an
Authorization header.
- The signed URL lasts one hour by default.
- The underlying asset is retained for 24 hours on free plans and seven days on paid plans.
expires_atis the asset-retention deadline, not the current URL-token deadline.- Call
wabery.conversations.listMessages(conversationId)to obtain a freshly signed URL while the asset remains retained. - Download and store files promptly, and check
statusbefore usingurl.
if (event.event === "message.received") { for (const asset of event.payload.assets ?? []) { if (asset.status !== "available" || !asset.url) continue;
const response = await fetch(asset.url); if (!response.ok) throw new Error(`Failed to download ${asset.id}`);
await storeFile({ id: asset.id, fileName: asset.file_name, contentType: asset.mime_type, bytes: await response.arrayBuffer(), retainedUntil: asset.expires_at, }); }}foreach ($event["payload"]["assets"] ?? [] as $asset) { if ($asset["status"] !== "available" || empty($asset["url"])) { continue; }
$handle = curl_init($asset["url"]); if ($handle === false) { throw new RuntimeException("Could not initialize cURL"); }
curl_setopt_array($handle, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]);
$bytes = curl_exec($handle); $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE); if ($bytes === false || $status < 200 || $status >= 300) { throw new RuntimeException("Failed to download " . $asset["id"]); }
file_put_contents( __DIR__ . "/downloads/" . basename($asset["file_name"]), $bytes, );}from pathlib import Path
import requests
for asset in event["payload"].get("assets", []): if asset["status"] != "available" or not asset.get("url"): continue
response = requests.get(asset["url"], timeout=(10, 60)) response.raise_for_status()
destination = Path("downloads") / Path(asset["file_name"]).name destination.write_bytes(response.content)See Webhooks & events for signature verification and delivery retries, and Conversations & history for pagination and URL refresh.