Trackily Docs

Webhooks (entrants)

Trackily reçoit des webhooks de plusieurs sources externes : Shopify (orders, products), WooCommerce, Stripe (paiements e-commerce + paiements natifs par compte), PayPal (par compte), et les MTA (Mailgun, SES, Postmark) pour les bounces / complaints. Chaque endpoint vérifie une signature HMAC pour rejeter les requêtes non authentifiées.

Note importante : Trackily n'émet pas de webhooks sortants génériques. Si tu cherches à propager un événement Trackily (paiement reçu, conversion enregistrée, lead capturé) vers un système externe, regarde plutôt : conversion triggers email (/docs/email/automations), postbacks vers ad networks (/docs/api/postback), ou script tags injection (Shopify uniquement).

Liste des endpoints

Endpoint Source Auth
POST /webhook/shopify Shopify Admin → Notifications HMAC-SHA256 header X-Shopify-Hmac-SHA256
POST /webhook/woocommerce WooCommerce → Settings → Advanced → Webhooks HMAC-SHA256 header X-WC-Webhook-Signature
POST /webhook/stripe-ecom Stripe (legacy — e-commerce externe) Signature header Stripe-Signature
POST /webhook/stripe/:account_id Stripe (par payment_account natif) Signature header Stripe-Signature
POST /webhook/paypal/:account_id PayPal (par payment_account natif) PayPal webhook verification + secret

Les webhooks MTA (Mailgun, SES, Postmark) pour bounces / complaints sont également exposés mais documentés dans leur intégration spécifique.

Pattern global — raw body capture

Pour vérifier les signatures HMAC, on a besoin du body brut (avant JSON parsing). Express body-parser détruit cette possibilité par défaut. Trackily contourne via un middleware global :

// Capture raw body for all /webhook/* routes
app.use('/webhook', express.raw({ type: '*/*', limit: '2mb' }), (req, res, next) => {
  req.rawBody = req.body;  // Buffer
  // Best-effort JSON parse — handler choose to use parsed or raw
  try { req.body = JSON.parse(req.rawBody.toString()); } catch {}
  next();
});

Conséquence : dans chaque handler, req.rawBody est le Buffer original (utilisé pour le HMAC), req.body est le JSON parsé (pratique pour la business logic).

POST /webhook/shopify

Setup côté Shopify

  1. Shopify Admin → Settings → Notifications → Webhooks → "Create webhook"
  2. Event : choisir parmi orders/create, orders/paid, orders/cancelled, orders/fulfilled, etc.
  3. Format : JSON
  4. URL : https://trackily.online/webhook/shopify
  5. Webhook API version : v2023-10 ou plus récent

Setup côté Trackily

Settings → Integrations → Shopify webhook secret. Coller le secret HMAC fourni par Shopify (stocké chiffré).

Alternative legacy : ?key=... query param + shopify_webhook_key setting. Plus faible (leak via access logs) mais accepté pour compat.

Comportement

