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

# Recevoir et vérifier les rappels

> Umeme signe chaque rappel avec sa clé privée. Vous le vérifiez avec la clé publique de GET /v1/cle-publique : un rappel qui ne passe pas la vérification n'est pas d'Umeme, et vous l'ignorez.

## 1. Pourquoi une clé publique

> [card] Aucun secret à garder
>
> Vous ne détenez qu'une clé publique : personne ne peut signer un rappel à la place d'Umeme, ni pour vous, ni pour un autre intégrateur.

> [card] Umeme change de clé sans vous
>
> Chaque signature nomme sa clé (`keyid`), et deux clés restent actives pendant un changement.

> [card] Le corps est couvert
>
> La signature porte sur le condensat du corps : un rappel modifié en route échoue à la vérification.

> [!NOTE]
>
> Umeme ne promet aucune adresse IP de départ : ses serveurs sortent par plusieurs liens. La signature est votre seule garantie d'origine.

## 2. Ce que vous recevez

Quand une vente atteint son statut final, Umeme envoie un `POST` en HTTPS à l'URL de rappel de votre fiche. Le corps est celui de `GET /v1/ventes/{uuid}`.

```http Rappel reçu par votre serveur {4,5,6} theme={"dark"}
POST /rappels/umeme HTTP/1.1
Host: votre-serveur.example
Content-Type: application/json
Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Signature-Input: umeme=("@method" "@target-uri" "content-digest");created=1793782407;keyid="umeme-2026-11";alg="ecdsa-p256-sha256"
Signature: umeme=:MEUCIQDkF3m0pQ1yKu2…3fVw==:
Umeme-Evenement-Id: evt_01JC9W3Z7Q8T5N2M4K6P0R1S3V

{"uuid": "94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb", "statut": "REUSSIE", "jeton": "47035727296518417129"}
```

| En-tête | Ce qu'il porte |
| :- | :- |
| `Content-Digest` | Le SHA-256 du corps brut, en base64 (RFC 9530). |
| `Signature-Input` | Ce que la signature couvre, son heure (`created`) et sa clé (`keyid`). |
| `Signature` | La signature ECDSA P-256, en base64 (RFC 9421). |
| `Umeme-Evenement-Id` | L'identifiant de cet événement, le même à chaque tentative : il sert à écarter les doublons. |

Répondez `200`. Sinon, Umeme réessaie huit fois en 25 minutes :

**0:00**1ʳᵉ**1:00**2ᵉ**2:00**3ᵉ**5:00**4ᵉ**10:00**5ᵉ**15:00**6ᵉ**20:00**7ᵉ**25:00**8ᵉ

Chaque tentative attend `10 s` la connexion et `30 s` la réponse. Après la huitième, le rappel se renvoie à la demande pendant 90 jours.

## 3. Vérifier un rappel, étape par étape

Huit étapes, toutes avant de toucher à la vente.

**1. Garder le corps brut**

Lisez les octets reçus, avant tout décodage JSON. Un JSON relu puis réécrit ne donne plus le même condensat.

**2. Vérifier le condensat**

Calculez le SHA-256 du corps brut, encodez-le en base64, et comparez `sha-256=:…:` à `Content-Digest`. Différent : le corps a changé en route, refusez.

**3. Lire Signature-Input**

Il dit quels éléments sont signés, quand (`created`, en secondes Unix) et avec quelle clé (`keyid`). Refusez une signature de plus de 5 minutes.

**4. Reconstruire la base de signature**

C'est le texte qu'Umeme a signé : une ligne par élément, dans l'ordre de `Signature-Input`, séparées par un retour à la ligne, sans retour final.

```text Base de signature theme={"dark"}
"@method": POST
"@target-uri": https://votre-serveur.example/rappels/umeme
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"@signature-params": ("@method" "@target-uri" "content-digest");created=1793782407;keyid="umeme-2026-11";alg="ecdsa-p256-sha256"
```

`@target-uri` est votre URL de rappel, telle qu'enregistrée dans votre fiche. `@signature-params` se recopie tel quel de `Signature-Input`, après `umeme=`.

**5. Prendre la clé du keyid**

Cherchez le `keyid` dans la réponse de `GET /v1/cle-publique`, gardée en cache (section 5).

**6. Vérifier la signature**

ECDSA sur la courbe P-256, avec SHA-256. La signature est brute : `r` puis `s`, 64 octets. Certaines bibliothèques attendent du DER : le code ci-dessous convertit quand il le faut.

**7. Écarter les doublons**

