Your idea is safe; NDA signed before discussion
BLE / BluetoothNordic nRF52GATT DesignFirmware EngineeringIoT Products

Designing Custom GATT Services & Characteristics for BLE Products on Nordic Platforms

From UUID assignment to attribute table decisions — a practitioner's guide to building production-grade custom GATT profiles, drawing on real firmware from our golf ball telemetry, wearable sprint tracker, and haptic controller projects.

Upwork Top RatedGoogle Reviews 4.9
DigitalMonk Firmware Team
DigitalMonk Firmware Team
Embedded Systems & BLE Firmware · Jalandhar, IN · Alpine, CA · Coventry, UK

Bluetooth Low Energy's Generic Attribute Profile — GATT — is the structured data layer that makes every BLE product legible to the world. Yet most tutorials stop at "Heart Rate Service" and call it a day. That is fine for evaluation kits; it is useless in production.

When we ship firmware for clients — whether it is a smart golf ball tracking launch angle at 900 Hz, a wrist-worn sprint sensor broadcasting stride data over BLE, or a haptic controller that needs sub-10 ms command latency — we design the GATT profile from scratch. We choose UUIDs deliberately, define characteristic formats with precision, and implement the attribute table so the host-side stack can enforce permissions without a single line of custom gatekeeping code.

This tutorial walks through exactly how we do that. It is aimed at firmware engineers who already know what BLE is and want to stop copying Nordic example profiles and start designing real ones.

1. GATT Architecture — What You Actually Control

The GATT layer sits above ATT (Attribute Protocol) and organises data into a hierarchy of Services → Characteristics → Descriptors. A service groups logically related data. A characteristic is the fundamental unit — it has a value, a set of properties (read / write / notify / indicate), and optionally descriptors that describe it further.

Mental model: Think of a service as a database table, characteristics as columns, and each ATT handle as a row index. The GATT server (your nRF52 device) owns the table. The GATT client (mobile app, gateway, PC) queries and mutates it using ATT operations.

UUID Strategy — 16-bit vs 128-bit

Bluetooth SIG allocates 16-bit UUIDs for standardised services like Battery (0x180F) or Device Information (0x180A). Everything custom uses a 128-bit UUID in the vendor-defined space. The Nordic convention is to define a base UUID and derive individual characteristics by incrementing a single byte — the approach used in all three of the projects referenced in this article.

UUID TypeLengthUse CaseAir Bytes Per Handle
SIG-Assigned (16-bit)2 bytesStandard profiles — HRS, BAS, DIS2
Vendor-Custom (128-bit)16 bytesAll proprietary services16 (first exchange only — cached after)

The 14-byte overhead for 128-bit UUIDs is a one-time cost per connection during service discovery. After that, the ATT handle — a simple 2-byte integer — is used for all operations. Performance in steady-state data streaming is completely unaffected.

2. Implementing a Custom GATT Service in Zephyr RTOS

Zephyr's BLE stack provides a declarative macro system for registering GATT services at compile time. The attribute table is placed in a read-only section of flash — zero runtime allocation overhead. Here is how the Golf Ball Telemetry service is implemented from scratch using nRF Connect SDK. (If you haven't yet chosen between nRF5 SDK and nRF Connect SDK for your Nordic product, our nRF5 SDK vs nRF Connect SDK comparison covers that decision.)

Step 1 — Define UUIDs

golf_ball_service.hC
#ifndef GOLF_BALL_SERVICE_H_
#define GOLF_BALL_SERVICE_H_

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>

/* ---- Base UUID: 6E400001-B5A3-F393-E0A9-E50E24DCCA9F ---- */
/* Convention: vary only the first 4 hex digits per characteristic */

#define GBS_UUID_BASE \
    BT_UUID_128_ENCODE(0x6E400001, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9F)

#define GBS_UUID_SERVICE BT_UUID_DECLARE_128(GBS_UUID_BASE)

#define GBS_UUID_LAUNCH_DATA \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x6E400101, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9F))

#define GBS_UUID_CONFIG \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x6E400102, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9F))

#define GBS_UUID_STREAM_CTRL \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x6E400103, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9F))

#define GBS_UUID_STATUS \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE(0x6E400104, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9F))

