> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pulse-sell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sécurité et signature

> Vérifier qu'un envoi vient bien de Pulse-Sell, en Node.js, Python et PHP.

N'importe qui peut envoyer une requête à l'adresse de votre serveur. La signature prouve qu'un envoi vient bien de Pulse-Sell, et n'a pas été modifié ni rejoué.

## En-têtes de chaque envoi

| En-tête | Contenu |
| - | - |
| `X-PulseSell-Signature` | `t=<secondes>,v1=<HMAC-SHA256 hexadécimal>` |
| `X-PulseSell-Event` | Nom de l'événement, par exemple `sale.completed`. |
| `X-PulseSell-Event-Id` | Identifiant **stable** de l'événement : dédoublonnez dessus. |
| `X-PulseSell-Delivery` | Identifiant de cet envoi précis. |
| `X-PulseSell-Attempt` | Numéro de la tentative (1 à 5). |
| `User-Agent` | `PulseSell-Pulse/1.0` |

## Comment la signature est calculée

Pulse-Sell calcule un HMAC-SHA256 du texte `<t>.<corps brut>` avec le secret de votre Pulse (celui qui commence par `pulse_whsec_`). Le résultat, en hexadécimal, est placé dans `v1`, et l'horodatage `t` est **dans** le texte signé : un attaquant ne peut pas le changer.

**1. Lisez le corps brut**

Prenez les octets reçus **avant** tout parsing JSON. Ré-encoder un objet change les espaces et l'ordre, et la signature ne correspondra plus.

**2. Extrayez t et v1**

Découpez l'en-tête sur la virgule, puis sur le signe égal.

**3. Refusez les envois anciens**

Si `t` s'écarte de plus de **5 minutes** de l'heure de votre serveur, refusez : c'est un rejeu.

**4. Recalculez et comparez**

Calculez le HMAC de `t + "." + corps`, puis comparez en **temps constant**.

**5. Répondez 2xx, puis traitez**

Ne parsez le JSON qu'après vérification. Répondez vite, et faites le travail long en arrière-plan.

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.PULSE_SECRET; // pulse_whsec_...

function verify(rawBody, header, secret, toleranceSec = 300) {
  if (!header) return false;
  const p = Object.fromEntries(header.split(",").map((s) => s.trim().split("=")));
  if (!p.t || !p.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(p.t)) > toleranceSec) return false; // rejoué ?
  const mac = crypto.createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
  const a = Buffer.from(mac);
  const b = Buffer.from(p.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// IMPORTANT : le corps BRUT, pas express.json()
app.post("/webhooks/pulse-sell", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("x-pulsesell-signature"), SECRET)) return res.sendStatus(400);

  const event = JSON.parse(raw);
  // Dédoublonnez sur event.id : un envoi peut arriver plus d'une fois
  console.log(event.event, event.id);
  res.sendStatus(200);
});
```

```python Python (Flask) theme={null}
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PULSE_SECRET"]  # pulse_whsec_...

def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        t, v1 = parts["t"], parts["v1"]
    except (AttributeError, KeyError, ValueError):
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False  # rejoué ?
    mac = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, v1)

@app.post("/webhooks/pulse-sell")
def hook():
    raw = request.get_data()  # corps brut
    if not verify(raw, request.headers.get("X-PulseSell-Signature", ""), SECRET):
        abort(400)
    event = request.get_json()
    # Dédoublonnez sur event["id"]
    return "", 200
```

```php PHP theme={null}
<?php
$secret = getenv('PULSE_SECRET'); // pulse_whsec_...
$raw = file_get_contents('php://input');           // corps brut
$header = $_SERVER['HTTP_X_PULSESELL_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $p) {
    [$k, $v] = array_pad(explode('=', trim($p), 2), 2, '');
    $parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';

$fresh = ctype_digit($t) && abs(time() - (int) $t) <= 300;      // rejoué ?
$mac = hash_hmac('sha256', $t . '.' . $raw, $secret);

if (!$fresh || !hash_equals($mac, $v1)) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
// Dédoublonnez sur $event['id']
http_response_code(200);
```

## Bonnes pratiques de sécurité

* **Un secret par Pulse.** Il est distinct de votre clé d'API : le compromettre ne donne aucun accès à l'API.
* **Stockez le secret dans une variable d'environnement**, jamais dans le code.
* **Utilisez toujours le corps brut** pour la vérification.
* **Comparez en temps constant** (`timingSafeEqual`, `compare_digest`, `hash_equals`), jamais avec `==`.
* **Dédoublonnez** : un même événement peut être livré plus d'une fois (relance, réponse perdue). Gardez l'identifiant `event.id` (ou `X-PulseSell-Event-Id`) déjà traité.
* **Ne vous fiez pas à l'adresse IP** de l'émetteur : fiez-vous à la signature.
* **Servez votre endpoint en HTTPS uniquement.**
* **Ne journalisez pas** les e-mails de vos clients en clair.

## Changer le secret

Si le secret a fuité, régénérez-le : `POST /pulses/{id}/rotate-secret` (permission `pulses.write`) ou dans le tableau de bord. L'ancien secret cesse de signer **immédiatement** ; le nouveau est affiché une seule fois. Mettez à jour votre serveur aussitôt : les envois signés avec le nouveau secret échoueront à la vérification tant que vous ne l'avez pas fait, puis seront relancés.

> [!WARNING]
>
> Si vous avez migré depuis un ancien webhook, sachez que la méthode de signature est différente : l'horodatage fait maintenant partie du texte signé.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.