HTTP request step signing
Every call from an HTTP Request step carries a signed header. See its format, what is signed and how to verify it on your server with a short Node example.
The HTTP Request step calls an endpoint you own from inside a workflow. Because anyone could send a request to that URL, every request from the step carries a signature that proves it came from your Notifly environment and was not altered. Your endpoint should verify it before acting on the request.
You add the step from the Actions group of the workflow editor's add-step menu. In its Headers list the dashboard shows a read-only novu-signature row with the value <calculated when request is sent>: the header is added automatically at send time and you do not configure it. The header keeps the novu- prefix because Notifly's workflow engine descends from Novu; the name is what your code reads.
The header
novu-signature: t=1760000000000,v1=3b1f...e9tis the time the request was signed, in milliseconds since the Unix epoch.v1is a lowercase hex HMAC-SHA256.
The HMAC is computed over the string <t>.<body> using the secret key of the environment the workflow runs in (the key shown under API Keys) as the HMAC key. Development and Production sign with different keys.
<body> is the JSON text of the request body, exactly as sent. A request that sends no body, which is every GET, DELETE, HEAD and OPTIONS request and any request whose body is empty, is signed with the body {}, so verify those against the string {}.
Verify a request
Verify against the raw bytes you received, before any JSON parsing and re-serializing, because the signature covers the exact text. Reject requests whose timestamp is far from your clock to limit replays, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_AGE_MS = 5 * 60 * 1000;
export function verifyNotiflySignature(rawBody: string, header: string, secretKey: string): boolean {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
if (Math.abs(Date.now() - Number(timestamp)) > MAX_AGE_MS) return false;
const expected = createHmac('sha256', secretKey).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(received, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}In an Express handler, capture the raw body (for example with express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } })), call verifyNotiflySignature(req.rawBody ?? '{}', req.get('novu-signature') ?? '', process.env.NOTIFLY_SECRET_KEY!), and answer 401 when it returns false. For a request that has no body, pass '{}'.
Keep the secret key on your server. It is the same value you use as the ApiKey for the REST API, so never put it in browser or mobile code.
Good to know
- The URL must use
httporhttpsand carry no embedded credentials, and it must point at a public endpoint. Notifly rejectslocalhost,metadata.google.internal, single-label host names such asapi, host names ending in.internal,.localor.lan, and private or link-local IP addresses. It also resolves the host name before connecting and rejects it if any address is private or link-local. A rejected step fails with an error in the activity feed and nothing is sent. - Redirects are followed up to three times, and every target is checked the same way. When a redirect moves to a different host, the
novu-signatureandAuthorizationheaders, cookies and the request body are not forwarded, and307/308redirects to a different host are refused. Respond directly from the endpoint and do not rely on redirects as a trust boundary. - The step's method defaults to
POSTand its timeout to 5000 ms. - Your endpoint should answer with a JSON object so later steps can use its response.
- The step editor's test request is a real request signed the same way, with the secret key of the environment you are editing in, so you can check your verification before you publish.