---
title: Request Signing and Security
description: 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…
url: "https://rackhelm.com/documentation/custom-api-request-signing/"
updated: "2026-09-24"
category: "Integrations: Custom API"
---

# 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:

```php
$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

```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

```javascript
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](/wp-content/uploads/rackhelm-docs/integrations-customapi-signing-secret-banner.png)
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. |
