Dos webhooks, un solo esquema de firma
MarginTide tiene dos conectores de webhook independientes. Ambos firman cada solicitud con HMAC-SHA256 y rechazan los puntos de conexión que no sean HTTPS públicos. Elija el que necesite su integración — la mayoría de los receptores solo necesitan uno.
¿Cuál necesito?
Escritura por webhook
MarginTide llama a su punto de conexión para preguntar y luego cambiar SUS precios. Solicitud/respuesta sincrónica — su receptor responde a cada solicitud en línea.
Eventos salientes
MarginTide notifica a su punto de conexión cuando algo sucede en su espacio de trabajo — una ejecución se completa, se activa una alerta crítica, se acepta o aplica una recomendación. Envío sin confirmación con reintentos — su receptor solo necesita acusar recibo.
Requisitos del punto de conexión
- Solo HTTPS. Los puntos de conexión HTTP simples se rechazan.
- La IP resuelta queda fijada a la dirección utilizada para la conexión — no hay reenlace de DNS entre la verificación y el envío.
- No se siguen las redirecciones. Su punto de conexión debe responder directamente.
- Se rechazan los rangos de direcciones privadas, de bucle invertido, de enlace local y de metadatos en la nube.
La escritura por webhook verifica cada requisito en el momento del envío, en cada solicitud.
Los eventos salientes verifican cada requisito tanto al registrar el punto de conexión como de nuevo en el momento de la entrega.
Escritura por webhook
MarginTide envía una solicitud HTTPS firmada a su receptor para resolver un SKU o aplicar un cambio de precio aprobado. Su receptor responde con un cuerpo JSON tipado.
Carga útil de la solicitud — commerce.resolve
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
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)
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
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. |
Códigos de motivo
commerce.resolve response (error)
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
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. |
Carga útil de la solicitud — commerce.price.set
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
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)
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
ok | true | Discriminator for the success branch. |
appliedPrice | string (decimal) | The price your system actually applied. |
Códigos de motivo
commerce.price.set response (error)
Cada solicitud de escritura por webhook lleva estos campos.
| Campo | Tipo | Descripción |
|---|---|---|
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 identifica a cuál de sus espacios de trabajo pertenece la solicitud — es obligatorio cuando un mismo receptor atiende a varios espacios de trabajo o tiendas; puede ignorarlo sin problema si su receptor solo atiende a un espacio de trabajo.
Firma
Encabezado de firma: X-Agnotiq-Signature
El encabezado lleva el esquema, una marca de tiempo y un resumen hexadecimal HMAC-SHA256 de la marca de tiempo y el cuerpo bruto de la solicitud, firmado con su secreto de firma.
Contrato de respuesta
Responda con un cuerpo JSON tipado. Un código de motivo reconocido le indica a MarginTide por qué no se pudo completar una resolución o un cambio de precio (por ejemplo, un SKU desconocido).
Secreto de firma
El secreto de firma se muestra en su totalidad exactamente una vez, al momento de conectar o inmediatamente después de una rotación. MarginTide no puede volver a mostrarlo.
Solo se conservan los últimos 4 caracteres para mostrarlos después.
Rotar genera un nuevo secreto e invalida el anterior de inmediato — no hay ventana de superposición, así que actualice su receptor con el nuevo secreto antes de rotar, o espere una breve interrupción en las entregas aceptadas.
Sonda de prueba
Use "Enviar prueba" desde la página de configuración de la integración para enviar una solicitud de resolución firmada para el SKU de prueba reservado. Respóndala (encontrado o no encontrado) para demostrar que su verificación de firma funciona — no se escribe ningún precio.
Eventos salientes
MarginTide envía por POST un evento firmado a cada punto de conexión suscrito a ese tipo de evento. La entrega es de mejor esfuerzo y nunca bloquea una ejecución de precios.
Tipos de eventos
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Evento | Descripción |
|---|---|
run.completed | Una ejecución de precios terminó (con éxito o con error). |
alert.critical | Se activó una alerta de precios crítica. |
recommendation.accepted | Se aceptó una recomendación de precios. |
recommendation.applied | Se aplicó una recomendación de precios. |
ping | Evento solo de prueba, enviado por la acción "Enviar prueba" del punto de conexión. |
Envelope
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
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
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
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
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
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
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
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
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
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
Suscriba un punto de conexión a cualquier combinación de estos eventos.
| Campo | Tipo | Descripción |
|---|---|---|
message | "ping" | Always the literal string "ping". |
webhook_id | string (uuid) | The endpoint id being tested. |
workspace_id | string (uuid) | Originating workspace. |
Firma
Encabezado de firma: X-Agnotiq-Signature
El encabezado lleva el esquema, una marca de tiempo y un resumen hexadecimal HMAC-SHA256 de la marca de tiempo y el cuerpo bruto de la solicitud, firmado con el secreto de firma del punto de conexión.
Contrato de respuesta
Cualquier código de estado 2xx confirma la entrega. Cualquier otro se trata como un intento fallido.
| Calendario de reintentos |
|---|
| Attempt 2 — 60s after attempt 1 |
| Attempt 3 — 300s after attempt 2 |
| Attempt 4 — 1800s after attempt 3 |
| Attempt 5 — 7200s after attempt 4 |
Límite de intentos: 5
Una vez alcanzado el límite de intentos, la entrega se marca como carta muerta y no se realizan más intentos — es visible en las entregas recientes del punto de conexión.
Secreto de firma
El secreto de firma de cada punto de conexión se muestra en su totalidad exactamente una vez, al crear el punto de conexión o inmediatamente después de una rotación. MarginTide no puede volver a mostrarlo.
Solo se conservan los últimos 4 caracteres para mostrarlos después.
Rotar el secreto de un punto de conexión genera uno nuevo e invalida el anterior de inmediato — actualice primero su receptor, o espere que las entregas firmadas con el secreto anterior sean rechazadas mientras tanto.
Sonda de prueba
Use "Enviar prueba" desde la fila del punto de conexión para enviar un evento ping firmado. Acúselo con cualquier código de estado 2xx para demostrar que su verificación de firma funciona.
Verificar la firma
Vuelva a calcular el resumen HMAC-SHA256 sobre la marca de tiempo y el cuerpo bruto de la solicitud usando su secreto de firma, compárelo con el encabezado usando una comparación de tiempo constante, y rechace la solicitud si la marca de tiempo queda fuera de la ventana de repetición.
Ventana de repetición: 300s
Compare siempre los resúmenes con una función de tiempo constante — una comparación simple de cadenas o de bytes revela información de temporización que un atacante puede usar para falsificar una firma válida un byte a la vez.
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)
Vector de prueba de respuesta conocida
Use este secreto, marca de tiempo y cuerpo fijos para confirmar que su implementación produce exactamente la firma que se muestra a continuación antes de apuntarla a tráfico real.
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=4cde38ded48071a9a44cc5fc5750a3c9d404ddb4f85fb87812aaae02c46272abReceptor de muestra
Un receptor de Node.js en un solo archivo, sin dependencias, que verifica ambos esquemas de firma, responde a las solicitudes de escritura por webhook para un mapa de SKU fijo, registra los eventos salientes, maneja ambas sondas de prueba y rechaza firmas incorrectas o vencidas. Cópielo como punto de partida.
#!/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);
});

