Archiver les messages reçus et télécharger les photos et documents (sauvegarde, preuve, rétention)

Les messages reçus sont stockés côté API pour la durée de votre plan puis purgés ; pour les garder, copiez chaque message.inbound et téléchargez le média tout de suite. Les messages que vous envoyez par l'API ne sont pas dans GET /v1/messages/inbound.

Archiver les messages reçus et télécharger les photos et documents (sauvegarde, preuve, rétention)

Astuce
Les messages reçus sont stockés côté API pour la durée de votre plan puis purgés ; pour les garder, copiez chaque message.inbound et téléchargez le média tout de suite. Les messages que vous envoyez par l'API ne sont pas dans GET /v1/messages/inbound.

Scénario

Un client vous envoie la photo d'un bon de commande. Vous voulez garder le message et la photo pour de bon : dans votre base, pour un litige ou un audit. Ou un client conteste un message reçu il y a trois mois. L'API garde une copie, mais pas indéfiniment. Pour conserver au-delà, vous devez copier.

Ce que l'API conserve

Chaque message reçu est stocké automatiquement, même si vous n'avez pas de webhook. Vous le relisez avec GET /v1/messages/inbound. Le stockage dure selon votre plan de stockage ; passé ce délai, une purge quotidienne supprime définitivement les messages (et leurs médias hébergés).

curl

curl -X GET "https://wa-api.geskap.com/v1/storage" \
  -H "Authorization: Bearer $GESKAP_API_KEY"

Node.js

