sendpage API

Create, track and delete showcase sites programmatically, then read your prospects' link clicks from an event feed. Full v1 API reference.

https://sendpage.io/api/v1

Authentification

Créez une clé depuis vos réglages, onglet API. Elle n'est affichée qu'une fois : nous n'en conservons qu'une empreinte, donc une clé perdue se remplace, elle ne se retrouve pas.

En-tĂŞte
Authorization: Bearer sp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

N'utilisez jamais une clé depuis un navigateur. Elle porte l'identité complète de votre compte et serait lisible par quiconque inspecte la page. L'absence volontaire d'en-têtes CORS rend d'ailleurs l'appel direct impossible : c'est une garde, pas un oubli.

Crédits

Une création de site consomme un crédit, débité à la mise en file. Un job qui échoue définitivement, reprises épuisées, est remboursé. Le solde est renvoyé à chaque création dans credits_remaining, et l'API répond 402 quand il est épuisé.

Durée de vie des sites

Un site créé par l'API est une démo de prospection. Il reste en ligne 14 jours, puis passe hors ligne 7 jours : ses visiteurs voient une page d'indisponibilité, mais le site existe encore. Il est ensuite supprimé définitivement, avec les mêmes effets que DELETE /sites/{site_id}.

Pour le garder, appelez POST /sites/{site_id}/keep : il devient permanent, comme un site créé depuis le tableau de bord, pour 1 crédit débité une seule fois. C'est gratuit quand votre client paie la facture de ce site via sendpage : le paiement le garde automatiquement. Le crédit de création n'est pas remboursé à la suppression.

Les dates sont fixées à la création et ne bougent plus : ni les clics ni les modifications ne prolongent une démo. Seuls les sites créés par l'API à partir du 24 septembre 2026 sont concernés ; les sites plus anciens et ceux créés depuis le tableau de bord restent hébergés sans limite.

Champs du site
keptbooleanrequistrue si le site n'expire pas : démo gardée, ou site créé hors de ce cycle de vie. false : la démo sera supprimée à deletes_at
expires_atnumber | nullrequisFin de la période en ligne, en millisecondes ; null si le site n'expire pas
offline_atnumber | nullrequisPassage hors ligne, en millisecondes ; null tant que le site est en ligne
deletes_atnumber | nullrequisSuppression prévue, en millisecondes ; null si le site n'expire pas

Une démo non gardée ne peut recevoir ni sous-domaine, ni domaine, ni être dupliquée depuis le tableau de bord : gardez-la d'abord.

Proposez de garder le site dans votre application dès que le prospect répond, et avant deletes_at : après, le site n'existe plus (GET renvoie 404) et son lien non plus.

Après la suppression ou le renommage d'une démo, son ancienne adresse reste réservée 180 jours : un SMS déjà envoyé n'ouvrira jamais le site d'un autre compte.

Bornes

Requêtes60 par minute et par clé
Créations de site200 par heure et par clé

Les bornes sont par clé et non par compte : une intégration emballée ne bloque pas les autres. Un dépassement rend un 429, avec un en-tête Retry-After quand l'attente est connue.

Créer un site

POST/sites

Crée le site et met sa génération en file. Répond 202 Accepted, et non 200 : le site existe, son contenu pas encore. Suivez le job pour savoir quand il est prêt.

Corps de la requĂŞte
company_namestringrequis1 à 100 caractères
metierstringrequis1 à 100 caractères
villestringrequis1 à 100 caractères
telephonestringrequis6 à 30 caractères
slugstringoptionnelRéassaini côté serveur, et suffixé s'il est déjà pris. Court de préférence : il allonge le lien envoyé par SMS
palettestringoptionnelDéfaut : minimaliste
RequĂŞte
curl -X POST https://sendpage.io/api/v1/sites \
  -H "Authorization: Bearer $SENDPAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Plomberie Martin",
    "metier": "plombier",
    "ville": "Bordeaux",
    "telephone": "0556000000"
  }'
Réponse
{
  "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
  "slug": "plomberie-martin",
  "job_id": "c7d00e6e-bf48-4bb1-a0d1-6f1a7496601e",
  "status": "queued",
  "credits_remaining": 9,
  "public_url": "https://sendpage.io/demo/plomberie-martin",
  "tracking_url": "https://sendpage.io/demo/plomberie-martin?sp=Q7fK2mP9xT4a",
  "kept": false,
  "expires_at": 1789997228056,
  "deletes_at": 1790602028056
}

