Webhooks

Delivery headers, HMAC signature verification, retries and what counts as success.

A webhook trigger POSTs a JSON payload you define to a URL you control, every time an event gets past its gate. It is the way to make something else happen in response to on-chain activity without polling your database.

Configuring one#

Drag a Webhook node onto the canvas, name it, add the payload inputs you need, and wire fields or transform outputs into them. Then set the destination URL and write the payload builder.

payload.ts
export function buildPayload(
  signature: string,
  owner: string,
  amount: u64,
): string {
  // Must return valid JSON. u64 is carried as a string so no precision
  // is lost in the receiving JSON parser.
  return `{"signature":"${signature}","owner":"${owner}","amount":"${amount.toString()}"}`;
}

What a delivery looks like#

Deliveries are POST requests with Content-Type: application/json and a body that is exactly the string your buildPayload returned.

Request headers
FieldTypeDescription
X-Delivery-IdstringUnique per delivery attempt. Log it — it is what you quote when a delivery goes missing.
X-Syncro-Trigger-RefstringIdentifies which trigger produced this. Use it to route when several triggers share an endpoint.
X-Syncro-Version-IdstringThe indexer version that produced the event. Useful for correlating a change in payload shape with a deploy.
X-Signaturesha256=<hex>HMAC-SHA256 of the raw request body, keyed with your signing secret. Always verify it.

Verifying the signature#

The signing secret is issued by the platform on the trigger's first save and shown, masked, on the node. Copy it into your receiving service's configuration — it is the only thing proving a request came from Syncro.

receiver.ts
import crypto from "node:crypto";
import express from "express";

const app = express();

// The signature covers the RAW body — verify before any JSON parsing.
app.post(
  "/syncro",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const received = req.get("X-Signature") ?? "";
    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", process.env.SYNCRO_WEBHOOK_SECRET!)
        .update(req.body)
        .digest("hex");

    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("bad signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // Acknowledge fast, then do the work out of band.
    res.status(200).send("ok");
    void handle(event);
  },
);

The secret does not rotate on its own#

It is minted once, on the first save of that trigger. Editing the destination URL, renaming the trigger, changing the payload code, or restoring an older version of the indexer all leave the secret intact — your receiver keeps working across all of those.

Retries and what counts as success#

Only a 2xx response is a successful delivery. Anything else is a failure and is retried with exponential backoff, up to the retry count configured on the node — between 0 and 10, defaulting to 3.

Renaming changes the trigger reference#

The reference sent in X-Syncro-Trigger-Ref is derived from the indexer, the trigger kind and the trigger name. Renaming a trigger — or converting it between webhook and WebSocket — changes that reference. Your secret and configuration follow the rename automatically, but anything of yours that routes on the literal reference string needs updating.

Building a good receiver#

  • Respond fast, work later. Acknowledge with a 200 and process asynchronously. A slow handler turns into a timeout, which turns into a retry, which turns into a duplicate.
  • Expect duplicates. A response that was slow rather than lost will be retried even though you handled it. Deduplicate on X-Delivery-Id or on something stable in the payload.
  • Do not assume ordering. Retries mean an older event can arrive after a newer one. If order matters, put a slot or timestamp in the payload and sort on it.
  • Return 2xx for events you deliberately ignore. Rejecting them wastes your retry budget on nothing.