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 - read a temperature resource
  • coap://sensor.local/v1/humidity - read a humidity resource
  • coap://actuator.local/v1/led/state - manage LED state
  • coap://gateway.local/v1/config/network - update network settings

Poor resource design (verbs - avoid):

  • coap://sensor.local/getTemperature - verb in URL
  • coap://actuator.local/turnOnLED - action embedded in path

REST operations on resources:

  • GET on .../v1/temperature reads the current value.
  • PUT on .../v1/led/state updates the LED state.
  • POST on .../v1/logs creates a new log entry.
  • DELETE on .../v1/logs/2025-01 removes an old log bucket.

48.3.2 URI Naming Conventions

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

Recommended structure: host + version + resource type + device ID + subresource

Examples:

  • .../v1/devices/temp42/reading
  • .../v1/devices/temp42/metadata
  • .../v1/devices/temp42/config

Best practices:

  • Version your API: use /v1/ so future changes do not break existing clients.
  • Use plural nouns: prefer /devices/temp42 over a singular collection name.
  • Keep it short: every character adds bytes on constrained links.
  • Lowercase with hyphens: /motion-sensors stays readable and URL-safe.
  • Avoid deep nesting: stop around 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:

  • text/plain (0) - minimal bytes for a single value like "23.5".
  • application/json (50) - easiest to inspect during development and debugging.
  • application/cbor (60) - smaller binary payload for production constrained devices.
  • application/octet-stream (42) - raw binary only when both sides already share the schema.

Example - Temperature reading in different formats:

  • Text/plain (4 bytes): 23.5
  • JSON (42 bytes): device=temp42, value=23.5, unit=C
  • CBOR (~20 bytes): binary map carrying the same fields in a compact form

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: GET coap://sensor.local/v1/temperature returns "23.5".
  • v2: GET coap://sensor.local/v2/temperature returns a richer CBOR payload with value, unit, and timestamp.
  • Rollout rule: legacy devices stay on v1 while new devices adopt v2.

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 a new resource
  • 2.04 Changed - PUT updated an existing resource
  • 2.05 Content - GET returned a payload
  • 4.00 Bad Request - request syntax or payload was invalid
  • 4.01 Unauthorized - authentication is required
  • 4.04 Not Found - resource does not exist
  • 4.05 Method Not Allowed - method does not apply to that resource
  • 5.00 Internal Server Error - server failed while handling the request

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:

  1. Is the data critical?
  2. If yes, use CON.
  3. If no, ask whether the data will be sent again soon.
  4. If yes, use NON.
  5. If no, use CON so you can detect loss.

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:

  • Response code: 4.29 Too Many Requests
  • Error key: RATE_LIMIT_EXCEEDED
  • Example limit: 10 requests/minute
  • Retry hint: 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 .../v1/sensors/{sensor_id}/moisture .../v1/sensors/{sensor_id}/battery .../v1/sensors/{sensor_id}/config
  • Normal operation sensor sends NON POST to .../v1/sensors/field3-42/moisture payload uses compact CBOR with value, unit, and timestamp
  • Critical alerts sensor sends CON POST to .../v1/sensors/field3-42/alert payload includes type, threshold, and current reading
  • Battery monitoring gateway uses GET on .../battery with Observe enabled sensor notifies only when thresholds are crossed
  • Version rollout v1 handles moisture today v2 adds soil temperature and pH later

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 the coap-client CLI.

48.8.1 Python CoAP Server (Gateway)

The gateway server only needs a few moving parts:

  • Create a TemperatureResource that stores the latest value and timestamp.
  • Implement render_get() to return JSON with CoAP code 2.05 Content.
  • Implement render_put() to update the reading and call updated_state() for Observe subscribers.
  • Add a ConfigResource for reporting interval settings.
  • Register both resources under the v1/temperature and v1/config paths.
  • Start the server with create_server_context(...), binding to UDP port 5683.

Minimal GET response flow:

  • Read self.value and self.last_updated.
  • Encode a JSON object with value, unit, and timestamp as bytes.
  • Return an aiocoap.Message with content_format=50.

48.8.2 Python CoAP Client (Sensor)

The sensor client loop is similarly compact:

  • Create a client context with create_client_context().
  • Build a GET message for .../v1/temperature.
  • Decode the JSON payload returned with 2.05 Content.
  • Build a PUT message carrying a JSON body with the new value.
  • Expect 2.04 Changed when the update succeeds.

What to verify during testing:

  • GET returns 2.05 Content with a JSON payload.
  • PUT returns 2.04 Changed.
  • Wrong URI returns 4.04 Not Found.
  • Unsupported method returns 4.05 Method Not Allowed.

48.8.3 CLI Testing with coap-client

  • Install the CLI with apt install libcoap2-bin on Linux or brew install libcoap on macOS.
  • Read a value: coap-client -m get .../v1/temperature
  • Update a value: coap-client -m put .../v1/temperature body: {"value":23.1}
  • Observe changes: coap-client -m get -s 60 .../v1/temperature
  • Discover resources: coap-client -m get .../.well-known/core

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