public_url est l'adresse publique du site ; tracking_url, la même avec le jeton de suivi, est le lien à mettre dans le SMS. Envoyez le SMS une fois le job en succeeded : avant, le prospect ouvrirait un site encore vide. Les deux champs valent null quand le site n'a aucune adresse publique qui le serve ; voir Suivi des clics. kept, expires_at et deletes_at donnent la durée de vie du site : voir Durée de vie des sites.

Suivre la génération

GET/jobs/{job_id}

Interrogez toutes les quelques secondes. Un site prend une quinzaine de secondes une fois admis, davantage si la file est chargée.

Réponse
{
  "job_id": "c7d00e6e-bf48-4bb1-a0d1-6f1a7496601e",
  "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
  "status": "succeeded",
  "attempts": 1,
  "error": null,
  "images_generated": 8,
  "images_failed": 0,
  "enqueued_at": 1788787628056,
  "finished_at": 1788787643120
}

status vaut queued, running, succeeded, failed ou cancelled. Un échec est repris automatiquement jusqu'à trois fois, avec une attente croissante. failed signifie que les reprises sont épuisées — et que le crédit a été remboursé.

Lister vos sites

GET/sites

Du plus récent au plus ancien, paginé par curseur.

Paramètres d'URL
limitnumberoptionnel1 à 100, défaut 25
cursorstringoptionnelnext_cursor de la page précédente
Réponse
{
  "sites": [
    {
      "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
      "slug": "plomberie-martin",
      "company_name": "Plomberie Martin",
      "metier": "plombier",
      "ville": "Bordeaux",
      "status": "draft",
      "generation_status": "ready",
      "subdomain": "plomberie-martin",
      "custom_domain": null,
      "public_url": "https://plomberie-martin.sendpage.io",
      "tracking_url": "https://plomberie-martin.sendpage.io?sp=Q7fK2mP9xT4a",
      "clicks": {
        "count": 2,
        "first_at": 1788790007000,
        "last_at": 1788876412000,
        "first_interaction_at": 1788790012000
      },
      "kept": true,
      "expires_at": null,
      "offline_at": null,
      "deletes_at": null,
      "created_at": 1788787628056,
      "updated_at": 1788787643120
    }
  ],
  "next_cursor": null
}

public_url, tracking_url et clicks sont détaillés dans Suivi des clics ; kept, expires_at, offline_at et deletes_at dans Durée de vie des sites.

Lire un site

GET/sites/{site_id}

Le site sous la même forme que dans la liste, compteurs de clics compris. Utile pour relire les clics d'un prospect ; pour réagir au clic lui-même, relevez le flux d'événements.

Réponse
{
  "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
  "slug": "plomberie-martin",
  "company_name": "Plomberie Martin",
  "metier": "plombier",
  "ville": "Bordeaux",
  "status": "draft",
  "generation_status": "ready",
  "subdomain": "plomberie-martin",
  "custom_domain": null,
  "public_url": "https://plomberie-martin.sendpage.io",
  "tracking_url": "https://plomberie-martin.sendpage.io?sp=Q7fK2mP9xT4a",
  "clicks": {
    "count": 2,
    "first_at": 1788790007000,
    "last_at": 1788876412000,
    "first_interaction_at": 1788790012000
  },
  "kept": true,
  "expires_at": null,
  "offline_at": null,
  "deletes_at": null,
  "created_at": 1788787628056,
  "updated_at": 1788787643120
}

404 not_found si le site n'existe pas ou appartient Ă  un autre compte.

Garder un site

POST/sites/{site_id}/keep

Rend le site permanent : il ne passera pas hors ligne et ne sera pas supprimé. Coûte 1 crédit, débité une seule fois. Un site déjà gardé, ou qui n'expire pas, répond sans rien débiter (charged vaut false). Possible jusqu'à la suppression, semaine hors ligne comprise : le site revient alors en ligne.

RequĂŞte
curl -X POST https://sendpage.io/api/v1/sites/3ffccc9b-da6b-4b1c-839d-0b16e02e7e29/keep \
  -H "Authorization: Bearer $SENDPAGE_API_KEY"
