Facturino / Documentation / Webhooks

Webhooks

Les webhooks permettent à Facturino de notifier votre serveur en temps réel quand un événement se produit (facture payée, devis accepté, dépôt rejeté par la PA…). C'est l'alternative event-driven au polling, sans consommation de quota d'API.

Configurer un endpoint

Créez un endpoint depuis votre tableau de bord (Paramètres → Webhooks) ou via l'API :

POST /v1/webhook-endpoints
{
  "url": "https://api.your-app.com/facturino-webhook",
  "events": ["invoice.paid", "invoice.rejected", "quote.accepted"],
  "description": "Synchronisation finance"
}

À la création, Facturino retourne le champ secret à conserver en lieu sûr — il sert à signer toutes les notifications. Le secret n'est renvoyé en clair qu'à la création (masqué sur les lectures suivantes) ; en cas de perte, faites tourner l'endpoint.

L'URL doit être publique et résolvable. L'endpoint est vérifié à la création : HTTPS obligatoire, hôte résolvable en DNS vers une adresse publique. Les hôtes internes (localhost, IP privées) ou non résolvables sont refusés avec un 400 invalid_field_value. En développement, exposez votre serveur local via un tunnel (ngrok, cloudflared).

Format de la requête

Chaque notification est une requête POST en JSON avec quatre headers Facturino :

POST /your-webhook-endpoint HTTP/1.1
Host: api.your-app.com
Content-Type: application/json
Facturino-Signature: t=1709891260,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Facturino-Event-Id: evt_a1b2c3d4e5f6
Facturino-Event-Type: invoice.paid
User-Agent: Facturino-Webhooks/1.0
HeaderDescription
Facturino-SignatureSignature HMAC-SHA256 (format t={ts},v1={sig})
Facturino-Event-IdIdentifiant unique de l'événement (préfixe evt_) — utilisez-le pour la déduplication côté consommateur
Facturino-Event-TypeType de l'événement (ex: invoice.paid) — pratique pour router sans parser le body
User-AgentFacturino-Webhooks/1.0

Payload

Tous les événements partagent la même structure :

{
  "id": "evt_a1b2c3d4e5f6",
  "object": "event",
  "type": "invoice.paid",
  "livemode": true,
  "apiVersion": "2026-03-01",
  "created": "2026-04-22T13:48:10.000Z",
  "data": {
    "id": "inv_8f2k4m9n",
    "object": "invoice",
    "status": "paid",
    "previous_status": "approved"
  },
  "request": null
}

Vérifier la signature

La signature suit le format Stripe : t={timestamp_unix},v1={hmac_sha256_hex} avec un payload signé "{ts}.{raw_body}".

Critique : vérifiez la signature à partir du corps brut de la requête HTTP, pas du JSON re-sérialisé. Une re-sérialisation modifie l'ordre des clés ou les espaces et casse la vérification. Configurez votre framework pour préserver l'octet-stream original (ex: express.raw()).

Node.js + Express :

import crypto from 'crypto'
import express from 'express'

const WEBHOOK_SECRET = process.env.FACTURINO_WEBHOOK_SECRET

function verifySignature(rawBody, header, tolerance = 300) {
  if (!header) return false

  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('=')),
  )
  const ts = Number(parts.t)
  const sig = parts.v1
  if (!ts || !sig) return false

  // Anti-replay : rejette les requêtes trop anciennes
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false

  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${ts}.${rawBody}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex'),
  )
}

// IMPORTANT : utiliser express.raw() pour préserver l'octet-stream original
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifySignature(req.body.toString(), req.headers['facturino-signature'])) {
    return res.status(401).end()
  }

  const event = JSON.parse(req.body.toString())
  switch (event.type) {
    case 'invoice.paid':
      markOrderPaid(event.data.id)
      break
    case 'invoice.rejected':
      alertFinanceTeam(event.data.id)
      break
  }
  // Toujours répondre 200 rapidement — traitement async si besoin
  res.status(200).end()
})

Python + Flask :

import hmac, hashlib, time
from flask import Flask, request, abort

WEBHOOK_SECRET = os.environ["FACTURINO_WEBHOOK_SECRET"]

def verify_signature(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts.get("t", 0))
    sig = parts.get("v1", "")
    if not ts or not sig:
        return False
    if abs(time.time() - ts) > tolerance:
        return False

    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        f"{ts}.{raw_body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

@app.post("/webhook")
def webhook():
    if not verify_signature(request.get_data(), request.headers.get("Facturino-Signature")):
        abort(401)
    event = request.get_json()
    if event["type"] == "invoice.paid":
        mark_order_paid(event["data"]["id"])
    return "", 200

PHP :

function verifySignature(string $payload, ?string $header, int $tolerance = 300): bool {
    if (!$header) return false;
    parse_str(str_replace(',', '&', $header), $parts);
    $ts = (int) ($parts['t'] ?? 0);
    $sig = $parts['v1'] ?? '';
    if (!$ts || !$sig) return false;
    if (abs(time() - $ts) > $tolerance) return false;

    $expected = hash_hmac('sha256', "$ts.$payload", getenv('FACTURINO_WEBHOOK_SECRET'));
    return hash_equals($expected, $sig);
}