/* Packed launch data frame — 20 bytes, fits default ATT MTU */
struct gbs_launch_frame {
    int16_t accel_x, accel_y, accel_z; /* scale ÷ 2048 for g */
    int16_t gyro_x, gyro_y, gyro_z; /* scale ÷ 16.4 for dps */
    uint32_t timestamp_us;
    uint16_t seq;
} __packed;

struct gbs_config {
    uint16_t sample_rate_hz;
    uint8_t lpf_cutoff_code;
    uint8_t reserved;
} __packed;

int gbs_notify_launch(const struct gbs_launch_frame *frame);
int gbs_notify_status(uint8_t batt_pct, uint8_t err_flags);
void gbs_streaming_enable(bool enable);

#endif /* GOLF_BALL_SERVICE_H_ */

Step 2 — Register the GATT Service

golf_ball_service.cC
#include "golf_ball_service.h"
#include <zephyr/logging/log.h>

LOG_MODULE_REGISTER(gbs, LOG_LEVEL_DBG);

static bool streaming_active = false;
static struct gbs_config config = { .sample_rate_hz = 100, .lpf_cutoff_code = 3 };

static ssize_t read_config(struct bt_conn *conn,
                        const struct bt_gatt_attr *attr,
                        void *buf, uint16_t len, uint16_t offset)
{
    return bt_gatt_attr_read(conn, attr, buf, len, offset, &config, sizeof(config));
}

static ssize_t write_config(struct bt_conn *conn,
                         const struct bt_gatt_attr *attr,
                         const void *buf, uint16_t len,
                         uint16_t offset, uint8_t flags)
{
    if (offset != 0 || len != sizeof(struct gbs_config)) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }
    memcpy(&config, buf, len);
    imu_set_odr(config.sample_rate_hz);
    return len;
}
... (abridged for brevity)
Why static attribute tables? Setting CONFIG_BT_GATT_DYNAMIC_DB=n places the entire service definition in flash at link time. This eliminates heap allocation, makes the memory layout deterministic, and gives you a compile-time error if the table is malformed — catching bugs that a dynamic approach would only surface at runtime.

2. Three Real GATT Profiles from DigitalMonk Projects

The best way to understand profile design decisions is to see them applied to real constraints. Below are three condensed profiles drawn from shipped DigitalMonk firmware.

Smart Golf Ball — Launch Telemetry

nRF52840 inside the ball. IMU data streamed over BLE notifications at up to 100 Hz. Club face angle, ball speed, and spin axis packed into a compact binary format.

Base: 6E400001-B5A3-F393-E0A9-E50E24DCCA9F
🏃

Wearable Sprint / Jump Tracker

nRF52832 on the wrist. Step cadence, jump height via barometric delta, and BLE heart rate relay. Dual-service profile: custom biomechanics service + SIG Heart Rate.

Base: A1B2C3D4-E5F6-7890-ABCD-EF1234567890
📳

Haptic Controller — Command Interface

nRF52833. Central sends haptic pattern IDs as write-without-response. Device sends back a completion event via notify. Bidirectional command/ack over two characteristics.

Base: FEED0001-C0FF-EE00-BEEF-CAFEBABEFADE

2a. Golf Ball Telemetry — High-Throughput Notification Design

The golf ball project's primary constraint was bandwidth. An nRF52840 running at 2M PHY with a 7.5 ms connection interval can push roughly 2,350 bytes per second net. Our IMU packet was 20 bytes, giving comfortable headroom at 100 Hz (2,000 bytes/s nominal). The GATT profile reflects those constraints directly.

CharacteristicUUID (last byte)LengthPropertiesDescription
LAUNCH_DATA...0120 BNOTIFYPacked IMU frame: accel XYZ (3×int16), gyro XYZ (3×int16), timestamp (uint32), seq (uint16)
CONFIG...024 BREAD/WRITESample rate (uint16 Hz), filter cutoff (uint8), padding
STREAM_CTRL...031 BWRITE NR0x01 = start, 0x00 = stop. Write-without-response for minimum latency.
STATUS...042 BREAD NOTIFYBattery % (uint8) + error flags (uint8). Notified on change.
Design decision — Write-Without-Response on STREAM_CTRL

STREAM_CTRL uses write-without-response intentionally. A standard write requires an ATT response PDU from the server, burning a connection event for an acknowledgement you do not need on a start/stop toggle. We mitigate unreliability with a subsequent STATUS read to confirm state before the app shows "streaming active."