Un `Umeme-Evenement-Id` déjà traité : répondez `200` sans rien refaire.

**8. Répondre, puis traiter**

Répondez `200` en moins de 30 secondes, puis traitez le statut de la vente. Signature fausse : répondez `401` et ne touchez à rien.

**9. Et voilà !**

Vous ne croyez plus que les rappels d'Umeme. Voici le même vérificateur, prêt à copier.

## 4. Le code, prêt à copier

```text Pseudo-code theme={"dark"}
à la réception d'un rappel
  corps ← les octets reçus, avant tout décodage JSON
  si sha-256(corps) ≠ Content-Digest → refuser
  params ← Signature-Input, après « umeme= »
  si created date de plus de 5 minutes → refuser
  base ← "@method", "@target-uri", "content-digest", "@signature-params"
  clé ← GET /v1/cle-publique, par keyid
  si ECDSA P-256 (base, Signature, clé) est fausse → refuser
  sinon → le rappel vient d'Umeme
```

```javascript Node.js icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/nodejs.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=60e7eb54ba25d5cb5e9d62e0896b99c7" theme={"dark"}
import { createHash, createPublicKey, verify } from "node:crypto";

// cles : la réponse de GET /v1/cle-publique, gardée en cache.
export function verifierRappel(entetes, corpsBrut, urlDeRappel, cles) {
  // Étape 2 : le corps n'a pas changé en route.
  const condensat = createHash("sha256").update(corpsBrut).digest("base64");
  if (entetes["content-digest"] !== `sha-256=:${condensat}:`) {
    return false;
  }

  // Étape 3 : ce que la signature couvre, quand, avec quelle clé.
  const entree = entetes["signature-input"];
  const params = entree.slice(entree.indexOf("=") + 1);
  const keyid = /keyid="([^"]+)"/.exec(params)[1];
  const created = Number(/created=(\d+)/.exec(params)[1]);
  if (Math.abs(Date.now() / 1000 - created) > 300) {
    return false;
  }

  // Étape 4 : la base de signature.
  const base = [
    '"@method": POST',
    `"@target-uri": ${urlDeRappel}`,
    `"content-digest": ${entetes["content-digest"]}`,
    `"@signature-params": ${params}`,
  ].join("\n");

  // Étape 5 : la clé du keyid. Inconnue : rechargez GET /v1/cle-publique.
  const cle = cles.find((c) => c.keyid === keyid);
  if (!cle) {
    return false;
  }

  // Étape 6 : ECDSA P-256 sur la signature brute (r puis s).
  const signature = Buffer.from(/:(.+):/.exec(entetes["signature"])[1], "base64");
  return verify(
    "sha256",
    Buffer.from(base),
    { key: createPublicKey(cle.cle), dsaEncoding: "ieee-p1363" },
    signature,
  );
}
```

```python Python icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/python.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=41e570f5c5c97d07c3c878a6704f2bed" theme={"dark"}
import base64
import hashlib
import re
import time

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature


# cles : la réponse de GET /v1/cle-publique, gardée en cache.
def verifier_rappel(entetes, corps_brut, url_de_rappel, cles):
    # Étape 2 : le corps n'a pas changé en route.
    condensat = base64.b64encode(hashlib.sha256(corps_brut).digest()).decode()
    if entetes["Content-Digest"] != f"sha-256=:{condensat}:":
        return False

    # Étape 3 : ce que la signature couvre, quand, avec quelle clé.
    params = entetes["Signature-Input"].split("=", 1)[1]
    keyid = re.search(r'keyid="([^"]+)"', params).group(1)
    created = int(re.search(r"created=(\d+)", params).group(1))
    if abs(time.time() - created) > 300:
        return False

    # Étape 4 : la base de signature.
    base = "\n".join([
        '"@method": POST',
        f'"@target-uri": {url_de_rappel}',
        f'"content-digest": {entetes["Content-Digest"]}',
        f'"@signature-params": {params}',
    ])

    # Étape 5 : la clé du keyid. Inconnue : rechargez GET /v1/cle-publique.
    cle = next((c for c in cles if c["keyid"] == keyid), None)
    if cle is None:
        return False

    # Étape 6 : la bibliothèque attend du DER ; la RFC donne r puis s.
    brute = base64.b64decode(re.search(r":(.+):", entetes["Signature"]).group(1))
    r = int.from_bytes(brute[:32], "big")
    s = int.from_bytes(brute[32:], "big")
    publique = serialization.load_pem_public_key(cle["cle"].encode())
    try:
        publique.verify(encode_dss_signature(r, s), base.encode(), ec.ECDSA(hashes.SHA256()))
    except Exception:
        return False
    return True
```

