Sending media through an API looks simple:
Choose a file
Build JSON
POST the request
In production, failures usually happen across several separate boundaries:
Media source
↓
Request validation
↓
Account readiness
↓
Provider acceptance
↓
Delivery
↓
Read receipt
A file opening in your browser does not prove that a remote sender can retrieve it. An API response with status: accepted does not prove that the recipient received it. A timed-out request does not prove that nothing happened.
This guide shows how to build and troubleshoot URL-based media sends with UnifyPort without unsafe retries or incorrect delivery claims.
Start with the sending contract
UnifyPort sends media through:
POST /v1/messages
Supported media message types include:
image
video
audio
document
file
Provider support still varies. A shared endpoint does not mean every connected channel supports every media type.
A URL-based image request looks like this:
{
"account_id": "acc_8c21d0",
"to": {
"id": "conversation_42",
"type": "user"
},
"message": {
"type": "image",
"url": "https://media.example.com/orders/order-42.jpg",
"caption": "Your order is ready"
}
}
The media source can use a documented field such as:
message.url
message.file_url
message.file_key
Do not provide conflicting source fields unless the API explicitly documents their precedence. Start with one source and keep the request unambiguous.
A local filename is not a media URL
This is not remotely accessible:
{
"message": {
"type": "image",
"url": "/Users/alice/Desktop/photo.jpg"
}
}
Neither is this:
{
"message": {
"type": "image",
"url": "./uploads/photo.jpg"
}
}
Those paths only have meaning on your machine or application server.
A remote media sender needs an absolute HTTP or HTTPS URL:
{
"message": {
"type": "image",
"url": "https://media.example.com/uploads/photo.jpg"
}
}
A data: URL is not a substitute for a documented remote media source.
If the file begins locally, first place it in an approved storage system and obtain a controlled URL.
“Works in my browser” is not enough
A browser may successfully open a URL because it has:
- login cookies;
- a cached session;
- VPN access;
- internal DNS;
- a previously completed redirect;
- credentials stored by an extension.
The sender retrieving the media does not inherit your browser session.
For example:
https://app.example.com/download/invoice-42
may return the PDF for your authenticated browser but return an HTML login page to an unauthenticated client.
Before sending, test the URL from a separate trusted environment without browser cookies.
Inspect:
- the final HTTP status;
- redirect behavior;
-
Content-Type; - response size;
- whether the response contains media bytes or HTML;
- URL expiration;
- DNS and network accessibility.
Do not perform unrestricted server-side fetches against arbitrary user-provided URLs. Use approved storage origins or a carefully designed allowlist to avoid introducing an SSRF vulnerability.
Use controlled media storage
A production media source should have:
- HTTPS;
- a known storage origin;
- a predictable content type;
- sufficient URL validity;
- narrowly scoped access;
- no credentials embedded in the URL authority;
- logs that redact sensitive query strings.
Signed URLs are useful, but their expiration must account for asynchronous retrieval.
A URL that works during request construction may expire while the message waits in a queue.
There is no universal fetch deadline that applies to every provider and delivery path. Choose an expiration window that matches your own queueing and delivery expectations, then monitor failures.
Build a minimal request
A small JavaScript helper can validate the application-controlled fields:
function buildMediaRequest({
accountId,
conversation,
type,
url,
caption,
}) {
const mediaTypes = new Set([
"image",
"video",
"audio",
"document",
"file",
]);
if (!accountId) {
throw new Error("accountId is required");
}
if (!conversation?.id || !conversation?.type) {
throw new Error("conversation id and type are required");
}
if (!mediaTypes.has(type)) {
throw new Error(`Unsupported media type: ${type}`);
}
const source = new URL(url);
if (!["http:", "https:"].includes(source.protocol)) {
throw new Error("Media source must use HTTP or HTTPS");
}
if (source.username || source.password) {
throw new Error("Do not embed credentials in the media URL");
}
const message = {
type,
url: source.href,
};
if (caption !== undefined) {
if (type === "audio") {
throw new Error("Caption is not valid for this audio request");
}
if (typeof caption !== "string") {
throw new Error("caption must be a string");
}
message.caption = caption;
}
return {
account_id: accountId,
to: {
id: conversation.id,
type: conversation.type,
},
message,
};
}
This helper validates syntax. It does not prove that:
- the URL is remotely reachable;
- the bytes are valid media;
- the account is connected;
- the provider supports the type;
- the recipient will receive the message.
Those require separate checks.
Send from the server
Keep the API key out of browser code:
async function sendMedia(requestBody) {
const response = await fetch(
"https://api.unifyport.ai/v1/messages",
{
method: "POST",
headers: {
"X-Api-Key": process.env.UNIFYPORT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
},
);
const body = await response.json();
if (!response.ok) {
throw new Error(
`${response.status} ${
body.error?.code ?? "unknown_error"
}`,
);
}
return body;
}
Do not log the API key, signed media URL, or unnecessary recipient data.
Do not copy inbound attachments into outbound requests
Inbound media and outbound media use different contracts.
An inbound event may contain fields such as:
{
"attachments": [
{
"type": "image",
"url": "https://example.com/temporary-download",
"mimetype": "image/jpeg"
}
]
}
That object is not automatically a valid outbound message.
The locator may also be temporary or provider-specific.
Build a new outbound request using the documented send fields:
{
"message": {
"type": "image",
"url": "https://approved-storage.example.com/image.jpg"
}
}
If an inbound locator expired, recover the file through the provider’s documented download flow, place it in approved storage, and then construct the outbound request.
Check provider support before showing the control
Do not let users select a media type that the connected provider cannot send.
Your application should evaluate:
provider
account mode
message type
current capability matrix
A conceptual capability check might look like:
function canSendMedia({
provider,
type,
capabilities,
}) {
return capabilities[provider]?.send?.includes(type) === true;
}
If a provider returns:
unsupported_message_type
do not retry the unchanged request.
Disable the control or offer a supported fallback.
A unified API reduces integration differences, but it does not erase provider capabilities.
Check account readiness separately
Authorization does not always mean the messaging runtime is ready.
Before enabling automated sends, distinguish:
Account exists
Account is authorized
Runtime is connected
Provider supports the action
Recipient is valid
An error such as:
provider_not_ready
should trigger account-state diagnosis, not immediate reauthorization or blind retries.
Inspect the account’s authentication and runtime state, then decide whether the connection needs recovery.
Accepted is not delivered
An accepted response means the API accepted the request for processing.
It does not mean:
The provider delivered the file
The recipient received the file
The recipient opened the file
Track distinct states:
created
submitted
accepted
delivered
read
failed
unknown
For example:
const outboundOperation = {
operationId: "send_order_42_image_1",
state: "accepted",
messageId: "msg_3003",
requestId: "req_91c4",
deliveredAt: null,
readAt: null,
};
Only supported delivery evidence should advance the record to delivered or read.
Not every provider emits every receipt. Missing confirmation should remain unknown, not automatically become failed.
Timeouts create uncertain outcomes
A request timeout does not prove that the provider rejected the message.
This is unsafe:
try {
await sendMedia(request);
} catch {
await sendMedia(request);
}
The first request may have succeeded after your client stopped waiting. The retry can produce a duplicate message.
Instead, persist an outbound operation before sending:
const operation = await createOutboundOperation({
accountId,
conversationId,
mediaUrlReference: "order-42-image",
state: "submitting",
});
Then record the result:
try {
const response = await sendMedia(requestBody);
await markAccepted(operation.id, {
messageId: response.data?.message_id,
});
} catch (error) {
if (isDefinitiveRejection(error)) {
await markFailed(operation.id, error);
} else {
await markOutcomeUnknown(operation.id, error);
}
}
An unknown result needs reconciliation or operator review. It should not automatically trigger another irreversible send.
Keep diagnostic IDs for support
Save the returned message identifier and request_id when available.
They help correlate:
- client logs;
- API responses;
- provider processing;
- support investigations;
- webhook receipts.
A request_id is a tracing identifier, not an idempotency token.
Do not reuse it as proof that two send attempts are the same business operation.
Your own stable operation ID should represent intent:
send-media:order-42:customer-copy:1
Debug the failing boundary
Use the observed result to choose the next check.
| Observation | Next check |
|---|---|
| Local path or relative URL | Move the file to reachable HTTP(S) storage |
| URL works only while logged in | Remove session dependency and inspect actual response bytes |
| URL has expired | Generate a new approved source with sufficient validity |
unsupported_message_type |
Provider capability and selected media type |
provider_not_ready |
Authentication and runtime status |
| Request rejected with validation error | Request schema and required fields |
| Request times out | Preserve the uncertain operation; do not blindly resend |
Response says accepted
|
Store identifiers and wait for supported delivery evidence |
| Delivery receipt never arrives | Provider receipt support, webhook health and unknown-state policy |
Branch on machine-readable error codes instead of parsing human-readable error messages.
Test one variable at a time
Begin with the smallest possible test:
One connected account
One known conversation
One supported media type
One small file
One approved HTTPS URL
No optional provider metadata
Avoid combining:
- a new account connection;
- an expiring URL;
- an unsupported media type;
- a group conversation;
- optional duration metadata;
- an automated retry system.
If the request fails, changing several variables at once destroys the evidence needed to identify the boundary.
Suggested acceptance tests
Before production, test:
- [ ] A small reachable HTTPS image.
- [ ] A URL that returns an HTML login page.
- [ ] An expired signed URL.
- [ ] A redirect to an inaccessible origin.
- [ ] An unsupported provider/type combination.
- [ ] A disconnected account.
- [ ] A request-validation failure.
- [ ] An accepted request with delayed delivery evidence.
- [ ] A lost or timed-out client response.
- [ ] Duplicate webhook receipts.
- [ ] Out-of-order delivery events.
- [ ] A repeated send job after worker restart.
- [ ] Redaction of signed URLs and credentials in logs.
The goal is not only to prove the happy path. It is to prove that an uncertain result does not create duplicate customer messages.
A production state machine
A useful outbound state model is:
draft
↓
submitting
├── accepted
│ ├── delivered
│ │ └── read
│ ├── failed
│ └── unknown
├── failed
└── unknown
Transitions should come from evidence:
| Transition | Evidence |
|---|---|
draft → submitting |
Worker claims the operation |
submitting → accepted |
Successful API response |
submitting → failed |
Definitive API rejection |
submitting → unknown |
Timeout or lost response |
accepted → delivered |
Supported delivery event |
accepted → failed |
Supported failure evidence |
delivered → read |
Supported read evidence |
This prevents the UI from presenting “delivered” immediately after request acceptance.
Optional metadata should remain optional
Some providers support additional media metadata.
For example, WhatsApp audio and video requests may accept duration information through:
{
"provider_data": {
"seconds": 12
}
}
Do not copy an inbound millisecond value directly into a field expressed in seconds.
Also do not invent optional metadata when it is not required.
If the provider can process the media without it, omitting uncertain metadata is safer than sending a guessed value.
Takeaway
Reliable media sending requires more than valid JSON.
Trace the complete path:
Supported media type
↓
Reachable approved URL
↓
Valid request
↓
Ready account
↓
Accepted operation
↓
Provider delivery evidence
Remember:
A local path is not a URL.
A browser session is not remote accessibility.
Accepted is not delivered.
A timeout is not a confirmed failure.
A request ID is not an idempotency key.
Start with one explicit media source, store the outbound operation before sending, and keep uncertain outcomes separate from retries.
References
- UnifyPort: Send media message
- UnifyPort: Media sending guide
- UnifyPort: Message support matrix
- UnifyPort: Error reference
- UnifyPort: Webhook event reference
- UnifyPort: Request tracing guide
- Telegram Bot API: Sending files
This article was adapted from an original UnifyPort technical guide with AI-assisted editing.
Top comments (0)