Recipes

n8n recipe

Receive Cortex triggers in an n8n workflow. Self-hosted or n8n Cloud; visual editor with a Webhook trigger, a Function node for HMAC verification, a channel node (Slack / Email / HTTP Request / anything in n8n's catalog), and an HTTP Request node for trigger.complete.

Why n8n

  • Visual workflow editor — non-engineers can extend the handler.
  • Self-hostable; Sustainable Use License (n8n is broadly self-host-friendly).
  • ~400+ built-in nodes for downstream channels.

Workflow shape

  1. Webhook node — HTTP method POST, response: “Respond Immediately” with status code 200. (Cortex needs the ack in ≤30s; we'll process async on the workflow side.)
  2. Function node — Verify HMAC (snippet below). On signature mismatch, throw to short-circuit the workflow.
  3. Channel node — Slack, Gmail, Twilio, in-app HTTP, whatever your app uses.
  4. HTTP Request node — trigger.complete — POST back to Cortex with outcome + external ref.

Function node — verify the signature

javascriptn8n Function node
// Inputs from the Webhook node:
// - $input.first().json.body  (string)  — needs Webhook node "Raw Body" option enabled
// - $input.first().json.headers (object)
const crypto = require('crypto');

const WEBHOOK_SECRET = $env.CORTEX_WEBHOOK_SECRET;
const FIVE_MIN = 5 * 60 * 1000;

const body = $input.first().binary?.data?.toString('utf8')
  ?? JSON.stringify($input.first().json.body);
const headers = $input.first().json.headers ?? {};
const sig = (headers['x-cortex-signature'] || '').replace(/^sha256=/, '');
const ts  = headers['x-cortex-timestamp'] || '';

const tsMs = Date.parse(ts);
if (Number.isNaN(tsMs) || Math.abs(Date.now() - tsMs) > FIVE_MIN) {
  throw new Error('stale_or_bad_timestamp');
}

const expected = crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(ts + '.' + body, 'utf8')
  .digest('hex');

const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sig, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  throw new Error('signature_invalid');
}

// Pass the parsed trigger to downstream nodes
return [{ json: JSON.parse(body) }];

HTTP Request node — trigger.complete

Configure as POST to:

text
https://mcp.cortex.jakeselby.com/v1/triggers/{{$json["trigger_id"]}}/complete

Headers:

text
Authorization:    Bearer {{$env.CORTEX_PRINCIPAL_TOKEN}}
Idempotency-Key:  cb_{{$json["trigger_id"]}}
Content-Type:     application/json

Body (JSON):

json
{
  "outcome":      "executed",
  "external_ref": "{{$node[\"YourChannelNode\"].json[\"id\"]}}",
  "executed_at":  "{{$now.toISOString()}}"
}