Roucoule

API Roucoule#

Base : https://roucoule.dev/api/v1

Contrat stable. Les endpoints documentés ici sont publics et stables. Le client de référence open-source @roucoule/send les consomme ; tu peux écrire le tien (Python, Rust, shell + curl, etc.) — Roucoule ne stocke aucun identifiant SMTP côté serveur. Les changements incompatibles seront annoncés via le changelog.


Authentification#

API publique (inscription)#

En-tête requis :

X-Roucoule-Public-Key: rck_…

La clé publique du feed (rck_…) est visible dans la page Intégration de chaque feed. Elle identifie le feed sans exposer de secret — tu peux l'inclure dans du code front-end.

Pour les soumissions HTML classiques (formulaire <form method="POST">), la clé peut aussi être passée dans un champ caché publicKey au lieu de l'en-tête.

API authentifiée (endpoints de gestion)#

En-tête requis :

Authorization: Bearer rcl_…

Le token (rcl_…) est créé depuis la page /me de l'interface d'administration (voir Tokens API). Il donne accès à tous les feeds de ton compte.

Une session admin (cookie roucoule_session) est aussi acceptée pour les requêtes faites depuis un navigateur connecté.


Format des erreurs#

Toutes les erreurs retournent du JSON :

json
{ "error": "code_snake_case", "message": "Description lisible." }
Code HTTP Description
invalid_email 400 Email invalide ou corps de requête malformé
captcha_failed 400 Captcha absent ou invalide
invalid_status 400 Valeur de status non autorisée (PATCH article)
not_ready 400 Article pas au statut ready_to_send
validation_error 400 Payload invalide (format inconnu, body mal formé…)
consent_required 400 Header X-Roucoule-Consent-Attested manquant
forbidden_origin 403 Origine HTTP non autorisée pour ce feed
invalid_api_key 403 Clé publique absente ou ne correspond pas au feed
unauthorized 401 Token Bearer absent, invalide ou révoqué
not_found 404 Feed, article ou abonné introuvable
too_large 413 Payload trop volumineux (>10 000 lignes à l'import)
rate_limited 429 Trop de requêtes (en-tête Retry-After inclus)
sync_failed 502 Erreur lors de la synchronisation RSS

API publique#

POST /feeds/{feedId}/subscribers#

Inscrit un email à un feed. L'opération est idempotente : si l'email est déjà actif, la réponse est 200 (aucun doublon créé). Si l'abonné était désinscrit, il est réactivé.

En-tête requis : X-Roucoule-Public-Key: rck_…

Corps JSON :

json
{
  "email": "alice@example.com",
  "consent": {
    "userAgent": "Mozilla/5.0 …",
    "consentAt": "2026-05-19T10:00:00Z",
    "ip": "1.2.3.4",
    "doubleOptInVerified": false
  },
  "customData": { "firstName": "Alice" },
  "captchaToken": "…",
  "website": ""
}
Champ Requis Description
email Oui Adresse email (validée et canonicalisée côté serveur)
consent Non Métadonnées de consentement RGPD
consent.userAgent Non User-Agent du navigateur
consent.consentAt Non ISO 8601 — horodatage du consentement
consent.ip Non IP du visiteur (sinon dérivée de X-Forwarded-For)
consent.doubleOptInVerified Non true si tu gères toi-même la confirmation
customData Non Dictionnaire string → string (prénom, segment…)
captchaToken Conditionnel Requis si le feed a le captcha activé
website Non Champ honeypot — laisser vide dans un formulaire réel

Réponses :

HTTP Corps Condition
201 { "subscriberId": "sub_…" } Nouvel abonné créé
200 { "subscriberId": "sub_…" } Email déjà inscrit
400 { "error": "invalid_email", … } Email invalide
400 { "error": "captcha_failed", … } Captcha invalide
403 { "error": "invalid_api_key", … } Mauvaise clé publique
403 { "error": "forbidden_origin", … } Origine non autorisée
429 { "error": "rate_limited", … } Limite de débit

Limites de débit : 5 req/min par IP, 30 req/h par IP, 1 000 req/h par feed.

Exemple curl :

bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers \
  -H "Content-Type: application/json" \
  -H "X-Roucoule-Public-Key: rck_YYYY" \
  -d '{
    "email": "alice@example.com",
    "consent": { "userAgent": "curl/8.0", "consentAt": "2026-05-19T10:00:00Z" }
  }'

