48  CoAP API Design Best Practices

In 60 Seconds

Designing CoAP APIs follows REST principles: structure resources as nouns (e.g., /sensors/temp/value), use CoAP methods as verbs (GET, POST, PUT, DELETE), and select compact content formats like CBOR over JSON to minimize payload size on constrained networks. Proper response codes (2.xx/4.xx/5.xx), DTLS security, and rate limiting are essential for robust production IoT APIs.

48.1 Learning Objectives

By the end of this chapter, you will be able to:

  • Design RESTful CoAP APIs: Construct resource hierarchies using nouns with proper URI conventions and versioning strategies
  • Distinguish Content Formats: Compare JSON, CBOR, and text/plain payloads and select the appropriate format for constrained versus development environments
  • Implement Error Handling: Apply proper CoAP response codes (2.xx/4.xx/5.xx) and construct helpful error payloads for production systems
  • Configure Security Controls: Configure DTLS, rate limiting, and per-device access control for production CoAP deployments
  • Calculate Energy Tradeoffs: Calculate battery life impact of CON versus NON message types and justify the selection for different IoT use cases
  • Diagnose API Pitfalls: Identify and resolve common CoAP API problems such as Observe notification floods and proxy caching issues

Designing a CoAP API means deciding how IoT devices expose their data and accept commands. Think of it as creating a menu for your sensor – you define endpoints like /temperature or /status that other devices can read. Good API design makes your IoT system easy to use and understand, even for developers who have never seen your device before.

“I want other devices to read my temperature, but I also want them to set my sampling rate,” said Sammy the Sensor. “How do I organize all that?”

Max the Microcontroller sketched out a plan. “You create a resource tree, Sammy! Your root is /sensor, and under it you have /sensor/temperature for reading data and /sensor/config for settings. When someone sends a GET to /sensor/temperature, they get your latest reading. When they send a PUT to /sensor/config, they can change your sampling rate.”

“Keep your URIs short!” advised Bella the Battery. “Every extra character in the path costs bytes, and CoAP packets should stay small enough to fit in a single UDP datagram. Use /temp instead of /temperature-reading-in-celsius. Every byte saved is energy saved!”

Lila the LED added: “And don’t forget content negotiation! Some devices want your data in JSON, others in CBOR – which is like compressed JSON. Your API should let the requester choose the format using the Accept option. One sensor, multiple formats, everyone is happy!”

48.2 Prerequisites

Before diving into this chapter, you should be familiar with:

48.3 RESTful Resource Design

Minimum Viable Understanding: CoAP REST Design

Core Concept: CoAP follows REST principles - design resources as nouns, use HTTP-like methods as verbs. The URI identifies WHAT you’re accessing, the method specifies HOW.

Why It Matters: Consistent RESTful design makes APIs intuitive for developers, enables caching, supports proxying, and allows standard tooling to work seamlessly.

Key Takeaway: Good resource design: coap://sensor.local/v1/temperature (noun). Bad design: coap://sensor.local/getTemperature (verb in URL).

48.3.1 Good vs. Bad Resource Design

Good resource design (nouns):

coap://sensor.local/v1/temperature      # Resource (noun)
coap://sensor.local/v1/humidity         # Resource (noun)
coap://actuator.local/v1/led/state      # Resource (noun)
coap://gateway.local/v1/config/network  # Resource (noun)

Poor resource design (verbs - avoid):

coap://sensor.local/getTemperature      # Verb in URL - wrong!
coap://actuator.local/turnOnLED         # Verb in URL - wrong!

REST operations on resources:

GET    coap://sensor.local/v1/temperature     # Read current value
PUT    coap://actuator.local/v1/led/state     # Update LED state
POST   coap://gateway.local/v1/logs           # Create new log entry
DELETE coap://gateway.local/v1/logs/2025-01   # Delete old logs

48.3.2 URI Naming Conventions

CoAP URIs should be short (constrained bandwidth) but descriptive:

Recommended structure:

coap://{host}/{version}/{resource_type}/{device_id}/{subresource}

Examples:
coap://sensors.local/v1/devices/temp42/reading
coap://sensors.local/v1/devices/temp42/metadata
coap://sensors.local/v1/devices/temp42/config

Best practices:

Practice Example Rationale
Version your API /v1/ Allows future changes without breaking clients
Use plural nouns /devices/temp42 Consistent collection semantics
Keep it short Every character counts Constrained networks
Lowercase with hyphens /motion-sensors URL standard
Avoid deep nesting Max 3-4 levels /devices/sensors/temp/reading is too deep

48.3.3 Interactive URI Analysis

48.3.4 Payload Format Selection

Choose content format based on your constraints:

Content-Format Code Size Use Case
text/plain 0 Minimal Simple sensor values: “23.5”
application/json 50 Large Development, debugging
application/cbor 60 Small Production constrained devices
application/octet-stream 42 Varies Binary sensor data