🔵

Hire an nRF Developer — DigitalMonk

Need a production-grade BLE firmware engineer with hands-on Nordic nRF52 / nRF9160 experience? Our team has shipped GATT profiles exactly like these.

100HzIMU notification rate — Golf Ball
<10msWrite-to-motor latency — Haptic
2M PHYNordic LE 2M for max throughput
3+Custom GATT profiles shipped

3. Security Model — Pairing, Bonding, and Permission Levels

Every GATT characteristic carries permission flags that the ATT layer enforces automatically. There are four read/write permission levels, and they map directly to the security level of the connection at the time the ATT operation arrives.

Permission LevelRequiresUse When
PERM_READOpen connectionNon-sensitive data — firmware version, battery level
PERM_READ_ENCRYPTEncrypted link (pairing done)User health data, session stats
PERM_READ_AUTHENAuthenticated pairing (MITM protection)Config writes that affect device behaviour
PERM_WRITE_AUTHENAuthenticated + bondedFactory reset, OTA trigger, device rename
Whitelist vs. pairing: For point-to-point products with a single known peer, advertising filter whitelisting is often simpler and more reliable than implementing a full pairing flow. It requires a bonding step to populate the whitelist, but eliminates mid-session pairing dialogs entirely — a real benefit on embedded products with no display.

4. Descriptors — CCCD, CPFD, and User Description

Descriptors add metadata and control to characteristics. You will encounter three in almost every production profile.

DescriptorUUIDPurpose
Client Characteristic Configuration (CCCD)0x2902Client writes 0x0001 to enable notify, 0x0002 for indicate. Per-connection state — must be persisted for bonded devices.
Characteristic Presentation Format (CPFD)0x2904Declares scalar format, unit (Bluetooth GATT unit codes), and namespace. Enables generic display apps to show correct units without a proprietary app.
Characteristic User Description (CUD)0x2901Human-readable label shown in generic BLE explorer tools. Invaluable during hardware bringup and client-side debugging.
📱

BLE WiFi Setup App for IoT Devices — DigitalMonk

See how we built a production BLE provisioning flow — GATT profile, iOS companion app, and zero-touch WiFi credential transfer — for real IoT products.

5. Common GATT Design Mistakes — And How We Fixed Them

These are problems we have debugged in client code that came to us mid-project. Each one cost days to diagnose. We are sharing them so you do not have to repeat them.

  • 1
    Hardcoding ATT handle offsets

    Developers often reference characteristic value attributes using a hardcoded index into the service's attribute array. When you later add a descriptor or reorder characteristics, the index shifts silently and notifications fire on the wrong characteristic — or stop entirely. Fix: look up attribute pointers by UUID at initialisation time, store the pointer, and never use magic index numbers.

  • 2
    Calling notify from an ISR

    The BLE stack's notify function can block internally on a semaphore. Called from an interrupt service routine, this triggers an assertion in the scheduler. Always offload to a workqueue or a kernel thread. The golf ball firmware uses a work item submission from the IMU data-ready interrupt handler — the ISR only signals the queue; the ATT PDU is built and sent from thread context.

  • 3
    Forgetting CCCD persistence across reconnections

    By default, the CCCD value (notify enabled / disabled) is per-connection and resets to zero on disconnect. Bonded clients expect the subscription to persist. Enable persistent settings storage and call the relevant CCCD store function in your pairing callback. We caught this in the wearable project only after a user reported "BLE drops every time the phone screen turns off."

  • 4
    Using indicate when notify is correct — and vice versa

    Indication is acknowledged: the server must wait for the client's ATT confirmation before sending the next indication. If you send indications in a high-rate sensor loop, you will queue ATT PDUs faster than they can be confirmed. Use notify for streaming data and indicate only for critical one-shot events — OTA completion, error alerts, factory-reset confirmation.

  • 5
    Ignoring Maximum Transmission Unit (MTU)

    Default ATT MTU is 23 bytes, leaving only 20 bytes for the characteristic value after the 3-byte ATT header. If your payload exceeds 20 bytes, you must negotiate a higher MTU at connection time. Not doing this causes silent truncation — the ATT layer drops excess bytes with no error return. The golf ball's 20-byte frame was sized exactly to fit within default MTU; the wearable's stride frame was 8 bytes — safe by design.