```php PHP icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/php.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=706823b5881c1d4e78720e369520cc78" theme={"dark"}
<?php

// $cles : la réponse de GET /v1/cle-publique, gardée en cache.
function verifierRappel(array $entetes, string $corpsBrut, string $urlDeRappel, array $cles): bool
{
    // Étape 2 : le corps n'a pas changé en route.
    $condensat = base64_encode(hash('sha256', $corpsBrut, true));
    if ($entetes['content-digest'] !== "sha-256=:$condensat:") {
        return false;
    }

    // Étape 3 : ce que la signature couvre, quand, avec quelle clé.
    $entree = $entetes['signature-input'];
    $params = substr($entree, strpos($entree, '=') + 1);
    preg_match('/keyid="([^"]+)"/', $params, $keyid);
    preg_match('/created=(\d+)/', $params, $created);
    if (abs(time() - (int) $created[1]) > 300) {
        return false;
    }

    // Étape 4 : la base de signature.
    $base = implode("\n", [
        '"@method": POST',
        '"@target-uri": ' . $urlDeRappel,
        '"content-digest": ' . $entetes['content-digest'],
        '"@signature-params": ' . $params,
    ]);

    // Étape 5 : la clé du keyid. Inconnue : rechargez GET /v1/cle-publique.
    $cle = current(array_filter($cles, fn ($c) => $c['keyid'] === $keyid[1]));
    if (!$cle) {
        return false;
    }

    // Étape 6 : openssl attend du DER ; la RFC donne r puis s.
    preg_match('/:(.+):/', $entetes['signature'], $signature);
    $der = brutVersDer(base64_decode($signature[1]));
    return openssl_verify($base, $der, $cle['cle'], OPENSSL_ALGO_SHA256) === 1;
}

function brutVersDer(string $brute): string
{
    $entier = function (string $octets): string {
        $octets = ltrim($octets, "\x00");
        if (ord($octets[0]) > 0x7f) {
            $octets = "\x00" . $octets;
        }
        return "\x02" . chr(strlen($octets)) . $octets;
    };
    $suite = $entier(substr($brute, 0, 32)) . $entier(substr($brute, 32));
    return "\x30" . chr(strlen($suite)) . $suite;
}
```

```java Java icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/java.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=a99792afd2b2bcd5b3c64f5157b8469e" theme={"dark"}
// cles : la réponse de GET /v1/cle-publique, gardée en cache.
static boolean verifierRappel(Map<String, String> entetes, byte[] corpsBrut,
        String urlDeRappel, List<CleUmeme> cles) throws Exception {
    // Étape 2 : le corps n'a pas changé en route.
    var condensat = Base64.getEncoder().encodeToString(
        MessageDigest.getInstance("SHA-256").digest(corpsBrut));
    if (!entetes.get("content-digest").equals("sha-256=:" + condensat + ":")) {
        return false;
    }

    // Étape 3 : ce que la signature couvre, quand, avec quelle clé.
    var entree = entetes.get("signature-input");
    var params = entree.substring(entree.indexOf('=') + 1);
    var keyid = extraire(params, "keyid=\"([^\"]+)\"");
    var created = Long.parseLong(extraire(params, "created=(\\d+)"));
    if (Math.abs(Instant.now().getEpochSecond() - created) > 300) {
        return false;
    }

    // Étape 4 : la base de signature.
    var base = String.join("\n",
        "\"@method\": POST",
        "\"@target-uri\": " + urlDeRappel,
        "\"content-digest\": " + entetes.get("content-digest"),
        "\"@signature-params\": " + params);

    // Étape 5 : la clé du keyid. Inconnue : rechargez GET /v1/cle-publique.
    var cle = cles.stream().filter(c -> c.keyid().equals(keyid)).findFirst();
    if (cle.isEmpty()) {
        return false;
    }

    // Étape 6 : Java lit directement la signature brute (r puis s).
    var brute = Base64.getDecoder().decode(extraire(entetes.get("signature"), ":(.+):"));
    var verificateur = Signature.getInstance("SHA256withECDSAinP1363Format");
    verificateur.initVerify(cle.get().publique());
    verificateur.update(base.getBytes(StandardCharsets.UTF_8));
    return verificateur.verify(brute);
}

static String extraire(String texte, String motif) {
    var m = Pattern.compile(motif).matcher(texte);
    if (!m.find()) {
        throw new IllegalArgumentException(motif);
    }
    return m.group(1);
}
```