Example - Temperature reading in different formats:

# Text/plain (4 bytes) - best for simple values
"23.5"

# JSON (42 bytes) - good for debugging
{"device":"temp42","value":23.5,"unit":"C"}

# CBOR (20 bytes) - production choice
A3 66 64 65 76 69 63 65 66 74 65 6D 70 34 32...

Recommendation:

  • Battery sensors: Use text/plain or CBOR (minimize bytes)
  • Development: Use JSON (easy debugging)
  • Production: Use CBOR (efficient, supports rich structures)

48.3.5 Interactive Payload Comparison

48.3.6 Versioning Strategy

IoT devices often run for years - plan for API evolution:

URI versioning (recommended for CoAP):

coap://sensor.local/v1/temperature    # Original API
coap://sensor.local/v2/temperature    # New version with metadata

Why URI versioning for IoT:

  • Simple for embedded clients
  • No custom header parsing needed
  • Clear in logs and debugging
  • Works with all CoAP libraries

Version migration example:

# v1: Simple temperature reading
GET coap://sensor.local/v1/temperature
Response: "23.5"

# v2: Rich metadata added
GET coap://sensor.local/v2/temperature
Response (CBOR): {"value":23.5,"unit":"C","timestamp":1642259400}

# Legacy devices continue using v1
# New devices adopt v2 when convenient

48.4 Error Handling

Use proper CoAP response codes and provide helpful error payloads:

48.4.1 Standard Response Codes

2.01 Created              # POST created new resource
2.04 Changed              # PUT updated resource
2.05 Content              # GET successful with payload
4.00 Bad Request          # Invalid syntax
4.01 Unauthorized         # Authentication required
4.04 Not Found            # Resource doesn't exist
4.05 Method Not Allowed   # GET on write-only resource
5.00 Internal Server Error

48.4.2 Error Payload Format

JSON for human readability:

{
  "error": {
    "code": "SENSOR_OFFLINE",
    "message": "Device has not reported in 5 minutes",
    "timestamp": "2025-01-15T10:30:00Z",
    "device_id": "temp42",
    "retry_after": 300
  }
}

CBOR for production (more efficient):

{1: "SENSOR_OFFLINE", 2: "Device offline", 3: 1642259400, 4: "temp42", 5: 300}

48.5 Message Type Selection

Choose between CON and NON based on criticality:

Use CON (Confirmable) for:

  • Actuator commands (LED on/off, valve open/close)
  • Configuration changes
  • Alerts and alarms
  • Any operation where failure must be detected

Use NON (Non-confirmable) for:

  • Frequent sensor readings (temperature every minute)
  • Telemetry streams
  • Status updates
  • Any data where next update supersedes previous

Decision tree:

