IGNITRAOpen the app

Webhooks

Webhooks go both ways: the Webhook action sends signed events to your server, and the Inbound webhook trigger gives a flow its own URL that anything can POST to.

Outbound: the envelope

Every Webhook step POSTs one JSON document with the run and the full trigger data. The format is fixed and versioned by type; for a body of your own, use the HTTP request action.

{
  "type": "run.action",
  "run_id": "run_…",
  "flow_id": "fl_…",
  "flow": { "id": "fl_…", "name": "Big USDG buys", "version": 3 },
  "step_id": "notify",
  "idempotency_key": "4663:0x…:12:fl_…:notify",
  "run_idempotency_key": "4663:0x…:12:fl_…",
  "test": false,
  "attempt": 1,
  "trigger": {
    "tx_hash": "0x…",
    "block_number": 1234567,
    "log_index": 12,
    "from": "0x…",
    "to": "0x…",
    "amount_decimal": "25000",
    "token": { "address": "0x…", "symbol": "USDG", "decimals": 6 },
    "explorer_url": "https://…/tx/0x…"
    // … every data field of the trigger (see Triggers)
  },
  "sent_at": "2026-11-07T02:00:00.000Z"
}

Headers

X-Ignitra-Signaturev1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>; several, comma-separated, while a secret rotates
X-Ignitra-TimestampUnix seconds when the request was signed. Reject anything more than 5 minutes off (replays).
X-Ignitra-Idempotency-Key<run key>:<step id>, identical on every retry: deduplicate on it
X-Ignitra-DeliveryA fresh UUID per attempt, for your logs
X-Ignitra-Eventrun.action or run.reverted
X-Ignitra-Testtrue on TEST runs (also "test": true in the body)

Which secret signs

  • A URL in the step: your account's webhook secret, under Settings. Every inline-URL webhook of yours uses it.
  • A webhook connection: that connection's own secret, shown once when you add it. Copy it then; IGNITRA stores it encrypted and never shows it again.
  • HTTP requests are signed the same way (over the raw body, an empty string for GET) unless the step sets "sign": false.

Verify the signature

Compute the HMAC over the raw body as received: parsing and re-serialising JSON changes the bytes. Compare in constant time and check the timestamp.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a string), before JSON parsing.
export function verifyIgnitra(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = Number(headers["x-ignitra-timestamp"]);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; // replayed or stale
  const mac = createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
  const expected = Buffer.from("v1=" + mac);
  // During a secret rotation several signatures are sent, comma-separated.
  return String(headers["x-ignitra-signature"] ?? "").split(",").some((candidate) => {
    const given = Buffer.from(candidate.trim());
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
import express from "express";

const app = express();
const seen = new Set(); // use your database in production

app.post("/ignitra", express.text({ type: "application/json" }), (req, res) => {
  if (!verifyIgnitra(req.body, req.headers, process.env.IGNITRA_WEBHOOK_SECRET)) return res.sendStatus(401);
  const key = req.headers["x-ignitra-idempotency-key"];
  if (seen.has(key)) return res.sendStatus(200); // a retry of something already handled
  seen.add(key);

  const event = JSON.parse(req.body);
  if (event.type === "run.action" && !event.test) {
    console.log(event.flow.name, event.trigger.tx_hash);
  }
  res.sendStatus(200); // answer fast: slow work belongs in your own queue
});

Python

import hashlib
import hmac
import time


def verify_ignitra(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300) -> bool:
    """raw_body: the request body exactly as received, before JSON parsing."""
    try:
        timestamp = int(headers.get("x-ignitra-timestamp", ""))
    except ValueError:
        return False
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False  # replayed or stale
    mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    expected = f"v1={mac}"
    # During a secret rotation several signatures are sent, comma-separated.
    return any(hmac.compare_digest(c.strip(), expected) for c in headers.get("x-ignitra-signature", "").split(","))


# Flask:
#   @app.post("/ignitra")
#   def hook():
#       if not verify_ignitra(request.get_data(), request.headers, os.environ["IGNITRA_WEBHOOK_SECRET"]):
#           abort(401)
#       event = request.get_json()
#       ...
#       return "", 200

Retries and idempotency

  • Answer with any 2xx within 10 seconds. Do slow work after answering.
  • Timeouts, network errors, 408, 425, 429 and 5xx are retried with backoff (about 10 s, 30 s, 90 s…, five attempts); Retry-After is honoured. Other 4xx answers are final.
  • Redirects are not followed: point the webhook at the final URL.
  • Delivery is at-least-once: a retry, or a crash between your answer and IGNITRA recording it, can deliver the same step again. Store X-Ignitra-Idempotency-Key and ignore repeats.
  • After the last attempt the step is a dead letter: it shows under Needs attention on the dashboard. Once your receiver is fixed, Retry sends it again with the same X-Ignitra-Idempotency-Key; the run's other steps that already delivered are not sent again.
  • Your answer is recorded in the run trace (status, timing, the first 500 characters), and a JSON answer is available to later steps as {{steps.<step_id>.body}}.
  • Webhook URLs must be public: private, loopback and link-local addresses are refused, also when a hostname resolves to one.

run.reverted

By default flows run on confirmed blocks, which are not reorganised in practice. In fast mode, a flow runs on the newest block; if that block is reorganised away, every webhook the run delivered receives a signed notice. Undo whatever the original event did, matching on idempotency_key. A notice is sent once and its delivery is recorded on the run.

reason is reorg (the unconfirmed block was replaced) or deep_reorg (an already confirmed block was replaced, which should not happen in practice).

{
  "type": "run.reverted",
  "run_id": "run_…",
  "flow_id": "fl_…",
  "step_id": "notify",
  "idempotency_key": "4663:0x…:12:fl_…:notify",
  "reason": "reorg",
  "reverted_at": "2026-11-07T02:00:41.000Z",
  "trigger": { "tx_hash": "0x…", "block_number": 1234567 },
  "sent_at": "2026-11-07T02:00:42.000Z"
}

Inbound webhooks

A flow with the Inbound webhook trigger gets a URL of its own, shown on the flow page. The token in it is the credential: keep it private, and rotate it on the flow page (or POST /v1/flows/:id/hook/rotate) if it leaks, which invalidates the old URL at once.

curl -X POST "https://…/v1/hooks/fl_…/<token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"order": "1042", "total": 99.5, "customer": {"wallet": "0x8366a39cc670b4001a1121b8f6a443a643e40951"}}'
  • Send JSON, form fields or text (up to 64 KB). JSON and forms become {{trigger.body.…}}; text arrives as {{trigger.body.text}}. Query parameters are under trigger.query.
  • Non-sensitive headers (content type, user agent, and X-… headers that do not look like credentials) are kept as trigger.headers, with dashes as underscores: x_order_source.
  • Send an Idempotency-Key header (1–200 characters) to make retries safe: a repeated key never creates a second run.
  • Each hook accepts 60 requests per minute and 5,000 per day.
202{ "accepted": true, "duplicate": false, "request_id": "req_…" }: stored, the flow runs on it
202 (duplicate)the Idempotency-Key was seen before: same request_id, no second run
404unknown flow or wrong token (deliberately the same answer)
409the flow is paused: resume it to accept requests
402the owner's free plan has used this month's runs (nothing would run)
413the body is over 64 KB
429over the hook's rate (per minute) or the daily intake cap; honour Retry-After