GUIDE
Authentication
Every request carries three headers:
Authorization: Bearer rr_live_EXAMPLEKEYID0000000000000
X-RR-Timestamp: 1790000000
X-RR-Signature: 8c7f2a...
Building the signature
signing_string = key_id + "." + timestamp + "." + METHOD + "." + host + path
+ "." + sha256_hex(raw_body)
signature = hmac_sha256(secret, signing_string) // lowercase hex
Three details that trip people up:
host |
Hostname without the port. staging.roadrunnerzw.com, not …:443 |
path |
Includes the /api/v1 prefix and the query string |
raw_body |
The exact bytes you send. An empty body hashes the empty string, not {} or [] |
The key id and host are part of the signing string deliberately: a signature minted for staging cannot be replayed against production.
Worked example
key_id rr_live_ABC
timestamp 1790000000
method POST
host staging.roadrunnerzw.com
path /api/v1/deliveries
body {"a":1}
signing_string:
rr_live_ABC.1790000000.POST.staging.roadrunnerzw.com/api/v1/deliveries.<sha256 of body>
Code
PHP
$ts = (string) time();
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256',
"{$keyId}.{$ts}.POST.staging.roadrunnerzw.com/api/v1/deliveries."
. hash('sha256', $body),
$secret);
Node
const ts = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify(payload);
const hash = crypto.createHash('sha256').update(body).digest('hex');
const sig = crypto.createHmac('sha256', secret)
.update(`${keyId}.${ts}.POST.staging.roadrunnerzw.com/api/v1/deliveries.${hash}`)
.digest('hex');
Python
ts = str(int(time.time()))
body = json.dumps(payload, separators=(",", ":"))
h = hashlib.sha256(body.encode()).hexdigest()
sig = hmac.new(secret.encode(),
f"{key_id}.{ts}.POST.staging.roadrunnerzw.com/api/v1/deliveries.{h}".encode(),
hashlib.sha256).hexdigest()
bash
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIG=$(printf '%s' "$KEY.$TS.POST.$HOST$PATH.$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
Validation rules
A request is refused unless all hold:
- the key exists, is not revoked and has not expired
- the timestamp is within 300 seconds of ours: compare against
server_timefromGET /v1/pingif you suspect clock drift - the signature matches
- that signature has not been used before
- the key has the scope the endpoint requires
Scopes
| Scope | Grants |
|---|---|
deliveries:read |
Listing and fetching deliveries |
deliveries:write |
Creating and editing deliveries |
deliveries:cancel |
Cancelling a delivery |
quotes:write |
Requesting quotes |
webhooks:manage |
Managing webhook endpoints |
Cancelling is kept apart from deliveries:write on purpose, so an
integration that only books deliveries cannot also cancel them. A key missing
the scope an endpoint needs is refused with 403 insufficient_scope, naming
the scope it wanted.
Ask us if a key needs a permission it does not have. We can change what an existing key may do without reissuing it, so there is no new secret to copy and nothing to swap in your integration.