Réponse
{
  "charged": true,
  "credits_remaining": 8,
  "site": {
    "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
    "slug": "plomberie-martin",
    "public_url": "https://sendpage.io/demo/plomberie-martin",
    "kept": true,
    "expires_at": null,
    "offline_at": null,
    "deletes_at": null
  }
}

site est l'objet complet, comme dans Lire un site (abrégé ici). credits_remaining donne le solde après l'appel, qu'il ait débité ou non. 402 no_credits si le solde est épuisé ; 404 not_found si le site n'existe pas, n'existe plus ou appartient à un autre compte.

Supprimer un site

DELETE/sites/{site_id}

Supprime définitivement le site : contenu, factures, statistiques, demandes de contact, images, et libération du sous-domaine. Irréversible et sans confirmation — une API n'a pas de dialogue. L'adresse d'un site créé par l'API à partir du 24 septembre 2026 reste ensuite réservée 180 jours : aucun autre site ne peut la reprendre.

Réponse
204 No Content

Lire les événements

GET/events

Les événements du compte, du plus ancien au plus récent, à partir d'un curseur. C'est par ce point d'entrée que votre application apprend qu'un prospect a ouvert son lien : elle l'interroge à intervalle régulier et n'a besoin d'aucune adresse publique. Voir Lire les clics.

Paramètres d'URL
afterstringoptionnelnext_cursor de la réponse précédente, ou latest pour partir de maintenant. Absent : tout l'historique conservé
limitnumberoptionnel1 à 100, défaut 100
RequĂŞte
curl "https://sendpage.io/api/v1/events?after=41" \
  -H "Authorization: Bearer $SENDPAGE_API_KEY"
Réponse
{
  "events": [
    {
      "id": "evt_5f0c6a1e9b8d4c3fa2e17b6d0c9e8f41",
      "type": "site.clicked",
      "seq": 42,
      "created_at": 1788790007000,
      "data": {
        "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
        "slug": "plomberie-martin",
        "clicked_at": 1788790007000,
        "first_click": true,
        "first_interaction": false,
        "click_count": 1,
        "device": "mobile",
        "signal": "dwell"
      }
    },
    {
      "id": "evt_9a41c0d27e3b4f58b6c2d1e0f7a9b384",
      "type": "site.clicked",
      "seq": 43,
      "created_at": 1788790012000,
      "data": {
        "site_id": "3ffccc9b-da6b-4b1c-839d-0b16e02e7e29",
        "slug": "plomberie-martin",
        "clicked_at": 1788790012000,
        "first_click": false,
        "first_interaction": true,
        "click_count": 1,
        "device": "mobile",
        "signal": "interaction"
      }
    }
  ],
  "next_cursor": "43",
  "has_more": false
}

Ici, un prospect a lu la page puis l'a touchée : le premier événement porte signal dwell, le second signal interaction, first_click false et first_interaction true. next_cursor est toujours présent, inchangé quand rien n'est arrivé : stockez-le tel quel et renvoyez-le dans after. has_more vaut true quand d'autres événements attendent derrière cette page : rappelez aussitôt. after=latest rend une liste vide et le curseur courant. Les seq croissent toujours, mais peuvent sauter des valeurs, après la suppression d'un site par exemple. 400 invalid_cursor si after n'est ni latest ni un entier positif.

Suivi des clics

Chaque site a un lien suivi, tracking_url : son adresse publique, public_url, suivie du paramètre sp, un jeton de 12 caractères. Mettez tracking_url dans le SMS ; quand le prospect l'ouvre, les compteurs clicks du site avancent et un événement site.clicked entre dans le flux du compte, que votre application relève par GET /events.

Le jeton appartient au site, pas au message : il reste le même d'une lecture à l'autre, et un lien déjà envoyé continue d'être compté tant que son adresse sert la page. Avec un site par prospect, un clic désigne donc un prospect.

Objet clicks
countnumberrequisVisiteurs distincts par jour UTC : un prospect qui rouvre le lien le même jour compte une fois, le lendemain il compte de nouveau
first_atnumber | nullrequisPremier clic, en millisecondes
last_atnumber | nullrequisDernier nouveau visiteur, en millisecondes
first_interaction_atnumber | nullrequisPremière interaction observée sur le site, en millisecondes
Signaux
dwellPage visible 5 secondes sans geste : lecture probable
interactionToucher, clavier ou molette : signal fort

