L’email comme une app. Pas comme un message.

Interface minimaliste d

Interface minimaliste d'une application sur mobile

La plupart des gens pensent l’email comme un texte avec un bouton à la fin. Un message qui dit quelque chose, et un appel à l’action qui espère un clic.

Je le pense autrement. Un email est une interface. Il a des états. Il a une navigation. Il a une logique de flux. Et surtout — il a un seul objectif : amener l’utilisateur à l’action suivante avec le moins de friction possible.

Ce changement de perspective change tout dans la façon de concevoir ses emails.

L’email comme premier écran

Quand une médiathèque reçoit mon mail de prospection, elle ne reçoit pas un texte. Elle reçoit un écran avec deux actions possibles :

  • Oui, envoyez-moi un devis → elle entre dans le tunnel de vente
  • Non merci → elle entre dans le tunnel de feedback

C’est exactement la logique d’un écran d’onboarding dans une application mobile. Une question. Deux chemins. Zéro bruit parasite.

La différence avec un email classique : je ne lui demande pas de répondre. Je lui demande de cliquer. Répondre demande de la rédaction, de la réflexion, de l’effort. Cliquer demande une demi-seconde et un index.

La friction tue la conversion. Chaque étape supplémentaire entre l’intention et l’action coûte des utilisateurs.

C’est vrai dans une app. C’est vrai dans un email.

Les boutons comme navigation

Dans une application, les boutons naviguent vers des écrans. Dans mon email de devis, c’est exactement ce qui se passe — chaque bouton amène vers une page dédiée, contextualisée, avec les bonnes informations au bon moment.

Le mail de devis contient quatre actions distinctes :

1. Voir les thématiques → page publique de mon site de conférences. Pour le responsable culturel qui veut affiner le sujet avant de s’engager.

2. Proposer des dates → formulaire de disponibilités pré-rempli avec le numéro de devis. Pour celui qui est convaincu et veut passer à la planification.

3. Signer le devis en ligne → page de signature électronique avec canvas HTML5. Pour celui qui est prêt à valider.

4. Transmettre à ma direction → lien mailto: pré-rempli avec le sujet, le corps du mail, et le lien vers le devis. Pour celui qui n’a pas le pouvoir de signature mais veut faire suivre.

Ce dernier bouton est souvent le plus sous-estimé. Dans une structure culturelle publique, la personne qui reçoit le mail n’est presque jamais celle qui signe. En lui donnant un bouton “transmettre à ma direction” avec un mail pré-rédigé, je supprime une étape entière du processus décisionnel — et je m’assure que le bon message arrive au bon interlocuteur, formulé correctement, sans que mon contact ait à rédiger quoi que ce soit.

mailto:?subject=Proposition d'intervention : Démystifier l'IA
&body=Bonjour,

Je vous transmets ce projet de conférence sur l'IA.
Vous trouverez le récapitulatif et le devis ici : [lien]

Qu'en pensez-vous ?

Un bouton. Un mail pré-rédigé. Zéro effort pour mon contact.

Le kit de communication comme feature

Une app bien conçue anticipe les besoins suivants de l’utilisateur. Quand quelqu’un accepte un devis de conférence, son besoin suivant c’est : communiquer sur l’événement. Affiche, texte de présentation, codes couleurs pour les visuels.

J’aurais pu attendre qu’il me les demande. J’aurais pu les envoyer dans un second mail. J’ai choisi de les inclure directement dans le mail du devis — parce que c’est là qu’il en a besoin, au moment où il vient de décider de signer.

Le mail contient donc :

  • Le PDF du devis
  • Ma photo de profil
  • Une affiche PNG haute définition
  • Deux textes de présentation prêts à copier (version courte et longue)
  • Les codes couleurs de la charte graphique
  • La police typographique utilisée (Montserrat, Google Fonts)

Ce n’est pas un gadget. C’est une réduction de friction. Chaque élément que je fournis proactivement est une relance que je n’aurai pas à gérer, une question à laquelle je n’aurai pas à répondre, un délai que je supprime dans le processus de décision.

Le délai aléatoire comme design d’expérience

Un devis qui arrive 9 secondes après un clic casse l’illusion. Le destinataire comprend immédiatement qu’il est dans un tunnel automatisé — et ça change sa perception de la relation.

J’ai introduit un délai aléatoire entre 3 et 8 minutes entre le clic et l’envoi du devis. C’est suffisant pour que ça ressemble à quelqu’un qui a pris le temps de préparer le document. Pas trop long pour créer de l’impatience.

$delay = rand(180, 480); // 3 à 8 minutes
$sendAfter = date('Y-m-d H:i:s', strtotime('+' . $delay . ' seconds'));

C’est du design d’expérience appliqué à un email automatisé. Exactement comme un skeleton screen dans une app qui simule un chargement pour que l’interface paraisse plus réactive — même si les données sont déjà là.

L’objectif n’est pas de tromper. C’est d’éviter que la mécanique soit visible. Une bonne automatisation ne devrait pas ressembler à une automatisation.

Ce que ça implique dans la conception

Penser l’email comme une app, ça change la façon de le construire :

On ne rédige plus, on conçoit. La question n’est plus “qu’est-ce que je veux dire ?” mais “quelle action je veux déclencher, et comment je supprime les obstacles entre l’intention et l’action ?”

Chaque bouton est un écran suivant. Avant d’ajouter un CTA, je me demande vers quoi il pointe, ce que l’utilisateur trouvera là, et si cette page est prête à le recevoir dans le bon contexte.

On anticipe le besoin suivant. Un utilisateur qui vient de signer a besoin d’assets de communication. Un utilisateur qui vient de s’inscrire a besoin d’instructions claires. Ces besoins sont prévisibles — autant les satisfaire proactivement plutôt que de les gérer en réactif.

La friction est l’ennemi. Chaque champ de formulaire supplémentaire, chaque étape de confirmation, chaque mail qu’on leur demande de rédiger eux-mêmes — c’est une fuite dans le tunnel. On colmate les fuites avant de lancer la campagne.

L’email n’est pas un canal. C’est une surface.

Les grandes apps ont des surfaces : l’écran principal, les notifications push, les widgets. L’email est une surface comme les autres — avec ses contraintes propres (pas de JavaScript, clients mail capricieux, attention limitée) mais aussi ses avantages uniques : il arrive dans un espace personnel, il persiste, il peut être transféré, il peut contenir des pièces jointes.

La plupart des emails d’entreprise traitent cette surface comme un Post-it. Un texte, un lien, une signature. J’essaie de la traiter comme un écran — avec une architecture d’information, une hiérarchie visuelle, et une logique de flux pensée pour l’utilisateur qui est de l’autre côté.

Ce n’est pas plus compliqué. C’est juste une question de perspective.

How Commit–Reveal Makes a 50-50 Coin Flip Actually Fair on Base

Everyone in crypto says “provably fair”. Fewer people are willing to walk through the exact mechanics.

This is how we implemented a true 50-50 P2P USDC coin flip on Base using a simple commit–reveal pattern, zero rake, and non-custodial smart contracts:

1. The design constraints

We started with a few hard rules:

  • No house edge or rake. Winner takes 100% of the pot.
  • Yoss.gg never holds user funds in a custodial wallet.
  • Every flip must be independently verifiable on-chain.
  • UX has to work for someone who has never touched a wallet before.