Soumission HTML (formulaire classique) :

html
<form
  method="POST"
  action="https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers"
>
  <input type="hidden" name="publicKey" value="rck_YYYY" />
  <input type="hidden" name="redirect" value="https://monsite.fr/merci" />
  <input type="email" name="email" required />
  <input type="text" name="website" style="display: none" />
  <button type="submit">S'inscrire</button>
</form>

En mode formulaire, le serveur redirige vers redirect (si l'origine est autorisée) ou vers /subscribed de l'instance Roucoule.

CORS : l'en-tête Access-Control-Allow-Origin est renvoyé si l'origine de la requête figure dans la liste des origines autorisées du feed. Si la liste est vide, toutes les origines sont autorisées.


API authentifiée#

Tous les endpoints ci-dessous nécessitent :

Authorization: Bearer rcl_…

Feeds#

Méthode Route Description
POST /feeds/{id}/sync Synchronise le flux RSS et détecte les nouveaux articles

POST /feeds/{id}/sync#

Déclenche manuellement la synchronisation RSS du feed. Retourne un objet décrivant les nouveaux articles détectés.

bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/sync \
  -H "Authorization: Bearer rcl_ZZZZ"

Réponses :

HTTP Description
200 Synchronisation réussie (JSON)
404 Feed introuvable
502 Erreur lors du fetch du flux RSS

Articles#

Méthode Route Description
PATCH /feeds/{id}/articles/{articleId} Édite customSubject, customIntro, status
POST /feeds/{id}/articles/{articleId}/mark-sent Marque l'article comme envoyé
POST /feeds/{id}/articles/{articleId}/cancel Annule l'article
GET /feeds/{id}/articles/{articleId}/package Retourne les emails rendus, un par destinataire
POST /feeds/{id}/articles/{articleId}/preview Retourne un aperçu rendu pour un abonné donné

PATCH /feeds/{id}/articles/{articleId}#

Édite les champs personnalisables d'un article. Seuls les champs fournis sont modifiés.

Corps JSON :

Champ Type Valeurs acceptées
customSubject string Sujet personnalisé (vide = utiliser le template)
customIntro string Introduction personnalisée
status string "ready_to_send", "cancelled", "detected"

Passer status: "ready_to_send" positionne aussi preparedAt à l'heure actuelle.

bash
curl -X PATCH https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: application/json" \
  -d '{ "customSubject": "Mon sujet personnalisé", "status": "ready_to_send" }'

Réponses : 200 (article mis à jour), 400 invalid_status, 404 not_found.

POST /feeds/{id}/articles/{articleId}/mark-sent#

Clôt un envoi : passe l'article en status: "sent" et enregistre un résultat par destinataire. Roucoule en dérive sentToCount, un SendJob clos et autant de Delivery que d'entrées, ce qui permet à l'UI de montrer qui a reçu et qui a échoué.

Corps JSON requis :

json
{
  "results": [
    { "subscriberId": "sub_a1", "status": "sent" },
    {
      "subscriberId": "sub_a2",
      "status": "failed",
      "error": "550 mailbox full"
    }
  ]
}
Champ Type Description
results array Un résultat par destinataire que tu as tenté
results[].subscriberId string L'ID renvoyé par GET …/package
results[].status string "sent" ou "failed"
results[].error string Optionnel ; raison de l'échec si status=failed

Notes :

bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/mark-sent \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: application/json" \
  -d '{ "results": [ { "subscriberId": "sub_a1", "status": "sent" } ] }'

Réponse 200 : l'article mis à jour, plus un résumé :

json
{ "…": "…", "sentToCount": 1, "deliveries": { "sent": 1, "failed": 0 } }

Réponses : 200 (article mis à jour), 400 invalid_body, 400 invalid_subscriber, 404 not_found.

POST /feeds/{id}/articles/{articleId}/cancel#

Annule l'article (passe son statut à cancelled).

bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/cancel \
  -H "Authorization: Bearer rcl_ZZZZ"

Réponses : 200 (article mis à jour), 404 not_found.

GET /feeds/{id}/articles/{articleId}/package#

Retourne l'intégralité des emails à envoyer, rendus et prêts à l'emploi — un objet par abonné actif. L'article doit être au statut ready_to_send, sinon la requête échoue avec 400 not_ready.

bash
curl https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/package \
  -H "Authorization: Bearer rcl_ZZZZ"

Réponses : 200 (package JSON), 400 not_ready, 404 not_found.

Format du package#
json
{
  "articleId": "art_…",
  "subject": "[Mon Blog] Mon article",
  "fromHint": { "name": "Thomas", "email": "thomas@blog.fr" },
  "recipients": [
    {
      "subscriberId": "sub_…",
      "email": "alice@example.com",
      "htmlBody": "<html>…</html>",
      "textBody": "Version texte brut…",
      "headers": {
        "List-Unsubscribe": "<https://roucoule.dev/u/uns_…>",
        "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
      }
    }
  ]
}
Champ Description
articleId Identifiant de l'article
subject Sujet rendu (template + variables)
fromHint.name Nom d'expéditeur tel que configuré sur le feed
fromHint.email Email d'expéditeur tel que configuré sur le feed
recipients[].subscriberId ID interne de l'abonné (pour mark-sent et logs)
recipients[].email Adresse email de destination
recipients[].htmlBody Corps HTML rendu avec les variables de l'abonné
recipients[].textBody Version texte brut (fallback)
recipients[].headers En-têtes email à inclure sur chaque message

Important : reporte toujours les champs headers sur chaque email lors de l'envoi. Ils activent la désinscription en un clic (RFC 8058) dans Gmail et Apple Mail.

POST /feeds/{id}/articles/{articleId}/preview#

Retourne un aperçu rendu (HTML + texte) pour un abonné donné, ou pour un abonné fictif si aucun n'est fourni.

Corps JSON optionnel :

json
{ "subscriberId": "sub_…" }
bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/preview \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: application/json" \
  -d '{ "subscriberId": "sub_AAAA" }'

Réponses : 200 (objet { html, text, subject }), 404 not_found.


Subscribers#

Méthode Route Description
GET /feeds/{id}/subscribers/export Export CSV ou JSON des abonnés actifs
POST /feeds/{id}/subscribers/import Import CSV ou JSON (attestation requise)
DELETE /feeds/{id}/subscribers/{subId} Suppression définitive (RGPD)
POST /feeds/{id}/subscribers/{subId}/unsubscribe Désinscription manuelle

GET /feeds/{id}/subscribers/export#

Retourne un fichier (Content-Disposition: attachment) listant les abonnés actifs uniquement — c'est le format portable, prêt à réimporter ailleurs.

Query : format=csv (défaut) ou format=json.

Colonnes CSV : email, subscribed_at (ISO 8601), source (signup | import). Les abonnés désinscrits ne sont pas inclus ; pour la suppression RGPD, voir DELETE …/subscribers/{subId}. Le token d'unsub n'est jamais exporté (secret).

JSON : tableau d'objets { email, subscribedAt, source }.

bash
# CSV (défaut)
curl https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers/export \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -o subscribers.csv

# JSON
curl "https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers/export?format=json" \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -o subscribers.json

Réponses : 200, 400 validation_error (format inconnu), 404 not_found.

POST /feeds/{id}/subscribers/import#

Importe un lot d'abonnés en masse depuis un CSV ou un JSON. Idempotent : un email déjà actif est compté comme doublon (skip), un email désinscrit est réactivé. Les nouveaux abonnés sont marqués source: "import".

Header obligatoire : X-Roucoule-Consent-Attested: true — atteste que les personnes importées ont explicitement consenti à recevoir tes emails et que tu peux le prouver si nécessaire. Roucoule ne stocke pas de preuve : c'est ta responsabilité.

Content-Type :

Limite : 10 000 lignes par requête. Au-delà, scinder en plusieurs imports.

bash
curl -X POST https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers/import \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: text/csv" \
  -H "X-Roucoule-Consent-Attested: true" \
  --data-binary @subscribers.csv

Réponse 200 :

json
{
  "imported": 142,
  "reactivated": 3,
  "skipped_duplicate": 7,
  "skipped_invalid": [
    { "line": 12, "email": "not-an-email", "reason": "email invalide" }
  ],
  "total_rows": 153
}

Réponses : 200, 400 consent_required, 400 validation_error, 401 unauthorized, 404 not_found, 413 too_large.

DELETE /feeds/{id}/subscribers/{subId}#

Supprime définitivement un abonné et toutes ses données. Irréversible. À utiliser pour répondre aux demandes de suppression RGPD.

bash
curl -X DELETE \
  https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers/sub_YYYY \
  -H "Authorization: Bearer rcl_ZZZZ"

Réponses : 200 { "deleted": true }, 404 not_found.

POST /feeds/{id}/subscribers/{subId}/unsubscribe#

Désinscrit manuellement un abonné (passe son statut à unsubscribed, raison "owner"). L'abonné n'est pas supprimé : ses données restent en base pour la traçabilité.

bash
curl -X POST \
  https://roucoule.dev/api/v1/feeds/feed_XXXX/subscribers/sub_YYYY/unsubscribe \
  -H "Authorization: Bearer rcl_ZZZZ"

Réponses : 200 (objet abonné mis à jour), 404 not_found.


Tokens API#

Les tokens API ne sont pas gérés via des endpoints REST ; ils sont créés et révoqués depuis l'interface d'administration, page /me.


Envoyer un article#

L'envoi se fait toujours côté user — Roucoule ne stocke pas tes identifiants SMTP. Trois étapes :

  1. Préparer l'article — via l'admin ou PATCH …/articles/{id} avec "status": "ready_to_send".
  2. Récupérer le packageGET …/articles/{id}/package : tu obtiens un objet prêt à l'emploi, un email rendu par abonné, avec les en-têtes List-Unsubscribe.
  3. Envoyer + confirmer — envoie chaque email via ton SMTP, puis appelle POST …/articles/{id}/mark-sent en remontant le résultat de chaque destinataire (status: sent|failed, error?).

Le client de référence open-source est @roucoule/send. Consulte docs/sending pour le guide utilisateur complet.

Flux minimaliste :

bash
# 1. Mettre l'article en ready_to_send
curl -X PATCH https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ready_to_send" }'

# 2. Récupérer le package
curl https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/package \
  -H "Authorization: Bearer rcl_ZZZZ" > package.json

# 3. … envoyer via ton SMTP …

# 4. Confirmer l'envoi (un résultat par destinataire)
curl -X POST \
  https://roucoule.dev/api/v1/feeds/feed_XXXX/articles/art_YYYY/mark-sent \
  -H "Authorization: Bearer rcl_ZZZZ" \
  -H "Content-Type: application/json" \
  -d '{
        "results": [
          { "subscriberId": "sub_a1", "status": "sent" },
          { "subscriberId": "sub_a2", "status": "failed", "error": "550 mailbox full" }
        ]
      }'