Le handler :

  1. Vérifie HMAC (sec.shopifyVerifySignature(secret, rawBody, sigHeader)). Si KO → 401 Unauthorized.
  2. Parse le payload (order Shopify).
  3. Extrait le click_id Trackily depuis l'une de 5 sources, dans l'ordre :
    • note_attributes (recommandé — _tly_click)
    • landing_site URL params (_tly_click, click_id, puis ref — seulement s'il a la forme d'un id de clic Trackily : un ref=instagram n'attribue pas la commande depuis le 2026-10-04)
    • referring_site URL params
    • tags (regex extract, tly:<id>)
    • note du client (tly_click=<id>)
  4. Écrit une ligne shopify_order au journal des événements (pixel_events), avec ou sans clic — une par commande Shopify (data.shopify_order_id) et par financial_status (un nouvel essai de Shopify n'en ajoute pas ; deux boutiques au même numéro ont chacune la leur).
  5. Si click_id trouvé : matching avec la clicks row (par son id, sinon par sub1/sub2) ; si aucune conversion n'existe déjà pour cette commande, création d'une conversion row avec le revenue de l'order converti en USD au taux de exchange_rates (montant d'origine, devise, taux et détail de la commande dans custom_data ; taux manquant : créée quand même, devise_convertie: false). La clé est le numéro (transaction_id = name) ; s'il porte déjà la conversion d'un autre clic — une autre boutique, Shopify numérote chaque boutique depuis #1001 —, la clé distincte <name>@<id Shopify>. Le statut suit le paiement (depuis le 2026-10-04 ; AVANT, toute commande reçue créait une conversion approved, notifiée et postée avant paiement) :
    • commande pas payée (pending, authorized, partially_paid…) → conversion pending, ni notification ni postback ;
    • paid (orders/paid, la synchronisation, mark_order_as_paid) → approved ; la notification « conversion » et le postback S2S partent à cette bascule, une seule fois ;
    • orders/cancelled (ou cancelled_at), refunded, voided, ou refunds/create d'un remboursement total → rejected, revenu et payout à 0. Un remboursement partiel ne change rien. Une conversion rejected ne revient jamais, une approved ne redescend jamais en pending (Shopify livre ses webhooks dans le désordre). Le sujet est lu dans l'en-tête X-Shopify-Topic : déclare orders/create, orders/paid, orders/cancelled et refunds/create sur la même URL (orders/updated est lu aussi). Tout autre sujet (orders/delete, orders/edited, orders/fulfilled…) est accusé 200 sans rien écrire. refunds/create ne porte que l'id de la commande : Trackily la relit chez Shopify (la boutique de X-Shopify-Shop-Domain dans Boutiques, sinon celle des réglages shopify_shop_domain / shopify_access_token) pour retrouver sa conversion par son numéro. Sans accès à la boutique, le remboursement ne change rien (trace dans le journal du serveur). Sans clic retrouvé (la synchronisation ne lit pas la note), seule la conversion de CETTE commande bascule : celle de la clé <numéro>@<id>, ou du numéro si son custom_data.shopify_order_id est l'id de la commande — jamais celle d'une autre boutique au même numéro. La synchronisation par boutique (POST /admin/api/stores/:id/sync-orders) et l'outil de l'assistant sync_shopify_orders passent par le même traitement (même clé, même statut, même conversion de devise) ; une commande qu'ils avaient convertie sous leur ancienne clé (shopify-<id>, <id>) n'est pas reconvertie.
  6. Si pas de click_id : log + skip (l'order est enregistrée côté Shopify, juste pas attribuée).

Les étapes 3 à 6 sont le traitement partagé lib/shopify-commande-payee.js (depuis le 2026-10-04) : la synchronisation des commandes l'appelle aussi, et l'outil MCP mark_order_as_paid l'appelle après avoir marqué la commande payée chez Shopify — le webhook orders/paid qui arrive ensuite n'ajoute alors rien (ni conversion, ni notification, ni ligne de journal).

Code de retour : 200 OK même si pas de matching (sinon Shopify retry 19 fois et tu pollues les logs).

Tracking côté checkout

Pour que _tly_click se retrouve dans note_attributes, injecte un script tag Shopify (cf. tool MCP create_script_tag) qui :

// /trackily-tag.js
(function() {
  var click = new URLSearchParams(location.search).get('_tly_click')
            || document.cookie.match(/_tly_click=([^;]+)/)?.[1];
  if (click && window.Shopify && window.Shopify.checkout) {
    // Set as cart attribute
    fetch('/cart/update.js', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ attributes: { _tly_click: click } })
    });
  }
})();

POST /webhook/woocommerce

Setup côté WooCommerce

  1. WP Admin → WooCommerce → Settings → Advanced → Webhooks → Add webhook
  2. Topic : Order created, Order updated, etc.
  3. Delivery URL : https://trackily.online/webhook/woocommerce
  4. Secret : générer un secret, copier dans Settings → Integrations → WooCommerce webhook secret côté Trackily

Comportement

Vérifie X-WC-Webhook-Signature (HMAC-SHA256 base64 du body brut avec le secret). Sinon idem Shopify : extract click_id depuis customer_note / meta_data, create conversion.

POST /webhook/stripe-ecom

Webhook Stripe global pour le e-commerce externe (legacy — quand Trackily orchestrait des paiements Stripe sans système payment_accounts par compte). Vérifie Stripe-Signature avec stripe_ecom_webhook_secret.

Évite ce path — préfère /webhook/stripe/:account_id pour le commerce natif.

POST /webhook/stripe/:account_id

