Back to app
Agnotiq MarginTide Pricing IntelligenceWebhook Connectors

MarginTide — Webhook connectors guide

Two webhooks, one signature scheme

MarginTide has two independent webhook connectors. Both sign every request with HMAC-SHA256 and refuse endpoints that are not public HTTPS. Pick the one your integration needs — most receivers only need one.

Which one do I want?

Write-back

MarginTide calls your endpoint to ask about, and then change, YOUR prices. Synchronous request/response — your receiver answers each request inline.

Outbound events

MarginTide notifies your endpoint when something happens in your workspace — a run completes, a critical alert fires, a recommendation is accepted or applied. Fire-and-forget with retries; your receiver just acknowledges.

Endpoint requirements

  • HTTPS only. Plain HTTP endpoints are refused.
  • The resolved IP is pinned to the address used for the connection — no DNS rebinding between check and send.
  • Redirects are not followed. Your endpoint must answer directly.
  • Private, loopback, link-local, and cloud-metadata address ranges are refused.

Write-back checks every requirement at send time, on every request.

Outbound events check every requirement both when you register the endpoint and again at delivery time.

Write-back

MarginTide sends a signed HTTPS request to your receiver to resolve a SKU or apply an approved price change. Your receiver answers with a typed JSON reply.

Request payload — commerce.resolve

Every write-back request carries these fields.

FieldTypeDescription
skustringCatalog SKU to resolve (or the reserved test SKU).
currencystringISO-4217 currency code of the originating workspace.
workspace_idstring (uuid)Originating Agnotiq workspace id. Signature-covered (rides in the body). Lets a receiver serving several workspaces at one URL attribute the request without trial-verifying every secret it holds.

commerce.resolve response (ok)

Every write-back request carries these fields.

FieldTypeDescription
oktrueDiscriminator for the success branch.
externalProductRefstringOpaque handle your system will recognise in a later commerce.price.set. Echoed verbatim, never parsed.
livePricestring (decimal)Current live price in `currency`; captured as the rollback target.

Reason codes

commerce.resolve response (error)

Every write-back request carries these fields.

FieldTypeDescription
okfalseDiscriminator for the failure branch.
reasonstringOne of sku_not_found / ambiguous_sku / auth_failed (any other value maps to a transport failure).
messagestring (optional)Optional human-readable detail.

Request payload — commerce.price.set

Every write-back request carries these fields.

FieldTypeDescription
externalProductRefstringThe handle returned by the preceding commerce.resolve.
pricestring (decimal)Recommended price to apply, as a decimal string.
currencystringISO-4217 currency code.
idempotencyKeystringEquals the X-Agnotiq-Delivery header; stable across retries of the same write-back — dedupe on it.
workspace_idstring (uuid)Originating Agnotiq workspace id; same semantics as on commerce.resolve.

commerce.price.set response (ok)

Every write-back request carries these fields.

FieldTypeDescription
oktrueDiscriminator for the success branch.
appliedPricestring (decimal)The price your system actually applied.

Reason codes

commerce.price.set response (error)

Every write-back request carries these fields.

FieldTypeDescription
okfalseDiscriminator for the failure branch.
reasonstringauth_failed maps to an auth failure; any other value (or a schema-invalid 2xx body) maps to vendor_rejected.
messagestring (optional)Optional human-readable detail.

workspace_id identifies which of your workspaces the request belongs to — required when one receiver serves multiple workspaces or storefronts; ignore it safely if your receiver only ever serves one.

Signature

Signature header: X-Agnotiq-Signature

The header carries the scheme, a timestamp, and an HMAC-SHA256 hex digest of the timestamp and raw request body, signed with your signing secret.

Response contract

Reply with a typed JSON body. A recognized reason code tells MarginTide why a resolve or price-set could not complete (for example, an unknown SKU).

Signing secret

The signing secret is shown in full exactly once, at connect time or immediately after a rotation. MarginTide cannot display it again.

Only the last 4 characters are retained for display afterward.

Rotating mints a new secret and invalidates the old one immediately — there is no overlap window, so update your receiver with the new secret before rotating, or expect a short gap in accepted deliveries.

Test probe

Use "Send test" from the integration settings page to send a signed resolve request for the reserved test SKU. Answer it (found or not found) to prove your signature verification works — no price is written.

Outbound events

MarginTide POSTs a signed event to every endpoint subscribed to that event type. Delivery is best-effort and never blocks a pricing run.

Event types

Subscribe an endpoint to any combination of these events.

EventDescription
run.completedA pricing run finished (succeeded or failed).
alert.criticalA critical pricing alert fired.
recommendation.acceptedA pricing recommendation was accepted.
recommendation.appliedA pricing recommendation was applied.
pingTest-only event sent by the endpoint's "Send test" action.

Envelope

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
versionstring (semver)Semver of the envelope contract.
idstring (uuid)Delivery id; equals X-Agnotiq-Delivery.
typestringEvent type discriminator (one of the 4 events, or ping).
created_atstring (ISO-8601)UTC event emit time.
dataobjectPer-type payload — see the event tables below.

