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.
| Field | Type | Description |
|---|---|---|
sku | string | Catalog SKU to resolve (or the reserved test SKU). |
currency | string | ISO-4217 currency code of the originating workspace. |
workspace_id | string (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.
| Field | Type | Description |
|---|---|---|
ok | true | Discriminator for the success branch. |
externalProductRef | string | Opaque handle your system will recognise in a later commerce.price.set. Echoed verbatim, never parsed. |
livePrice | string (decimal) | Current live price in `currency`; captured as the rollback target. |
Reason codes
commerce.resolve response (error)
Every write-back request carries these fields.
| Field | Type | Description |
|---|---|---|
ok | false | Discriminator for the failure branch. |
reason | string | One of sku_not_found / ambiguous_sku / auth_failed (any other value maps to a transport failure). |
message | string (optional) | Optional human-readable detail. |
Request payload — commerce.price.set
Every write-back request carries these fields.
| Field | Type | Description |
|---|---|---|
externalProductRef | string | The handle returned by the preceding commerce.resolve. |
price | string (decimal) | Recommended price to apply, as a decimal string. |
currency | string | ISO-4217 currency code. |
idempotencyKey | string | Equals the X-Agnotiq-Delivery header; stable across retries of the same write-back — dedupe on it. |
workspace_id | string (uuid) | Originating Agnotiq workspace id; same semantics as on commerce.resolve. |
commerce.price.set response (ok)
Every write-back request carries these fields.
| Field | Type | Description |
|---|---|---|
ok | true | Discriminator for the success branch. |
appliedPrice | string (decimal) | The price your system actually applied. |
Reason codes
commerce.price.set response (error)
Every write-back request carries these fields.
| Field | Type | Description |
|---|---|---|
ok | false | Discriminator for the failure branch. |
reason | string | auth_failed maps to an auth failure; any other value (or a schema-invalid 2xx body) maps to vendor_rejected. |
message | string (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.
| Event | Description |
|---|---|
run.completed | A pricing run finished (succeeded or failed). |
alert.critical | A critical pricing alert fired. |
recommendation.accepted | A pricing recommendation was accepted. |
recommendation.applied | A pricing recommendation was applied. |
ping | Test-only event sent by the endpoint's "Send test" action. |
Envelope
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
version | string (semver) | Semver of the envelope contract. |
id | string (uuid) | Delivery id; equals X-Agnotiq-Delivery. |
type | string | Event type discriminator (one of the 4 events, or ping). |
created_at | string (ISO-8601) | UTC event emit time. |
data | object | Per-type payload — see the event tables below. |
run.completed data
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
run_id | string (uuid) | The run this event reports on. |
workspace_id | string (uuid) | Originating workspace. |
status | succeeded | failed | Run outcome. |
trigger_source | string | What triggered the run (scheduled / manual / sample / api). |
started_at | string | null | ISO-8601 start time. |
completed_at | string | null | ISO-8601 completion time. |
products_checked | integer | Count of products checked in the run. |
link | string (uri) | Deep link to the run in the app. |
alert.critical data
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
alert_id | string (uuid) | The alert this event reports on. |
workspace_id | string (uuid) | Originating workspace. |
severity | critical | Always "critical" for this event. |
kind | string | Alert kind code. |
product_id | string (uuid) | null | Affected product, if any. |
title | string | Human-readable alert title. |
currency | string | ISO-4217 currency code. |
current_price | number | null | Current price at alert time. |
competitor_price | number | null | Triggering competitor price. |
pct_change | number | null | Percentage change that triggered the alert. |
link | string (uri) | Deep link to the alert in the app. |
recommendation.accepted data
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
recommendation_id | string (uuid) | The recommendation this event reports on. |
workspace_id | string (uuid) | Originating workspace. |
product_id | string (uuid) | Affected product. |
from_status | string | Prior recommendation status. |
to_status | accepted | Always "accepted" for this event. |
currency | string | ISO-4217 currency code. |
recommended_price | number | null | The recommended price. |
link | string (uri) | Deep link to the recommendation in the app. |
recommendation.applied data
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
recommendation_id | string (uuid) | The recommendation this event reports on. |
workspace_id | string (uuid) | Originating workspace. |
product_id | string (uuid) | Affected product. |
from_status | string | Prior recommendation status. |
currency | string | ISO-4217 currency code. |
recommended_price | number | null | The recommended price. |
link | string (uri) | Deep link to the recommendation in the app. |
to_status | applied | Always "applied" for this event. |
applied_actual_price | number | The price actually applied. |
ping data
Subscribe an endpoint to any combination of these events.
| Field | Type | Description |
|---|---|---|
message | "ping" | Always the literal string "ping". |
webhook_id | string (uuid) | The endpoint id being tested. |
workspace_id | string (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=b99f548315a20e8ae7282b0966e3f276406611fcdee790b8863398416d35c9fbping (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=4cde38ded48071a9a44cc5fc5750a3c9d404ddb4f85fb87812aaae02c46272abSample 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);
});

