Skip to main content
GOV.UK Notify integrates with multiple third-party providers for delivering notifications. The provider architecture is designed for resilience, failover, and provider-agnostic interfaces.

Architecture Overview

Location: app/clients/

Client Hierarchy

class Client(ABC):  # Base class for all providers
    @property
    @abstractmethod
    def name(self):
        pass

class SmsClient(Client):     # app/clients/sms/__init__.py
    def send_sms(to, content, reference, international, sender)
    
class EmailClient(Client):   # app/clients/email/__init__.py
    def send_email(from_address, to_address, subject, body, ...)

Provider Registry

Location: app/clients/__init__.py:26
class NotificationProviderClients:
    def __init__(self, sms_clients, email_clients):
        self.sms_clients = {**sms_clients}     # {"mmg": MMGClient, "firetext": FiretextClient}
        self.email_clients = {**email_clients} # {"ses": AwsSesClient}
        
    def get_client_by_name_and_type(self, name, notification_type):
        # Returns appropriate client instance

SMS Providers

MMG Client

Location: app/clients/sms/mmg.py
class MMGClient(SmsClient):
    name = "mmg"
    
    def __init__(self, current_app, statsd_client):
        self.api_key = current_app.config.get("MMG_API_KEY")
        self.mmg_url = current_app.config.get("MMG_URL")
        self.receipt_url = current_app.config.get("MMG_RECEIPT_URL")
    
    def try_send_sms(self, to, content, reference, international, sender):
        data = {
            "reqType": "BULK",
            "MSISDN": to,
            "msg": content,
            "sender": sender,
            "cid": reference,  # Notification ID
            "multi": True,
        }
        
        if self.receipt_url:
            data["delurl"] = self.receipt_url
        
        response = self.requests_session.request(
            "POST",
            self.mmg_url,
            data=json.dumps(data),
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Basic {self.api_key}"
            },
            timeout=60,
        )
Response Status Mapping: Location: app/clients/sms/mmg.py:8
mmg_response_map = {
    "2": {  # Permanent failure
        "status": "permanent-failure",
        "substatus": {
            "1": "Number does not exist",
            "4": "Rejected by operator",
            "11": "Service for Subscriber suspended",
            "2052": "Destination number blacklisted",
        },
    },
    "3": {  # Delivered
        "status": "delivered",
        "substatus": {
            "2": "Delivered to operator",
            "5": "Delivered to handset"
        },
    },
    "4": {  # Temporary failure
        "status": "temporary-failure",
        "substatus": {
            "6": "Absent Subscriber",
            "15": "Expired",
            "32": "Delivery Failure",
        },
    },
    "5": {  # System errors
        "status": "permanent-failure",
        "substatus": {
            "23": "Duplicate message id",
            "24": "Message formatted incorrectly",
            "25": "Message too long",
        },
    },
}

Firetext Client

Similar structure to MMG:
  • International SMS support
  • Separate API key for international
  • Different response format

SMS Client Features

Location: app/clients/sms/__init__.py:24
class SmsClient(Client):
    def send_sms(self, to, content, reference, international, sender):
        start_time = monotonic()
        
        try:
            response = self.try_send_sms(to, content, reference, international, sender)
            self.record_outcome(True)
        except SmsClientResponseException as e:
            self.record_outcome(False)
            raise e
        finally:
            elapsed_time = monotonic() - start_time
            self.statsd_client.timing(f"clients.{self.name}.request-time", elapsed_time)
            
        return response
    
    def record_outcome(self, success):
        if success:
            self.statsd_client.incr(f"clients.{self.name}.success")
        else:
            self.statsd_client.incr(f"clients.{self.name}.error")