Those constraints immediately kill most “crypto casino” architectures. So we built a minimal primitive instead: one contract, one coin, one flip.

2. Why commit–reveal and not just blockhash

Using blockhash or timestamps for randomness looks simple but is trivial to manipulate—especially for miners/validators and contracts that can choose when to settle.

Commit–reveal gives you:

  • A private secret at commit time
  • A public commitment stored on-chain
  • A reveal step that proves you did not change your secret after seeing the other side

In our case, each player contributes entropy and the contract combines both secrets so neither side can force a win alone.

3. A minimal flow for a coin flip

At a high level:

  1. Player A chooses a random 256-bit secret sA and computes hA = keccak256(sA).
  2. Player A posts a game with stake X USDC and commitment hA. Funds go into the contract.
  3. Player B matches the game with stake X USDC and their own commitment hB.
  4. Once both commitments are locked, each player reveals their secret (sA, sB).
  5. The contract verifies keccak256(sA) == hA and keccak256(sB) == hB.
  6. We derive randomness from keccak256(sA, sB) and take the least significant bit as the coin flip.
  7. Winner receives the full 2X USDC pot.

No oracle, no trusted server, no off-chain “magic RNG”. If either player refuses to reveal, the contract has explicit timeout logic for refunds/penalties depending on who stalled.

4. Non-custodial by default

All funds live in the contract, never in a Yoss.gg wallet. We use USDC on Base for:

  • Stable, non-volatile stakes
  • Cheap gas
  • Fast settlement

The contract enforces:

  • Exact stakes from both sides
  • No skimmed fees or silent rake
  • Direct payouts to the players’ smart accounts

Because there is no house edge, there is nothing for us to siphon off even if we wanted to. The pot flows from Player A + Player B straight to the winner.

5. Making this usable for non-crypto players

Everything above is great for devs and fairness nerds. Most players just want to click a button, flip a coin, and see numbers go up or down.

To bridge that gap we:

  • Use smart accounts (ERC-4337) so users can sign up with email instead of a browser wallet.
  • Abstract gas on Base so they are not juggling ETH balances.
  • Integrate fiat on-ramps (MoonPay, Coinbase, Transak) so they can land directly in USDC on Base.

Under the hood, they are still using the same non-custodial contract and commit–reveal flow. They just never have to think about it unless they want to audit the chain.

6. How you can verify a flip yourself

If you are the kind of person who reads commit–reveal explainers (hi, friend), verification is straightforward:

  1. Look up the game and its commitments on Base.
  2. Fetch the revealed secrets from the resolution transaction.
  3. Locally compute keccak256(sA) and keccak256(sB) and confirm they match the commitments.
  4. Compute keccak256(sA, sB) yourself and check that the least significant bit matches the winner recorded on-chain.

If any of those checks fail, something is wrong—and you do not have to take our word for it.

7. Why we care about boring math

Crypto is full of “provably fair” banners glued onto opaque systems. We went the opposite way: minimal game, maximal transparency.

A single coin, zero rake, and a commit–reveal you can verify in a 20-line script.

If more games looked like this, we would have fewer rug stories and more people willing to touch on-chain betting at all.

If you are building something similar on Base (or want to), I would love to see your approach and tradeoffs.

J’ai automatisé tout mon cycle de vente. Voilà ce qui s’est passé.

Un bureau épuré avec un ordinateur portable ouvert

Un bureau épuré avec un ordinateur portable ouvert

Il y a quelques semaines, j’ai envoyé 456 emails à des médiathèques et centres culturels pour proposer ma conférence Démystifier l’IA. Je n’ai pas passé 900 minutes à les rédiger. Je n’ai pas non plus passé une seule minute à générer les devis qui ont suivi.

Résultat : 38 demandes de devis en 4 jours. 8,67% de taux de réponse sur un public froid, sans aucune relation préalable.

Ce chiffre ne tombe pas du ciel. Il est le produit d’un flow entièrement automatisé — du premier email jusqu’à la signature électronique du contrat. Voilà comment ça marche, pourquoi ça marche, et ce que j’ai appris en le construisant.

Le problème que je voulais résoudre

Je suis développeur indépendant. Je fais des conférences sur l’IA. Entre les deux, il y a un cycle de vente que la plupart des indépendants gèrent à la main :

  1. Trouver les contacts
  2. Envoyer un email
  3. Attendre
  4. Relancer
  5. Envoyer un devis en PDF
  6. Attendre encore
  7. Relancer encore
  8. Recevoir le bon pour accord par fax (oui, en 2026)

C’est épuisant, chronophage, et surtout — c’est du temps que je ne passe pas à coder ou à préparer mes conférences. J’ai décidé d’automatiser les étapes 2 à 8. Entièrement.

Le flow, étape par étape

1. La liste de contacts

Tout commence avec un fichier CSV : médiathèques.csv. Nom de la structure, email, adresse, code postal, ville.

Un script Python filtre les emails invalides, exclut les zones trop éloignées (déplacements trop coûteux), et nettoie les noms de structures — parce qu’une médiathèque qui s’appelle "Médiathèque intercommunale du Pays de l'Arbresle (MIPA)" dans un CSV ne mérite pas d’apparaître telle quelle dans un email.

def clean_library_name(name):
    name = re.split(r'.|,', name)[0]
    name = name.replace('"', '').strip()
    return name if len(name) > 2 else 'Médiathèque'

2. Le calcul des frais de déplacement

C’est le détail qui change tout : chaque devis intègre automatiquement les vrais frais kilométriques, calculés à la volée.

Le script géocode l’adresse de la structure via l’API gouvernementale api-adresse.data.gouv.fr, puis calcule la distance aller-retour via OSRM (un routeur open source). Si la structure est à moins de 30 km, les frais sont à zéro. Au-delà, le tarif kilométrique s’applique.

distance_km = data["routes"][0]["distance"] / 1000
frais = round(distance_km * 2 * tarif, 2) if distance_km >= SEUIL_KM else 0.0

Résultat : une médiathèque à 15 km reçoit un devis à 300 €. Une autre à 80 km reçoit un devis à 324 €. Automatiquement. Sans que j’aie tapé un seul chiffre.

3. L’email initial

Le mail envoyé via Brevo est volontairement court. Une seule question. Deux boutons.

Seriez-vous intéressé de recevoir un devis pour une conférence sur l’intelligence artificielle dans votre structure ?

[Oui, envoyez-moi un devis] · [Non merci]

Pas de présentation interminable. Pas de liste à puces. Juste une décision binaire à prendre.

Le bouton “Non merci” est aussi important que le “Oui” — il redirige vers un formulaire de refus qui me donne les vraies raisons (budget, timing, pas intéressé…) et propose de rester dans ma liste ou d’en sortir. Je collecte de la donnée même sur les non.

4. La génération du devis

Quand quelqu’un clique sur “Oui”, un token unique est créé côté serveur. Ce token déclenche :

  • La création d’un client en base de données (ou la mise à jour s’il existe déjà)
  • La génération d’un devis numéroté (DEV-202603-XXXX)
  • La génération d’un PDF via TCPDF
  • La mise en file d’attente de l’email (email_queue)