Webhook Stripe par payment_account. Chaque payment_account natif a son propre webhook secret (stocké dans payment_accounts.webhook_secret, chiffré). L'URL inclut l'account_id pour identifier quel secret utiliser.

Setup

  1. Création du payment_account dans Trackily (Settings → Commerce → Payment methods). Récupérer son account_id.
  2. Stripe Dashboard → Developers → Webhooks → Add endpoint
  3. URL : https://trackily.online/webhook/stripe/<account_id>
  4. Events : payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, customer.subscription.created, etc.
  5. Copier le whsec_... signing secret dans Trackily UI (champ webhook_secret du payment_account).

Comportement

  1. Lit le payment_account par account_id (déchiffre webhook_secret automatiquement via CIPHER_TABLES).
  2. Vérifie signature Stripe-Signature via Stripe.webhooks.constructEvent(rawBody, sig, webhook_secret).
  3. Switch sur event.type :
    • payment_intent.succeeded → mark order paid, déclenche post-purchase email automation
    • payment_intent.payment_failed → mark order failed
    • charge.refunded → record refund
    • customer.subscription.created/updated/deleted → update native_subscriptions row
  4. Code de retour : 200 OK (Stripe retry sinon).

Auto-création de webhook au moment du checkout

Quand un buyer paye via Stripe pour la première fois sur une instance fresh, Trackily peut auto-créer le webhook (extrait de server.js) :

const webhookUrl = `${req.protocol}://${req.get('host')}/webhook/stripe/native-pending`;
const webhookCreate = await fetch('https://api.stripe.com/v1/webhook_endpoints', { ... });

C'est best-effort — si tu préfères le contrôle explicite, désactive et configure manuellement.

POST /webhook/paypal/:account_id

Webhook PayPal par payment_account. URL : https://trackily.online/webhook/paypal/<account_id>.

Setup

PayPal Developer Dashboard → My Apps & Credentials → ton App → Add webhook :

  • URL : https://trackily.online/webhook/paypal/<account_id>
  • Events : PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, BILLING.SUBSCRIPTION.CREATED, etc.

Le webhook_id retourné par PayPal va dans payment_accounts.webhook_secret (overloaded : pour PayPal, ce champ stocke le webhook_id qui sert à la vérification).

Vérification

Pas une simple HMAC : PayPal demande de POST une request de vérification vers leur API avec les headers reçus + le body. Plus lourd, mais c'est leur convention. Implémenté via paypal-helpers.js.

Pattern de retry

Source Retry policy
Shopify 19 retries sur 48h, exponential backoff
WooCommerce configurable côté WP, défaut ~3 retries
Stripe 3 jours, ~10 retries, exponential backoff
PayPal 25 retries sur 3 jours

Si ton handler crash ou retourne 5xx, attends-toi à recevoir le même payload N fois. L'idempotence est de ta responsabilité :

  • Côté Trackily, on dédup via la combo (external_event_id, source) quand disponible.
  • Pour les events sans ID stable (rare), on dédup par (order_external_id, event_type) en assumant qu'un même event ne fire qu'une fois dans une fenêtre courte.

Debugging

Réception sans matching

Si un webhook arrive mais aucune conversion n'est créée :

  1. Vérifie côté logs [Shopify] Webhook received qu'il est bien reçu.
  2. Si pas reçu : firewall / DNS / signature secret erroné.
  3. Si reçu mais "No click_id found" : ton tracking côté checkout n'injecte pas _tly_click dans les note_attributes / landing_site. Vérifie le script tag.

Signature failure

[Shopify] HMAC verification failed

Causes habituelles :

  • Secret recopié avec espaces ou retour à la ligne en trop
  • Tu utilises l'API version où Shopify a changé le format de signature (rare, mais arrive)
  • Un proxy en amont (Nginx, Cloudflare) modifie le body en transit (mauvaise config buffer)

Mitigations : strict timing-safe compare déjà en place, copier-coller le secret depuis Shopify directement (pas via un mot de passe manager qui ajoute des chars invisibles).

Volume

Si tu reçois >100 webhooks/seconde (gros store Shopify avec orders/* + products/* + customers/*), le rate limit de Node Express peut saturer. Mitigations :

  • Désactive les events que tu n'utilises pas (côté Shopify)
  • Augmente le rate limit nginx en amont
  • Considère un worker queue dédié (out of scope pour Trackily 1.0)

Voir aussi