Skip to main content
Most of the API endpoints in this repo are for internal use. These are all defined within top-level folders under app/ and tend to have the structure app/<feature>/rest.py.

Overview

Public APIs are intended for use by services and are all located under app/v2/ to distinguish them from internal endpoints. Originally we did have a “v1” public API, where we tried to reuse/expose existing internal endpoints. The needs for public APIs are sufficiently different that we decided to separate them out. Any “v1” endpoints that remain are now purely internal and no longer exposed to services.

New APIs

Here are some pointers for how we write public API endpoints.

Each endpoint should be in its own file in a feature folder

Example: app/v2/inbound_sms/get_inbound_sms.py This helps keep the file size manageable but does mean a bit more work to register each endpoint if we have many that are related. Note that internal endpoints are grouped differently: in large rest.py files.

Each group of endpoints should have an __init__.py file

Example:
from flask import Blueprint

from app.v2.errors import register_errors

v2_notification_blueprint = Blueprint(
    "v2_notifications", 
    __name__, 
    url_prefix='/v2/notifications'
)

register_errors(v2_notification_blueprint)
Note that the error handling setup by register_errors (defined in app/v2/errors.py) for public API endpoints is different to that for internal endpoints (defined in app/errors.py).

Each endpoint should have an adapter in each API client

Example: Ruby Client adapter to get template by ID. All our clients should fully support all of our public APIs. Each adapter should be documented in each client (example). We should also document each public API endpoint in our generic API docs (example). Note that internal endpoints are not documented anywhere.

Each endpoint should specify the authentication it requires

This is done as part of registering the blueprint in app/__init__.py e.g.
v2_notification_blueprint.before_request(requires_auth)
application.register_blueprint(v2_notification_blueprint)

Public API Structure

Directory Organization

app/v2/
├── __init__.py
├── errors.py                    # Public API error handlers
├── notifications/
│   ├── __init__.py             # Blueprint registration
│   ├── post_notifications.py   # Send notification
│   ├── get_notifications.py    # Get notification status
│   └── get_notification_by_id.py
├── template/
│   ├── __init__.py
│   ├── get_template.py
│   └── get_templates.py
└── inbound_sms/
    ├── __init__.py
    └── get_inbound_sms.py

Authentication

Public API endpoints require JWT authentication:
from app.authentication.auth import requires_auth

v2_notification_blueprint.before_request(requires_auth)
The requires_auth decorator:
  1. Extracts JWT from Authorization header
  2. Validates signature against service API key
  3. Checks key is not expired
  4. Loads service and attaches to g.service_id

Error Handling

Public APIs use consistent error responses:
from app.v2.errors import register_errors

register_errors(v2_notification_blueprint)
Error format:
{
  "errors": [
    {
      "error": "ValidationError",
      "message": "phone_number is a required property"
    }
  ],
  "status_code": 400
}
Vs internal API errors which may have different structure.

Best Practices

Input Validation

Use JSON schemas for request validation:
from app.schema_validation import validate
from app.v2.notifications.notification_schemas import post_sms_request_schema

@v2_notification_blueprint.route('/sms', methods=['POST'])
def post_sms_notification():
    request_json = request.get_json()
    validate(request_json, post_sms_request_schema)
    # ...

Response Serialization

Return consistent JSON responses:
from flask import jsonify

def get_notification_by_id(notification_id):
    notification = notifications_dao.get_notification_with_personalisation(
        str(g.service_id),
        notification_id,
        key_type=None,
    )
    
    return jsonify(notification.serialize_for_v2()), 200

Versioning Strategy

  • All public APIs under /v2/ prefix
  • Breaking changes require new version (e.g., /v3/)
  • Non-breaking changes can be added to existing version
  • Maintain backwards compatibility within version

Rate Limiting

Public APIs enforce rate limits:
API_RATE_LIMIT_ENABLED = True

DEFAULT_LIVE_SERVICE_RATE_LIMITS = {
    EMAIL_TYPE: 250_000,
    SMS_TYPE: 250_000,
    LETTER_TYPE: 20_000,
    INTERNATIONAL_SMS_TYPE: 100,
}
Rate limits checked in app/notifications/validators.py.

Pagination