Le devis ne part pas immédiatement. Un délai aléatoire entre 3 et 8 minutes est introduit volontairement — parce qu’un devis qui arrive 11 secondes après un clic, ça fait robot. Un devis qui arrive 5 minutes après, ça ressemble à quelqu’un qui a pris le temps de le préparer.

$delay = rand(180, 480); // entre 3 et 8 minutes
$sendAfter = date('Y-m-d H:i:s', strtotime('+' . $delay . ' seconds'));

5. L’email du devis

Le devis PDF est joint à l’email, mais l’email lui-même est pensé comme un kit complet :

  • Le PDF du devis
  • Ma photo de profil
  • Une affiche PNG haute définition pour les communications
  • Les textes prêts à copier-coller pour les flyers et réseaux sociaux
  • Les codes couleurs de la charte graphique
  • Trois boutons d’action : voir les thématiques, proposer des dates, signer en ligne

L’idée : zéro friction. La structure reçoit tout ce dont elle a besoin pour dire oui et pour communiquer sur l’événement. Elle n’a pas à me relancer pour un visuel.

6. La signature électronique

Le devis contient un lien vers une page de signature en ligne. Le client dessine sa signature sur un canvas HTML5, clique sur “Bon pour accord”, et :

  • La signature est horodatée avec l’IP
  • Un PDF final est regénéré avec la signature incrustée
  • Le client reçoit son exemplaire signé par email
  • Je reçois une notification avec un lien vers le CRM
const signaturePad = new SignaturePad(canvas, {
  backgroundColor: "rgba(255, 255, 255, 0)",
  penColor: "#1a1a1a",
});

Tout ça sans que j’intervienne une seule fois.

Les chiffres

Étape Volume
Emails envoyés 900
Demandes de devis 47 (5,2%)
Devis générés automatiquement 47
Minutes passées manuellement 0

Pour contextualiser : la moyenne B2B du cold email en 2025 tourne autour de 4% de taux de réponse. Les meilleures campagnes atteignent 10%. Un taux de réponse de 8,67% sur un public entièrement froid, c’est dans le top 10% mondial des campagnes cold email — surtout pour un indépendant sans équipe commerciale.

Ce que j’ai appris

Le bouton “Non merci” est sous-estimé. La plupart des campagnes de cold mail ignorent les refus. Moi je les collecte, je les analyse, et je m’en sers pour améliorer le ciblage. Un refus pour raison budgétaire n’est pas la même chose qu’un refus par désintérêt.

Le délai aléatoire change la perception. Un email qui arrive trop vite casse l’illusion. Trois à huit minutes de délai, c’est suffisant pour que ça ressemble à une interaction humaine.

Donner plus que ce qu’on demande. Envoyer le devis ET le kit de communication ET les textes prêts à l’emploi, c’est retirer une étape au client. Moins il doit faire, plus il avance vite vers la signature.

Automatiser ne veut pas dire déshumaniser. Le mail initial est court, direct, non-corporate. Il n’a pas l’air d’un mail automatisé parce que je n’ai pas essayé de le rendre impressionnant — j’ai essayé de le rendre honnête.

La stack

  • Python — script d’envoi, géocodage, calcul de distance (OSRM)
  • Brevo — envoi transactionnel
  • PHP — backend CRM, génération de devis, API
  • TCPDF — génération PDF
  • MySQL — clients, devis, queue d’emails
  • Signature Pad (JS) — signature électronique côté client

Tout le code tourne sur un hébergement mutualisé standard. Pas de serverless, pas de microservices, pas de Kubernetes. Un VHost PHP et une base MySQL suffisent.

Si tu es indépendant et que tu passes encore du temps à envoyer des devis manuellement, je t’encourage à regarder ce que tu peux automatiser. Pas tout d’un coup — une étape à la fois. Commence par le devis. Puis la relance. Puis la signature.

Le temps que tu récupères, tu peux le mettre ailleurs. Dans le travail que tu aimes. Ou dans un article de blog.

PostgreSQL Streaming WAL Archiver and a backup tool in Go (pgrwl)

A production-grade, cloud-native PostgreSQL WAL archiving system designed for:

  • streaming WAL to S3 with compression, encryption, and retention
  • Kubernetes-native PostgreSQL backup workflows
  • zero data loss and reliable Point-in-Time Recovery (PITR)

Project

https://github.com/hashmap-kz/pgrwl

Features

  • WAL receiver (replication protocol)
  • Continuous WAL streaming
  • Backup to S3 (MinIO, AWS, etc.)
  • Backup to SFTP (backup server)
  • WAL compression (gzip, zstd-ready)
  • WAL encryption (AES-GCM)
  • WAL retention management
  • WAL monitoring and observability
  • Kubernetes & container ready
  • Helm chart support
  • YAML / JSON / ENV config
  • Lightweight single binary
  • Structured logging
  • Integration tests (containerized)
  • Unit tests
  • Backup automation (streaming basebackup)
  • Continuous backup for PostgreSQL

Key Capabilities

Streaming WAL

  • Uses PostgreSQL replication protocol
  • Supports synchronous WAL streaming
  • Enables zero data loss setups

Storage Backends

  • S3-compatible storage
  • SFTP backup servers

Compression + Encryption

Pipeline based on filename:

000000010000000000000001.gz.aes

Flow:

  • compress -> encrypt -> upload
  • download -> decrypt -> decompress

Architecture

PostgreSQL
   | (replication protocol)
WAL Receiver
   |
Local FS (fsync)
   |
Uploader (S3 / SFTP)
   |
Retention manager
   |
HTTP server (restore_command)

Continuous Backup

  • real-time WAL streaming
  • safe off-site storage
  • full PITR support
  • near-zero RPO

Kubernetes Ready

  • run as StatefulSet
  • works with StatefulSets / CNPG / Virtual Machines
  • deploy via Helm
  • GitOps-friendly

Configuration Example

main:
  listen_port: 7070
  directory: wals
receiver:
  slot: pgrwl_v5
log:
  level: trace
  format: text
  add_source: true

Testing

  • integration tests with real PostgreSQL containers
  • end-to-end WAL validation
  • unit-tested components

Why pgrwl?

  • simple deployment (single binary)
  • production-grade reliability
  • cloud-native design
  • built for Kubernetes and containers
  • secure and efficient WAL handling

Contribute

Summary

pgrwl is a lightweight, powerful, production-ready WAL archiving solution that brings:

  • streaming
  • security
  • automation
  • observability

to PostgreSQL backups.

How to Add Page Numbers to a PDF for Free — No Sign Up, No Watermark

If you’ve ever had to submit a report, share a proposal, or send a multi-page document to someone, you already know the quiet embarrassment of handing over an unnumbered PDF. Readers flip back and forth with no reference point. “Go to page 7” means nothing when there’s no page 7 visible. Page numbers aren’t decorative — they’re functional.
The problem? Most tools that let you add page numbers to a PDF online are either bloated with upsells, shove a watermark on every page, or demand you create an account before you can do anything useful.
That’s exactly the problem iHatePDF.cv was built to solve.

