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 :
{ "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 :
{
"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 :
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) :
<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.
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.
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 :
{
"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 :
- Liste vide acceptée (cas légitime : 0 abonné actif).
- Validation stricte : un
subscriberIdqui n'appartient pas au feed renvoie400 invalid_subscriber. Idem siresultsmanque ou est mal formé (400 invalid_body). - Idempotent : un retour client peut rappeler l'endpoint, les
Deliverysont écrits par(articleId, subscriberId)en upsert.
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é :
{ "…": "…", "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).
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.
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#
{
"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
headerssur 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 :
{ "subscriberId": "sub_…" }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 }.
# 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.jsonRé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 :
text/csv(ou par défaut) — colonneemailrequise,subscribed_at(ISO 8601) optionnelle, autres colonnes ignorées silencieusement.application/json— tableau d'objets{ email, subscribedAt? }.
Limite : 10 000 lignes par requête. Au-delà, scinder en plusieurs imports.
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.csvRéponse 200 :
{
"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.
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é.
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.
- Créer un token : formulaire sur
/me→ le token en clair (rcl_…) est affiché une seule fois. Copie-le immédiatement. - Révoquer un token : bouton « Révoquer » sur la même page.
- Les tokens ont une portée
full_access(accès à tous tes feeds). - Le serveur ne stocke que le SHA-256 du token ; si tu perds la valeur en clair, génère-en un nouveau.
Envoyer un article#
L'envoi se fait toujours côté user — Roucoule ne stocke pas tes identifiants SMTP. Trois étapes :
- Préparer l'article — via l'admin ou
PATCH …/articles/{id}avec"status": "ready_to_send". - Récupérer le package —
GET …/articles/{id}/package: tu obtiens un objet prêt à l'emploi, un email rendu par abonné, avec les en-têtesList-Unsubscribe. - Envoyer + confirmer — envoie chaque email via ton SMTP, puis appelle
POST …/articles/{id}/mark-senten 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 :
# 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" }
]
}'