List endpoints support pagination:
@v2_notification_blueprint.route('', methods=['GET'])
def get_notifications():
    page = request.args.get('page', 1, type=int)
    page_size = request.args.get('page_size', API_PAGE_SIZE, type=int)
    
    # API_PAGE_SIZE = 250
    
    return jsonify(
        notifications=[n.serialize_for_v2() for n in notifications],
        links={
            "current": url_for('.get_notifications', page=page, _external=True),
            "next": url_for('.get_notifications', page=page+1, _external=True) if has_next else None,
        }
    )

Example Endpoint

Complete example of a public API endpoint:
# app/v2/notifications/post_notifications.py

from flask import request, jsonify
from app.v2.notifications import v2_notification_blueprint
from app.schema_validation import validate
from app.v2.notifications.notification_schemas import post_sms_request_schema
from app.notifications.process_notifications import (
    persist_notification,
    send_notification_to_queue,
)

@v2_notification_blueprint.route('/sms', methods=['POST'])
def post_sms_notification():
    """
    Send an SMS notification
    
    POST /v2/notifications/sms
    {
        "phone_number": "+447700900123",
        "template_id": "...",
        "personalisation": {...},
        "reference": "..."
    }
    """
    request_json = request.get_json()
    
    # 1. Validate request
    validate(request_json, post_sms_request_schema)
    
    # 2. Load service from auth
    service = services_dao.dao_fetch_service_by_id(g.service_id)
    
    # 3. Load template
    template = templates_dao.dao_get_template_by_id_and_service_id(
        template_id=request_json['template_id'],
        service_id=service.id,
    )
    
    # 4. Validate and format recipient
    recipient_data = validate_and_format_recipient(
        send_to=request_json['phone_number'],
        key_type=g.api_user.key_type,
        service=service,
        notification_type=SMS_TYPE,
    )
    
    # 5. Check rate limits
    check_service_over_daily_message_limit(
        service,
        g.api_user.key_type,
        notification_type=SMS_TYPE,
    )
    
    # 6. Persist notification
    notification = persist_notification(
        template_id=template.id,
        template_version=template.version,
        recipient=recipient_data,
        service=service,
        personalisation=request_json.get('personalisation'),
        notification_type=SMS_TYPE,
        api_key_id=g.api_user.id,
        key_type=g.api_user.key_type,
        client_reference=request_json.get('reference'),
    )
    
    # 7. Queue for delivery
    send_notification_to_queue(notification, research_mode=False)
    
    # 8. Return response
    return jsonify(
        id=str(notification.id),
        reference=notification.client_reference,
        uri=url_for(
            'v2_notifications.get_notification_by_id',
            notification_id=notification.id,
            _external=True
        ),
        template={
            "id": str(template.id),
            "version": template.version,
            "uri": template.get_link(),
        },
        content={
            "body": str(notification.content),
        },
    ), 201

Testing Public APIs

Unit Tests

Test each endpoint in isolation:
def test_post_sms_notification_returns_201(client, sample_template):
    data = {
        'phone_number': '+447700900123',
        'template_id': str(sample_template.id),
    }
    
    auth_header = create_authorization_header(
        service_id=sample_template.service_id
    )
    
    response = client.post(
        '/v2/notifications/sms',
        data=json.dumps(data),
        headers=[('Content-Type', 'application/json'), auth_header]
    )
    
    assert response.status_code == 201
    json_resp = json.loads(response.get_data(as_text=True))
    assert json_resp['id']

Integration Tests

Functional tests using API clients:
from notifications_python_client import NotificationsAPIClient

def test_send_sms_integration():
    client = NotificationsAPIClient(
        base_url=API_HOST_NAME,
        api_key=test_api_key
    )
    
    response = client.send_sms_notification(
        phone_number='+447700900123',
        template_id=template_id,
    )
    
    assert response['id']

Client Libraries

Official clients that must support all public APIs:

Documentation

Public APIs must be documented in:
  1. Client README - Usage examples
  2. Client DOCUMENTATION.md - Full method reference
  3. Tech Docs - notifications-tech-docs API reference
  4. Admin UI - API documentation page
  • app/v2/ - Public API endpoints
  • app/v2/errors.py - Public API error handlers
  • app/authentication/auth.py - JWT authentication
  • app/schema_validation/ - Request validation schemas
  • app/notifications/process_notifications.py - Notification processing

Build docs developers (and LLMs) love