Why Adding Page Numbers to a PDF Is Harder Than It Should Be
Let’s be honest about the PDF ecosystem for a second. It’s dominated by a handful of giants — Adobe Acrobat at the premium end, and a pile of “free” tools in the middle that aren’t really free. You go to edit a PDF and you’re either paying $20/month or dealing with watermarks, file size limits, forced account registrations, and files being uploaded to servers you’ve never heard of.
For something as simple as adding a page number to a document, none of that should be necessary.
Searches like “free pdf editor no sign up”, “add page numbers to pdf free”, and “free online pdf editor without watermark” get tens of thousands of queries every month — and most of the results lead to tools that quietly disappoint. The free tier runs out. The watermark appears. The sign-up wall goes up.

What iHatePDF.cv’s Page Numbers Tool Actually Does
The Add Page Numbers to PDF tool on iHatePDF.cv is refreshingly straightforward. You drop in your PDF, configure how you want the numbers to look, and download. That’s it. No account. No watermark. No upload to a remote server.
Here’s what you can actually configure:

  1. Number Format
    Not everyone wants plain “1, 2, 3.” The tool gives you five formats to choose from:

1, 2, 3 — the standard numeric format
i, ii, iii — Roman numerals, great for front matter in academic documents or legal filings
A, B, C — alphabetical, useful for appendices or exhibit labelling
Page 1 — written-out format, common in formal reports
1 / 10 — shows progress (current page out of total), good for presentations and manuals

  1. Position — Six Placements
    You can place your page number in any of six spots on the page:

Top left, top center, top right
Bottom left, bottom center, bottom right

An interactive position picker in the UI makes this visual and intuitive — you click the spot on a mini page diagram, not a dropdown.

  1. Color Customization
    Seven presets (dark grey, black, red, blue, green, purple, orange) plus a full color picker if you want to match your brand or document style exactly.
  2. Font Size and Weight
    Slider control from 6pt to 48pt, plus a toggle between regular and bold. Small touches, but they matter when the numbers need to blend into a polished document or stand out on a technical one.
  3. Start Number and Skip First Page
    You can start numbering from any number — useful when your PDF is a chapter in a larger document that starts at page 34, for example. There’s also a single checkbox to skip the first page, which is the right behavior for most cover-page documents.

The Privacy Angle: Files Never Leave Your Browser
This is worth spelling out clearly because it’s non-obvious and genuinely important.
iHatePDF.cv processes everything locally in your browser using PDF-lib and PDF.js. Your file is never uploaded to any server. The page numbers are added entirely client-side, in memory, and the resulting file is downloaded directly to your device.
For anyone working with confidential documents — legal contracts, financial reports, HR materials — this isn’t a minor feature. It’s the whole point. Tools that upload your files to process them are making a choice you may not be aware of. iHatePDF.cv made the opposite choice.

Live Preview Before You Commit
One thing that separates this tool from the more barebones free options is the live preview panel. As you adjust the format, position, color, and font size, the first five pages of your PDF update in real time to show you exactly where the numbers will appear and what they’ll look like.
This catches problems before they become problems. You can see immediately if “bottom-center” looks awkward on your particular layout, or if the font size you chose is too small to read comfortably.

Who This Is Actually For
The short answer: anyone who works with PDFs regularly and doesn’t want to pay Adobe’s subscription fee for a one-off formatting task.
More specifically:
Students and researchers — Academic papers, thesis documents, and reports almost universally require page numbers. Roman numerals for the front matter, Arabic numerals for the body is a classic format that this tool handles directly by letting you set a custom start number per section.
Legal and compliance professionals — Legal documents need precise pagination. The ability to choose “Page 1” format or “1 / 10” totals, and to place numbers in a specific corner per organizational style guides, is practical here.
Business users — Proposals, decks exported as PDFs, business reports handed off to clients. Page numbers make these documents look finished and professional.
Developers and technical writers — Documentation and manuals are obvious candidates. The fact that this tool is entirely browser-based also makes it a clean fit for privacy-conscious technical environments.

How It Compares to the Usual Alternatives
ToolFree?Watermark?Upload Required?No Sign-Up?Adobe AcrobatPaidNoYesNoiLovePDFLimitedSometimesYesPartialSmallpdfLimitedSometimesYesPartialiHatePDFYesNoNoYes
The positioning is deliberate. iHatePDF.cv isn’t trying to compete on feature breadth with Acrobat. It’s competing on the things that actually matter for most common PDF tasks: free, fast, private, no friction.

Step-by-Step: Adding Page Numbers in About 30 Seconds

Go to ihatepdf.cv/page-numbers
Click “Choose PDF” or drag and drop your file
Select your number format (1,2,3 / i,ii,iii / A,B,C / Page 1 / 1/10)
Click your preferred position on the page diagram
Adjust color, font size, and weight if needed
Set your start number, toggle “skip first page” if you have a cover
Click Add Numbers
Your numbered PDF downloads instantly

The live preview updates as you go, so by the time you hit the button, you already know exactly what you’re getting.

Final Thought
The best tools get out of your way. They do the one thing they promise, they do it well, and they don’t try to upsell you at every step or hold your file hostage behind a paywall.
The iHatePDF.cv page numbers tool is exactly that. It’s free, it’s private, it works in your browser without an account, and it gives you more formatting control than most tools you’d actually pay for.
If you regularly deal with PDFs — and in 2025, who doesn’t — it’s worth bookmarking.

Try it at ihatepdf.cv/page-numbers — free, no sign up, no watermark.

FastAPI + OCR Pipeline: Should You Use BackgroundTasks or Celery? A Complete Guide

FastAPI + OCR Pipeline: Should You Use BackgroundTasks or Celery? A Complete Guide

You’ve just deployed your document processing system, and everything works great—until your first user uploads a 50-page scanned document at 2 PM on a Tuesday. Their browser hangs. Your FastAPI server is locked up. And you’re frantically googling whether you made a terrible architectural decision.

Welcome to the real-world challenge of building async document processing systems. I’ve been there, and I’m going to walk you through exactly how to avoid this situation—and when to know it’s time to level up your infrastructure.

The Root Cause: Why This Matters

Here’s the thing about OCR processing: it’s computationally expensive. Whether you’re using Tesseract, EasyOCR, or a more sophisticated solution, you’re looking at CPU-intensive operations that can take anywhere from 2-30 seconds per document. In a traditional synchronous architecture, this would completely block your server, making it unable to handle other requests.

FastAPI gives us concurrency through async/await, but there’s a critical distinction many developers miss: async doesn’t mean parallel execution for CPU-bound tasks. OCR is CPU-bound, not I/O-bound. When your Python interpreter is crunching through image processing algorithms, async context switching won’t help—your server thread is still occupied.

This is where the decision between BackgroundTasks and Celery becomes pivotal.

Understanding FastAPI BackgroundTasks

BackgroundTasks is FastAPI’s built-in solution for fire-and-forget operations. It’s elegant, requires zero external dependencies, and works like this:

from fastapi import FastAPI, UploadFile, File, BackgroundTasks
from fastapi.responses import JSONResponse
import uuid
import time
from pathlib import Path

app = FastAPI()

# Simple in-memory store for demo (use database in production)
job_status = {}

def perform_ocr(file_path: str, job_id: str):
    """Simulates OCR processing"""
    try:
        job_status[job_id] = {"status": "processing", "progress": 0}

        # Simulate OCR work
        time.sleep(5)  # Replace with actual OCR

        job_status[job_id] = {
            "status": "completed",
            "progress": 100,
            "result": "Extracted text from document"
        }

        # Cleanup
        Path(file_path).unlink(missing_ok=True)

    except Exception as e:
        job_status[job_id] = {
            "status": "failed",
            "error": str(e)
        }