run.completed data

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
run_idstring (uuid)The run this event reports on.
workspace_idstring (uuid)Originating workspace.
statussucceeded | failedRun outcome.
trigger_sourcestringWhat triggered the run (scheduled / manual / sample / api).
started_atstring | nullISO-8601 start time.
completed_atstring | nullISO-8601 completion time.
products_checkedintegerCount of products checked in the run.
linkstring (uri)Deep link to the run in the app.

alert.critical data

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
alert_idstring (uuid)The alert this event reports on.
workspace_idstring (uuid)Originating workspace.
severitycriticalAlways "critical" for this event.
kindstringAlert kind code.
product_idstring (uuid) | nullAffected product, if any.
titlestringHuman-readable alert title.
currencystringISO-4217 currency code.
current_pricenumber | nullCurrent price at alert time.
competitor_pricenumber | nullTriggering competitor price.
pct_changenumber | nullPercentage change that triggered the alert.
linkstring (uri)Deep link to the alert in the app.

recommendation.accepted data

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
recommendation_idstring (uuid)The recommendation this event reports on.
workspace_idstring (uuid)Originating workspace.
product_idstring (uuid)Affected product.
from_statusstringPrior recommendation status.
to_statusacceptedAlways "accepted" for this event.
currencystringISO-4217 currency code.
recommended_pricenumber | nullThe recommended price.
linkstring (uri)Deep link to the recommendation in the app.

recommendation.applied data

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
recommendation_idstring (uuid)The recommendation this event reports on.
workspace_idstring (uuid)Originating workspace.
product_idstring (uuid)Affected product.
from_statusstringPrior recommendation status.
currencystringISO-4217 currency code.
recommended_pricenumber | nullThe recommended price.
linkstring (uri)Deep link to the recommendation in the app.
to_statusappliedAlways "applied" for this event.
applied_actual_pricenumberThe price actually applied.

ping data

Subscribe an endpoint to any combination of these events.

FieldTypeDescription
message"ping"Always the literal string "ping".
webhook_idstring (uuid)The endpoint id being tested.
workspace_idstring (uuid)Originating workspace.

Signature

Signature header: X-Agnotiq-Signature

The header carries the scheme, a timestamp, and an HMAC-SHA256 hex digest of the timestamp and raw request body, signed with the endpoint's signing secret.

Response contract

Any 2xx status code acknowledges the delivery. Anything else is treated as a failed attempt.

Retry schedule
Attempt 2 — 60s after attempt 1
Attempt 3 — 300s after attempt 2
Attempt 4 — 1800s after attempt 3
Attempt 5 — 7200s after attempt 4

Attempt cap: 5

After the attempt cap is reached, the delivery is marked dead-letter and no further attempts are made — it is visible in the endpoint's recent deliveries.

Signing secret

Each endpoint's signing secret is shown in full exactly once, when the endpoint is created or immediately after a rotation. MarginTide cannot display it again.

Only the last 4 characters are retained for display afterward.

Rotating an endpoint's secret mints a new one and invalidates the old one immediately — update your receiver first, or expect deliveries signed with the old secret to be rejected in the meantime.

Test probe

Use "Send test" from the endpoint's row to send a signed ping event. Acknowledge it with any 2xx status to prove your signature verification works.

Verifying the signature

Recompute the HMAC-SHA256 digest over the timestamp and raw request body using your signing secret, compare it to the header using a constant-time comparison, and reject the request if the timestamp falls outside the replay window.

Replay window: 300s

Always compare digests with a constant-time function — a plain string or byte-array equality check leaks timing information an attacker can use to forge a valid signature one byte at a time.

Node.js

const crypto = require("node:crypto");

function computeSignature(secret, t, rawBody) {
  const basestring = "v1:" + t + ":" + rawBody;
  return crypto.createHmac("sha256", secret).update(basestring).digest("hex");
}

function verifySignature(secret, header, rawBody, nowSeconds) {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header || "");
  if (!match) return false;
  const t = Number(match[1]);
  const v1 = match[2];
  const now = nowSeconds != null ? nowSeconds : Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > 300) return false;
  const expected = computeSignature(secret, t, rawBody);
  const expectedBuf = Buffer.from(expected, "utf8");
  const actualBuf = Buffer.from(v1, "utf8");
  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

Python

import hashlib
import hmac
import re
import time


def compute_signature(secret, t, raw_body):
    basestring = f"v1:{t}:{raw_body}"
    return hmac.new(secret.encode("utf-8"), basestring.encode("utf-8"), hashlib.sha256).hexdigest()


def verify_signature(secret, header, raw_body, now_seconds=None):
    match = re.match(r"^t=(\d+),v1=([0-9a-f]+)$", header or "")
    if not match:
        return False
    t = int(match.group(1))
    v1 = match.group(2)
    now = now_seconds if now_seconds is not None else int(time.time())
    if abs(now - t) > 300:
        return False
    expected = compute_signature(secret, t, raw_body)
    return hmac.compare_digest(expected, v1)

Known-answer test vector

Use this fixed secret, timestamp, and body to confirm your implementation produces the exact signature below before pointing it at real traffic.

commerce.resolve (write-back)

