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.
| Champ | 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)
Chaque requête d'écriture par webhook transporte ces champs.
| Champ | 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. |
Codes de motif
commerce.resolve response (error)
Chaque requête d'écriture par webhook transporte ces champs.
| Champ | 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. |
Charge utile de la requête — commerce.price.set
Chaque requête d'écriture par webhook transporte ces champs.
| Champ | 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)
Chaque requête d'écriture par webhook transporte ces champs.
| Champ | Type | Description |
|---|---|---|
ok | true | Discriminator for the success branch. |
appliedPrice | string (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.
| Champ | 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 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énement | Description |
|---|---|
run.completed | Une exécution de tarification s'est terminée (réussie ou échouée). |
alert.critical | Une alerte de tarification critique s'est déclenchée. |
recommendation.accepted | Une recommandation de tarification a été acceptée. |
recommendation.applied | Une 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.
| Champ | 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
Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.
| Champ | 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
Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.
| Champ | 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
Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.
| Champ | 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
Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.
| Champ | 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
Abonnez un point de terminaison à n'importe quelle combinaison de ces événements.
| Champ | 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
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=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=4cde38ded48071a9a44cc5fc5750a3c9d404ddb4f85fb87812aaae02c46272abRé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);
});