@app.post("/upload/")
async def upload_document(file: UploadFile = File(...), 
                         background_tasks: BackgroundTasks = None):
    # Save uploaded file
    job_id = str(uuid.uuid4())
    file_path = f"/tmp/{job_id}_{file.filename}"

    with open(file_path, "wb") as f:
        contents = await file.read()
        f.write(contents)

    # Initialize job status
    job_status[job_id] = {"status": "pending"}

    # Queue background task
    background_tasks.add_task(perform_ocr, file_path, job_id)

    return {
        "job_id": job_id,
        "status": "queued",
        "message": "Document queued for processing"
    }

@app.get("/status/{job_id}")
async def get_status(job_id: str):
    if job_id not in job_status:
        return {"error": "Job not found"}, 404

    return job_status[job_id]

Strengths:

  • Zero infrastructure overhead
  • Easy to implement and test
  • Great for prototypes and MVPs
  • No external dependency management

Critical Limitations:

  • Tasks run on the same process
  • No persistence—if the server restarts, tasks are lost
  • No retry logic
  • No task prioritization
  • Limited scalability
  • No distributed processing

For a prototype with light traffic, this works beautifully. But here’s the catch: BackgroundTasks are tied to the lifespan of your application. If you restart the server while processing 10 documents, those jobs simply disappear.

When Celery + Redis Becomes Essential

Let me be direct: Celery is overkill for a research prototype, but it’s necessary the moment you care about reliability.

Celery with Redis provides:

  • Persistent task queues (tasks survive restarts)
  • Automatic retries with exponential backoff
  • Task prioritization and routing
  • Distributed workers (scale horizontally)
  • Progress tracking with hooks
  • Dead letter queues for failed tasks

Here’s a production-ready Celery setup:

from fastapi import FastAPI, UploadFile, File, HTTPException
from celery import Celery
from celery.result import AsyncResult
import uuid
from pathlib import Path
import pytesseract
from PIL import Image
import redis

# Celery configuration
celery_app = Celery(
    "ocr_pipeline",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1"
)

celery_app.conf.update(
    task_serializer='json',
    accept_content=['json'],
    result_serializer='json',
    timezone='UTC',
    enable_utc=True,
    task_track_started=True,
    task_acks_late=True,
    worker_prefetch_multiplier=1,  # Process one task at a time
)

app = FastAPI()
redis_client = redis.Redis(host='localhost', port=6379, db=2)

# Define Celery task
@celery_app.task(bind=True, max_retries=3)
def process_document(self, file_path: str, job_id: str):
    """
    Process document with OCR
    bind=True allows access to self for retries
    max_retries=3 enables automatic retry on failure
    """
    try:
        # Update status
        redis_client.hset(f"job:{job_id}", 
                         mapping={"status": "processing", "progress": "0"})

        # Open image and perform OCR
        image = Image.open(file_path)
        extracted_text = pytesseract.image_to_string(image)

        # Store result
        redis_client.hset(f"job:{job_id}", 
                         mapping={
                             "status": "completed",
                             "progress": "100",
                             "result": extracted_text[:5000]  # Store first 5000 chars
                         })

        # Cleanup
        Path(file_path).unlink(missing_ok=True)

        return {"status": "success", "job_id": job_id}

    except Exception as exc:
        # Automatic retry with exponential backoff
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)

@app.post("/upload/")
async def upload_document(file: UploadFile = File(...)):
    """Accept file upload and queue for processing"""
    job_id = str(uuid.uuid4())
    file_path = f"/tmp/{job_id}_{file.filename}"

    # Save file
    contents = await file.read()
    with open(file_path, "wb") as f:
        f.write(contents)

    # Initialize job in Redis
    redis_client.hset(f"job:{job_id}", 
                     mapping={"status": "pending", "created_at": str(uuid.uuid4())})

    # Queue task with priority
    task = process_document.apply_async(
        args=[file_path, job_id],
        priority=5,  # 0-9, higher = more important
        expires=3600  # Task expires after 1 hour
    )

    return {
        "job_id": job_id,
        "task_id": task.id,
        "status": "queued"
    }

@app.get("/status/{job_id}")
async def get_status(job_id: str):
    """Get current job status"""
    job_data = redis_client.hgetall(f"job:{job_id}")

    if not job_data:
        raise HTTPException(status_code=404, detail="Job not found")

    return {k.decode(): v.decode() for k, v in job_data.items()}

@app.get("/cancel/{job_id}")
async def cancel_job(job_id: str):
    """Cancel a running job"""
    job_data = redis_client.hgetall(f"job:{job_id}")
    if not job_data:
        raise HTTPException(status_code=404, detail="Job not found")

    # Mark as cancelled
    redis_client.hset(f"job:{job_id}", "status", "cancelled")

    return {"status": "Job cancelled"}

The Decision Matrix

Criterion BackgroundTasks Celery + Redis
Prototype/MVP ✅ Excellent ⚠️ Overkill
Task Persistence ❌ Lost on restart ✅ Full persistence
Retries ❌ Manual implementation ✅ Built-in
Scaling ❌ Single process ✅ Infinite workers
Monitoring ❌ Limited ✅ Comprehensive
Setup Time 5 minutes 30-45 minutes
Operational Complexity Low Medium

Recommended OCR Stack

For your use case, I’d recommend:

  1. EasyOCR for general documents (good balance of accuracy and speed)
  2. Tesseract as a lightweight alternative (faster, slightly lower accuracy)
  3. PaddleOCR for non-English documents

Implement a human-in-the-loop correction pipeline:

@celery_app.task(bind=True)
def process_with_confidence_feedback(self, file_path: str, job_id: str):
    """
    Process with confidence scores for human review
    """
    import easyocr

    reader = easyocr.Reader(['en'], gpu=False)
    image = Image.open(file_path)

    results = reader.readtext(str(image))

    extracted_data = {
        "high_confidence": [],
        "needs_review": [],
        "raw_results": []
    }

    for (bbox, text, confidence) in results:
        item = {"text": text, "confidence": confidence}

        if confidence > 0.85:
            extracted_data["high_confidence"].append(item)
        else:
            extracted_data["needs_review"].append(item)

        extracted_data["raw_results"].append(item)

    redis_client.hset(f"job:{job_id}", 
                     "result", 
                     json.dumps(extracted_data))

    return extracted_data

Common Pitfalls and Edge Cases

1. Memory Leaks in BackgroundTasks
BackgroundTasks doesn’t clear references immediately. For large-scale file processing, this accumulates. Solution: Use Celery or implement explicit cleanup.

2. Lost Jobs on Server Restart
BackgroundTasks tasks are memory-only. Always use a persistent queue for production. Even a lightweight solution like RQ (Redis Queue) is better than nothing.

3. Timeout Issues
OCR can take unpredictably long for complex documents. Set realistic timeouts and implement graceful degradation.

4. File System Cleanup
Temporary files can accumulate if processing fails. Always use try/finally or context managers.

5. Concurrent File Access
Multiple workers accessing the same file simultaneously causes corruption. Use unique file names and proper locking.

My Recommendation for Your Research Project

Start with BackgroundTasks, but architect