Mettez tracking_url dans le SMS, et nulle part ailleurs : affichez public_url partout où le lien est visible (site, e-mail, fiche client). Ne publiez jamais tracking_url : chaque ouverture par un inconnu fausserait les compteurs de votre prospect.

Sur /demo, tracking_url fait 41 caractères plus la longueur du slug ; sur un sous-domaine ou un domaine, le jeton ajoute 16 caractères à public_url. Passez un slug court à la création : chaque caractère compte dans un SMS.

public_url et tracking_url valent null quand le site n'a aucune adresse publique qui le serve, et tracking_url seul quand le suivi des clics est désactivé. Prenez toujours le lien rendu par l'API, sans le reconstruire.

Sur sendpage.io/demo, les ouvertures depuis un navigateur connecté à un compte sendpage sont ignorées, pour que vos propres vérifications ne comptent pas ; sur un sous-domaine ou un domaine, elles comptent. Testez donc toujours votre lien depuis une fenêtre de navigation privée.

Nous ne conservons ni ne transmettons l'IP, le navigateur ou la position du visiteur. Les compteurs d'un site sont effacés 365 jours après son dernier clic.

Lire les clics

Votre application apprend les clics en relevant le flux d'événements du compte, GET /events : elle l'interroge à intervalle régulier, et n'a donc besoin d'aucune adresse publique. Chaque clic retenu y ajoute un événement site.clicked, numéroté par seq dans l'ordre d'enregistrement.

Flux d'événements
OrdreDu plus ancien au plus récent, par seq croissant
Taille d'une page100 événements au plus
Conservation30 jours
Relève conseilléeUne fois toutes les 15 minutes
Après un envoi de SMSUne fois par minute pendant 3 heures
Intervalle minimal30 secondes entre deux relèves
Événements par site20 par jour UTC, plus celui qui porte first_interaction
Événement
idstringrequisIdentifiant stable de l'événement (evt_…)
typestringrequissite.clicked, seul type émis aujourd'hui. Ignorez un type inconnu
seqnumberrequisPosition dans le flux du compte, toujours croissante
created_atnumberrequisCréation de l'événement, en millisecondes
dataobjectrequisContenu propre au type
data de site.clicked
site_idstringrequisSite dont le lien a été ouvert
slugstringrequisSlug du site
clicked_atnumberrequisInstant du clic, en millisecondes
first_clickbooleanrequisPremier visiteur compté sur ce site
first_interactionbooleanrequisPremière interaction observée sur ce site : l'événement à attendre
click_countnumberrequisCompteur count du site après ce clic
devicestringrequismobile, tablet ou desktop
signalstringrequisdwell ou interaction

Première installation : appelez GET /events?after=latest et stockez next_cursor. Vous partez de maintenant, sans rejouer jusqu'à 30 jours d'historique.

Ensuite, passez le curseur stocké dans after, traitez les événements reçus, et enregistrez le nouveau next_cursor seulement après. Une application arrêtée en plein traitement reprend ainsi à la même page, sans rien perdre. Tant que has_more vaut true, rappelez aussitôt.

Relevez le flux une fois toutes les 15 minutes. Après l'envoi d'une campagne SMS, relevez-le une fois par minute pendant 3 heures à compter du dernier envoi : c'est dans ce délai que tombent presque tous les clics. L'heure d'envoi reste dans votre application, l'API n'a pas besoin de la connaître.

Ne relevez jamais plus d'une fois toutes les 30 secondes : chaque relève compte dans la borne de requêtes de la clé, et un clic n'a pas besoin d'être vu à la seconde.

Les événements sont conservés 30 jours. Un ordinateur resté éteint rattrape donc ses clics au redémarrage, en reprenant à son curseur, tant qu'il revient dans ce délai ; au-delà, les plus anciens sont perdus. Les événements d'un site supprimé disparaissent avec lui.

Le curseur ne saute aucun événement et n'en rend jamais deux fois : dédoublonner sur id n'est pas nécessaire. Ce n'est pas nuisible non plus, et cela protège un traitement interrompu avant l'enregistrement du curseur, qui relira sa dernière page.

Relever le flux (JavaScript)
const headers = { Authorization: `Bearer ${KEY}` };

