Verify our signature
The HMAC-SHA256 signing contract — the exact canonical payload, worked examples, and copy-pasteable verification in Node and Python.
Your endpoint is on the public internet, so anyone who learns its URL can call it. Every request we send is signed, and verifying that signature is how you know a call really came from Assistive.
This is the page to get right. A verification bug produces an
unauthorized outcome with no further explanation, and
almost every one comes down to building a slightly different canonical string than we did.
What we send
Every outbound call carries these headers:
| Header | Value |
|---|---|
X-Assistive-Signature | Hex-encoded HMAC-SHA256 of the canonical payload. |
X-Assistive-Timestamp | Unix seconds, as a string, at the moment we signed. |
User-Agent | Assistive-Context/1 |
Content-Type | application/json — on POST only. |
Plus any custom headers you configured on the source.
The canonical payload
The signature is computed over four parts, joined by dots:
payload = timestamp + "." + method + "." + request_target + "." + body
signature = hex( HMAC-SHA256( signing_secret, payload ) )| Part | What it is |
|---|---|
timestamp | The exact string we sent in X-Assistive-Timestamp. |
method | GET or POST, uppercase. |
request_target | The path and query of the URL we called — e.g. /shipments/X1 or /shipments/X1?full=true. Just / if there's no path. |
body | The raw JSON request body. Empty string on a GET. |
The literal strings
Don't reconstruct these from the description — copy them. This is exactly what we sign:
GET https://api.acme.com/shipments/X1
payload → 1751808000.GET./shipments/X1.POST https://api.acme.com/shipments body: {"shipment_id":"X1"}
payload → 1751808000.POST./shipments.{"shipment_id":"X1"}Note the trailing dot on the GET
Look at the end of the GET payload: …/shipments/X1. — it ends with a dot.
The body is empty, but the separator is still there. There are four parts joined by three dots, always, and an empty fourth part doesn't remove the dot before it.
This is the single most common way to get the signature wrong. If your GET verification fails and everything else looks right, check whether you're joining three parts with two dots.
What the signature actually guarantees
The method and target are inside the payload on purpose. It means the signature isn't merely "Assistive called at time T" — it's "Assistive asked for this resource, with this method, at time T."
A signature captured from one request will not verify against a different path. Someone who
sniffs a call to /shipments/X1 cannot reuse that signature to make you serve
/shipments/X2, or to turn a GET into a POST.
The signing secret
Each source has its own signing secret: 256 bits of entropy, base64url-encoded, with a csk_
prefix. We store it encrypted at rest.
You can see it in the dashboard when the source is created, when you view that source individually, and when you rotate it. It is redacted from the source list — so if you're looking at all your sources and the secret shows as hidden, open the one you want rather than assuming it's lost.
Rotating the secret takes effect immediately
There is no overlap window. The moment you rotate, the old secret stops verifying — and every request we send is signed with the new one.
So if your server is still holding the old secret, every lookup starts failing as
unauthorized at once. Deploy the new secret to your
server at the same moment you rotate, not afterwards.
Replay protection — your job, not ours
We send a timestamp. We do not enforce a freshness window on your behalf — your server has to check it.
Here's why it matters. The signature proves the request came from us and names what was asked for, but on its own it says nothing about when you received it. Without a freshness check, someone who captures a valid request can replay it against your endpoint tomorrow, and it will verify perfectly.
Reject any request whose X-Assistive-Timestamp is more than 5 minutes old.
The timestamp is inside the signed payload, so an attacker can't backdate it without invalidating the signature. That's what makes the check work: they can't forge a fresh timestamp, and a stale one is one you refuse.
Worked verification
Both examples do the same four things, and all four are load-bearing:
- Rebuild the payload from the request as received — the method, the path and query off the request line, and the raw body.
- Read the raw body, before any JSON parser touches it.
- Compare in constant time.
- Reject stale timestamps.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.ASSISTIVE_SIGNING_SECRET; // csk_…
const MAX_AGE_SECONDS = 5 * 60;
// Capture the RAW body. Do not let express.json() parse it first —
// a re-serialized body will not match byte-for-byte.
app.use(express.raw({ type: '*/*' }));
function verify(req) {
const signature = req.get('X-Assistive-Signature');
const timestamp = req.get('X-Assistive-Timestamp');
if (!signature || !timestamp) return false;
// 1. Freshness. Without this, a captured request replays forever.
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || Math.abs(age) > MAX_AGE_SECONDS) return false;
// 2. Rebuild the canonical payload from the request as received.
// req.originalUrl is the path AND query — exactly what we signed.
// On a GET the body is "", but the separator dot remains.
const body = req.body?.length ? req.body.toString('utf8') : '';
const payload = `${timestamp}.${req.method}.${req.originalUrl}.${body}`;
const expected = crypto
.createHmac('sha256', SECRET)
.update(payload)
.digest('hex');
// 3. Constant-time compare. Never use ===.
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.get('/shipments/:id', (req, res) => {
if (!verify(req)) return res.sendStatus(401);
const shipment = lookUpShipment(req.params.id);
if (!shipment) return res.sendStatus(404); // a real answer — see Outcomes
res.json({ data: { status: shipment.status, eta: shipment.eta } });
});Read the raw body, before your JSON parser
This is the second-most-common failure, after the trailing dot.
We sign the exact bytes we sent. If a body-parser middleware deserializes the JSON and
your code re-serializes it to rebuild the payload, you'll get a string that is semantically
identical and byte-for-byte different — a re-ordered key, a dropped space, "a":1 versus
"a": 1 — and the HMAC will not match.
In Express, that means express.raw() before express.json(), or capturing the raw buffer
in a verify hook. In Flask, request.get_data(), not request.json. The rule is the same
everywhere: hash the bytes you received, not a reconstruction of them.
When it still won't verify
Don't guess — use the dry run. It works
while the source is still disabled, and it returns the literal signed_payload we computed
the signature over.
Log the payload string your server built, run a test, and diff the two. The difference will be
staring at you: a missing dot, a query string you dropped, a re-serialized body, an
originalUrl where you used path.