6. Testing Your GATT Profile

A GATT profile is firmware — it must be tested systematically, not just "opened in nRF Connect and it looked fine." Below is the test matrix we run on every BLE product before client delivery.

Test CategoryTool / MethodWhat to Verify
Service DiscoverynRF Connect Mobile / DesktopAll services, characteristics, and descriptors appear with correct UUIDs and properties
Read / Write FunctionalnRF Connect write panelWrite-back values match expected endianness and structure layout
Notification RatenRF Connect + log timestampPacket interval matches configured ODR; no gaps or bursts under steady state
MTU NegotiationWireshark + Bluetooth HCI snoop (Android) or PacketLogger (macOS)MTU Exchange request/response PDUs visible; agreed MTU matches expectations
Reconnect BehaviourManual disconnect/reconnect × 50CCCD state persists if bonded; subscription restored without app restart
Permission EnforcementnRF Connect (unauthenticated) → write protected characteristicATT error 0x05 (Insufficient Authentication) returned correctly
Stress / ThroughputCustom Python script via bleak libraryNo packet loss at target ODR for 30+ minute continuous run
Pro tip — HCI Snoop on Android: Enable HCI snoop logs in Developer Options → "Enable Bluetooth HCI snoop log." Pull the resulting btsnoop_hci.log with adb and open in Wireshark filtered on btle. You can inspect every ATT PDU exchanged — invaluable when debugging permission errors or MTU issues that are completely invisible in application logs.

7. GATT Design Checklist — 10 Decisions Before You Write a Line of Code

Every production BLE profile we ship at DigitalMonk goes through this checklist before firmware implementation starts. These are the questions that prevent the most expensive mistakes.

  • 1
    UUID strategy — Standard SIG profile exists? Use it. Custom? Define a base 128-bit UUID and derive characteristics by incrementing one byte.
  • 2
    Data rate budget — Peak bytes/second? Does it fit within connection interval × PDU size at the target PHY (1M vs 2M)?
  • 3
    Properties chosen for semantics — Streaming → NOTIFY. Config → READ/WRITE. Commands → WRITE_WITHOUT_RESP. Critical alerts → INDICATE.
  • 4
    Payload layout documented — Packed structs with units, scale factors, and endianness in comments. Static assert on sizeof() in firmware.
  • 5
    MTU handled — Any payload > 20 bytes? Plan MTU exchange at connection time before you plan the packet format.
  • 6
    Security model mapped — Each characteristic assigned to the minimum permission level it actually needs — not the maximum possible.
  • 7
    CCCD persistence decided — Bonded product? Persist CCCD on write. Ephemeral product? Document the re-subscription UX in the app spec before coding starts.
  • 8
    Notify context is thread-safe — Notify / indicate never called from ISR context. Workqueue or dedicated BLE thread only.
  • 9
    Attribute references stored at init — Characteristic value attribute pointers looked up once by UUID at boot. No magic index numbers anywhere in the codebase.
  • 10
    Test matrix defined before sprint starts — Wireshark sniffer log, 30-min stress run, reconnect × 50, permission rejection test — all before client acceptance testing.

Conclusion

A well-designed GATT profile is invisible to end users — the app just works, data arrives clean, and reconnects are seamless. A poorly designed one means dropped frames, pairing failures at 2 AM on a factory floor, and a firmware engineer spending a sprint debugging what should have been a 30-minute design decision made before the first line of code was written.

The patterns in this article — deliberate UUID strategy, property selection tied to protocol semantics, thread-safe notification paths, and a security model that maps to actual threat level — are not theoretical. They come directly from the golf ball that had to survive pocket lint, the wrist tracker that had to reconnect cleanly after a sprint interval, and the haptic controller that had to fire a pattern in under 10 ms from the moment a write landed.

If you are building a Nordic-based BLE product and need this kind of embedded expertise — engineers who have already made these mistakes so you do not have to — take a look at what we do at DigitalMonk's nRF development practice. We work on fixed-scope engagements and long-term embedded team augmentation across the US, UK, and India.

Need a BLE Profile Built Right the First Time?

DigitalMonk's firmware team has shipped custom GATT profiles on nRF52810, nRF52832, nRF52840, and nRF9160 across consumer, medical, and industrial projects. From schematic to App Store.

Talk to an nRF Developer →
Get a Free Project Estimate