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/v1Authentification
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.
Authorization: Bearer sp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxN'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.
| kept | boolean | requis | true 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_at | number | null | requis | Fin de la période en ligne, en millisecondes ; null si le site n'expire pas |
| offline_at | number | null | requis | Passage hors ligne, en millisecondes ; null tant que le site est en ligne |
| deletes_at | number | null | requis | Suppression 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
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
/sitesCré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.
| company_name | string | requis | 1 à 100 caractères |
| metier | string | requis | 1 à 100 caractères |
| ville | string | requis | 1 à 100 caractères |
| telephone | string | requis | 6 à 30 caractères |
| slug | string | optionnel | Réassaini côté serveur, et suffixé s'il est déjà pris. Court de préférence : il allonge le lien envoyé par SMS |
| palette | string | optionnel | Défaut : minimaliste |
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"
}'{
"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
/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.
{
"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
/sitesDu plus récent au plus ancien, paginé par curseur.
| limit | number | optionnel | 1 à 100, défaut 25 |
| cursor | string | optionnel | next_cursor de la page précédente |
{
"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
/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.
{
"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
/sites/{site_id}/keepRend 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.
curl -X POST https://sendpage.io/api/v1/sites/3ffccc9b-da6b-4b1c-839d-0b16e02e7e29/keep \
-H "Authorization: Bearer $SENDPAGE_API_KEY"{
"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
/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.
204 No ContentLire les événements
/eventsLes é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.
| after | string | optionnel | next_cursor de la réponse précédente, ou latest pour partir de maintenant. Absent : tout l'historique conservé |
| limit | number | optionnel | 1 à 100, défaut 100 |
curl "https://sendpage.io/api/v1/events?after=41" \
-H "Authorization: Bearer $SENDPAGE_API_KEY"{
"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.
| count | number | requis | Visiteurs distincts par jour UTCÂ : un prospect qui rouvre le lien le mĂŞme jour compte une fois, le lendemain il compte de nouveau |
| first_at | number | null | requis | Premier clic, en millisecondes |
| last_at | number | null | requis | Dernier nouveau visiteur, en millisecondes |
| first_interaction_at | number | null | requis | Première interaction observée sur le site, en millisecondes |
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.
| id | string | requis | Identifiant stable de l'événement (evt_…) |
| type | string | requis | site.clicked, seul type émis aujourd'hui. Ignorez un type inconnu |
| seq | number | requis | Position dans le flux du compte, toujours croissante |
| created_at | number | requis | Création de l'événement, en millisecondes |
| data | object | requis | Contenu propre au type |
| site_id | string | requis | Site dont le lien a été ouvert |
| slug | string | requis | Slug du site |
| clicked_at | number | requis | Instant du clic, en millisecondes |
| first_click | boolean | requis | Premier visiteur compté sur ce site |
| first_interaction | boolean | requis | Première interaction observée sur ce site : l'événement à attendre |
| click_count | number | requis | Compteur count du site après ce clic |
| device | string | requis | mobile, tablet ou desktop |
| signal | string | requis | dwell 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.
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é."
}
}| 400 | invalid_body | JSON ou champs invalides ; fields détaille lesquels |
| 400 | invalid_cursor | Curseur after illisible : renvoyez next_cursor tel quel, ou latest |
| 401 | unauthorized | Clé absente, invalide ou révoquée |
| 402 | no_credits | Solde épuisé — rechargez ou attendez le renouvellement |
| 403 | subscription_inactive | Abonnement suspendu ou résilié |
| 404 | not_found | Ressource inexistante ou appartenant Ă un autre compte |
| 409 | slug_taken | Aucun slug libre dérivable |
| 429 | rate_limited | Borne dépassée ; l'en-tête Retry-After donne l'attente quand elle est connue |
| 500 | internal_error | Erreur interne |
| 503 | unavailable | Indisponibilité 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.
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.