In IoT Blink Tutorial for Onomondo and Conexa, the board joined the LTE-M network and proved it was online by turning on an LED. In this second part, we take the next step: we turn the nRF9151 into a tiny cellular web server that exposes a real API over the network. That means your device is no longer just “connected” — it can now be controlled remotely.
Your first tutorial already set up the nRF9151 DK, the Onomondo SIM, the VPN workflow, and the Zephyr/nRF Connect SDK environment, including the nrf9151dk/nrf9151/ns board target.
On the Onomondo side, the key pieces are already there: their IoT SIMs are delivered activated, the documented APN is onomondo, they provide private static IPs, and they document OpenVPN access so a computer or server can connect to devices over their network when the user has VPN access enabled. That makes this tutorial a very natural next step after the first LTE attach example.
We will expose three endpoints from the nRF9151:
GET /status-> returns whether LED0 is on or offPOST /led/on-> turns LED0 onPOST /led/off-> turns LED0 off
The application flow is simple:
- Boot the board
- Initialize the modem
- Attach to LTE-M
- Start the Zephyr HTTP server
- Control the board remotely with
curl
IoT Blink was outbound connectivity: the device joined the network.
New tutorial is about inbound control: the device opens a TCP port and waits for commands.
Step 2. Understand the Server ArchitectureZephyr’s HTTP server is a real subsystem, not just a hand-written socket loop. It runs in the background, supports dynamic resources, and requires CONFIG_HTTP_SERVER=y.
One important detail from the Zephyr docs is that each HTTP service also needs its own linker section for registered resources. Without that extra file and CMake entry, projects using HTTP_RESOURCE_DEFINE(...) will not be set up correctly.
Although the nRF9151 device has an IP address, pings will fail because the SIM is inside protected network. To access your device via its IP, you must install the OpenVPN with configuration file on your computer.
Navigate to openvpn.net and download the OpenVPN desktop version compatible with your operating system.
Launch OpenVPN Connect.
Click on your email address in the top-right corner. Select "API docs" from the dropdown list.
Now you will get to the docs page
Select the OpenVPN tab from the list.
Download the onomondo.ovpn file from the page.
Since you already have the configuration file, go to the bottom of the screen, select Upload file, and navigate to the location where you saved it on your computer.
Confirm file import.
After importing file click Connect.
To connect, log in using the same credentials you used for the Onomondo portal.
Log in using the same credentials you used for the Onomondo portal to establish the connection.
With your VPN tunnel active, you now have a direct line to your nRF9151. You're no longer just sending data into a void—you can actively manage, ping, and communicate with your hardware from anywhere.
Now, let’s get to the real fun: writing some code.
Step 4. Create a new Zephyr applicationCreate a blank Zephyr application in the same nRF Connect SDK environment you used in IoT Blink.
Use the same board target as before:
nrf9151dk/nrf9151/nsIf you are using the nRF Connect VS Code extension, keep the same SDK/toolchain selection you used. Your first tutorial used the Nordic SDK flow and this exact non-secure nRF9151 DK board target.
A good project name would be:
cellular_http_ledProject structureYour project should look like this:
cellular_http_led/
├── CMakeLists.txt
├── prj.conf
├── sections-rom.ld
└── src/
└── main.cStep 5. Configure the BuildAdd the linker sectionZephyr’s HTTP server places services and resources into iterable linker sections. The service section is handled internally, but the resource section must be defined by the application for each service name. Since our service is called led_svc, we need a matching resource section for http_resource_desc_led_svc.
Create sections-rom.ld app directory:
#include <zephyr/linker/iterable_sections.h>
ITERABLE_SECTION_ROM(http_resource_desc_led_svc, Z_LINK_ITERABLE_SUBALIGN)Update CMakeLists.txtCreate this CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(cellular_http_led)
target_sources(app PRIVATE src/main.c)
zephyr_linker_sources(SECTIONS sections-rom.ld)
zephyr_linker_section(NAME http_resource_desc_led_svc
KVMA RAM_REGION GROUP RODATA_REGION)This matches the Zephyr HTTP server documentation pattern: add the linker file, then register a linker section whose name matches the service-specific resource section.
Add prj.confFor this project we need GPIO, modem/LTE support, and Zephyr networking with HTTP server support. Zephyr’s own HTTP server sample enables CONFIG_NETWORKING, CONFIG_NET_IPV4, CONFIG_NET_TCP, CONFIG_NET_SOCKETS, CONFIG_HTTP_PARSER, and CONFIG_HTTP_SERVER, so this tutorial follows the same direction while keeping the config focused on the nRF9151 use case.
Create prj.conf:
# Networking
CONFIG_NETWORKING=y
CONFIG_NET_SOCKETS=y
CONFIG_NET_NATIVE=y
CONFIG_NET_IPV4=y
CONFIG_NET_TCP=y
CONFIG_NET_UDP=y
# Modem library
CONFIG_NRF_MODEM_LIB=y
CONFIG_LTE_LINK_CONTROL=y
# Modem networking wrapper
CONFIG_NET_SOCKETS_OFFLOAD=y
# HTTP Server
CONFIG_HTTP_SERVER=y
# GPIO
CONFIG_GPIO=y
# Logging
CONFIG_LOG=y
CONFIG_LTE_LINK_CONTROL_LOG_LEVEL_INF=y
CONFIG_NRF_MODEM_LIB_NET_IF=y
CONFIG_NET_LOG=yI added a verbose network diagnostics:
CONFIG_NET_LOG=yStep 6. Implement the HTTP LED ControlAdd the application codeCreate src/main.c:
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/net/http/server.h>
#include <zephyr/net/http/service.h>
#include <zephyr/logging/log.h>
#include <modem/lte_lc.h>
#include <modem/nrf_modem_lib.h>
#include <stdio.h>
LOG_MODULE_REGISTER(led_app, LOG_LEVEL_INF);
static K_SEM_DEFINE(lte_connected, 0, 1);
/* ================= LED SETUP ================= */
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
static bool led0_is_on = false;
static void turn_led_on(void)
{
gpio_pin_set_dt(&led0, 1);
led0_is_on = true;
}
static void turn_led_off(void)
{
gpio_pin_set_dt(&led0, 0);
led0_is_on = false;
}
/* ================= HTTP ENDPOINTS ================= */
static int on_status_get(struct http_client_ctx *client, enum http_data_status status,
const struct http_request_ctx *req, struct http_response_ctx *res, void *user_data)
{
static char body[64];
if (status != HTTP_SERVER_DATA_FINAL) return 0;
LOG_INF("API REQUEST: GET /status");
int len = snprintf(body, sizeof(body), "{\"led0\":\"%s\"}", led0_is_on ? "on" : "off");
res->status = HTTP_200_OK;
res->body = (uint8_t *)body;
res->body_len = len;
res->final_chunk = true;
return 0;
}
static int on_led_on_post(struct http_client_ctx *client, enum http_data_status status,
const struct http_request_ctx *req, struct http_response_ctx *res, void *user_data)
{
static char body[64];
if (status != HTTP_SERVER_DATA_FINAL) return 0;
LOG_INF("API REQUEST: POST /led/on");
turn_led_on();
int len = snprintf(body, sizeof(body), "{\"status\":\"success\", \"led0\":\"on\"}");
res->status = HTTP_200_OK;
res->body = (uint8_t *)body;
res->body_len = len;
res->final_chunk = true;
return 0;
}
static int on_led_off_post(struct http_client_ctx *client, enum http_data_status status,
const struct http_request_ctx *req, struct http_response_ctx *res, void *user_data)
{
static char body[64];
if (status != HTTP_SERVER_DATA_FINAL) return 0;
LOG_INF("API REQUEST: POST /led/off");
turn_led_off();
int len = snprintf(body, sizeof(body), "{\"status\":\"success\", \"led0\":\"off\"}");
res->status = HTTP_200_OK;
res->body = (uint8_t *)body;
res->body_len = len;
res->final_chunk = true;
return 0;
}
/* ================= HTTP SERVER DEFINITION ================= */
static uint16_t service_port = 8080;
HTTP_SERVICE_DEFINE(led_svc, "0.0.0.0", &service_port, 2, 10, NULL, NULL, NULL);
static struct http_resource_detail_dynamic status_detail = {
.common = {
.type = HTTP_RESOURCE_TYPE_DYNAMIC,
.bitmask_of_supported_http_methods = BIT(HTTP_GET),
},
.cb = on_status_get,
};
HTTP_RESOURCE_DEFINE(status_resource, led_svc, "/status", &status_detail);
static struct http_resource_detail_dynamic led_on_detail = {
.common = {
.type = HTTP_RESOURCE_TYPE_DYNAMIC,
.bitmask_of_supported_http_methods = BIT(HTTP_POST),
},
.cb = on_led_on_post,
};
HTTP_RESOURCE_DEFINE(led_on_resource, led_svc, "/led/on", &led_on_detail);
static struct http_resource_detail_dynamic led_off_detail = {
.common = {
.type = HTTP_RESOURCE_TYPE_DYNAMIC,
.bitmask_of_supported_http_methods = BIT(HTTP_POST),
},
.cb = on_led_off_post,
};
HTTP_RESOURCE_DEFINE(led_off_resource, led_svc, "/led/off", &led_off_detail);
/* ================= LTE CONNECTION ================= */
static void lte_handler(const struct lte_lc_evt *evt)
{
switch (evt->type) {
case LTE_LC_EVT_NW_REG_STATUS:
if (evt->nw_reg_status == LTE_LC_NW_REG_REGISTERED_HOME ||
evt->nw_reg_status == LTE_LC_NW_REG_REGISTERED_ROAMING) {
LOG_INF("--- LTE Network Attached! ---");
k_sem_give(<e_connected);
}
break;
default:
break;
}
}
/* ================= MAIN ================= */
int main(void)
{
int err;
LOG_INF("Starting Basic API LED Controller");
/* Initialize LED0 */
if (!device_is_ready(led0.port)) {
LOG_ERR("LED device is not ready!");
return -1;
}
gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
/* Initialize Modem */
err = nrf_modem_lib_init();
if (err) {
LOG_ERR("Modem init failed: %d", err);
return err;
}
err = lte_lc_connect_async(lte_handler);
if (err) {
LOG_ERR("LTE connect failed: %d", err);
return err;
}
LOG_INF("Waiting for network connection...");
k_sem_take(<e_connected, K_FOREVER);
LOG_INF("LTE Connected. Bringing up API server...");
/* Start Server */
err = http_server_start();
if (err) {
LOG_ERR("HTTP server failed: %d", err);
} else {
LOG_INF("HTTP server started on port %d", service_port);
}
/* Idle Loop */
while (1) {
k_sleep(K_SECONDS(1));
}
}Step 7. Build and RunBuild and flashIf you prefer the command line:
west build -b nrf9151dk/nrf9151/ns
west flashIf you are following the same flow as IoT Blink Tutorial in VS Code, add a build configuration for nrf9151dk/nrf9151/ns, then build and flash from the nRF Connect extension. Your tutorial already used that exact board target in the Nordic VS Code workflow.
Open the serial log after flashing. A successful boot should look roughly like this:
Starting Basic API LED Controller
Waiting for network connection...
--- LTE Network Attached! ---
LTE Connected. Bringing up API server...
HTTP server started on port 8080Connect from your laptopBecause Onomondo documents private static IPs and provides OpenVPN access for computers and servers, the usual flow is:
- Connect your laptop to Onomondo OpenVPN
- Find your device IP at the Onomondo Portal
- Send HTTP requests to port
8080of the device IP address.
Replace <DEVICE_IP> below with your device’s IP.
Check status:
curl http://<DEVICE_IP>:8080/statusExpected response:
{"led0":"off"}Turn LED on:
curl -X POST http://<DEVICE_IP>:8080/led/onExpected response:
{"status":"success", "led0":"on"}Turn LED off:
curl -X POST http://<DEVICE_IP>:8080/led/offExpected response:
{"status":"success", "led0":"off"}Read status again:
curl http://<DEVICE_IP>:8080/statusNow the API is doing real work over cellular.
Step 9. Understand the ImplementationHow the code works1. LED state is tracked in software
The physical LED is controlled with:
gpio_pin_set_dt(&led0, 1);
gpio_pin_set_dt(&led0, 0);But we also keep a software flag:
static bool led0_is_on = false;That gives us an easy way to answer /status without reading the pin back.
2. LTE attach is asynchronous
Instead of blocking forever in a simple connect call, this example uses:
lte_lc_connect_async(lte_handler);Then the app waits on a semaphore until the modem reports either home registration or roaming registration. This keeps the boot logic clear: network first, server second.
3. Each API route is a dynamic HTTP resource
The three handlers are registered with:
HTTP_RESOURCE_DEFINE(...)Each one points to a callback that prepares the JSON response.
This is exactly the kind of dynamic resource model the Zephyr HTTP server is designed for. The server library handles sockets and connection management in the background, while your callback focuses on request-specific logic.
4. The service listens on all interfaces on port 8080
This line creates the service:
HTTP_SERVICE_DEFINE(led_svc, "0.0.0.0", &service_port, 2, 10, NULL, NULL, NULL);"0.0.0.0"means bind on all available IPv4 interfaces8080is the listening port2is the concurrent client setting10is the backlog
5. The main loop stays simple
Once the modem is attached and the HTTP server is started, the application has nothing else to do in the foreground, so it just sleeps:
while (1) {
k_sleep(K_SECONDS(1));
}That is enough because the HTTP server subsystem runs in the background.
Step 10. Optimize cellular data with ProtobufProtobuf to save expensive cellurar bytesSending JSON over a cellular network is incredibly expensive.
If you send smth like
{"device": 9151, "temp": 24.5, "bat": 85}that is 42 bytes of text.
Or in our case server reply over cellular is shorter, but still heavy:
{"led0":"on"}On an IoT data plan, where you pay per byte and every millisecond the radio is transmitting drains the battery, sending brackets, quotes, and spaces is a massive waste.
By using Protocol Buffers (Protobuf), we compress that exact same data into pure machine-readable binary. It shrinks from bytes down — saving 70% on your cellular data bill and battery life!
Zephyr makes this incredibly easy using the industry-standard embedded library called NanoPB. Here is exactly how to add it to your project.
Update prj.confWe just need to tell Zephyr to include the NanoPB library. Add this to your prj.conf:
# Enable Nano Protocol Buffers (Protobuf)
CONFIG_NANOPB=yCreate the .proto fileCreate a new file in your src folder called status.proto. This file defines the shape of your data.
src/status.proto
The most annoying part of Protobuf used to be compiling the .proto file into C code. Zephyr automates this completely! Open your CMakeLists.txt (in the root of your project) and add the zephyr_nanopb_sources line:
Open your CMakeLists.txt file and add this line before your target_sources. This automatically converts the .proto file into C code (status.pb.h and status.pb.c):
zephyr_nanopb_sources(app src/status.proto)Update the HTTP Server CodeNow update on_status_get() function that packs our sensor data into a tiny binary buffer, ready to be sent over the LTE modem.
Add new headers
// NanoPB encoding library
#include <pb_encode.h>
// Auto-generated from your .proto file
#include "src/status.pb.h"Replace the logic for on_status_get() function with this:
static int on_status_get(struct http_client_ctx *client, enum http_data_status status,
const struct http_request_ctx *req, struct http_response_ctx *res, void *user_data)
{
// A small 16-byte buffer to hold our compressed binary data
static uint8_t pb_buffer[16];
if (status != HTTP_SERVER_DATA_FINAL) return 0;
LOG_INF("API REQUEST: GET /status");
// 1. Initialize the Protobuf message
DeviceStatus status_msg = DeviceStatus_init_zero;
status_msg.led0_on = led0_is_on; // Pass the current LED state
// 2. Create the stream and encode the data into binary
pb_ostream_t stream = pb_ostream_from_buffer(pb_buffer, sizeof(pb_buffer));
bool encode_status = pb_encode(&stream, DeviceStatus_fields, &status_msg);
if (!encode_status) {
LOG_ERR("Protobuf encoding failed: %s", PB_GET_ERROR(&stream));
res->status = HTTP_500_INTERNAL_SERVER_ERROR;
return 0;
}
LOG_INF("Encoded Protobuf payload size: %d bytes!", stream.bytes_written);
// 3. Send the binary buffer to the Zephyr HTTP Server
res->status = HTTP_200_OK;
// Point to our binary array
res->body = pb_buffer;
// Only send the exact number of encoded bytes
res->body_len = stream.bytes_written;
res->final_chunk = true;
return 0;
}What will happen when you run curl now?When you compile and flash this code, your nRF9151 will stop sending {"led0":"on"}.
Your payload went from being 15 bytes of JSON text down to 2 bytes of pure binary data. Over thousands of requests, this saves a massive amount of cellular data and battery life.
To actually read it on your computer, you would pipe the output into the Protobuf decoder, like this:
curl -s http://100.119.103.90:8080/status | protoc --decode=DeviceStatus src/status.protoThe computer will display:
led0_on: trueAnd the board will send a log to the serial terminal:
[00:04:48.839,019] <inf> led_app: Encoded Protobuf payload size: 2 bytes!Build fails around HTTP resources
Make sure you created both:
sections-rom.ld- the
zephyr_linker_sources(...)andzephyr_linker_section(...)lines inCMakeLists.txt
This is required by the Zephyr HTTP server design for per-service resource sections.
Board attaches to LTE, but API is unreachable
The server may be running correctly, but your laptop may not be on the right network path. Connect through Onomondo OpenVPN first, because that is the documented method for connecting from a computer or server to devices on their network.
SIM connects inconsistently
Verify the basics from IoT Blink Tutorial:
- active SIM in the Onomondo portal
- LTE-M coverage in your area
- correct APN if explicitly required:
onomondo
Build complains about missing networking symbols
Compare your config with the Zephyr HTTP server sample. The standard sample enables CONFIG_NETWORKING, CONFIG_NET_IPV4, CONFIG_NET_TCP, CONFIG_NET_SOCKETS, CONFIG_HTTP_PARSER, and CONFIG_HTTP_SERVER.
With this project, your nRF9151 is no longer just a blinking demo board.
It is now:
- attached to a real cellular network
- reachable over an API path
- controllable from a remote machine
- structured as a Zephyr networking application, not a one-off test
That is the bridge from “board is online” to “device is a real IoT node.”


_UoqlmTWtmc.png?auto=compress%2Cformat&w=48&h=48&fit=fill&bg=ffffff)







Comments