Want This Automated for Your Business?

I build custom AI bots, automation pipelines, and trading systems that run 24/7 and generate revenue on autopilot.

Hire me on Fiverr — AI bots, web scrapers, data pipelines, and automation built to your spec.

Browse my templates on Gumroad — ready-to-deploy bot templates, automation scripts, and AI toolkits.

Recommended Resources

If you want to go deeper on the topics covered in this article:

Some links above are affiliate links — they help support this content at no extra cost to you.

One sentence in VS Code. My entire Notion workspace becomes a live interactive briefing and the AI handles the rest.

This is a submission for the Notion MCP Challenge

I maintain an open source AI agent SDK. I’m building a startup. I do both alone, from Addis Ababa, at 24, no team.

Every morning I open Notion and spend 15 minutes manually figuring out what’s actually on fire. What’s overdue. What’s tied to which goal. I piece it together across five databases, hold it in working memory, then try to work.

That 15 minutes compounds. Every day. It’s not a productivity problem – it’s a tax on building alone.

People who have a chief of staff don’t pay that tax. I can’t afford one. So I built one.

The Thing Nobody Had Done Before

Before this, “AI + Notion” meant: AI reads your data and writes a text summary back at you. You still had to act on it yourself. You still had to go to Notion and change things.

Chief of Staff breaks both of those constraints at once.

First: the UI lives inside the chat. When you ask for your briefing, a full rendered dashboard appears inside the conversation — task rows, progress bars, overdue indicators, action buttons. It’s not a screenshot. It’s not a link. It’s a live React app running inside an iframe inside VS Code Copilot or Claude. You can interact with it. Check off a task and it’s gone from the list and marked done in Notion in the same click.

Checking a checkbox on a visual task — inside VS Code, without opening Notion, without leaving your editor — that had never been built before.

Second: the agent doesn’t hand you a report. It executes. The action buttons in the dashboard don’t navigate you somewhere. They tell the AI to go do the work. The AI calls the right MCP tool, reasons through the changes, and writes them back to Notion. You direct. It executes. The loop closes inside a single conversation.

This is what a chief of staff actually does. Not informing you. Acting on your behalf.

What I Built

Chief of Staff is an MCP App that reads your Notion workspace every morning and briefs you — then handles the work you tell it to.

You type: “Give me my morning briefing.”

A live, interactive dashboard renders directly inside VS Code Copilot Chat — or Claude. Not a link to an external tool. Not a text summary. A real UI with real data, living inside your editor. You can click a checkbox and the task is marked done in Notion. You can click a button and the agent reschedules your entire overdue pile. You never leave your editor.

That’s new. Nobody had shipped this before.

  • ⚡ Plan my week → the AI generates a task breakdown and creates every task directly in your Notion database
  • 📅 Reschedule overdue tasks → the AI looks at everything overdue, picks sensible new dates based on priority, patches them all in Notion. The guilt pile disappears.
  • 📋 Write weekly review → the AI pulls your completed tasks, synthesizes what happened, writes a full structured page into your workspace
  • 🎯 Break down stalled goal → the AI takes a goal sitting at 5% and creates 4-6 concrete sub-tasks with due dates in Notion

The briefing is the interface. Notion is where the work lands.

GitHub: https://github.com/Garinmckayl/chief-of-staff

How I Used Notion MCP

Notion MCP is the reason the write path exists. Without it I’d need custom integrations per action. With it, the AI can read and write across the entire workspace through one protocol, and every agent tool is just a description of what needs to happen.

The 8 MCP tools

Tool What it does
chief_of_staff_briefing Renders the live interactive dashboard as an MCP App
get_notion_briefing_data Reads your workspace — discovers databases dynamically, no hardcoded IDs
complete_notion_task Marks a task done — detects whether Status is a select, native status, or checkbox
create_notion_tasks Writes an AI-generated task plan straight into your Notion database
reschedule_overdue_tasks Updates due dates — the AI picks the dates and explains each one
write_weekly_review Creates a structured weekly review page in your workspace
break_down_goal Generates sub-tasks for a stalled goal and creates them in Notion
get_completed_tasks Fetches done tasks from the past N days for the weekly review

The generative UI layer

The interactive dashboard is a live React app that renders inside the chat – compiled to a single self-contained HTML string and returned as a tool response. When the AI calls chief_of_staff_briefing, the entire UI materialises: task rows, progress bars, overdue indicators, action buttons. All driven by your real Notion data.

The component catalog defines everything the AI can compose:

FocusCard      — the single most important thing right now
TaskList       — grouped task rows with heading and count
TaskRow        — individual task with completion checkbox that writes to Notion
GoalProgress   — progress bar with live percentage
InsightBadge   — win / tip / warning / pattern pill
AgentAction    — the button that triggers real Notion writes
SectionHeader  — section divider

The AI fills this catalog from your actual Notion data. Every task row is real. Every progress bar reflects a real goal. The AgentAction component fires an event that the AI receives and routes to the right MCP tool. Visual layer and execution layer are the same system.

The agentic loop

The dashboard and the agent tools are two halves of the same system. The briefing shows the situation. The AgentAction buttons close the loop.

When you click “Reschedule overdue tasks,” the AI gets a run_agent event, calls get_notion_briefing_data to see what’s actually overdue, reasons about dates based on priority, and calls reschedule_overdue_tasks with the full update list. Notion gets patched. You touched nothing.

mcpServer.tool(
  "reschedule_overdue_tasks",
  `Reschedule overdue tasks by updating their due dates in Notion.
  First call get_notion_briefing_data to get current overdue tasks.
  Decide sensible new due dates based on priority and today's date.
  Spread them out — don't dump everything on one day.`,
  {
    updates: z.array(z.object({
      taskId: z.string(),
      newDueDate: z.string(),
      reason: z.string(),
    })),
  },
  async ({ updates }) => {
    const results = await rescheduleTasks(updates);
    return { content: [{ type: "text", text: JSON.stringify(results) }] };
  }
);

The reason field is intentional. The AI isn’t just moving dates — it’s explaining why. You can see the reasoning in the tool call output. That’s what makes it feel like delegation rather than automation.

Dynamic workspace discovery

No hardcoded database IDs. The system discovers your workspace by reading property shapes — it inspects what fields each database has, not what it’s named. Databases with progress or percent fields are classified as goal trackers. Databases with status or due date fields are classified as task lists. This means it adapts to however you’ve structured your workspace — different column names, different layouts, different numbers of databases.

// Goals have progress fields — exclude them from task DBs
if (hasProgress) {
  goalDbs.push({ id: db.id, name: title });
} else if (hasStatus || hasDue) {
  taskDbs.push({ id: db.id, name: title });
}

Works on any Notion workspace structure, out of the box.

The parts that were actually hard

completeTask silently did nothing for weeks. It was calling the Notion native status type, but most databases use a select field for Status. The silent fallback was to archive the page instead. Fixed it by reading the page schema first and detecting the actual property type before writing.

Goal databases kept appearing as task databases. Any database with a Status column and a date field got classified as tasks. My Goals DB has both. Fixed by checking for a progress/percent field first — if it exists, it’s a goal DB.

Neither was hard to fix. Both would silently break the demo if I hadn’t caught them.

Technical Stack

