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-Signature | v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>; several, comma-separated, while a secret rotates |
| X-Ignitra-Timestamp | Unix 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-Delivery | A fresh UUID per attempt, for your logs |
| X-Ignitra-Event | run.action or run.reverted |
| X-Ignitra-Test | true 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 "", 200Retries 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-Afteris 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-Keyand 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 undertrigger.query. - Non-sensitive headers (content type, user agent, and
X-…headers that do not look like credentials) are kept astrigger.headers, with dashes as underscores:x_order_source. - Send an
Idempotency-Keyheader (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 |
| 404 | unknown flow or wrong token (deliberately the same answer) |
| 409 | the flow is paused: resume it to accept requests |
| 402 | the owner's free plan has used this month's runs (nothing would run) |
| 413 | the body is over 64 KB |
| 429 | over the hook's rate (per minute) or the daily intake cap; honour Retry-After |