const res = await fetch("https://wa-api.geskap.com/v1/storage", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.GESKAP_API_KEY}`,
  },
});
console.log(res.status, await res.json());

PHP

$ch = curl_init("https://wa-api.geskap.com/v1/storage");
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => "GET",
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("GESKAP_API_KEY"),
  ],
]);
$res = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $res;

Python

import os, requests

res = requests.request(
    "GET",
    "https://wa-api.geskap.com/v1/storage",
    headers={"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"},
    timeout=15,
)
print(res.status_code, res.json())

La réponse donne retention_days, le nombre de messages, la place utilisée et le plus ancien message conservé. Les plans et leurs durées sont des réglages : lisez-les dans GET /v1/public/pricing (rubrique plans de stockage) plutôt que de les écrire en dur.

Le chemin sûr : copier au fil de l'eau

message.inbound sur votre webhook
   │
   ├─► écrire le message dans votre base (clé : wa_message_id)
   └─► si media_url n'est pas null : télécharger le fichier maintenant dans votre stockage

Rattrapez les trous (panne de votre serveur) avec GET /v1/messages/inbound?since=<dernier received_at> : avec since, les messages reviennent du plus ancien au plus récent, strictement après ce curseur. Avancez le curseur au received_at du dernier message traité. Le paramètre limit va de 1 à 200 (50 par défaut).

Node.js

// Copy a received message, then its media, into YOUR storage.
import fs from "node:fs/promises";
const API = "https://wa-api.geskap.com";
const auth = { Authorization: `Bearer ${process.env.GESKAP_API_KEY}` };

export async function archive(msg) {            // msg = webhook data, or one item of GET /v1/messages/inbound
  await saveMessage(msg);                       // your DB, unique on msg.wa_message_id
  if (!msg.media_url) return;
  // The API answers 302 to a signed address valid 1 h: do not send your key to it.
  const hop = await fetch(msg.media_url, { headers: auth, redirect: "manual" });
  const signed = hop.headers.get("location");
  if (!signed) return;                          // nothing hosted for this message
  const file = await fetch(signed);
  await fs.writeFile(`archive/${msg.wa_message_id}`, Buffer.from(await file.arrayBuffer()));
}

// Catch-up after an outage: oldest first, strictly after the cursor.
export async function catchUp(cursor) {
  for (;;) {
    const res = await fetch(`${API}/v1/messages/inbound?since=${encodeURIComponent(cursor)}&limit=200`, { headers: auth });
    const { messages } = await res.json();
    if (!messages.length) return cursor;
    for (const m of messages) await archive(m);
    cursor = messages[messages.length - 1].received_at;
  }
}

PHP

<?php
// Copy a received message, then its media, into YOUR storage.
const API = "https://wa-api.geskap.com";

function api_get(string $url, bool $follow = false) {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => $follow,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("GESKAP_API_KEY")],
    ]);
    $body = curl_exec($ch);
    $redirect = curl_getinfo($ch, CURLINFO_REDIRECT_URL);
    return [$body, $redirect];
}

function archive(array $msg): void {            // $msg = webhook data, or one item of GET /v1/messages/inbound
    save_message($msg);                         // your DB, unique on wa_message_id
    if (empty($msg["media_url"])) return;
    // The API answers 302 to a signed address valid 1 h: do not send your key to it.
    [, $signed] = api_get($msg["media_url"]);
    if (!$signed) return;                       // nothing hosted for this message
    file_put_contents("archive/" . $msg["wa_message_id"], file_get_contents($signed));
}

// Catch-up after an outage: oldest first, strictly after the cursor.
function catch_up(string $cursor): string {
    while (true) {
        [$body] = api_get(API . "/v1/messages/inbound?since=" . urlencode($cursor) . "&limit=200");
        $messages = json_decode($body, true)["messages"];
        if (!$messages) return $cursor;
        foreach ($messages as $m) archive($m);
        $cursor = end($messages)["received_at"];
    }
}

Python

# Copy a received message, then its media, into YOUR storage.
import os, requests

API = "https://wa-api.geskap.com"
AUTH = {"Authorization": f"Bearer {os.environ['GESKAP_API_KEY']}"}

def archive(msg):            # msg = webhook data, or one item of GET /v1/messages/inbound
    save_message(msg)        # your DB, unique on msg["wa_message_id"]
    if not msg.get("media_url"):
        return
    # The API answers 302 to a signed address valid 1 h: do not send your key to it.
    hop = requests.get(msg["media_url"], headers=AUTH, allow_redirects=False, timeout=15)
    signed = hop.headers.get("Location")
    if not signed:
        return               # nothing hosted for this message
    with open(f"archive/{msg['wa_message_id']}", "wb") as f:
        f.write(requests.get(signed, timeout=60).content)

def catch_up(cursor):
    """After an outage: oldest first, strictly after the cursor."""
    while True:
        r = requests.get(f"{API}/v1/messages/inbound", headers=AUTH,
                         params={"since": cursor, "limit": 200}, timeout=15)
        messages = r.json()["messages"]
        if not messages:
            return cursor
        for m in messages:
            archive(m)
        cursor = messages[-1]["received_at"]

curl

# 1) Read messages after a cursor (oldest first).
curl "https://wa-api.geskap.com/v1/messages/inbound?since=2026-10-30T09:00:00%2B00:00&limit=200" \
  -H "Authorization: Bearer $GESKAP_API_KEY"

# 2) Get the signed address of a media (302, do not follow with your key).
SIGNED=$(curl -s -o /dev/null -w '%{redirect_url}' \
  "https://wa-api.geskap.com/v1/media/9d3e0000-0000-0000-0000-000000000000" \
  -H "Authorization: Bearer $GESKAP_API_KEY")

# 3) Download it right away (valid 1 h).
curl -o photo.jpg "$SIGNED"

Les médias

Et les messages que j'envoie ?

Ils ne sont pas dans GET /v1/messages/inbound. Cette route ne liste que les messages reçus (direction: "in") et ceux envoyés depuis le téléphone en Coexistence ("echo"). Pour vos envois par l'API, l'API ne garde que la trace d'envoi : GET /v1/messages/{id} (statut) et GET /v1/logs?category=sends (modèle, destinataire masqué, statut, crédits). Pas le texte envoyé. Tenez donc votre propre journal d'envoi : enregistrez, au moment de l'appel, le texte ou le template, les variables, et l'id renvoyé.

Attention
Piège : sans copie de votre côté, tout ce qui dépasse la rétention de votre plan est perdu pour de bon. La purge est définitive.
Attention
Piège : l'adresse signée du média expire au bout d'1 heure. Une adresse conservée dans votre base ne marchera plus demain : téléchargez le fichier, ou rappelez GET /v1/media/{id} pour en obtenir une nouvelle tant que le message existe.
Attention
Piège : pour ne pas traiter deux fois le même message (rattrapage après un webhook déjà reçu), dédoublonnez sur wa_message_id.

Pour le webhook, voir Vérifier la signature d'un webhook. Pour recevoir une photo dans une conversation, voir Recevoir une photo ou un document.

Preuve — vérifié dans le code de l'API : public_api.py, api_inbound.py, storage_plans.py, api_usage.py, webhooks.py.