Skip to content
ROAD RUNNER

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_time from GET /v1/ping if 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.