Signing requests
For a robot company's engineer. Your robot signs every call to a building's gateway with its own secret; only GET /v1/health and the API spec, GET /v1/openapi.yaml, go unsigned. This page says exactly what is signed, gives two test vectors to check your code against, and has the same code in four languages. The full guide is Connecting your robots.
What is signed
The building gives you a robot ID and a secret for each robot. For each call, build one message, sign it with HMAC-SHA256 keyed with the secret, and send the result in three headers.
The message, byte by byte:
- METHOD:
GETorPOST, in capitals. The API takes only these two. - A newline: one byte,
0x0A. Not\r\n. - PATH: the request target exactly as you send it on the request line. It starts with
/v1/and includes the query string: sign/v1/passages?since=1767225000, not/v1/passages. Percent-encoding stays as you send it. No scheme, host or port. - A newline.
- TIMESTAMP: unix time in whole seconds, as decimal ASCII digits, such as
1767225600. The same string goes inX-Timestamp. - A newline. It is always there: with no body, the message ends with this newline.
- BODY: the exact bytes of the request body as you send them, and nothing after them. Nothing at all for a GET, or for a POST with no body, such as
POST /v1/passages/<id>/cancel.
The key is the secret's characters as bytes, exactly as the building gave it. It may look like hex: don't decode it. Don't add the newline that a file or echo puts at the end.
The signature is the 32-byte HMAC written as 64 lowercase hex digits. Send lowercase, as the examples do; the gateway accepts uppercase too.
Send three headers on every signed call:
| Header | Value |
|---|---|
X-Robot-Id | The robot ID. |
X-Timestamp | The TIMESTAMP you signed. |
X-Signature | The 64 hex digits. |
No other header is signed. With a body, also send Content-Type: application/json, and send the body with a Content-Length, at most 64 KiB; a chunked body is refused with 411. A body is a JSON object. The gateway checks the signature first, then the time: a TIMESTAMP more than 30 seconds before or after the gateway's clock is refused as a replay.
Test vectors
Check your code against these before you call a gateway. They use a made-up secret that no gateway holds:
- secret:
test-secret-for-docs-only-0123456789abcdef - robot ID:
acme-1 - timestamp:
1767225600(1 January 2026, 00:00:00 UTC)
Case (b) sends this body, 74 bytes: compact JSON, keys in this order, no newline at the end.
{"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"}
In the table, each \n stands for one newline byte.
| Case | String to sign | Bytes | X-Signature |
|---|---|---|---|
(a) GET /v1/me, no body | GET\n/v1/me\n1767225600\n | 22 | 3af30bff48743432ed2fafb9b3b53d3d2e8fccc00539510cb538f7834ffd1643 |
(b) POST /v1/passages, the body above | POST\n/v1/passages\n1767225600\n{"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"} | 103 | aa1ffc064a330301fd0587c858a564129d55c0b40dba28e16abf6a97ba721b5e |
Case (a) on the wire:
GET /v1/me HTTP/1.1
Host: gateway.example:8443
X-Robot-Id: acme-1
X-Timestamp: 1767225600
X-Signature: 3af30bff48743432ed2fafb9b3b53d3d2e8fccc00539510cb538f7834ffd1643
The reference client computed both signatures, and each example below prints them with --vector. A gateway would refuse both as stale: they are for checking your code, not for sending. To check case (a) with openssl alone:
printf 'GET\n/v1/me\n1767225600\n' | openssl dgst -sha256 -hmac test-secret-for-docs-only-0123456789abcdef
The code
Each file holds a signing function and one call. With no arguments it calls GET /v1/me as your robot and prints the answer: who your robot is in this building, and the gateway's clock as time. It reads three environment variables:
GATEWAY_URL: the gateway's address, with scheme, host and port and no path, such ashttps://sandbox.plynth.buildfor the sandbox.GATEWAY_ROBOT_ID: the robot ID.GATEWAY_ROBOT_SECRET: the robot's secret. An environment variable doesn't show inpsthe way a flag does. To keep it out of your shell history too, set it withread -rs GATEWAY_ROBOT_SECRET; export GATEWAY_ROBOT_SECRET.
These are the kit's names, so the file your company's page downloads with each secret, plynth-sandbox.env, sets all three: set -a; . ./plynth-sandbox.env; set +a.
With --vector it prints the two signatures above, one per line, and calls nothing. With METHOD PATH [BODY] it sends that call instead, such as POST /v1/heartbeat '{"battery":0.8}'. That's a quick way to tell a bug in your own signing from a call the gateway refuses for another reason.
These snippets sign; they don't pin. Their HTTP clients check the certificate against the computer's trusted authorities, which is right for the hosted sandbox. At a building, verify the gateway as the guide's "Verifying the gateway" says: pin its fingerprint, or trust the site's CA. Never turn verification off. Download the files: sign.sh, sign.py, sign.mjs and sign.cpp.
Shell: bash, curl and openssl
#!/usr/bin/env bash
# Sign a Plynth robot API call with openssl, and send it with curl (7.76 or later).
# GATEWAY_URL=https://... GATEWAY_ROBOT_ID=... GATEWAY_ROBOT_SECRET=... ./sign.sh [METHOD PATH [BODY]]
# ./sign.sh --vector (or SIGNING_VECTOR=1 ./sign.sh): the test vectors' signatures, one per line
# openssl takes the secret on its command line, where other users of this computer can see it in ps while it runs.
set -euo pipefail
# Hex HMAC-SHA256 keyed with the secret's own characters, over METHOD \n PATH \n TIMESTAMP \n and then the body.
sign() { # secret method path timestamp [body]
printf '%s\n%s\n%s\n%s' "$2" "$3" "$4" "${5:-}" | openssl dgst -sha256 -hmac "$1" | sed 's/^.*= //'
}
if [ "${1:-}" = "--vector" ] || [ "${SIGNING_VECTOR:-}" = "1" ]; then
key=test-secret-for-docs-only-0123456789abcdef
sign "$key" GET /v1/me 1767225600
sign "$key" POST /v1/passages 1767225600 '{"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"}'
exit
fi
: "${GATEWAY_URL:?}" "${GATEWAY_ROBOT_ID:?}" "${GATEWAY_ROBOT_SECRET:?}"
method=${1:-GET} path=${2:-/v1/me} body=${3:-}
timestamp=$(date +%s)
signature=$(sign "$GATEWAY_ROBOT_SECRET" "$method" "$path" "$timestamp" "$body")
# The body goes out as the same bytes that were signed: --data-binary sends it untouched.
curl -sS --fail-with-body --max-time 10 -w '\n' -X "$method" "${GATEWAY_URL%/}$path" \
-H "X-Robot-Id: $GATEWAY_ROBOT_ID" -H "X-Timestamp: $timestamp" -H "X-Signature: $signature" \
${body:+-H 'Content-Type: application/json' --data-binary "$body"}
openssl dgst -hmac takes the key as text, so the secret is used as given. It also puts the secret on openssl's command line while it runs, where other users of the computer can see it. Use the script to test, and one of the others on a robot computer that other people can log in to.
Python: standard library
Python 3.9 or later, nothing to install.
"""Sign a Plynth robot API call with Python's standard library, and send it.
GATEWAY_URL=https://... GATEWAY_ROBOT_ID=... GATEWAY_ROBOT_SECRET=... python3 sign.py [METHOD PATH [BODY]]
python3 sign.py --vector # the test vectors' signatures, one per line
"""
import hashlib
import hmac
import os
import sys
import time
import urllib.error
import urllib.request
def sign(secret, method, path, timestamp, body=b""):
"""Hex HMAC-SHA256 keyed with the secret's own characters, over METHOD \\n PATH \\n TIMESTAMP \\n and then the body."""
message = f"{method}\n{path}\n{timestamp}\n".encode() + body
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def call(method, path, body=b""):
"""The body is bytes, sent as signed: never serialize it again after sign()."""
timestamp = str(int(time.time()))
headers = {"X-Robot-Id": os.environ["GATEWAY_ROBOT_ID"], "X-Timestamp": timestamp,
"X-Signature": sign(os.environ["GATEWAY_ROBOT_SECRET"], method, path, timestamp, body)}
if body:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(os.environ["GATEWAY_URL"].rstrip("/") + path, data=body or None,
headers=headers, method=method)
with urllib.request.urlopen(request, timeout=10) as response:
return response.read().decode()
if __name__ == "__main__":
if sys.argv[1:] == ["--vector"]:
key = "test-secret-for-docs-only-0123456789abcdef"
print(sign(key, "GET", "/v1/me", "1767225600"))
print(sign(key, "POST", "/v1/passages", "1767225600",
b'{"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"}'))
sys.exit()
method = sys.argv[1] if len(sys.argv) > 1 else "GET"
path = sys.argv[2] if len(sys.argv) > 2 else "/v1/me"
body = sys.argv[3].encode() if len(sys.argv) > 3 else b""
try:
print(call(method, path, body))
except urllib.error.HTTPError as e:
sys.exit(f"HTTP {e.code}: {e.read().decode()}")
Node.js: built-in crypto
Node 18 or later, for fetch. No packages.
// Sign a Plynth robot API call with Node's built-in crypto (Node 18 or later), and send it.
// GATEWAY_URL=https://... GATEWAY_ROBOT_ID=... GATEWAY_ROBOT_SECRET=... node sign.mjs [METHOD PATH [BODY]]
// node sign.mjs --vector # the test vectors' signatures, one per line
import { createHmac } from "node:crypto";
// Hex HMAC-SHA256 keyed with the secret's own characters, over METHOD \n PATH \n TIMESTAMP \n and then the body.
// A string key is used as its UTF-8 bytes: the secret as given, never hex-decoded.
function sign(secret, method, path, timestamp, body = Buffer.alloc(0)) {
const message = Buffer.concat([Buffer.from(`${method}\n${path}\n${timestamp}\n`), body]);
return createHmac("sha256", secret).update(message).digest("hex");
}
// The body is a Buffer, sent as signed: never serialize it again after sign().
async function call(method, path, body = Buffer.alloc(0)) {
const { GATEWAY_URL, GATEWAY_ROBOT_ID, GATEWAY_ROBOT_SECRET } = process.env;
const timestamp = String(Math.floor(Date.now() / 1000)); // seconds, not milliseconds
const headers = { "X-Robot-Id": GATEWAY_ROBOT_ID, "X-Timestamp": timestamp,
"X-Signature": sign(GATEWAY_ROBOT_SECRET, method, path, timestamp, body) };
if (body.length) headers["Content-Type"] = "application/json";
const response = await fetch(GATEWAY_URL.replace(/\/+$/, "") + path,
{ method, headers, body: body.length ? body : undefined, signal: AbortSignal.timeout(10000) });
const text = await response.text();
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`);
return text;
}
const args = process.argv.slice(2);
if (args[0] === "--vector") {
const key = "test-secret-for-docs-only-0123456789abcdef";
console.log(sign(key, "GET", "/v1/me", "1767225600"));
console.log(sign(key, "POST", "/v1/passages", "1767225600",
Buffer.from('{"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"}')));
} else {
const [method = "GET", path = "/v1/me", body = ""] = args;
try {
console.log(await call(method, path, Buffer.from(body)));
} catch (e) {
console.error(e.message);
process.exitCode = 1;
}
}
C++17: OpenSSL 3 and libcurl
HMAC() is OpenSSL's one-call function, and OpenSSL 3 keeps it. The call uses libcurl; any HTTP client works if it sends the path and body you signed, byte for byte.
// Sign a Plynth robot API call in C++17 with OpenSSL 3's HMAC, and send it with libcurl.
// Linux: g++ -std=c++17 sign.cpp -lcrypto -lcurl -o sign (libssl-dev, libcurl4-openssl-dev)
// macOS: clang++ -std=c++17 sign.cpp -I/opt/homebrew/opt/openssl@3/include -L/opt/homebrew/opt/openssl@3/lib \
// -lcrypto -lcurl -o sign
// GATEWAY_URL=https://... GATEWAY_ROBOT_ID=... GATEWAY_ROBOT_SECRET=... ./sign [METHOD PATH [BODY]]
// ./sign --vector # the test vectors' signatures, one per line
#include <curl/curl.h>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <stdexcept>
#include <string>
// Hex HMAC-SHA256 keyed with the secret's own characters, over METHOD \n PATH \n TIMESTAMP \n and then the body.
std::string sign(const std::string& secret, const std::string& method, const std::string& path,
const std::string& timestamp, const std::string& body = "") {
const std::string message = method + "\n" + path + "\n" + timestamp + "\n" + body;
unsigned char mac[EVP_MAX_MD_SIZE];
unsigned int size = 0;
if (!HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()),
reinterpret_cast<const unsigned char*>(message.data()), message.size(), mac, &size)) {
throw std::runtime_error("HMAC-SHA256 failed");
}
static const char digits[] = "0123456789abcdef";
std::string hex;
for (unsigned int i = 0; i < size; ++i) {
hex += digits[mac[i] >> 4];
hex += digits[mac[i] & 0x0f];
}
return hex;
}
static size_t keep(char* data, size_t size, size_t count, void* answer) {
static_cast<std::string*>(answer)->append(data, size * count);
return size * count;
}
// The body goes to curl with its length, as the same bytes that were signed.
int call(const std::string& method, const std::string& path, const std::string& body) {
const char* url = std::getenv("GATEWAY_URL");
const char* robot = std::getenv("GATEWAY_ROBOT_ID");
const char* secret = std::getenv("GATEWAY_ROBOT_SECRET");
if (!url || !robot || !secret) {
std::cerr << "set GATEWAY_URL, GATEWAY_ROBOT_ID and GATEWAY_ROBOT_SECRET\n";
return 2;
}
std::string base = url;
while (!base.empty() && base.back() == '/') base.pop_back();
const std::string timestamp = std::to_string(std::time(nullptr));
CURL* curl = curl_easy_init();
if (!curl) return 1;
curl_slist* headers = nullptr;
headers = curl_slist_append(headers, ("X-Robot-Id: " + std::string(robot)).c_str());
headers = curl_slist_append(headers, ("X-Timestamp: " + timestamp).c_str());
headers = curl_slist_append(headers, ("X-Signature: " + sign(secret, method, path, timestamp, body)).c_str());
if (!body.empty()) headers = curl_slist_append(headers, "Content-Type: application/json");
std::string answer;
curl_easy_setopt(curl, CURLOPT_URL, (base + path).c_str());
if (method == "POST") {
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.data());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(body.size()));
}
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, keep);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &answer);
const CURLcode sent = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (sent != CURLE_OK) {
std::cerr << curl_easy_strerror(sent) << "\n";
return 1;
}
if (status >= 400) {
std::cerr << "HTTP " << status << ": " << answer << "\n";
return 1;
}
std::cout << answer << "\n";
return 0;
}
int main(int argc, char** argv) {
if (argc == 2 && std::string(argv[1]) == "--vector") {
const std::string key = "test-secret-for-docs-only-0123456789abcdef";
std::cout << sign(key, "GET", "/v1/me", "1767225600") << "\n"
<< sign(key, "POST", "/v1/passages", "1767225600",
R"({"passage_id":"3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14","resource_id":"door"})") << "\n";
return 0;
}
return call(argc > 1 ? argv[1] : "GET", argc > 2 ? argv[2] : "/v1/me", argc > 3 ? argv[3] : "");
}
When you get 401
The answer is JSON. code is the one to branch on, error says why in words, time is the gateway's clock in unix seconds, and request_id names the call in the gateway's log:
{"error": "signature mismatch", "code": "signature_mismatch", "time": 1767225647.2, "request_id": "9c1f0e2ab47d4c35"}
code | error | What it means | What to do |
|---|---|---|---|
unsigned | send X-Robot-Id, X-Timestamp and X-Signature | One of the three headers is missing or empty. | Send all three on every call but health and the spec. |
signature_mismatch | signature mismatch | The secret is wrong or was rotated, the robot ID isn't known here, or what you signed isn't what arrived. The words are the same for each on purpose, so an answer never says whether a robot ID exists. | Check the robot ID and secret, then work through the list below. |
timestamp_skew | timestamp outside ±30 s of gateway time | Your signature is right, since the gateway checks it first, but X-Timestamp is more than 30 seconds from the gateway's clock. | Compare your robot's clock with time in the answer, and fix it with NTP. |
Branch on code, not on the words: they may change, and in the raw answer the ± arrives escaped, as \u00b1. Quote the request_id when you write to Plynth. The gateway logs every refusal, and three in a day put an item on the owner's screen, so fix the cause before you retry.
For signature mismatch, in this order:
- Run your signing on the test vectors. If it doesn't print the table's signatures, the bug is in your signing. Hex-dump your string to sign and compare it with the table, byte by byte. A common slip is leaving out the newline after the timestamp when there is no body.
- Check the path. Sign the query string with the path. Sign the target your HTTP library actually sends: a library that builds the URL for you may percent-encode characters or resolve
/./and/../. Encode the path yourself, sign that string, and send it as it is. - Don't serialize the body twice. Serialize once, sign those bytes, and hand the same bytes to the HTTP client. If you pass the library an object instead (Python requests'
json=, axios with an object, a secondjson.dumps), it serializes again, and spacing, key order or escaping can change. No compression, and send aContent-Length. - Check the timestamp and method. The same TIMESTAMP string in the header and the message, in seconds, not milliseconds. The method in capitals, the same as on the request line.
- Check the secret. The whole text the building gave you, not decoded, with no newline at the end. After a rotation the old secret works only for its overlap, 24 hours with Rotate, and not at all after Revoke Old (Keys and security).
For the clock, keep NTP running on every robot (chrony or systemd-timesyncd). GET /v1/me also answers with the gateway's time, so a robot can check its clock when it starts.
The reference client
The reference client does all of this: plynth.client in the test kit. It signs every call as above, verifies the gateway by pin or CA, refuses plain HTTP anywhere but loopback, and keeps one connection open per thread. It is standard-library Python, so a Python fleet can use it as it stands:
from plynth.client import RobotClient
robot = RobotClient("https://gateway.example:8443", "acme-1", secret, pin="sha256:<the building's fingerprint>")
print(robot.me())
It serializes bodies with Python's json.dumps, which puts a space after each comma and colon, unlike case (b). The gateway doesn't mind: it checks the signature over the bytes that arrive, whatever their spacing. That is the rule in every language: sign the bytes you send, and send the bytes you signed.