Volver a la aplicación
Agnotiq MarginTide Pricing IntelligenceConectores de webhook

MarginTide — Guía de conectores de webhook

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.

CampoTipoDescripción
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)

Cada solicitud de escritura por webhook lleva estos campos.

CampoTipoDescripción
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.

Códigos de motivo

commerce.resolve response (error)

Cada solicitud de escritura por webhook lleva estos campos.

CampoTipoDescripción
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.

Carga útil de la solicitud — commerce.price.set

Cada solicitud de escritura por webhook lleva estos campos.

CampoTipoDescripción
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)

Cada solicitud de escritura por webhook lleva estos campos.

CampoTipoDescripción
oktrueDiscriminator for the success branch.
appliedPricestring (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.

CampoTipoDescripción
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 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.

EventoDescripción
run.completedUna ejecución de precios terminó (con éxito o con error).
alert.criticalSe activó una alerta de precios crítica.
recommendation.acceptedSe aceptó una recomendación de precios.
recommendation.appliedSe aplicó una recomendación de precios.
pingEvento 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.

CampoTipoDescripción
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

Suscriba un punto de conexión a cualquier combinación de estos eventos.

CampoTipoDescripción
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

Suscriba un punto de conexión a cualquier combinación de estos eventos.

CampoTipoDescripción
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

Suscriba un punto de conexión a cualquier combinación de estos eventos.

CampoTipoDescripción
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

Suscriba un punto de conexión a cualquier combinación de estos eventos.

CampoTipoDescripción
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

Suscriba un punto de conexión a cualquier combinación de estos eventos.

CampoTipoDescripción
message"ping"Always the literal string "ping".
webhook_idstring (uuid)The endpoint id being tested.
workspace_idstring (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=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

Receptor 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);
});

Documentación relacionada