Layer What
MCP server @modelcontextprotocol/sdk — stdio + StreamableHTTP transports
Generative UI React app compiled to a single HTML string, served as a tool response, rendered live inside the chat
Notion writes Direct REST API with dynamic schema detection
Bundler Vite + vite-plugin-singlefile (entire React app as one inlined HTML string)
Runtime Node.js + tsx

Run it in 60 seconds with GitHub Codespaces — the repo includes devcontainer.json with everything pre-configured, port 3333 forwarded, NOTION_API_KEY as the only required secret.

git clone https://github.com/Garinmckayl/chief-of-staff
cd chief-of-staff && npm install && npm run build
NOTION_API_KEY=your_key npm run start:stdio

Why This Matters

Before Chief of Staff, “AI + your data” meant a smarter search or a better summary. You still had to act on the output yourself. The AI was a reader. You were still the writer.

Chief of Staff makes the AI the writer too. It reads your workspace, shows you the situation visually, and when you point it at a problem — it fixes it. All in Notion. None of it requiring you to open a single Notion page.

I built this because I needed it. I’m a solo founder in Addis Ababa, maintaining open source infrastructure, building a startup, without a team, in a city where many of the tools the rest of the world assumes you have aren’t available to you. Claude Desktop doesn’t work here. I demo this in VS Code Copilot because that’s what I actually have access to.

That constraint shaped everything. It works with what you have. One workspace, one API key, one command.

Three weeks ago, building an interactive visual app that lives inside VS Code wasn’t possible. Now it is. And the first thing I built with it was a chief of staff — because that’s what I needed most.

This isn’t productivity software. It’s what happens when the person who builds infrastructure finally gets some infrastructure of their own.

Built for the Notion MCP Challenge
GitHub: https://github.com/Garinmckayl/chief-of-staff

Sentry Has a Free API: Here’s How to Use It for Error Tracking Automation

Sentry’s API goes far beyond just reporting errors. You can manage projects, query issues, automate triage, track releases, and build custom error monitoring dashboards — all via REST.

Why Use the Sentry API?

  • Automate issue triage and assignment
  • Track error trends across releases
  • Build custom alerting beyond Sentry’s defaults
  • Integrate error data into your incident management

Getting Started

Get an auth token from sentry.io > Settings > Auth Tokens:

export SENTRY_TOKEN="your-auth-token"
export SENTRY_ORG="your-org"

# List projects
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" 
  "https://sentry.io/api/0/projects/" | jq '.[] | {slug: .slug, platform: .platform, dateCreated: .dateCreated}'

# Get recent issues
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" 
  "https://sentry.io/api/0/projects/$SENTRY_ORG/my-project/issues/?query=is:unresolved&sort=date" 
  | jq '.[] | {title: .title, count: .count, firstSeen: .firstSeen, level: .level}'

Python Client

import requests

class SentryClient:
    def __init__(self, token, org):
        self.url = "https://sentry.io/api/0"
        self.headers = {'Authorization': f'Bearer {token}'}
        self.org = org

    def get_projects(self):
        resp = requests.get(f"{self.url}/organizations/{self.org}/projects/", headers=self.headers)
        return resp.json()

    def get_issues(self, project, query='is:unresolved', sort='date', limit=25):
        resp = requests.get(
            f"{self.url}/projects/{self.org}/{project}/issues/",
            params={'query': query, 'sort': sort, 'limit': limit},
            headers=self.headers
        )
        return resp.json()

    def get_issue_events(self, issue_id, limit=10):
        resp = requests.get(
            f"{self.url}/issues/{issue_id}/events/",
            params={'limit': limit},
            headers=self.headers
        )
        return resp.json()

    def resolve_issue(self, issue_id):
        resp = requests.put(
            f"{self.url}/issues/{issue_id}/",
            json={'status': 'resolved'},
            headers=self.headers
        )
        return resp.json()

    def assign_issue(self, issue_id, email):
        resp = requests.put(
            f"{self.url}/issues/{issue_id}/",
            json={'assignedTo': email},
            headers=self.headers
        )
        return resp.json()

    def create_release(self, project, version, commits=None):
        payload = {'version': version, 'projects': [project]}
        if commits:
            payload['commits'] = commits
        resp = requests.post(
            f"{self.url}/organizations/{self.org}/releases/",
            json=payload,
            headers=self.headers
        )
        return resp.json()

# Usage
sentry = SentryClient('your-token', 'your-org')

# Check unresolved issues
issues = sentry.get_issues('my-project', query='is:unresolved level:error')
for issue in issues:
    print(f"[{issue['level']:7s}] {issue['title'][:60]:60s} ({issue['count']} events)")

Automated Issue Triage

def auto_triage(sentry, project):
    issues = sentry.get_issues(project, query='is:unresolved !has:assignee', sort='priority')

    routing_rules = {
        'database': '[email protected]',
        'timeout': '[email protected]',
        'auth': '[email protected]',
        'payment': '[email protected]',
        'render': '[email protected]',
        'api': '[email protected]'
    }

    for issue in issues:
        title_lower = issue['title'].lower()
        assigned = False

        for keyword, assignee in routing_rules.items():
            if keyword in title_lower:
                sentry.assign_issue(issue['id'], assignee)
                print(f"Assigned '{issue['title'][:50]}' to {assignee}")
                assigned = True
                break

        if not assigned:
            # High-count errors go to on-call
            if issue['count'] > 100:
                sentry.assign_issue(issue['id'], '[email protected]')
                print(f"HIGH PRIORITY: '{issue['title'][:50]}' -> oncall")

auto_triage(sentry, 'my-project')

Error Rate Monitoring

def error_rate_report(sentry, project, hours=24):
    from datetime import datetime, timedelta

    since = (datetime.utcnow() - timedelta(hours=hours)).isoformat() + 'Z'

    # Get issues sorted by frequency
    issues = sentry.get_issues(project, query=f'is:unresolved firstSeen:>{since}', sort='freq')

    total_events = sum(int(i.get('count', 0)) for i in issues)

    print(f"=== Error Report (last {hours}h) ===")
    print(f"New issues: {len(issues)}")
    print(f"Total events: {total_events}")
    print()

    if issues:
        print("Top 10 errors:")
        for i, issue in enumerate(issues[:10], 1):
            print(f"  {i:2d}. [{issue['level']:5s}] {issue['title'][:55]:55s} x{issue['count']}")

error_rate_report(sentry, 'my-project')

Release Tracking

def deploy_with_sentry(sentry, project, version, commits):
    # Create release
    release = sentry.create_release(project, version, commits=[
        {'id': c['sha'], 'message': c['message']} for c in commits
    ])
    print(f"Release {version} created")

    # Mark as deployed
    resp = requests.post(
        f"{sentry.url}/organizations/{sentry.org}/releases/{version}/deploys/",
        json={'environment': 'production', 'name': f'Deploy {version}'},
        headers=sentry.headers
    )
    print(f"Marked as deployed to production")

    return release

# After deployment, monitor for regressions
def check_regression(sentry, project, version, threshold_minutes=30):
    import time
    time.sleep(threshold_minutes * 60)

    issues = sentry.get_issues(project, query=f'is:unresolved release:{version}')

    if len(issues) > 5:
        print(f"REGRESSION DETECTED: {len(issues)} new issues in {version}!")
        for issue in issues[:5]:
            print(f"  - {issue['title']} (x{issue['count']})")
        return True

    print(f"Release {version} looks healthy ({len(issues)} issues)")
    return False