> [!TIP]
>
> Le corps doit rester brut jusqu'à la vérification. Avec Express, prenez `express.raw()` sur la route du rappel ; avec Laravel, `$request->getContent()`.

La version Node.js est vérifiée par les tests de ce site contre de vraies signatures ECDSA P-256 : rappel valide, corps modifié, signature trop ancienne, clé inconnue, autre URL de rappel.

## 5. Rotation des clés

`GET /v1/cle-publique` renvoie toutes les clés actives. Pendant un changement, il y en a deux : la nouvelle signe, l'ancienne reste valable jusqu'à sa date de fin.

```json GET /v1/cle-publique {4,13} theme={"dark"}
{
  "cles": [
    {
      "keyid": "umeme-2026-11",
      "alg": "ecdsa-p256-sha256",
      "cle": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…\n-----END PUBLIC KEY-----",
      "depuis": "2026-11-01T00:00:00Z"
    },
    {
      "keyid": "umeme-2026-05",
      "alg": "ecdsa-p256-sha256",
      "cle": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----",
      "jusqua": "2026-11-30T00:00:00Z"
    }
  ]
}
```

* Gardez la liste en cache 24 heures.
* Un `keyid` inconnu ? Rechargez la liste **une fois**, puis refusez le rappel s'il manque encore.
* N'écrivez jamais la clé en dur dans votre code : vous rateriez le prochain changement.

## 6. Répondre et écarter les doublons

Un rappel peut arriver deux fois, si la réponse à une tentative s'est perdue. Rendez son traitement idempotent :

```text Pseudo-code theme={"dark"}
à la réception d'un rappel
  si la signature est fausse → répondre 401, ne rien faire
  si evenementId est déjà vu → répondre 200
  enregistrer(evenementId)
  mettre le traitement en file
  // répondre en moins de 30 s
  répondre 200
```

```javascript Node.js icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/nodejs.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=60e7eb54ba25d5cb5e9d62e0896b99c7" theme={"dark"}
import express from "express";

import { verifierRappel } from "./verifier-rappel.mjs";

const app = express();

// Le corps reste brut jusqu'à la vérification de la signature.
app.post("/rappels/umeme", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifierRappel(req.headers, req.body, process.env.URL_DE_RAPPEL, await clesPubliques())) {
    return res.sendStatus(401);
  }
  const evenementId = req.headers["umeme-evenement-id"];
  if (await evenements.dejaVu(evenementId)) {
    return res.sendStatus(200);
  }
  await evenements.enregistrer(evenementId);
  // Le traitement part en file : la réponse doit partir en moins de 30 s.
  await file.ajouter(JSON.parse(req.body));
  res.sendStatus(200);
});
```

```python Python icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/python.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=41e570f5c5c97d07c3c878a6704f2bed" theme={"dark"}
import json
import os

from flask import Flask, request

from verifier_rappel import verifier_rappel

app = Flask(__name__)


@app.post("/rappels/umeme")
def recevoir_rappel():
    # Le corps reste brut jusqu'à la vérification de la signature.
    corps_brut = request.get_data()
    if not verifier_rappel(request.headers, corps_brut, os.environ["URL_DE_RAPPEL"], cles_publiques()):
        return "", 401
    evenement_id = request.headers["Umeme-Evenement-Id"]
    if evenements.deja_vu(evenement_id):
        return "", 200
    evenements.enregistrer(evenement_id)
    # Le traitement part en file : la réponse doit partir en moins de 30 s.
    file.ajouter(json.loads(corps_brut))
    return "", 200
```

```php PHP icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/php.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=706823b5881c1d4e78720e369520cc78" theme={"dark"}
<?php

// Route POST /rappels/umeme. Le corps reste brut jusqu'à la vérification de la signature.
$corpsBrut = file_get_contents('php://input');
$entetes = array_change_key_case(getallheaders(), CASE_LOWER);

if (!verifierRappel($entetes, $corpsBrut, getenv('URL_DE_RAPPEL'), clesPubliques())) {
    http_response_code(401);
    exit;
}
$evenementId = $entetes['umeme-evenement-id'];
if ($evenements->dejaVu($evenementId)) {
    http_response_code(200);
    exit;
}
$evenements->enregistrer($evenementId);
// Le traitement part en file : la réponse doit partir en moins de 30 s.
$file->ajouter(json_decode($corpsBrut, true));
http_response_code(200);
```

