The gateway can send and receive multimedia messages (MMS) carrying images, audio, video, and arbitrary binary attachments, in addition to SMS and Data SMS.
This document covers:
- Enabling MMS on the device
- The
POST /messageshape for sending MMS - Webhook events for received MMS
- Fetching attachments from a received MMS
- Limits, gotchas, and carrier notes
- Android 5.0 (API 21) or newer. Android 10+ is recommended.
- A SIM with an MMS-capable plan and a working MMS APN.
SEND_SMS,READ_PHONE_STATE,RECEIVE_MMS,RECEIVE_WAP_PUSHpermissions granted. These are the same permissions the app already needs for SMS.- To actively download inbound MMS, the app must be the default SMS app (see below).
Sending MMS from a non-default app is allowed by the platform but many US carriers (Verizon notably) silently drop those messages. The gateway should be the default SMS app on any device that is expected to send or receive real MMS traffic.
From inside the app:
- Open the Settings tab.
- Tap Default SMS app.
- Accept the Android prompt to change the default.
The same preference shows whether the role is currently held. To hand the role back to another app (Google Messages, Verizon Messages, etc.) use Android's own Default apps screen.
Under the hood the app now declares the manifest receivers, activity, and
service required to qualify for the android.app.role.SMS role, so the
system can grant it without further configuration.
The existing POST /message endpoint gains a new mutually-exclusive
content field, mmsMessage. Exactly one of textMessage, dataMessage,
mmsMessage, or the deprecated message string must be present.
{
"phoneNumbers": ["+15551234567"],
"mmsMessage": {
"subject": "Optional subject line",
"text": "Optional text body shown alongside attachments",
"attachments": [
{
"contentType": "image/jpeg",
"name": "photo.jpg",
"data": "<base64-encoded bytes>"
}
]
},
"simNumber": 1,
"withDeliveryReport": true,
"priority": 100
}Field reference:
| Field | Type | Required | Notes |
|---|---|---|---|
phoneNumbers |
string[] |
yes | Recipient MSISDNs. E.164 format strongly recommended. |
mmsMessage.subject |
string |
no | Optional MMS subject. |
mmsMessage.text |
string |
no | Optional text/plain body part. Can coexist with attachments. |
mmsMessage.attachments[] |
Attachment[] |
conditional | At least one of text or attachments must be present. |
attachments[].contentType |
string |
yes | e.g. image/jpeg, image/png, audio/amr, video/mp4. |
attachments[].name |
string |
no | Suggested filename, forwarded as Content-Location + Content-Type name. |
attachments[].data |
string |
yes | Base64-encoded bytes. |
Other top-level message fields (simNumber, withDeliveryReport,
priority, ttl/validUntil, isEncrypted, id) behave the same way
they do for SMS.
Response is the same Accepted shape as SMS, with the request echoed back
including the stored mmsMessage content:
{
"id": "PyDmBQZZXYmyxMwED8Fzy",
"deviceId": "...",
"state": "Pending",
"isEncrypted": false,
"mmsMessage": { "...": "..." },
"recipients": [
{ "phoneNumber": "+15551234567", "state": "Pending", "error": null }
],
"states": {}
}Attachments are provided inline as data (base64-encoded bytes):
data: inline base64 bytes. For very large payloads, consider hosting the media elsewhere and sending it as a single-part text message containing a link, or keep the payload under a few hundred KB.
B64=$(base64 -w0 < photo.jpg)
curl -u "<user>:<pass>" -H 'Content-Type: application/json' \
-d "{
\"phoneNumbers\":[\"+15551234567\"],
\"mmsMessage\":{
\"text\":\"Say hi\",
\"attachments\":[{
\"contentType\":\"image/jpeg\",
\"name\":\"photo.jpg\",
\"data\":\"$B64\"
}]
}
}" \
http://<device_ip>:8080/message- The carrier delivers a WAP-push notification over SMS. The app
parses it and emits the
mms:receivedwebhook (metadata only — the message body is not yet downloaded). - If the app is the default SMS app, it calls
SmsManager.downloadMultimediaMessage()to fetch the full PDU from the carrier's MMSC over the dedicated MMS APN. - Once the PDU is downloaded and parsed, the gateway:
- Saves each non-SMIL attachment to private storage under
<appData>/files/mms-in/<messageId>/<partId>-<name>. - Inserts an
incoming_messagesrow (typeMMS_DOWNLOADED). - Emits the
mms:downloadedwebhook carrying part metadata, inline base64 bytes, and a relative URL to fetch the same bytes from the gateway.
- Saves each non-SMIL attachment to private storage under
If the app is not the default SMS app, step 2 is handled by whichever
app is default; once that app writes the message into content://mms,
the gateway's content observer picks it up and step 3 runs as usual.
Fired on the incoming WAP-push notification, before the content has been downloaded.
{
"event": "mms:received",
"payload": {
"messageId": "hex-derived-id",
"sender": "+15550009999",
"recipient": "+15551234567",
"simNumber": 1,
"transactionId": "T0123abcd",
"subject": "Photos",
"size": 43210,
"contentClass": "IMAGE_BASIC",
"receivedAt": "2026-04-20T17:31:00.000+00:00"
}
}Fired once the content has been retrieved and stored. Attachments are
included inline as base64 (data) and as a URL path on the gateway's
local HTTP server (url) — consumers may use either.
{
"event": "mms:downloaded",
"payload": {
"messageId": "hex-derived-id",
"sender": "+15550009999",
"recipient": "+15551234567",
"simNumber": 1,
"subject": "Photos",
"body": "Hi! Here's the photo.",
"attachments": [
{
"partId": 3,
"contentType": "image/jpeg",
"name": "photo.jpg",
"size": 38421,
"data": "<base64>",
"url": "/inbox/hex-derived-id/attachments/3"
}
],
"receivedAt": "2026-04-20T17:31:14.000+00:00"
}
}To avoid base64-inflated webhook payloads you can fetch attachments on demand. Authenticate with the same credentials used for the rest of the API.
curl -u "<user>:<pass>" \
http://<device_ip>:8080/inbox/<messageId>/attachments/<partId> \
-o photo.jpgContent-Type reflects the attachment's MIME type; Content-Disposition
carries the original filename when one was present.
Required auth scope: inbox:read.
GET /inbox — list received messages with optional attachment metadata.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
type |
string |
— | Filter by message type (SMS, MMS, MMS_DOWNLOADED, etc.). |
limit |
integer |
50 |
Max results (1–500). |
offset |
integer |
0 |
Result offset for pagination. |
from |
string |
epoch | ISO-8601 start of date range. |
to |
string |
now | ISO-8601 end of date range. |
deviceId |
string |
— | Must match the device's own ID if provided. |
includeAttachments |
boolean |
false |
When true, includes per-message attachment metadata (partId, name, size, contentType). |
By default attachment metadata is omitted to avoid unnecessary I/O on large result sets. Attachments are always available individually via the dedicated endpoint below.
Default response (attachments excluded):
[
{
"id": "hex-derived-id",
"type": "MMS_DOWNLOADED",
"sender": "+15550009999",
"recipient": "+15551234567",
"simNumber": 1,
"contentPreview": "Hi! Here's the photo.",
"createdAt": "2026-04-20T17:31:14.000+00:00",
"attachments": []
}
]Pass ?includeAttachments=true to include attachment metadata:
[
{
"id": "hex-derived-id",
"type": "MMS_DOWNLOADED",
"sender": "+15550009999",
"recipient": "+15551234567",
"simNumber": 1,
"contentPreview": "Hi! Here's the photo.",
"createdAt": "2026-04-20T17:31:14.000+00:00",
"attachments": [
{
"partId": 3,
"name": "photo.jpg",
"size": 38421,
"contentType": "image/jpeg"
}
]
}
]GET /inbox/{messageId}/attachments/{partId}— raw attachment bytes.
All endpoints are available in both Local and Cloud modes.
The cloud pull protocol (GET /message) accepts the same mmsMessage
object inside a Message payload. When the device pulls a cloud-queued
MMS, it composes the PDU locally and sends it via the same code path used
for local-server requests. State transitions (Processed / Sent / Failed)
are reported back to the cloud identically to SMS.
- PDU format. The gateway composes PDUs with AOSP's reference
PduComposer(ported from klinker-apps'spdu_alt, Apache-2.0), so the output matches Google Messages' format and is accepted by every MMSC we've tested. - Body type.
multipart/relatedis used automatically; Content-Type parameterstypeandstartreference the first text part so recipient clients render text + attachments correctly even without SMIL. - Size. Carriers impose per-message PDU limits, typically 300 KB to 1.2 MB. Verizon accepts up to ~1.2 MB in practice; T-Mobile and AT&T are similar. Large attachments (video) should be downscaled before sending.
- From. The gateway uses the WSP
insert-address-tokenso the MMSC fills in the sender's MSISDN — the approach AOSP and Google Messages both use. If your carrier rejects insert-address-token, pass an explicit From viaSubscriptionsHelper.getPhoneNumber()and the sender will fall back to that MSISDN. - Self-send. Some carriers (Verizon included) silently drop MMS addressed to the sender's own MSISDN. Use a second number for round-trip testing.
- Recipient parser. Once a recipient's messaging app rejects a malformed PDU, some MMSCs enter a backoff window (5–60 min) before re-pushing. If you're iterating on PDU issues, test against multiple recipient numbers/carriers to avoid getting stuck on one backoff.
- Send reports
Sentbut recipient never gets the message. The MMSC accepted our HTTP POST (that's whatSentmeans) but may have dropped the message later. Confirm the app is the default SMS app, the recipient isn't the sender's own number, and the recipient hasn't blocked the sender. MMS_ERROR_IO_ERRORon send. The MMS APN was not available or the MMSC returned a non-200. Check mobile data is up and that the active APN has typemms. The carrier's own SMS app will surface APN misconfiguration errors that our app cannot — use it as a sanity check first.MMS_ERROR_INVALID_APN/MMS_ERROR_NO_DATA_NETWORK. No MMS-typed APN configured. Add one through Android's Mobile network → Access point names settings, or install the carrier's config profile.- Inbound MMS shows as
mms:receivedbut never transitions tomms:downloaded. The app likely isn't the default SMS app, or the device dropped the MMS APN while downloading. Check the app's log screen forMMS download failedentries. - Inspecting the outgoing PDU for debugging. With USB debugging
enabled:
PDU files are deleted automatically after the
adb shell run-as me.capcom.smsgateway ls files/mms-out adb shell run-as me.capcom.smsgateway cat files/mms-out/<id>.pdu > out.pdu xxd out.pdu | head
mms:sentstate fires; to retain them across sends, flip the cleanup flag inMessagesService.processStateIntent.