ADR-0039: WhatsApp rich outbound messages and synchronous inbound media download
| Status | Accepted |
| Date | 2026-10-11 |
| Issue | #37 |
| Pull request | #134 |
| Related | ADR-0007, ADR-0014, ADR-0015, ADR-0019, ADR-0036, ADR-0038 |
Context and problem statement
Both WhatsApp adapters sent every activity as type: "text". Attachments were silently lost (an attachment-only
activity was sent with a null body), a message to a user who had not written in 24 hours failed without saying why,
and there was no way to send a template, the only message WhatsApp accepts outside that window. Inbound images, voice
notes and documents arrived as stubs carrying a provider media id that only the channel's credentials can resolve, and
that Meta turns into a download URL valid for a few minutes. Consumers of the activity (a bot behind a webhook channel,
a WebSocket client) had no file to show.
The issue asks for: outbound media, templates, interactive messages, location and contacts on both providers; inbound
media in Converger storage with a real contentUrl; template listing; and a non-retryable outside_window error.
Decision drivers
- No message is lost: a failed media download must not drop or block the inbound message.
- Consumers get a usable
contentUrlon the activity they receive, not a provider reference they cannot resolve. - Meta and Infobip must render the same activity the same way; the rules should live in one place.
- Provider credentials are never sent to a URL taken from a webhook body without checking it.
- Files are stored with the same checks as client uploads (size, sniffed type, tenant allowlist).
- No new infrastructure and no schema change.
Considered options
For outbound:
- A shared planner, provider renderers -
Converger.Channels.WhatsApp.Outboundturns an activity into a list of parts; each adapter renders the parts in its request shape. - Each adapter maps activities itself - two copies of the template, caption and card rules.
- One WhatsApp message per activity - first attachment only, the rest dropped or rejected.
For inbound media:
- Synchronous download before the activity is created - in the webhook request, through a new optional
fetch_media/2adapter callback. - Asynchronous download in an Oban job after creation - the activity is created with a stub and rewritten later.
- Keep stubs and leave downloading to consumers.
Pros and cons of the options
Option 1: shared planner
- Good, because the caption, template and card rules are written and tested once.
- Good, because a third WhatsApp provider only writes renderers.
- Bad, because the planner's vocabulary (parts) is one more internal shape.
Option 2: per-adapter mapping
- Good, because each adapter is self-contained.
- Bad, because the rules drift between providers; the same activity would look different on Meta and Infobip.
Option 3: one message per activity
- Good, because one delivery is exactly one provider message.
- Bad, because activities may carry up to 10 attachments; dropping them is the bug being fixed, and rejecting them makes multi-attachment activities undeliverable on WhatsApp.
Option 4: synchronous download
- Good, because the activity is created with its final attachments: every channel, webhook and socket gets the
contentUrlin the same delivery, and nothing is rewritten after the fact. - Good, because the duplicate check runs first, so a re-delivered webhook does not download again.
- Bad, because the webhook response waits for the download (bounded by a 10 s timeout per file and the upload size limit).
Option 5: asynchronous download
- Good, because the webhook is acknowledged at once.
- Bad, because the activity has already been delivered with a stub by the time the file exists; consumers would need an update event and a second delivery, and activities are append-only (edits are new activities, ADR-0036).
- Bad, because Meta's download URL may expire before a backed-up queue gets to the job.
Option 6: keep stubs
- Good, because nothing changes.
- Bad, because consumers cannot resolve the media without the channel's credentials, which they must not have.
Decision
Chosen options: "A shared planner, provider renderers" and "Synchronous download before the activity is created".
Outbound, Converger.Channels.WhatsApp.Outbound.parts/1 decides what an activity becomes:
metadata.template(name,language,components; Infobip may addtemplateData) is a template message, and the text and attachments are ignored.- Otherwise each attachment is one message:
application/vnd.converger.card.buttonsand.card.listbecome interactive messages (validated: 1 to 3 buttons, 1 to 10 list rows), location and contacts attachments become their messages, and anything with acontentUrlor provider media id becomes image, video, audio or document by content type (channelData.whatsappTypeoverrides, for stickers). The text is the caption of the first image, video or document or the body of the first interactive message, else a text message sent first. - Media sources: a Converger upload (tenant-checked) is uploaded to Meta's Media API, and sent to Infobip as a signed
storage or CDN URL (with local-disk storage there is none, so the delivery fails permanently with that reason); a
provider media id is reused; any other
http(s)URL is sent as a link. - The parts are sent in order in one delivery attempt; the first message id is the
provider_message_id, and every id is kept in the delivery metadata. A failure after the first part fails the delivery, and the retry resends the earlier parts (at-least-once). - Meta error
131047(more than 24 hours since the user's last message), synchronous or in a failed status, becomes a permanentoutside_window: ...error that tells the caller to send a template.
Inbound, Converger.Inbound.Media.store/3 runs after the duplicate check and conversation resolution, before
create_client_activity/2. For each attachment without contentUrl it calls the adapter's optional
fetch_media/2, stores the bytes with Converger.Uploads.create_attachment/3, and rewrites the attachment
(contentUrl, sniffed contentType, size). The stored file is linked to the activity, or deleted when the activity
is not created. A failure keeps the stub with channelData.downloadError. Downloads are streamed and stopped at the
upload size limit (Converger.Channels.MediaDownload). Meta resolves the media id with the access token; Infobip
fetches the webhook's media URL with the API key only when its host is the channel's base_url host. Channels opt out
with download_media: false.
Templates are listed by a second optional callback, list_templates/1, behind GET /api/v1/channels/:id/templates,
cached per node in ETS for 5 minutes (Converger.Channels.Templates). Meta needs a new optional config key, waba_id.
Consequences
Positive
- Attachments, templates, interactive messages, locations and contacts reach WhatsApp on both providers.
- Inbound media is a normal Converger attachment: authorized downloads, signed URLs and CDNs apply, and the bytes outlive the provider's retention.
- A message outside the 24-hour window is dead-lettered once with an actionable reason instead of being retried.
- The adapter behaviour gains two optional callbacks any channel can implement (Telegram, Slack and email media).
Negative and trade-offs
- A media webhook is acknowledged later, by up to the download timeout per file. Very slow provider downloads end as
stubs with
downloadError, not as lost messages. - Multi-part activities are at-least-once on WhatsApp: a partial failure resends the parts that were already sent.
- Infobip outbound media from uploads needs cloud storage or a CDN.
- Interactive messages use interim card content types until typed messages exist (#68).
- The template cache is per node; a channel update clears it only on the node that handled it, other nodes refresh within the TTL.
Follow-ups
- #68: typed cards and quick replies replace the card attachments.
- Native outbound reactions, edits and quoted replies (
context.message_id) on WhatsApp. - Infobip's 24-hour window error code, once confirmed, mapped to
outside_windowlike Meta's.
Implementation
- Planner:
lib/converger/channels/whatsapp/outbound.ex. - Renderers, media upload,
fetch_media/2,list_templates/1,131047mapping:whatsapp_meta.exandwhatsapp_infobip.ex. - Callbacks and dispatch:
fetch_media/2,list_templates/1inlib/converger/channels/adapter.ex. - Inbound download:
lib/converger/inbound/media.ex, called fromConverger.Inbound; bounded download inlib/converger/channels/media_download.ex. - Templates:
lib/converger/channels/templates.exandConvergerWeb.ChannelTemplateController. - Tests:
test/converger/channels/whatsapp/outbound_test.exs,test/converger_web/controllers/whatsapp_meta_media_test.exsandtest/converger_web/controllers/whatsapp_infobip_media_test.exs(Req.Test fixtures for both providers: upload and send of an image, inbound image download, templates, interactive messages, the 24-hour window).