Sommaire

Docs › Webhooks

Webhooks sortants

Disponible · Essentiel

Synkro prévient ton adresse HTTPS à chaque événement choisi. Chaque envoi est signé au format Standard Webhooks.

Créer un webhook sortant

Un webhook sortant envoie une requête POST à l'adresse de ton choix quand un événement arrive. Un propriétaire ou un administrateur le crée dans Administration › Intégrations, puis « Nouveau webhook sortant » : adresse en https, événements souhaités, canaux. Le secret de signature whsec_… n'est affiché qu'à la création et à la rotation.

Les événements

TypeEnvoyé quand
message.postedUn message est publié dans un canal (personne, intégration ou système).
ticket.createdUn ticket est créé, y compris depuis un message.
ticket.updatedUn champ d'un ticket change, hors statut et assignation. Les champs changés par une même opération sont groupés.
ticket.status_changedLe statut d'un ticket change.
ticket.assignedL'assigné d'un ticket change, y compris retiré (to vaut alors null).
ticket.commentedUn commentaire est ajouté à un ticket.
member.joinedUn interne rejoint l'organisation, ou un externe rejoint un canal (channel est alors présent). Jamais d'adresse e-mail.
call.startedUn appel de canal démarre (jamais un appel privé).
call.endedUn appel de canal se termine.
file.uploadedUne pièce jointe est prête dans un message de canal ou un ticket. Aucune adresse de téléchargement.
pingEnvoi de test, depuis l'écran des intégrations ou par POST /webhooks/{id}/test.

Les éditions et suppressions de messages, les réactions et les épingles ne sont pas émises. Un message écrit par une intégration n'est jamais renvoyé à cette même intégration, et une destination ne reçoit les messages des intégrations que si tu l'as demandé.

La charge

POST https://exemple.fr/synkro
Content-Type: application/json
User-Agent: Synkro-Webhooks/1.0 (+https://mysynkro.fr/developpeurs)
webhook-id: evt_…
webhook-timestamp: 1791555780
webhook-signature: v1,…
Synkro-Event: ticket.created
{
  "id": "evt_k57c3d4e5",
  "type": "ticket.created",
  "version": 1,
  "created_at": "2026-10-09T14:03:00.000Z",
  "organization": {
    "id": "o57m3n4p5",
    "slug": "nova",
    "name": "Nova Studio"
  },
  "data": {
    "ticket": {
      "id": "t57e8w2r4",
      "key": "ACME-42",
      "channel_id": "j57z9q4t1",
      "title": "Corriger la facture 2026-118",
      "type": "task",
      "status": "todo",
      "priority": "medium",
      "assignee": {
        "id": "k5712f3x9",
        "name": "Léa Martin"
      },
      "reporter": {
        "type": "app",
        "id": "n57a1b2c3",
        "name": "Script de facturation"
      },
      "sprint_id": null,
      "due_date": "2026-10-20",
      "labels": [
        "facturation"
      ],
      "description": "Le montant HT est faux.",
      "created_at": "2026-10-09T14:03:00.000Z",
      "updated_at": "2026-10-09T14:03:00.000Z",
      "url": "https://app.mysynkro.fr/nova/t/ACME-42"
    },
    "channel": {
      "id": "j57z9q4t1",
      "name": "client-acme"
    },
    "actor": {
      "type": "person",
      "id": "k5712f3x9",
      "name": "Léa Martin",
      "external": false
    }
  }
}

data est un instantané pris à l'émission, avec les mêmes formes que l'API. La charge fait 64 Kio au plus : au-delà, le texte d'un message ou d'un commentaire est tronqué et marqué truncated: true.

Vérifier la signature

Synkro suit la spécification Standard Webhooks. La signature est base64(HMAC-SHA256(clé, "<webhook-id>.<webhook-timestamp>.<corps>")), dans l'en-tête webhook-signature: v1,<signature>. La clé est le secret whsec_… sans son préfixe, décodé du base64. Vérifie sur le corps brut, avant tout décodage JSON, refuse un webhook-timestamp à plus de 5 minutes de ton heure, et compare en temps constant. Pendant la rotation d'un secret, qui dure 24 h, l'en-tête porte deux signatures séparées par une espace : accepte la requête si l'une des deux correspond.

Node

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return headers["webhook-signature"].split(" ").some((part) => {
    const given = Buffer.from(part.replace(/^v1,/, ""), "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Python

import base64, hashlib, hmac, time

def verify(secret, headers, raw_body):
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(part.removeprefix("v1,"), expected)
        for part in headers["webhook-signature"].split(" ")
    )

PHP

function verify(string $secret, array $headers, string $rawBody): bool {
    $id = $headers["webhook-id"];
    $timestamp = $headers["webhook-timestamp"];
    if (abs(time() - (int) $timestamp) > 300) return false;
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac("sha256", "$id.$timestamp.$rawBody", $key, true));
    foreach (explode(" ", $headers["webhook-signature"]) as $part) {
        if (hash_equals($expected, preg_replace('/^v1,/', '', $part))) return true;
    }
    return false;
}

Relances, doublons et rejeu

  • Réponds par un code 2xx, en 10 s au plus. Tout autre résultat (délai dépassé, erreur réseau, 408, 429, 4xx, 5xx) est relancé.
  • Relances : 8 tentatives en tout, après 0, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h et 24 h, soit environ 45 h. Un Retry-After en 429 ou 503 est respecté.
  • Un code 410 Gone désactive la destination tout de suite. Une destination sans aucun succès depuis 24 h, après au moins 10 échecs, est désactivée, et les administrateurs en sont prévenus par e-mail.
  • Livraison au moins une fois : un même événement peut arriver deux fois. Déduplique sur le champ id, identique d'une tentative et d'un rejeu à l'autre.
  • Aucun ordre n'est garanti : une relance peut arriver après un événement plus récent. Trie sur created_at.
  • L'historique des 30 derniers jours, le bouton « Rejouer » d'une livraison et l'envoi d'un ping de test sont dans l'écran de la destination. Les adresses internes ou privées sont refusées.
Une conversation confidentielle n'émet aucun événement, et un message privé n'en émet jamais.

Abonnements par l'API

Un outil peut créer lui-même ses webhooks sortants avec sa clé (« REST Hooks »), par exemple pour un déclencheur « quand un ticket est créé ».

curl -X POST "https://integrations.mysynkro.fr/v1/webhooks" \
  -H "Authorization: Bearer syk_key_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://exemple.fr/synkro", "events": ["ticket.created"] }'

La réponse (201) contient id, url, events, channel_ids et le secret whsec_…, rendu cette seule fois. Si tu rejoues la création avec la même Idempotency-Key, tu reçois le même abonnement sans secret : garde celui de la première réponse. channel_ids est facultatif : sans lui, l'abonnement couvre tous les canaux ouverts à la clé. Les autres routes :

  • GET /v1/webhooks et GET /v1/webhooks/{id} (read) ;
  • DELETE /v1/webhooks/{id} (write, réponse 204) ;
  • POST /v1/webhooks/{id}/test (write) : envoie un événement ping.

Un abonnement est un webhook sortant comme les autres : mêmes événements, même signature, mêmes relances. Il apparaît dans Administration › Intégrations sous l'intégration, et un administrateur peut le supprimer. Chaque intégration a 10 abonnements au plus. Créer, supprimer et tester demandent la portée write.

Le détail de chaque route est dans la référence de l'API.