TCP Keepalive: Linux-specific socket options for connection stability:
if platform.system() == "Linux":
    adapter.poolmanager.connection_pool_kw = {
        "socket_options": [
            (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
            (socket.SOL_TCP, socket.TCP_KEEPIDLE, 4),
            (socket.SOL_TCP, socket.TCP_KEEPINTVL, 2),
            (socket.SOL_TCP, socket.TCP_KEEPCNT, 8),
        ],
    }

Email Provider

AWS SES Client

Location: app/clients/email/aws_ses.py
class AwsSesClient(EmailClient):
    name = "ses"
    
    def __init__(self, region, statsd_client):
        self._client = boto3.client("sesv2", region_name=region)
        self.statsd_client = statsd_client
    
    def send_email(
        self,
        *,
        from_address: str,
        to_address: str,
        subject: str,
        body: str,
        html_body: str,
        reply_to_address: str | None,
        headers: list[dict[str, str]],
    ) -> str:
        reply_to_addresses = [punycode_encode_email(reply_to_address)] if reply_to_address else []
        to_addresses = [punycode_encode_email(to_address)]
        
        response = self._client.send_email(
            FromEmailAddress=from_address,
            Destination={"ToAddresses": to_addresses},
            Content={
                "Simple": {
                    "Subject": {"Data": subject},
                    "Body": {
                        "Text": {"Data": body},
                        "Html": {"Data": html_body}
                    },
                    "Headers": headers,
                },
            },
            ReplyToAddresses=reply_to_addresses,
        )
        
        return response["MessageId"]
Punycode Encoding: For international domain names:
def punycode_encode_email(email_address):
    local, hostname = email_address.split("@")
    return f"{local}@{hostname.encode('idna').decode('utf-8')}"
Error Handling:
try:
    response = self._client.send_email(...)
except botocore.exceptions.ClientError as e:
    if e.response["Error"]["Code"] == "InvalidParameterValue":
        raise EmailClientNonRetryableException(...)  # Don't retry
    elif e.response["Error"]["Code"] == "TooManyRequestsException":
        raise AwsSesClientThrottlingSendRateException(...)  # Retry
    else:
        raise AwsSesClientException(...)
Response Status Mapping: Location: app/clients/email/aws_ses.py:14
ses_response_map = {
    "Permanent": {
        "notification_status": "permanent-failure",
        "notification_statistics_status": STATISTICS_FAILURE,
    },
    "Temporary": {
        "notification_status": "temporary-failure",
        "notification_statistics_status": STATISTICS_FAILURE,
    },
    "Delivery": {
        "notification_status": "delivered",
        "notification_statistics_status": STATISTICS_DELIVERED,
    },
    "Complaint": {
        "notification_status": "delivered",  # Still counts as delivered
        "notification_statistics_status": STATISTICS_DELIVERED,
    },
}

Letter Provider

DVLA Client

Location: app/clients/letter/dvla.py Sends letters to DVLA print provider:
class DvlaClient:
    def send_letter(
        self,
        notification_id: str,
        reference: str,
        address: PostalAddress,
        postage: str,
        service_id: str,
        organisation_id: str,
        pdf_file: bytes,
        callback_url: str,
    ):
        # Sends PDF with metadata to DVLA API
Exception Types:
  • DvlaRetryableException - Temporary errors, retry
  • DvlaThrottlingException - Rate limited, retry with backoff
  • DvlaDuplicatePrintRequestException - Already sent (idempotent)

Provider Selection

Location: app/dao/provider_details_dao.py

Database Model

class ProviderDetails(db.Model):
    identifier = db.Column(db.String)      # mmg, firetext, ses, dvla
    notification_type = db.Column(...)     # sms, email, letter
    priority = db.Column(db.Integer)       # Lower = higher priority
    active = db.Column(db.Boolean)
    supports_international = db.Column(db.Boolean)

Selection Algorithm

  1. Filter by notification type
  2. Filter by active status
  3. Filter by international support (if needed)
  4. Sort by priority
  5. Return highest priority provider

Dynamic Provider Weights

Location: app/config.py:222
SMS_PROVIDER_RESTING_POINTS = {
    "mmg": 51,
    "firetext": 49
}
Providers drift from these resting points based on delivery performance. Scheduled task tend-providers-back-to-middle rebalances every 5 minutes.

Provider Callbacks

SMS Callbacks

MMG and Firetext POST delivery receipts to:
  • /notifications/sms/mmg
  • /notifications/sms/firetext
Authentication via:
MMG_INBOUND_SMS_AUTH = ["token1", "token2"]
FIRETEXT_INBOUND_SMS_AUTH = ["token1"]

Email Callbacks

AWS SES sends SNS notifications to /notifications/email/ses:
  • Delivery confirmations
  • Bounces (permanent/temporary)
  • Complaints

Letter Callbacks

DVLA sends status updates to signed callback URL:
def _get_callback_url(notification_id: UUID) -> str:
    signed_id = signing.encode(str(notification_id))
    return f"{API_HOST_NAME}/notifications/letter/status?token={signed_id}"

Delivery Flow

Location: app/delivery/send_to_providers.py

SMS Delivery

def send_sms_to_provider(notification):
    # 1. Get provider from database
    provider = get_provider_details_by_notification_type(
        "sms", 
        international=notification.international
    )
    
    # 2. Get client instance
    client = clients.get_sms_client(provider.identifier)
    
    # 3. Format content
    template = notification.template._as_utils_template_with_personalisation(
        notification.personalisation
    )
    
    # 4. Send
    response = client.send_sms(
        to=notification.normalised_to,
        content=str(template),
        reference=str(notification.id),
        international=notification.international,
        sender=notification.reply_to_text,
    )
    
    # 5. Update notification
    notification.status = "sending"
    notification.sent_at = datetime.utcnow()
    notification.sent_by = provider.identifier

Email Delivery

Similar flow with additional:
  • HTML rendering
  • Unsubscribe link injection
  • Email file attachments
  • Reply-to header

Letter Delivery

  1. Generate PDF from template
  2. Upload to S3
  3. Virus scan
  4. Send to DVLA with callback URL

Monitoring & Metrics

StatsD Metrics

clients.{provider}.success          # Successful sends
clients.{provider}.error            # Failed sends  
clients.{provider}.request-time     # Latency histogram

Logging

Structured logging with provider context:
current_app.logger.info(
    "Provider request for %s succeeded",
    self.name,
    extra={"provider_name": self.name}
)

Configuration

Environment Variables

# MMG
MMG_API_KEY=...
MMG_URL=https://api.mmg.co.uk/jsonv2a/api.php
MMG_RECEIPT_URL=https://api.notifications.service.gov.uk/notifications/sms/mmg

# Firetext  
FIRETEXT_API_KEY=...
FIRETEXT_INTERNATIONAL_API_KEY=...
FIRETEXT_URL=https://www.firetext.co.uk/api/sendsms/json
FIRETEXT_RECEIPT_URL=...

# AWS SES (via boto3)
AWS_REGION=eu-west-1

# DVLA
DVLA_API_BASE_URL=https://uat.driver-vehicle-licensing.api.gov.uk
DVLA_API_TLS_CIPHERS=...  # Custom cipher suite

Testing

Simulated Addresses

SIMULATED_EMAIL_ADDRESSES = (
    "[email protected]",
    "[email protected]",
)

SIMULATED_SMS_NUMBERS = ("+447700900000", "+447700900111")
These trigger simulated responses without calling providers.
  • app/clients/ - Provider client implementations
  • app/delivery/send_to_providers.py - Delivery orchestration
  • app/dao/provider_details_dao.py - Provider selection
  • app/celery/provider_tasks.py - Delivery tasks

Build docs developers (and LLMs) love