secret: whsec_test0000000000000000000000000001
t:      1735689600
body:   {"workspace_id":"11111111-1111-4111-8111-111111111111","sku":"__agnotiq_test__","currency":"USD"}
sig:    t=1735689600,v1=b99f548315a20e8ae7282b0966e3f276406611fcdee790b8863398416d35c9fb

ping (outbound events)

secret: whsec_test0000000000000000000000000002
t:      1735689600
body:   {"version":"1.0.0","id":"22222222-2222-4222-8222-222222222222","type":"ping","created_at":"2025-01-01T00:00:00.000Z","data":{"message":"ping","webhook_id":"33333333-3333-4333-8333-333333333333","workspace_id":"11111111-1111-4111-8111-111111111111"}}
sig:    t=1735689600,v1=4cde38ded48071a9a44cc5fc5750a3c9d404ddb4f85fb87812aaae02c46272ab

Sample receiver

A single-file, zero-dependency Node.js receiver that verifies both signature schemes, answers write-back requests for a hardcoded SKU map, logs outbound events, handles both test probes, and rejects bad or expired signatures. Copy it as a starting point.

#!/usr/bin/env node
// Agnotiq sample webhook receiver — zero dependencies, node: builtins only.
// Verifies both signature schemes (091 write-back, 066 outbound events),
// answers commerce.resolve / commerce.price.set for a demo SKU catalog,
// logs 066 events, and handles both test probes.
//
// Run:  WRITEBACK_SECRET=whsec_... EVENTS_SECRET=whsec_... node receiver.js
// Port: set PORT (0 = ephemeral, the OS picks a free port).

"use strict";

const http = require("node:http");
const crypto = require("node:crypto");

const WRITEBACK_SECRET = process.env.WRITEBACK_SECRET || "";
const EVENTS_SECRET = process.env.EVENTS_SECRET || "";
const REPLAY_WINDOW_SECONDS = 300;
const SIGNATURE_VERSION = "v1";
const TEST_SKU = "__agnotiq_test__";

// Hardcoded demo catalog — replace with your own product lookup.
const SKU_CATALOG = {
  "DEMO-SKU-1": { externalProductRef: "demo-ref-1", livePrice: "19.99" },
};

function computeSignature(secret, t, rawBody) {
  const basestring = SIGNATURE_VERSION + ":" + t + ":" + rawBody;
  return crypto.createHmac("sha256", secret).update(basestring).digest("hex");
}

function verifySignature(secret, header, rawBody, nowSeconds) {
  if (!header) return false;
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header);
  if (!match) return false;
  const t = Number(match[1]);
  const v1 = match[2];
  const now = nowSeconds != null ? nowSeconds : Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > REPLAY_WINDOW_SECONDS) return false;
  const expected = computeSignature(secret, t, rawBody);
  const expectedBuf = Buffer.from(expected, "utf8");
  const actualBuf = Buffer.from(v1, "utf8");
  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks).toString("utf8");
    const eventType = req.headers["x-agnotiq-event"];
    const signatureHeader = req.headers["x-agnotiq-signature"];

    function respond(status, body) {
      res.writeHead(status, { "Content-Type": "application/json" });
      res.end(JSON.stringify(body));
    }

    if (eventType === "commerce.resolve" || eventType === "commerce.price.set") {
      if (!verifySignature(WRITEBACK_SECRET, signatureHeader, rawBody)) {
        respond(401, { ok: false, reason: "auth_failed", message: "bad or expired signature" });
        return;
      }
      let payload;
      try {
        payload = JSON.parse(rawBody);
      } catch (err) {
        respond(400, { ok: false, reason: "auth_failed", message: "invalid json" });
        return;
      }
      if (eventType === "commerce.resolve") {
        if (payload.sku === TEST_SKU) {
          respond(200, { ok: false, reason: "sku_not_found", message: "test probe" });
          return;
        }
        const entry = SKU_CATALOG[payload.sku];
        if (!entry) {
          respond(200, { ok: false, reason: "sku_not_found" });
          return;
        }
        respond(200, { ok: true, externalProductRef: entry.externalProductRef, livePrice: entry.livePrice });
        return;
      }
      // commerce.price.set — apply and echo back the price.
      respond(200, { ok: true, appliedPrice: payload.price });
      return;
    }

    // Outbound events (066): run.completed / alert.critical /
    // recommendation.accepted / recommendation.applied / ping.
    if (!verifySignature(EVENTS_SECRET, signatureHeader, rawBody)) {
      respond(401, { ok: false, message: "bad or expired signature" });
      return;
    }
    let envelope;
    try {
      envelope = JSON.parse(rawBody);
    } catch (err) {
      respond(400, { ok: false, message: "invalid json" });
      return;
    }
    console.log("[sample-receiver] event:", envelope.type, envelope.id);
    respond(200, { ok: true });
  });
});

const requestedPort = process.env.PORT ? Number(process.env.PORT) : 0;
server.listen(requestedPort, "127.0.0.1", () => {
  const address = server.address();
  const actualPort = address && typeof address === "object" ? address.port : requestedPort;
  console.log("listening " + actualPort);
});

Related documentation