Go :

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "strings"
  "time"
)

func VerifySignature(payload []byte, header, secret string, tolerance time.Duration) bool {
  if header == "" { return false }
  var ts int64; var sig string
  for _, p := range strings.Split(header, ",") {
    kv := strings.SplitN(p, "=", 2)
    switch kv[0] {
    case "t": fmt.Sscan(kv[1], &ts)
    case "v1": sig = kv[1]
    }
  }
  if time.Since(time.Unix(ts, 0)) > tolerance { return false }

  mac := hmac.New(sha256.New, []byte(secret))
  fmt.Fprintf(mac, "%d.%s", ts, payload)
  expected := hex.EncodeToString(mac.Sum(nil))
  expectedBytes, _ := hex.DecodeString(expected)
  sigBytes, _ := hex.DecodeString(sig)
  return hmac.Equal(expectedBytes, sigBytes)
}

Retry et livraison

Facturino considère la livraison réussie quand votre endpoint retourne 2xx en moins de 30 secondes. Toute autre réponse (timeout, 5xx, erreur réseau) déclenche un retry avec backoff exponentiel.

TentativeDélai depuis l'échec précédent
10 s (immédiat)
2+1 min
3+5 min
4+30 min
5+2 h

Après 5 tentatives échouées (≈ 2 h 36 min cumulés au plus), l'événement est marqué delivered: false et un email est envoyé à l'administrateur de l'endpoint. L'endpoint reste actif — vous pouvez rejouer l'événement à la main depuis Paramètres → Webhooks ou via POST /v1/events/:id/retry après avoir corrigé votre serveur.

Idempotence côté consommateur

Comme tout système distribué avec retry, Facturino garantit une livraison at-least-once — votre endpoint peut recevoir le même événement plusieurs fois. Stockez les Facturino-Event-Id traités dans une table d'idempotence avec un TTL de quelques jours :

-- Schéma SQL simple
CREATE TABLE webhook_events_processed (
  event_id VARCHAR(64) PRIMARY KEY,
  processed_at TIMESTAMP DEFAULT NOW()
);
-- À la réception
INSERT INTO webhook_events_processed (event_id) VALUES ('evt_a1b2c3')
ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
-- Si rien n'est retourné, l'événement a déjà été traité → ignorer

Types d'événements

Factures

ÉvénementDéclencheur
invoice.createdCréation d'un brouillon
invoice.finalizedFinalisation (numéro attribué)
invoice.sentEnvoi via PA ou téléchargement manuel
invoice.sendingDépôt en cours auprès de la PA
invoice.depositedDépôt confirmé par la PA
invoice.transmittedTransmission à la DGFiP
invoice.availableMise à disposition de l'acheteur
invoice.receivedPrise en charge par l'acheteur
invoice.approvedApprouvée par l'acheteur
invoice.refusedRefusée par l'acheteur
invoice.suspendedSuspendue par l'acheteur
invoice.rejectedRejetée par la PA (le référent est dans data ; récupérez le motif via GET /v1/invoices/:id)
invoice.paidPaiement intégral
invoice.partially_paidPaiement partiel
invoice.overdueDate d'échéance dépassée sans paiement complet

Devis

ÉvénementDéclencheur
quote.createdCréation d'un brouillon
quote.sentEnvoi au client
quote.viewedConsultation du devis par le client
quote.acceptedAcceptation
quote.refusedRefus
quote.expiredDate de validité dépassée
quote.convertedConversion en facture

Avoirs

ÉvénementDéclencheur
credit_note.createdCréation d'un brouillon
credit_note.finalizedFinalisation
credit_note.sentEnvoi
credit_note.credit_depositedDépôt PA
credit_note.credit_transmittedTransmission à la DGFiP
credit_note.credit_approvedApprobation acheteur
credit_note.credit_refusedRefus acheteur

Factures reçues (achat)

ÉvénementDéclencheur
invoice.incoming.receivedRéception d'une facture entrante via PA

Paiements

ÉvénementDéclencheur
payment.createdCréation d'un paiement
payment.receivedEncaissement (manuel ou Stripe)

Autres

ÉvénementDéclencheur
customer.created / updated / deletedCycle de vie client
ereporting.submittedDéclaration e-reporting transmise
recurring_invoice.generated / failedGénération d'une facture récurrente
export.readyExport FEC / RGPD disponible au téléchargement
subscription.created / cancelled / paused / renewedCycle de vie de votre abonnement Facturino

Tester un webhook

Depuis le tableau de bord, vous pouvez :

  • Envoyer un événement de test (ping) à votre endpoint
  • Rejouer un événement passé via POST /v1/events/:id/retry
  • Inspecter les tentatives de livraison via GET /v1/events/:id

Pour le développement local, utilisez un tunnel HTTPS comme ngrok ou cloudflared :

# Exposer localhost:3000 sur une URL publique HTTPS
ngrok http 3000
# Coller l'URL https://xxxx.ngrok.io/webhook dans le dashboard Facturino

Restriction par IP

Les webhooks Facturino sont émis depuis des plages d'IPs stables si vous avez besoin de les ajouter à un allowlist. Demandez la liste à jour au support — la signature reste la méthode d'authentification primaire.

Étapes suivantes