asssistive
Context Sources

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:

HeaderValue
X-Assistive-SignatureHex-encoded HMAC-SHA256 of the canonical payload.
X-Assistive-TimestampUnix seconds, as a string, at the moment we signed.
User-AgentAssistive-Context/1
Content-Typeapplication/jsonon 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 ) )
PartWhat it is
timestampThe exact string we sent in X-Assistive-Timestamp.
methodGET or POST, uppercase.
request_targetThe path and query of the URL we called — e.g. /shipments/X1 or /shipments/X1?full=true. Just / if there's no path.
bodyThe 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:

  1. Rebuild the payload from the request as received — the method, the path and query off the request line, and the raw body.
  2. Read the raw body, before any JSON parser touches it.
  3. Compare in constant time.
  4. 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.

On this page