// Relève par défaut, relève rapprochée après un envoi de SMS,
// et durée de ce régime rapproché
const POLL_MS = 900 * 1000;
const BURST_POLL_MS = 60 * 1000;
const BURST_WINDOW_MS = 3 * 60 * 60 * 1000;

async function readEvents(after) {
  const res = await fetch(`https://sendpage.io/api/v1/events?after=${after}`, { headers });
  // Sur une erreur, le curseur ne bouge pas : on réessaie à la relève suivante
  if (!res.ok) throw new Error(`sendpage ${res.status}`);
  return res.json();
}

// Première installation : partir de maintenant
let cursor = await store.get("sendpage_cursor");
if (cursor == null) {
  cursor = (await readEvents("latest")).next_cursor;
  await store.set("sendpage_cursor", cursor);
}

async function readNewEvents() {
  let page;
  do {
    page = await readEvents(cursor);
    for (const event of page.events) {
      if (event.type !== "site.clicked") continue;
      if (event.data.first_interaction) await markAsRead(event.data.site_id);
    }
    cursor = page.next_cursor;
    // Après le traitement, jamais avant
    await store.set("sendpage_cursor", cursor);
  } while (page.has_more);
}

let timer;
let running = false;

function scheduleNext(delay) {
  clearTimeout(timer);
  timer = setTimeout(poll, delay);
}

async function poll() {
  // Une relève à la fois : celle en cours planifie la suivante
  if (running) return;
  running = true;
  try {
    await readNewEvents();
  } catch {
    // Rien de perdu : la relève suivante reprend au curseur enregistré
  } finally {
    running = false;
  }
  const lastSentAt = (await store.get("sendpage_last_sent_at")) ?? 0;
  const afterSend = Date.now() - lastSentAt < BURST_WINDOW_MS;
  scheduleNext(afterSend ? BURST_POLL_MS : POLL_MS);
}

// À appeler juste après l'envoi d'une campagne SMS
async function onCampaignSent() {
  await store.set("sendpage_last_sent_at", Date.now());
  scheduleNext(BURST_POLL_MS);
}

poll();

Pour décider qu'un prospect a lu, attendez l'événement dont first_interaction vaut true, ou first_interaction_at dans l'API. dwell veut dire « probablement » : un antispam qui exécute la page peut en produire un. Un même visiteur peut envoyer dwell, puis interaction.

Erreurs

Toutes les erreurs partagent la mĂŞme forme.

{
  "error": {
    "code": "no_credits",
    "message": "Solde de crédits épuisé."
  }
}
400invalid_bodyJSON ou champs invalides ; fields détaille lesquels
400invalid_cursorCurseur after illisible : renvoyez next_cursor tel quel, ou latest
401unauthorizedClé absente, invalide ou révoquée
402no_creditsSolde épuisé — rechargez ou attendez le renouvellement
403subscription_inactiveAbonnement suspendu ou résilié
404not_foundRessource inexistante ou appartenant Ă  un autre compte
409slug_takenAucun slug libre dérivable
429rate_limitedBorne dépassée ; l'en-tête Retry-After donne l'attente quand elle est connue
500internal_errorErreur interne
503unavailableIndisponibilité momentanée, ou maintenance

Un 404 ne distingue pas « inexistant » de « pas à vous », délibérément : la distinction permettrait d'énumérer les identifiants des autres comptes.

Intégrer proprement

Créez, puis suivez le job. Ne supposez pas le site prêt au retour du POST.

JavaScript
const create = await fetch("https://sendpage.io/api/v1/sites", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ company_name, metier, ville, telephone }),
});
if (create.status === 402) throw new Error("crédits épuisés");
const { job_id, site_id } = await create.json();

// Le POST ne garantit PAS un site prĂŞt : on suit le job.
for (;;) {
  await new Promise((r) => setTimeout(r, 3000));
  const job = await (
    await fetch(`https://sendpage.io/api/v1/jobs/${job_id}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    })
  ).json();
  if (job.status === "succeeded") break;
  if (job.status === "failed") throw new Error(job.error);
}

Lancer un lot d'un coup est prévu : la file admet les jobs à son rythme et sert les comptes à tour de rôle, de sorte qu'un gros lot n'affame jamais les autres clients — ni ne se fait affamer par eux.