Message headers
Every message so far has been a subject and a payload. NATS lets you attach a third thing: headers, key/value metadata that rides with the message alongside the payload. The payload is the business data. A header is anything you want the receiver to know about that data without reading it out of the body: who sent it, what format it's in, or an ID to trace it by.
You've already seen a header without naming it. When a request finds no responders, the server answers with a header and no payload. This page shows the whole mechanism behind that signal.
The header format
A header block is text, and it looks like HTTP. It opens with a version line,
NATS/1.0, followed by one or more Key: Value pairs, each on its own line:
NATS/1.0
Content-Type: application/json
Acme-Request-Id: req_7f3c9a
Each pair is one key and one value. A key can appear on more than one line to carry several values, and the receiver reads back all of them. So a header is really a key mapped to a list of values, even though most keys hold just one.
NATS keeps the case of your keys exactly as you write them and treats them as
case-sensitive, so Content-Type and content-type are two different keys.
Many HTTP libraries fold keys to a single canonical case; NATS doesn't, so
pick one spelling for a key and use it on both ends.
The header block travels as part of the message but separately from the payload, so the body a receiver reads is exactly the bytes you published. A service that ignores headers sees the same JSON it always did.
Setting and reading headers
From the CLI, -H (or --header) attaches a header as a Key:Value pair, and
you repeat the flag for each one you want. nats pub takes it:
- CLI
- C
#!/bin/bash
# Publish an orders.created message with two headers attached. -H (or
# --header) takes a Key:Value pair; repeat it to add more than one header.
# The headers travel alongside the JSON body, they are not part of it.
#
# Content-Type tells receivers how to read the body
# Acme-Request-Id a value your own code sets, here the upstream request
# that produced this order
nats pub orders.created \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
-H 'Content-Type:application/json' \
-H 'Acme-Request-Id:req_7f3c9a'
// Build an orders.created message with two headers attached. The
// headers travel alongside the JSON body, they are not part of it:
// Content-Type tells receivers how to read the body
// Acme-Request-Id a value your own code sets, here the upstream
// request that produced this order
const char *order =
"{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}";
if (s == NATS_OK)
s = natsMsg_Create(&msg, "orders.created", NULL,
order, (int) strlen(order));
if (s == NATS_OK)
s = natsMsgHeader_Set(msg, "Content-Type", "application/json");
if (s == NATS_OK)
s = natsMsgHeader_Set(msg, "Acme-Request-Id", "req_7f3c9a");
if (s == NATS_OK)
s = natsConnection_PublishMsg(conn, msg);
On the receiving side, nats sub prints each header as a Key: Value line
above the body, so you can see exactly what the publisher attached:
- CLI
- C
#!/bin/bash
# Subscribe to orders.created and watch the headers arrive. nats sub prints
# each header as a Key: Value line, then a blank line, then the body. Run the
# publish snippet in another terminal to produce output like this (the two
# header lines can print in either order):
#
# [#1] Received on "orders.created"
# Content-Type: application/json
# Acme-Request-Id: req_7f3c9a
#
# {"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}
#
# Add --headers-only to print just the headers and skip the body, handy for
# inspecting metadata on a busy subject.
nats sub orders.created
// Print each message's headers as Key: Value lines, then a blank line,
// then the body -- so you can see exactly what the publisher attached.
static void
onMsg(natsConnection *nc, natsSubscription *sub, natsMsg *msg, void *closure)
{
const char* *keys = NULL;
int nkeys = 0;
int i;
if (natsMsgHeader_Keys(msg, &keys, &nkeys) == NATS_OK)
{
for (i = 0; i < nkeys; i++)
{
const char *val = NULL;
if (natsMsgHeader_Get(msg, keys[i], &val) == NATS_OK)
printf("%s: %s\n", keys[i], val);
}
free((void*) keys);
}
printf("\n%.*s\n", natsMsg_GetDataLength(msg), natsMsg_GetData(msg));
natsMsg_Destroy(msg);
}
// Subscribe to orders.created and watch the headers arrive. Run the
// headers publish snippet in another terminal to produce output.
if (s == NATS_OK)
s = natsConnection_Subscribe(&sub, conn, "orders.created", onMsg, NULL);
Client libraries expose the same thing as a header type on the message. You
build it with Set to give a key a value or Add to append another value to
a key, and read it back with Get for the first value of a key or Values
for the whole list.
Content-Type in the example is the one header nearly everyone uses: it tells
receivers how to read the body, application/json here. The rest of the keys
are yours to name. Keep the Nats- prefix out of your own keys, though, since
NATS reserves it for system headers; some of those drive
JetStream and are out of scope for core NATS.
A request ID and a trace ID
Request-reply is a natural place for headers. When the warehouse asks the inventory service whether an item is in stock, the question and the answer are two messages, and headers let you tag them without touching the order payload.
Attach an Acme-Request-Id to name this one call, and an Acme-Trace-Id, a
correlation ID: a value every message in the same logical operation
carries, so you can follow one order across services. The responder reads the
trace ID off the request and copies it onto the reply:
- CLI
- C
#!/bin/bash
# Carry a trace id through a request and watch it come back on the reply.
#
# Terminal 1 - an echo responder on orders.inventory.check. --echo reflects
# each request straight back and copies its headers onto the reply, so the
# trace id you send returns to you. A real inventory service would instead
# copy just the trace id onto its own {"in_stock":...} answer.
# nats reply orders.inventory.check --echo
#
# Terminal 2 - send the check with a trace id and a request id attached.
# nats request prints the reply's headers, so you will see Acme-Trace-Id come
# back (alongside a NATS-Reply-Counter the echo responder adds).
nats request orders.inventory.check \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
-H 'Acme-Trace-Id:trace_5e21' \
-H 'Acme-Request-Id:req_7f3c9a' \
--timeout 2s
// Carry a trace id through a request. Run a responder in another
// terminal first, e.g.: nats reply orders.inventory.check --echo
// (--echo reflects the request back and copies its headers onto the
// reply, so the trace id you send returns to you).
const char *order =
"{\"order_id\":\"ord_8w2k\",\"customer\":\"acme-co\","
"\"total_cents\":4200,\"ts\":\"2026-05-22T10:14:22Z\"}";
if (s == NATS_OK)
s = natsMsg_Create(&req, "orders.inventory.check", NULL,
order, (int) strlen(order));
if (s == NATS_OK)
s = natsMsgHeader_Set(req, "Acme-Trace-Id", "trace_5e21");
if (s == NATS_OK)
s = natsMsgHeader_Set(req, "Acme-Request-Id", "req_7f3c9a");
// Send the check and wait up to 2 seconds for the reply.
if (s == NATS_OK)
s = natsConnection_RequestMsg(&reply, conn, req, 2000);
// The trace id comes back on the reply's headers, so you can match
// this answer to the operation that asked the question.
if (s == NATS_OK)
{
const char *traceID = NULL;
if (natsMsgHeader_Get(reply, "Acme-Trace-Id", &traceID) == NATS_OK)
printf("Acme-Trace-Id: %s\n", traceID);
printf("%.*s\n",
natsMsg_GetDataLength(reply), natsMsg_GetData(reply));
}
Because the reply comes back carrying the trace ID you sent, you can match this answer to the order you asked about, even with many checks in flight at once. Your payload stayed a clean order document the whole way.
Status codes from the server
The server uses headers as well. It signals a client in-band with a
status code placed right on the version line. Instead of NATS/1.0 alone,
the line reads NATS/1.0 503, and the message carries no payload.
You've met exactly this. The no responders
signal is a reply with the header line NATS/1.0 503 and an empty body; your
client recognizes the code and turns it into a distinct no-responders error.
That's the whole mechanism: a status on the header line and no payload, read
by the client before your code ever sees a message.
Header support is negotiated at connect
Headers work only when both ends agree to them, and they settle that when the
connection opens. The server advertises header support in the INFO line it
sends first, a headers field that's on by default. The client reads that and
declares its own support in the CONNECT it sends back. By the time you publish
anything, both sides know headers are in play.
Every current server and client supports headers, so this negotiation succeeds
quietly and you never think about it. It has one visible failure mode: a server
with no_header_support set in its configuration, or one old enough to predate
the feature. Against such a server the client sees no header support
advertised, and any publish that carries a header fails on the client with
headers not supported by this server, before the message reaches the wire.
The Connecting page covers the INFO and CONNECT
handshake.
The same agreement gates the previous section: because the no-responders signal arrives as a header, a connection without header support can't receive it either.
Try it
Open two terminals against your running server.
# Terminal 1 — watch orders.created with headers rendered
nats sub orders.created
# Terminal 2 — publish an order tagged with a content type and a request ID
nats pub orders.created \
'{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}' \
-H 'Content-Type:application/json' \
-H 'Acme-Request-Id:req_7f3c9a'
Terminal 1 prints the headers above the body (the two header lines can print in either order):
[#1] Received on "orders.created"
Content-Type: application/json
Acme-Request-Id: req_7f3c9a
{"order_id":"ord_8w2k","customer":"acme-co","total_cents":4200,"ts":"2026-05-22T10:14:22Z"}
Now add --headers-only to the subscriber and publish again. You get the
header lines and no body, which is how you inspect metadata on a busy subject
without printing every payload.
Pitfalls
Reading a key with the wrong case. Header keys are case-sensitive. If a
publisher sends Content-Type and you read content-type, you get an empty
value and no error. Match the exact spelling the sender used, and settle on one
convention across your services.
Headers count against the payload limit. The server checks the combined
size of the header block and the body against max_payload, the same 1 MB
ceiling the payload faces on its own.
A large header block leaves less room for the body, and if the two together
cross the limit the server rejects the publish.
Putting real data in headers. Headers are for small metadata that describes
the message: an ID, a content type, a flag. Large or structured data belongs in
the payload, where receivers expect it. Data stuffed into headers inflates
every message and competes with the body for max_payload. Keep headers short
and keep the body authoritative.
Where you are
The Acme world now tags its messages with metadata that travels beside the payload:
- A publisher attaches headers with
-H(or a client'sSet/Add); a subscriber reads them withnats sub(orGet/Values). - A request carries a request ID and a trace ID, and the responder copies the trace ID onto the reply so you can correlate the two.
- The server uses headers in-band as well, which is how the
NATS/1.0 503no-responders signal reaches you. - Header support is agreed at connect, and every current server and client has it.
What's next
Headers travel with a message to wherever its subject leads. Sometimes you need
to change where that is: rewrite a subject as it enters the server, so
orders.created can also arrive under another name without the publisher
knowing. That's subject mapping, on the
next page.
See also
- Request-reply → No responders — the status header this page generalizes.
- Connecting — the INFO and CONNECT handshake that negotiates header support.
- Reference → Client protocol — the wire-level
HPUB/HMSGframes and the exact header format.