Part 9 of 18 · 5 min read
Messaging connectors and delivery receipts
Provider adapters, signed webhooks, preference checks and out-of-order delivery events.
Arunank Sharan · Kuzushi Labs ·
In this article
An appointment reminder passes through several systems before a patient can read it. Creating a communication record, accepting a provider request, sending the message and receiving a delivery receipt are different events. I want the interface to preserve those differences.
Our code has two generations of communication implementation. The older communication service contains a placeholder send path. The omnichannel module has a provider interface and concrete adapters, including Meta and Twilio WhatsApp paths and several SMS providers. The engineering task is to connect and verify one complete route, not count provider names in a factory.
The interface we already had
The provider abstraction includes normalized payloads, a result with an external identifier, error codes, retry information and webhook parsing. A shortened view of the existing result shape is:
@dataclass
class ProviderResult:
success: bool
external_id: Optional[str] = None
status: str = "sent"
error_code: Optional[str] = None
error_message: Optional[str] = None
is_retriable: bool = False
That shape is useful, but I would change the semantics of the default. An HTTP success from a messaging API often establishes acceptance, not delivery. The inspected WhatsApp adapters return sent after API acceptance, so the UI needs a more precise normalization rule before using that result as patient-contact evidence.
Define the reminder before choosing a provider
For a worked example, suppose the appointment is booked for 1 October at 14:30 clinic time. I would save the communication as an approved template request, with a verified recipient reference and minimal variables:
{
"communication_id": "comm-001",
"patient_id": "p-001",
"channel": "whatsapp",
"recipient_ref": "contact-verified-001",
"template": {"name": "appointment_reminder", "version": 3, "language": "en"},
"variables": {"date": "1 October", "time": "2:30 PM", "location": "Clinic A"},
"purpose": "appointment_service_message",
"not_before": "2026-09-30T03:30:00Z",
"expires_at": "2026-10-01T09:00:00Z"
}
This is a reference application envelope, not a provider request body. The adapter resolves the contact and template under the correct tenant, then translates the request into the selected provider's documented schema. The model does not supply credentials, arbitrary recipient URLs or a new destination telephone number.
The dispatcher should recheck communication preference, consent state where required by the deployment, quiet hours, template availability and message expiry immediately before sending. A preference changed after scheduling must affect the queued reminder. Channel policies and template requirements should be maintained as versioned connector configuration, not remembered from an old prompt.
Use the provider's actual webhook signature scheme
For a Twilio form webhook, the SDK validator uses the configured public URL, form parameters and signature. I would use the SDK rather than implement a generic HMAC helper and assume it applies to every provider. Twilio's webhook security documentation explains the signing inputs.
from twilio.request_validator import RequestValidator
def valid_twilio_callback(public_url, form, signature, auth_token):
# public_url comes from trusted deployment configuration.
# form is the original parsed form, including repeated values as supported.
return RequestValidator(auth_token).validate(
public_url, form, signature
)
This SDK example is illustrative and is not executed by the offline lab. Behind a reverse proxy, I would reconstruct the externally configured URL from trusted configuration rather than accepting a caller-supplied forwarded host. JSON webhooks require the provider's corresponding validation path. Different providers can sign different representations of the request.
After validation, store the callback durably before returning success. Bind the provider account or connection to a tenant using server configuration, then look up the external message ID. A callback body should not be allowed to choose another tenant.
Reduce duplicate and out-of-order callbacks
A callback may be delivered twice, and a sent update may arrive after delivered. I would retain the raw event in a restricted inbox and update a separate delivery projection through an explicit transition policy.
from prm_lab import delivery_transition
assert delivery_transition("pending", "accepted") == "accepted"
assert delivery_transition("delivered", "sent") == "delivered"
assert delivery_transition("delivered", "read") == "read"
The lab's reducer is an application example. Its event inbox and provider-specific status mapping must be completed in a real service. I would preserve unexpected or contradictory statuses for reconciliation, rather than discard the source evidence because it does not fit a happy-path enum. Twilio documents its outbound status callbacks; those statuses should be mapped explicitly for each channel.
Retry only after classifying the uncertainty
An invalid recipient or failed authentication is not fixed by exponential backoff. A rate limit may justify a scheduled retry within the message's expiry window. A transport timeout after submission is an unknown outcome, which may require looking up a provider receipt before sending again.
The original provider base offers a retry helper. I would audit each adapter's error mapping before enabling it for writes. A flag named is_retriable is only as reliable as the classification behind it.
For providers without a documented idempotency key or reliable lookup, the workflow should create a reconciliation task with the communication ID, submission time and request fingerprint. Switching immediately to another provider can produce duplicate reminders if the first provider already accepted the request.
Make the operational view answer a useful question
A coordinator needs to know which appointments have unconfirmed outreach, which messages were rejected and which require a different contact path. I would show counts by delivery state, age and failure category. “Messages sent today” alone can conceal an outage where every accepted request later fails.
The first connector acceptance run should cover a valid reminder, revoked preference, expired message, duplicate callback, reversed callback order, invalid signature and a timeout after acceptance. The implementation kit includes the reducer and an intentionally idempotent fake provider for exercising those state boundaries. It does not claim a live messaging integration was tested.