Real-World Use Case

A SaaS company with 50+ microservices built an automated incident pipeline with Sentry API. When a new error appears with 100+ events in 5 minutes, the system: (1) creates a PagerDuty incident, (2) assigns the issue to the right team based on error type, (3) collects recent deployment info, and (4) posts a summary to Slack with stack trace and affected users. Mean time to awareness dropped from 15 minutes to 30 seconds.

What You Can Build

  • Auto-triage system routing errors to the right team
  • Release quality gate blocking deploys with high error rates
  • Error budget tracker for SLO compliance
  • Incident reporter generating post-mortems from error data
  • Custom alerting beyond Sentry’s built-in rules

Need custom error monitoring solutions? I build observability tools and DevOps pipelines.

Email me: [email protected]
Check out my developer tools: https://apify.com/spinov001

Jenkins Has a Free API: Here’s How to Use It for CI/CD Automation

Jenkins REST API is one of the most overlooked features of the most popular CI/CD server. You can trigger builds, check status, manage plugins, create jobs, and build custom dashboards — all via simple HTTP calls.

Why Use the Jenkins API?

  • Trigger builds from Slack, GitHub webhooks, or custom tools
  • Monitor build health across all pipelines
  • Automate job creation and configuration
  • Build custom CI/CD dashboards

Getting Started

export JENKINS_URL="http://localhost:8080"
export JENKINS_USER="admin"
export JENKINS_TOKEN="your-api-token"

# Get Jenkins info
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" "$JENKINS_URL/api/json" | jq '{mode: .mode, numExecutors: .numExecutors, jobs: [.jobs[] | {name: .name, color: .color}]}'

# Trigger a build
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" -X POST "$JENKINS_URL/job/my-project/build"

# Trigger with parameters
curl -s -u "$JENKINS_USER:$JENKINS_TOKEN" -X POST "$JENKINS_URL/job/deploy/buildWithParameters?ENVIRONMENT=staging&VERSION=2.1.0"

Python Client

import requests
import time

class JenkinsClient:
    def __init__(self, url, user, token):
        self.url = url
        self.auth = (user, token)

    def get_jobs(self):
        resp = requests.get(f"{self.url}/api/json", auth=self.auth)
        return resp.json()['jobs']

    def get_build_info(self, job, build_number):
        resp = requests.get(f"{self.url}/job/{job}/{build_number}/api/json", auth=self.auth)
        return resp.json()

    def get_last_build(self, job):
        return self.get_build_info(job, 'lastBuild')

    def trigger_build(self, job, parameters=None):
        if parameters:
            resp = requests.post(f"{self.url}/job/{job}/buildWithParameters", params=parameters, auth=self.auth)
        else:
            resp = requests.post(f"{self.url}/job/{job}/build", auth=self.auth)
        return resp.status_code in (200, 201)

    def get_console_output(self, job, build_number):
        resp = requests.get(f"{self.url}/job/{job}/{build_number}/consoleText", auth=self.auth)
        return resp.text

    def wait_for_build(self, job, build_number=None, timeout=300):
        if not build_number:
            time.sleep(3)
            build_number = self.get_last_build(job)['number']

        start = time.time()
        while time.time() - start < timeout:
            info = self.get_build_info(job, build_number)
            if not info.get('building', True):
                return info
            time.sleep(5)
        raise TimeoutError(f"Build {build_number} timed out")

# Usage
jenkins = JenkinsClient('http://localhost:8080', 'admin', 'your-token')

# Check all jobs
for job in jenkins.get_jobs():
    status = 'PASS' if job['color'] == 'blue' else 'FAIL' if 'red' in job['color'] else job['color']
    print(f"{status:6s} | {job['name']}")

# Deploy and wait
jenkins.trigger_build('deploy', {'ENVIRONMENT': 'staging', 'VERSION': '2.1.0'})
result = jenkins.wait_for_build('deploy')
print(f"Build #{result['number']}: {result['result']}")

Build Health Dashboard

def build_health_report(jenkins):
    jobs = jenkins.get_jobs()

    print(f"{'Job':40s} {'Status':8s} {'Last Build':12s} {'Duration':10s}")
    print('-' * 75)

    for job in jobs:
        try:
            last = jenkins.get_last_build(job['name'])
            status = last.get('result', 'BUILDING')
            duration = last.get('duration', 0) / 1000
            timestamp = time.strftime('%Y-%m-%d', time.localtime(last['timestamp']/1000))
            print(f"{job['name']:40s} {status:8s} {timestamp:12s} {duration:8.1f}s")
        except Exception:
            print(f"{job['name']:40s} {'N/A':8s}")

build_health_report(jenkins)

Create Jobs Programmatically

def create_pipeline_job(jenkins, name, git_url, branch='main', jenkinsfile='Jenkinsfile'):
    config_xml = f"""<?xml version='1.1' encoding='UTF-8'?>
<flow-definition>
  <description>Auto-generated pipeline</description>
  <definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition">
    <scm class="hudson.plugins.git.GitSCM">
      <userRemoteConfigs>
        <hudson.plugins.git.UserRemoteConfig>
          <url>{git_url}</url>
        </hudson.plugins.git.UserRemoteConfig>
      </userRemoteConfigs>
      <branches>
        <hudson.plugins.git.BranchSpec>
          <name>*/{branch}</name>
        </hudson.plugins.git.BranchSpec>
      </branches>
    </scm>
    <scriptPath>{jenkinsfile}</scriptPath>
  </definition>
</flow-definition>"""

    resp = requests.post(
        f"{jenkins.url}/createItem?name={name}",
        data=config_xml,
        headers={'Content-Type': 'application/xml'},
        auth=jenkins.auth
    )
    return resp.status_code == 200

create_pipeline_job(jenkins, 'my-new-service', 'https://github.com/org/repo.git')

Slack Notification Integration

def notify_build_result(jenkins, job, build_number, slack_webhook):
    info = jenkins.get_build_info(job, build_number)

    color = '#36a64f' if info['result'] == 'SUCCESS' else '#ff0000'
    duration = info['duration'] / 1000

    payload = {
        'attachments': [{
            'color': color,
            'title': f"{job} #{build_number}: {info['result']}",
            'fields': [
                {'title': 'Duration', 'value': f"{duration:.1f}s", 'short': True},
                {'title': 'Branch', 'value': 'main', 'short': True}
            ],
            'footer': 'Jenkins CI'
        }]
    }

    requests.post(slack_webhook, json=payload)

Real-World Use Case

A development team with 200+ Jenkins jobs built a custom dashboard using the Jenkins API. It showed build health across all projects, highlighted flaky tests, and automatically triggered dependent builds when upstream projects succeeded. The dashboard reduced “is the build broken?” questions by 80%.

What You Can Build

  • Custom CI/CD dashboard with real-time build status
  • Auto-trigger system for dependent pipelines
  • Flaky test detector analyzing build history
  • Deploy bot triggered from Slack or Discord
  • Build analytics tracking speed and reliability trends

Need custom CI/CD automation? I build DevOps tools and pipelines.

Email me: [email protected]
Check out my developer tools: https://apify.com/spinov001