View Categories

Request Signing and Security

Every call a Custom API integration makes carries a set of correlation headers, and — unless you switch it off — an HMAC signature the receiving system can use to prove the request really came from your portal and is not a replay. This article documents the headers on the wire, the signature scheme, and how the far end should verify it.

The headers RackHelm sends #

These are added to every outbound call, on top of the integration’s default headers, the endpoint’s extra headers and whatever the auth type contributes.

Header Value
X-RackHelm-Event The lifecycle event — provision, suspend, terminate and so on.
X-RackHelm-Delivery The delivery record’s id. Log it: it is how you and the operator correlate your log with the entry in RackHelm’s Deliveries tab.
X-RackHelm-Integration The integration’s machine name (its slug).
X-RackHelm-Idempotency-Key A stable key for this occurrence of this event. Two attempts at the same event carry the same key.
X-RackHelm-Timestamp Unix time in seconds at the moment the request was built.
X-RackHelm-Signature Present only when signing is on. sha256= followed by the hex HMAC — see below.

Accept: application/json is sent by default, and Content-Type: application/json is added automatically whenever there is a JSON body and you have not set a content type yourself.

Note: Two places in the admin interface — the “copy it now” banner after a secret is generated, and the help text under the Sign outbound requests switch — still name the older X-Orbi-Signature and X-Orbi-Timestamp headers. The headers actually sent are the X-RackHelm-* ones in the table above. Configure the receiving system against these names.

The signature scheme #

The signed material is the timestamp, a full stop, then the raw request body:

$signature = 'sha256=' . hash_hmac(
    'sha256',
    $timestamp . '.' . $rawBody,
    $signingSecret
);

Signing the timestamp along with the body is what makes the timestamp header trustworthy: an attacker replaying a captured request cannot move the clock forward without invalidating the signature.

Three details matter when you implement the check:

  • Use the raw body bytes. RackHelm signs exactly the bytes it sends and sends exactly the bytes it signed — the body is written verbatim rather than re-encoded. Re-serialising the parsed JSON on your side will produce different bytes and a mismatched HMAC.
  • An empty body is an empty string. A call with no body — a GET, or any method whose payload template is empty — signs "<timestamp>.", with the separator still there.
  • The sha256= prefix is part of the header value. It names the algorithm so the scheme can be rotated later without you having to guess.

Verifying, in PHP #

$secret    = getenv('RACKHELM_SIGNING_SECRET');
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_RACKHELM_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_RACKHELM_SIGNATURE'] ?? '';

// 1. Reject anything older than five minutes.
if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit('stale timestamp');
}

// 2. Recompute and compare in constant time.
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('bad signature');
}

// 3. Only now decode and act on it.
$payload = json_decode($body, true);

Verifying, in Node #

const crypto = require('crypto');

// req.rawBody must be the unparsed body — capture it in your JSON body parser.
const ts  = req.get('X-RackHelm-Timestamp') || '';
const sig = req.get('X-RackHelm-Signature') || '';

if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

const expected = 'sha256=' + crypto
  .createHmac('sha256', process.env.RACKHELM_SIGNING_SECRET)
  .update(ts + '.' + req.rawBody)
  .digest('hex');

const ok = sig.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));

if (!ok) return res.sendStatus(401);

Using the idempotency key #

RackHelm will not send the same occurrence of the same event twice — the key is a unique index on the delivery table, and that insert is the send-once lock. But a delivery can be replayed deliberately by an operator, and the same event can legitimately recur (a service suspended in March and again in September is two events, with two keys).

The right behaviour on the receiving side is to record the key and treat a repeat as “already done, here is the same answer” rather than creating a second resource. That is what makes an operator’s replay of a failed call safe even when the original request actually reached you.

The signing secret #

A green banner at the top of the integration editor showing a signing secret with a copy button
The signing secret is shown exactly once, on the render that follows creating the integration or rotating its secret.
  • A 48-character secret is generated automatically when the integration is created, and shown once in a banner at the top of the editor.
  • It is stored encrypted, is excluded from the audit log, and cannot be read back — the banner is the only time it is ever visible.
  • To rotate, tick Generate a new signing secret on the Connection tab and save. The old secret stops working the moment you save, so update the receiving system in the same maintenance window.
  • If an integration somehow has signing on with no secret stored, the next save generates one rather than leaving it signing with an empty key.

Warning: Signing fails closed. If signing is enabled and no secret can be found, the call is abandoned before anything leaves the box and the failure is recorded on the delivery — RackHelm will not quietly send an unsigned request while the admin screen claims calls are signed.

What is never on the wire, and never in the log #

  • The signature header value is never stored. X-RackHelm-Signature, Authorization, Proxy-Authorization, Cookie and Set-Cookie are masked before the delivery row is written, and masked again before the log is rendered.
  • Credential-shaped keys inside JSON bodies are masked too — anything whose name contains password, token, api_key, secret, private_key, credential and similar. A key named license_key is deliberately not masked: that is the value the customer bought and is meant to read.
  • Certificate verification cannot be turned off. There is no option, config key or argument anywhere in this feature to disable it.
  • The destination is re-validated on every send, and the connection is pinned to the addresses that validation resolved, so a hostname cannot be re-pointed at an internal address between the check and the request.

There is no inbound endpoint #

The Custom API feature is outbound only. RackHelm calls your system; your system does not call back into RackHelm through this feature, and there is no callback URL to configure. If a provisioning step is asynchronous at the far end, capture a job or resource identifier in the endpoint’s response map and expose it on the service page, rather than waiting for a callback.

Troubleshooting #

Symptom Cause and fix
Signature never matches, body looks identical You are hashing a re-serialised body. Hash the raw bytes as received.
Signature never matches on calls that arrive with no body Hash timestamp + "." with an empty body string. GET never sends a body, whatever the payload template says.
Signature matches sometimes You are comparing without the sha256= prefix on one side, or trimming the header.
No signature header arrives at all Sign outbound requests is off on the Connection tab.
Everything fails after a rotation The old secret was replaced on save. Update the receiving system with the value from the one-time banner; if it was lost, rotate again and copy it this time.
Timestamps look minutes out Check clock sync on both machines before widening your tolerance window.