Retour à l'application
Agnotiq MarginTide Pricing IntelligenceConnecteurs webhook

MarginTide — Guide des connecteurs webhook

Deux webhooks, un seul schéma de signature

MarginTide dispose de deux connecteurs webhook indépendants. Les deux signent chaque requête avec HMAC-SHA256 et refusent les points de terminaison qui ne sont pas des HTTPS publics. Choisissez celui dont votre intégration a besoin — la plupart des récepteurs n'ont besoin que d'un seul.

Lequel me faut-il?

Écriture par webhook

MarginTide appelle votre point de terminaison pour demander, puis modifier, VOS prix. Requête/réponse synchrone — votre récepteur répond à chaque requête directement.

Événements sortants

MarginTide informe votre point de terminaison quand quelque chose se produit dans votre espace de travail — une exécution se termine, une alerte critique se déclenche, une recommandation est acceptée ou appliquée. Émission sans confirmation avec nouvelles tentatives — votre récepteur n'a qu'à accuser réception.

Exigences du point de terminaison

  • HTTPS uniquement. Les points de terminaison en HTTP simple sont refusés.
  • L'adresse IP résolue est épinglée à l'adresse utilisée pour la connexion — aucune reliaison DNS entre la vérification et l'envoi.
  • Les redirections ne sont pas suivies. Votre point de terminaison doit répondre directement.
  • Les plages d'adresses privées, de bouclage, de liaison locale et de métadonnées infonuagiques sont refusées.

L'écriture par webhook vérifie chaque exigence au moment de l'envoi, à chaque requête.

Les événements sortants vérifient chaque exigence à la fois lors de l'enregistrement du point de terminaison et de nouveau au moment de la livraison.

Écriture par webhook

MarginTide envoie une requête HTTPS signée à votre récepteur pour résoudre un article ou appliquer un changement de prix approuvé. Votre récepteur répond avec une réponse JSON typée.

Charge utile de la requête — commerce.resolve

Chaque requête d'écriture par webhook transporte ces champs.

ChampTypeDescription
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)

Chaque requête d'écriture par webhook transporte ces champs.

ChampTypeDescription
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.

Codes de motif

commerce.resolve response (error)

Chaque requête d'écriture par webhook transporte ces champs.

ChampTypeDescription
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.

Charge utile de la requête — commerce.price.set

Chaque requête d'écriture par webhook transporte ces champs.

ChampTypeDescription
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)

Chaque requête d'écriture par webhook transporte ces champs.

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

Codes de motif

commerce.price.set response (error)

Chaque requête d'écriture par webhook transporte ces champs.

ChampTypeDescription
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 identifie à quel espace de travail la requête appartient — requis lorsqu'un seul récepteur dessert plusieurs espaces de travail ou vitrines; vous pouvez l'ignorer sans risque si votre récepteur ne dessert qu'un seul espace de travail.

Signature

En-tête de signature: X-Agnotiq-Signature

L'en-tête transporte le schéma, un horodatage et un condensé hexadécimal HMAC-SHA256 de l'horodatage et du corps brut de la requête, signé avec votre secret de signature.

Contrat de réponse

Répondez avec un corps JSON typé. Un code de motif reconnu indique à MarginTide pourquoi une résolution ou un changement de prix n'a pas pu être complété (par exemple, un article inconnu).

Secret de signature

Le secret de signature est affiché en entier exactement une fois, au moment de la connexion ou immédiatement après une rotation. MarginTide ne peut pas l'afficher de nouveau.

Seuls les 4 derniers caractères sont conservés pour affichage par la suite.

La rotation crée un nouveau secret et invalide l'ancien immédiatement — il n'y a pas de fenêtre de chevauchement, alors mettez à jour votre récepteur avec le nouveau secret avant la rotation, ou attendez-vous à une courte interruption des livraisons acceptées.

Sonde de test

Utilisez « Envoyer un test » depuis la page des paramètres d'intégration pour envoyer une requête de résolution signée pour l'article de test réservé. Répondez-y (trouvé ou introuvable) pour prouver que votre vérification de signature fonctionne — aucun prix n'est écrit.

Événements sortants

MarginTide envoie un événement signé par POST à chaque point de terminaison abonné à ce type d'événement. La livraison est faite au mieux et ne bloque jamais une exécution de tarification.

Types d'événements

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ÉvénementDescription
run.completedUne exécution de tarification s'est terminée (réussie ou échouée).
alert.criticalUne alerte de tarification critique s'est déclenchée.
recommendation.acceptedUne recommandation de tarification a été acceptée.
recommendation.appliedUne recommandation de tarification a été appliquée.
pingÉvénement de test uniquement, envoyé par l'action « Envoyer un test » du point de terminaison.

Envelope

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ChampTypeDescription
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

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ChampTypeDescription
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

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ChampTypeDescription
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

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ChampTypeDescription
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

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

ChampTypeDescription
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

Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.

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

Signature

En-tête de signature: X-Agnotiq-Signature

L'en-tête transporte le schéma, un horodatage et un condensé hexadécimal HMAC-SHA256 de l'horodatage et du corps brut de la requête, signé avec le secret de signature du point de terminaison.

Contrat de réponse

Tout code d'état 2xx accuse réception de la livraison. Tout autre code est traité comme une tentative échouée.

Calendrier des nouvelles tentatives
Attempt 2 — 60s after attempt 1
Attempt 3 — 300s after attempt 2
Attempt 4 — 1800s after attempt 3
Attempt 5 — 7200s after attempt 4

Plafond de tentatives: 5

Une fois le plafond de tentatives atteint, la livraison est marquée comme lettre morte et aucune autre tentative n'est faite — elle est visible dans les livraisons récentes du point de terminaison.

Secret de signature

Le secret de signature de chaque point de terminaison est affiché en entier exactement une fois, à la création du point de terminaison ou immédiatement après une rotation. MarginTide ne peut pas l'afficher de nouveau.

Seuls les 4 derniers caractères sont conservés pour affichage par la suite.

La rotation du secret d'un point de terminaison crée un nouveau secret et invalide l'ancien immédiatement — mettez d'abord à jour votre récepteur, ou attendez-vous à ce que les livraisons signées avec l'ancien secret soient rejetées entre-temps.

Sonde de test

Utilisez « Envoyer un test » depuis la ligne du point de terminaison pour envoyer un événement ping signé. Accusez-en réception avec n'importe quel code d'état 2xx pour prouver que votre vérification de signature fonctionne.

Vérifier la signature

Recalculez le condensé HMAC-SHA256 sur l'horodatage et le corps brut de la requête à l'aide de votre secret de signature, comparez-le à l'en-tête à l'aide d'une comparaison à temps constant, et rejetez la requête si l'horodatage se situe en dehors de la fenêtre de relecture.

Fenêtre de relecture: 300s

Comparez toujours les condensés avec une fonction à temps constant — une simple comparaison de chaînes ou de tableaux d'octets révèle des informations de synchronisation qu'un attaquant peut exploiter pour forger une signature valide un octet à la fois.

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)

Vecteur de test à réponse connue

Utilisez ce secret, cet horodatage et ce corps fixes pour confirmer que votre implémentation produit exactement la signature ci-dessous avant de la pointer vers du trafic réel.

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

Récepteur d'exemple

Un récepteur Node.js dans un seul fichier, sans dépendance, qui vérifie les deux schémas de signature, répond aux requêtes d'écriture par webhook pour une correspondance d'articles codée en dur, consigne les événements sortants, gère les deux sondes de test et rejette les signatures invalides ou expirées. Copiez-le comme point de départ.

#!/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);
});

Documentation connexe