Is the data critical? -> YES -> CON
                      -> NO  -> Will data be sent again soon?
                                -> YES -> NON
                                -> NO  -> CON (don't risk losing it)

48.6 Security Best Practices

48.6.1 Always Use DTLS in Production

coaps://sensor.local/v1/temperature    # Secure CoAP

Authentication options:

  1. Pre-Shared Key (PSK) - Simplest for constrained devices
  2. Raw Public Key (RPK) - No certificate infrastructure needed
  3. X.509 Certificates - Enterprise deployments

48.6.2 Security Checklist

48.6.3 Rate Limiting

Protect your system from misbehaving devices:

Per-device limits:

CoAP response code: 4.29 Too Many Requests

Payload (JSON):
{
  "error": "RATE_LIMIT_EXCEEDED",
  "limit": "10 requests/minute",
  "retry_after": 45
}

Implementation strategies:

  • Token bucket algorithm (allow bursts, limit sustained rate)
  • Return Max-Age option to indicate when retry is allowed
  • Log violations for debugging

48.6.4 Interactive Rate Limit Analysis

48.7 Worked Example: Smart Agriculture API

Case Study: Smart Agriculture Sensor Network

Scenario: 200 soil moisture sensors across a farm, battery-powered, reporting to a central gateway.

API Design:

# Resource structure
coap://gateway.local/v1/sensors/{sensor_id}/moisture
coap://gateway.local/v1/sensors/{sensor_id}/battery
coap://gateway.local/v1/sensors/{sensor_id}/config

# Normal operation (NON messages for efficiency)
Sensor -> Gateway: NON POST /v1/sensors/field3-42/moisture
Payload (CBOR): {value: 35, unit: "%", timestamp: 1642259400}

# Critical alerts (CON for reliability)
Sensor -> Gateway: CON POST /v1/sensors/field3-42/alert
Payload (CBOR): {type: "LOW_MOISTURE", threshold: 20, current: 15}

# Gateway observes battery levels
Gateway -> Sensor: GET /v1/sensors/field3-42/battery (Observe: 0)
Sensor -> Gateway: Notifications when battery drops below thresholds

# Versioning for future expansion
v1: Basic moisture reporting
v2: Adds soil temperature and pH
(v1 sensors continue working while v2 rolls out)

Why this works:

  • NON messages save battery (no ACK overhead)
  • CON ensures critical alerts aren’t lost
  • CBOR minimizes bandwidth
  • Observe pattern prevents polling battery status
  • Versioning allows gradual upgrades

48.8 Working Code: Python CoAP Client and Server

Real request/response examples using aiocoap (Python) and coap-client (CLI).

48.8.1 Python CoAP Server (Gateway)

# pip install aiocoap
import asyncio
import aiocoap
import aiocoap.resource as resource
import json
import time

class TemperatureResource(resource.ObservableResource):
    """CoAP resource for temperature readings."""

    def __init__(self):
        super().__init__()
        self.value = 22.5
        self.last_updated = time.time()

    async def render_get(self, request):
        """GET /v1/temperature -> returns latest reading."""
        payload = json.dumps({
            "value": self.value,
            "unit": "C",
            "timestamp": int(self.last_updated)
        }).encode('utf-8')
        # Response code 2.05 Content
        return aiocoap.Message(
            payload=payload,
            content_format=50  # application/json
        )

    async def render_put(self, request):
        """PUT /v1/temperature -> updates reading from sensor."""
        data = json.loads(request.payload)
        self.value = data['value']
        self.last_updated = time.time()
        self.updated_state()  # Triggers Observe notifications
        return aiocoap.Message(code=aiocoap.CHANGED)

class ConfigResource(resource.Resource):
    """CoAP resource for device configuration."""

    def __init__(self):
        super().__init__()
        self.interval = 60  # seconds

    async def render_get(self, request):
        payload = json.dumps({"reporting_interval": self.interval}).encode()
        return aiocoap.Message(payload=payload, content_format=50)

    async def render_put(self, request):
        data = json.loads(request.payload)
        self.interval = data.get('reporting_interval', self.interval)
        return aiocoap.Message(code=aiocoap.CHANGED)

async def main():
    root = resource.Site()
    root.add_resource(['v1', 'temperature'], TemperatureResource())
    root.add_resource(['v1', 'config'], ConfigResource())
    await aiocoap.Context.create_server_context(root, bind=('::', 5683))
    print("CoAP server running on port 5683")
    await asyncio.get_event_loop().create_future()  # Run forever

asyncio.run(main())

48.8.2 Python CoAP Client (Sensor)

import asyncio
import aiocoap
import json

async def read_temperature():
    """GET request to read temperature."""
    context = await aiocoap.Context.create_client_context()
    request = aiocoap.Message(
        code=aiocoap.GET,
        uri='coap://localhost/v1/temperature'
    )
    response = await context.request(request).response
    print(f"Response code: {response.code}")  # 2.05 Content
    print(f"Payload: {response.payload.decode()}")
    # Output: {"value": 22.5, "unit": "C", "timestamp": 1698765432}

async def send_temperature(value):
    """PUT request to update temperature."""
    context = await aiocoap.Context.create_client_context()
    payload = json.dumps({"value": value}).encode()
    request = aiocoap.Message(
        code=aiocoap.PUT,
        uri='coap://localhost/v1/temperature',
        payload=payload,
        content_format=50  # application/json
    )
    response = await context.request(request).response
    print(f"Update response: {response.code}")  # 2.04 Changed

# What to observe:
# - GET returns 2.05 Content with JSON payload
# - PUT returns 2.04 Changed (no payload needed)
# - Wrong URI returns 4.04 Not Found
# - Unsupported method returns 4.05 Method Not Allowed

48.8.3 CLI Testing with coap-client

# Install: apt install libcoap2-bin  (or brew install libcoap)

# GET temperature reading
coap-client -m get coap://localhost/v1/temperature
# Output: {"value":22.5,"unit":"C","timestamp":1698765432}

# PUT new reading
coap-client -m put coap://localhost/v1/temperature \
  -e '{"value":23.1}'
# Output: (empty, 2.04 Changed)

# Observe (subscribe to changes for 60 seconds)
coap-client -m get -s 60 coap://localhost/v1/temperature
# Receives notification each time temperature changes

# Discovery: list all resources
coap-client -m get coap://localhost/.well-known/core
# Output: </v1/temperature>,</v1/config>

Scenario: Battery sensor reports every 60 seconds for 1 year using CR2032 (220 mAh @ 3V).

CON (Confirmable) message energy: \[ \begin{align} \text{TX message (50 ms @ 10 mA)} &= 50 \times 10^{-3} \times 10 \times 10^{-3} = 0.5 \text{ mAs} \\ \text{RX ACK (100 ms @ 5 mA)} &= 100 \times 10^{-3} \times 5 \times 10^{-3} = 0.5 \text{ mAs} \\ \text{Total per message} &= 1.0 \text{ mAs} = 0.278 \text{ } \mu\text{Ah} \end{align} \]

NON (Non-confirmable) message energy: \[ \begin{align} \text{TX message (50 ms @ 10 mA)} &= 0.5 \text{ mAs} \\ \text{No ACK wait} &= 0 \text{ mAs} \\ \text{Total per message} &= 0.5 \text{ mAs} = 0.139 \text{ } \mu\text{Ah} \end{align} \]

Annual comparison (525,600 messages): \[ \begin{align} \text{CON energy} &= 525{,}600 \times 0.278 = 146 \text{ mAh} \\ \text{Sleep energy} &= 0.005 \times 24 \times 365 = 44 \text{ mAh} \\ \text{Total CON} &= 146 + 44 = 190 \text{ mAh (battery life} = 220/190 = 1.16 \text{ years)} \\ \\ \text{NON energy} &= 525{,}600 \times 0.139 = 73 \text{ mAh} \\ \text{Total NON} &= 73 + 44 = 117 \text{ mAh} \\ \text{Battery life} &= \frac{220}{117} = 1.88 \text{ years} \end{align} \]

Result: CON drains battery in 14 months; NON achieves 23-month target. 63% longer battery life with NON.

48.8.4 Interactive Battery Life Optimizer

48.9 Common Pitfalls

Common Pitfall: CoAP Observe Notification Flood

The mistake: Configuring a server to send Observe notifications on every minor resource change, overwhelming clients.

Symptoms:

  • Client device becomes unresponsive or crashes
  • Battery drains rapidly (constant wake-ups)
  • Network congestion with notification traffic
  • Client sends RST messages repeatedly

Why it happens: Developers bind notifications directly to sensor sampling rates (e.g., 10 Hz accelerometer) without throttling.

The fix: Implement server-side notification throttling:

# BAD: Notify on every sensor reading
@coap_resource('/temperature')
def on_read():
    current_temp = read_sensor()
    notify_observers(current_temp)  # Called 10x/second!

# GOOD: Throttle with change threshold
MIN_NOTIFY_INTERVAL = 5.0  # seconds
CHANGE_THRESHOLD = 0.5     # degrees

@coap_resource('/temperature')
def on_read():
    current_temp = read_sensor()

    should_notify = (
        abs(current_temp - last_notified_temp) >= CHANGE_THRESHOLD or
        (time.time() - last_notify_time) >= MIN_NOTIFY_INTERVAL
    )

    if should_notify:
        notify_observers(current_temp)
        last_notified_temp = current_temp
        last_notify_time = time.time()

Prevention:

  • Set minimum notification intervals (5-60 seconds)
  • Implement change thresholds (only notify on significant changes)
  • Use Max-Age option to tell clients how long values are valid
  • Monitor client RST responses (indicates overwhelmed client)
Pitfall: HTTP-CoAP Proxy Caching Stale Data

The Mistake: HTTP-to-CoAP proxy aggressively caches based on Max-Age without considering that freshness requirements vary by use case.

The Fix: Implement per-client cache control at the proxy:

@app.route('/coap/<path:resource>')
async def proxy_coap(resource):
    # Client-specified freshness requirement
    client_max_age = int(request.headers.get('Cache-Control', 'max-age=60').split('=')[1])

    # Check cache with client's freshness requirement
    if coap_uri in cache:
        response, cached_time, server_max_age = cache[coap_uri]
        age = time.time() - cached_time
        effective_max_age = min(client_max_age, server_max_age)

        if age < effective_max_age:
            return response.payload  # Cache hit
    # ... fetch from device if stale

Key principle: Safety-critical clients should always request fresh data (max-age=0).

48.10 Concept Relationships

This chapter on CoAP API design connects to several key concepts:

Builds on:

Relates to:

Enables:

48.11 See Also

Related Chapters:

External Resources:

48.12 What’s Next

Chapter Focus Why Read It
CoAP Decision Framework Protocol selection Apply a structured decision process to choose CoAP vs MQTT vs HTTP for your specific IoT deployment
CoAP Advanced Features Block-wise transfer and Observe Extend your API designs with large payload transfers and efficient server-push notification patterns
CoAP Implementation Labs Hands-on coding Build and test production-ready CoAP servers and clients using aiocoap and libcoap
CoAP Fundamentals and Architecture Protocol foundations Revisit the full CoAP chapter index to fill any gaps before building production systems
MQTT API Patterns Alternative messaging model Compare CoAP’s request-response model against MQTT’s publish-subscribe approach for your use case
IoT Protocol Selection Multi-protocol comparison Evaluate CoAP, MQTT, HTTP, and AMQP side-by-side across bandwidth, power, and complexity dimensions