```java Java icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/java.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=a99792afd2b2bcd5b3c64f5157b8469e" theme={"dark"}
// Spring Boot. Le corps reste brut jusqu'à la vérification de la signature.
@PostMapping("/rappels/umeme")
ResponseEntity<Void> recevoirRappel(@RequestHeader Map<String, String> entetes, @RequestBody byte[] corpsBrut)
        throws Exception {
    if (!verifierRappel(entetes, corpsBrut, urlDeRappel, clesPubliques())) {
        return ResponseEntity.status(401).build();
    }
    var evenementId = entetes.get("umeme-evenement-id");
    if (evenements.dejaVu(evenementId)) {
        return ResponseEntity.ok().build();
    }
    evenements.enregistrer(evenementId);
    // Le traitement part en file : la réponse doit partir en moins de 30 s.
    file.ajouter(new String(corpsBrut, StandardCharsets.UTF_8));
    return ResponseEntity.ok().build();
}
```

## 7. Renvoyer un rappel

Votre serveur était en panne ? Redemandez le dernier rappel d'une vente, signé à nouveau, pendant 90 jours :

```bash cURL icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/bash.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=c7cc496df6d82b1c6cdaa5c132b8d4bc" theme={"dark"}
UUID="94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb"

curl -X POST "$UMEME_BASE_URL/ventes/$UUID/rappel" \
  -H "Authorization: Bearer $UMEME_CLE"
```

```javascript Node.js · fetch icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/nodejs.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=60e7eb54ba25d5cb5e9d62e0896b99c7" theme={"dark"}
// Node.js 18 et plus : fetch est intégré.
const baseUrl = process.env.UMEME_BASE_URL;

const uuid = "94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb";

const reponse = await fetch(`${baseUrl}/ventes/${uuid}/rappel`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.UMEME_CLE}`,
  },
});
const donnees = await reponse.json();
```

```javascript Node.js · Axios icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/axios.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=7e8bd7e79f903d3d1bffc290e52fcdc9" theme={"dark"}
// npm install axios
import axios from "axios";

const umeme = axios.create({
  baseURL: process.env.UMEME_BASE_URL,
  headers: { Authorization: `Bearer ${process.env.UMEME_CLE}` },
  timeout: 30000,
});

const uuid = "94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb";

const { data } = await umeme.post(`/ventes/${uuid}/rappel`);
```

```python Python icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/python.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=41e570f5c5c97d07c3c878a6704f2bed" theme={"dark"}
import os

import requests

BASE_URL = os.environ["UMEME_BASE_URL"]
ENTETES = {"Authorization": f"Bearer {os.environ['UMEME_CLE']}"}

uuid = "94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb"

reponse = requests.post(
    f"{BASE_URL}/ventes/{uuid}/rappel",
    headers=ENTETES,
    timeout=30,
)
donnees = reponse.json()
```

```php PHP icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/php.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=706823b5881c1d4e78720e369520cc78" theme={"dark"}
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client([
    'base_uri' => getenv('UMEME_BASE_URL') . '/',
    'headers' => ['Authorization' => 'Bearer ' . getenv('UMEME_CLE')],
    'timeout' => 30,
]);

$uuid = '94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb';

$reponse = $client->post("ventes/{$uuid}/rappel");
$donnees = json_decode($reponse->getBody(), true);
```

```java Java icon="https://mintcdn.com/dommi10/MMGXqk_5twdsE4Rl/logos/langages/java.svg?fit=max&auto=format&n=MMGXqk_5twdsE4Rl&q=85&s=a99792afd2b2bcd5b3c64f5157b8469e" theme={"dark"}
// Java 15 et plus : java.net.http et blocs de texte.
var baseUrl = System.getenv("UMEME_BASE_URL");
var client = HttpClient.newHttpClient();

var uuid = "94a223fe-fb34-47f2-aaa5-b80c4bf8a8eb";

var requete = HttpRequest.newBuilder(URI.create(baseUrl + "/ventes/" + uuid + "/rappel"))
    .header("Authorization", "Bearer " + System.getenv("UMEME_CLE"))
    .POST(HttpRequest.BodyPublishers.noBody())
    .build();

var reponse = client.send(requete, HttpResponse.BodyHandlers.ofString());
```

## 8. Tester dans la sandbox

* La sandbox signe avec sa propre clé : prenez celle de `https://sandbox.umeme.app/v1/cle-publique`, jamais celle de la production.
* Le compteur `09900000002` envoie son rappel deux fois, avec le même `Umeme-Evenement-Id` : votre dédoublonnage doit écarter le second.
* Modifiez un octet du corps reçu avant de vérifier : la vérification doit échouer à l'étape 2.


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