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.


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.
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.
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 Type | Length | Use Case | Air Bytes Per Handle |
|---|---|---|---|
| SIG-Assigned (16-bit) | 2 bytes | Standard profiles — HRS, BAS, DIS | 2 |
| Vendor-Custom (128-bit) | 16 bytes | All proprietary services | 16 (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.
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.)
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.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.
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-E50E24DCCA9FnRF52832 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-EF1234567890nRF52833. 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-CAFEBABEFADEThe 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.
| Characteristic | UUID (last byte) | Length | Properties | Description |
|---|---|---|---|---|
LAUNCH_DATA | ...01 | 20 B | NOTIFY | Packed IMU frame: accel XYZ (3×int16), gyro XYZ (3×int16), timestamp (uint32), seq (uint16) |
CONFIG | ...02 | 4 B | READ/WRITE | Sample rate (uint16 Hz), filter cutoff (uint8), padding |
STREAM_CTRL | ...03 | 1 B | WRITE NR | 0x01 = start, 0x00 = stop. Write-without-response for minimum latency. |
STATUS | ...04 | 2 B | READ NOTIFY | Battery % (uint8) + error flags (uint8). Notified on change. |
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."
Need a production-grade BLE firmware engineer with hands-on Nordic nRF52 / nRF9160 experience? Our team has shipped GATT profiles exactly like these.
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 Level | Requires | Use When |
|---|---|---|
PERM_READ | Open connection | Non-sensitive data — firmware version, battery level |
PERM_READ_ENCRYPT | Encrypted link (pairing done) | User health data, session stats |
PERM_READ_AUTHEN | Authenticated pairing (MITM protection) | Config writes that affect device behaviour |
PERM_WRITE_AUTHEN | Authenticated + bonded | Factory reset, OTA trigger, device rename |
Descriptors add metadata and control to characteristics. You will encounter three in almost every production profile.
| Descriptor | UUID | Purpose |
|---|---|---|
| Client Characteristic Configuration (CCCD) | 0x2902 | Client writes 0x0001 to enable notify, 0x0002 for indicate. Per-connection state — must be persisted for bonded devices. |
| Characteristic Presentation Format (CPFD) | 0x2904 | Declares 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) | 0x2901 | Human-readable label shown in generic BLE explorer tools. Invaluable during hardware bringup and client-side debugging. |
See how we built a production BLE provisioning flow — GATT profile, iOS companion app, and zero-touch WiFi credential transfer — for real IoT products.
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.
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.
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.
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."
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.
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.
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 Category | Tool / Method | What to Verify |
|---|---|---|
| Service Discovery | nRF Connect Mobile / Desktop | All services, characteristics, and descriptors appear with correct UUIDs and properties |
| Read / Write Functional | nRF Connect write panel | Write-back values match expected endianness and structure layout |
| Notification Rate | nRF Connect + log timestamp | Packet interval matches configured ODR; no gaps or bursts under steady state |
| MTU Negotiation | Wireshark + Bluetooth HCI snoop (Android) or PacketLogger (macOS) | MTU Exchange request/response PDUs visible; agreed MTU matches expectations |
| Reconnect Behaviour | Manual disconnect/reconnect × 50 | CCCD state persists if bonded; subscription restored without app restart |
| Permission Enforcement | nRF Connect (unauthenticated) → write protected characteristic | ATT error 0x05 (Insufficient Authentication) returned correctly |
| Stress / Throughput | Custom Python script via bleak library | No packet loss at target ODR for 30+ minute continuous run |
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.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.
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.
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 →