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
| Type | Envoyé quand |
|---|---|
message.posted | Un message est publié dans un canal (personne, intégration ou système). |
ticket.created | Un ticket est créé, y compris depuis un message. |
ticket.updated | Un champ d'un ticket change, hors statut et assignation. Les champs changés par une même opération sont groupés. |
ticket.status_changed | Le statut d'un ticket change. |
ticket.assigned | L'assigné d'un ticket change, y compris retiré (to vaut alors null). |
ticket.commented | Un commentaire est ajouté à un ticket. |
member.joined | Un interne rejoint l'organisation, ou un externe rejoint un canal (channel est alors présent). Jamais d'adresse e-mail. |
call.started | Un appel de canal démarre (jamais un appel privé). |
call.ended | Un appel de canal se termine. |
file.uploaded | Une pièce jointe est prête dans un message de canal ou un ticket. Aucune adresse de téléchargement. |
ping | Envoi 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-Afteren 429 ou 503 est respecté. - Un code
410 Gonedé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
pingde test sont dans l'écran de la destination. Les adresses internes ou privées sont refusées.
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/webhooksetGET /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.