Documentation Index
Fetch the complete documentation index at: https://mintlify.com/alphagov/notifications-api/llms.txt
Use this file to discover all available pages before exploring further.
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
- Filter by notification type
- Filter by active status
- Filter by international support (if needed)
- Sort by priority
- 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
- Generate PDF from template
- Upload to S3
- Virus scan
- 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 = (
"simulate-delivered@notifications.service.gov.uk",
"simulate-delivered-2@notifications.service.gov.uk",
)
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