DEV Community

Cover image for I built a booking system for Newsletter Ads. Here's every decision I made.
Jude Miracle
Jude Miracle

Posted on

I built a booking system for Newsletter Ads. Here's every decision I made.

I created the booking flow today. This marks the shift of Adsloty from being just a backend project to becoming a real product.

In my last two posts, I talked about important but hidden features like authentication and payments. While these are necessary, they aren't what draws people to the platform.

The booking flow is different. This is what users will actually engage with. It’s the process a sponsor goes through to find a newsletter, choose a date, submit their ad, and make a payment. It’s also the experience for a writer when a new booking request arrives in their dashboard, complete with an AI analysis.

If this experience is complicated, then nothing else matters.

What I was aiming for

I wanted booking an ad slot to be as easy as booking a flight. You browse, choose, pay, and you’re done. It shouldn’t feel like filling out a loan application or sending a cold email and waiting three days for a reply.

Adsloty aims to remove the hassle between deciding to sponsor a newsletter and completing the booking. Each extra step, unnecessary form field, or promise of “we’ll get back to you” makes it easier for someone to leave the page.

I designed the process for speed and clarity.

The sponsor's experience

Here's what sponsors see, step by step:

First, they discover. Sponsors look through newsletters grouped by topic—like tech, finance, wellness, or the creator economy—that match their products. Each listing shows key details: the number of subscribers, average open rates, cost per slot, and available dates.

// Newsletter discovery with filters
const { data: newsletters } = useQuery({
  queryKey: ['newsletters', { niche, minSubscribers, maxPrice, sortBy }],
  queryFn: () => newsletterApi.search({
    niche,
    min_subscribers: minSubscribers,
    max_price: maxPrice,
    sort_by: sortBy,
    page,
  }),
});
Enter fullscreen mode Exit fullscreen mode

No vanity metrics and no vague pricing. Everything is clear and easy to understand. A sponsor should be able to evaluate a newsletter in under 30 seconds.

Next is the booking form. After choosing a newsletter, the sponsor selects an available date and fills in their ad details: title, description, call-to-action text, link, and an optional image. That’s all they need to do.

I spent a lot of time deciding which fields to make required. My first version had twelve fields. I cut it down to six. Each field I removed was something I thought was nice to have but would slow people down. You can ask for more information later, but you can't win back a user who left because the form felt like homework.

const bookingSchema = z.object({
  newsletter_date: z.string().min(1, "Pick a date"),
  ad_title: z.string().min(5).max(100),
  ad_description: z.string().min(20).max(500),
  ad_url: z.string().url("Enter a valid URL"),
  ad_cta_text: z.string().max(30).optional(),
  ad_image_url: z.string().url().optional(),
});
Enter fullscreen mode Exit fullscreen mode

First, payment is easy. With one click, the sponsor opens Stripe Checkout. They see exactly what they're paying, confirm the payment, and that’s it. No need to enter card details on my site. I don't have to maintain or secure a custom payment form. Stripe's checkout takes care of everything—this includes PCI compliance, 3D Secure, Apple Pay, and Google Pay. I receive a notification when the payment is confirmed.

The whole process from browsing newsletters to confirming payment can take less than two minutes. That was the goal.

In the future I plan making it a escrow based system.

The writer's experience

The writer's dashboard shows a new booking request. It doesn’t just say "someone wants to buy an ad slot." It provides additional context.

This is where the AI analysis comes in.

When a sponsor submits their ad, the backend sends it to Google's Gemini API along with the newsletter's audience data. Before the writer even looks at the request, the AI has already scored it.:

// AI analysis runs automatically when a booking is created
let analysis = gemini_client.analyze_ad(AdAnalysisRequest {
    ad_title: &booking.ad_title,
    ad_description: &booking.ad_description,
    ad_url: &booking.ad_url,
    ad_cta_text: booking.ad_cta_text.as_deref(),
    newsletter_niche: &newsletter.niche,
    newsletter_audience_description: &newsletter.audience_description,
    subscriber_count: newsletter.subscriber_count,
    average_open_rate: newsletter.open_rate,
}).await?;

// Returns structured data, not a wall of text
// {
//   fit_score: 82,
//   tone_analysis: "Professional, Direct",
//   clarity_rating: "High",
//   estimated_clicks_min: 45,
//   estimated_clicks_max: 120,
//   recommendations: [
//     "CTA could be more specific — 'Start free trial' outperforms 'Learn more'",
//     "Ad copy aligns well with tech audience expectations",
//     "Consider adding a specific metric or social proof"
//   ]
// }
Enter fullscreen mode Exit fullscreen mode

You will receive a fit score between 0 and 100 for your ad. The score is based on tone analysis, clarity rating, and an estimated click range. You will also get three to five specific recommendations.

You don’t have to wonder if an ad is a good fit. Just open the request to see the score, read the recommendations, and decide whether to approve or reject with one button.

Initially, I thought about automatically rejecting ads that scored below a certain level. I chose not to do that. The AI is a tool to help, not a barrier. A score of 40 might be just right for a writer starting out who wants to earn money. A score of 90 might still be rejected if the writer doesn't like the brand. The writer makes the final decision, while the AI provides helpful information.

The state machine

Here's where things became more complex.

A booking is not simply "booked" or "not booked." It has a process. This process must be clear and organized because money is involved in every step.

draft → pending_payment → paid → confirmed → completed
                                           → rejected → refunded
                       → expired
                       → cancelled → refunded
Enter fullscreen mode Exit fullscreen mode

Let me explain each step:

  • Draft. The sponsor started filling out the form but hasn’t paid yet. They may have gotten distracted or are comparing newsletters. Their booking is still in the system, so they can return to it later.
  • Pending payment. The sponsor clicked "Book now" and was directed to Stripe Checkout. We are waiting for the payment confirmation.
  • Paid. Stripe confirmed the payment. The writer gets notified, and we run an AI analysis. The booking appears in the writer's dashboard.
  • Confirmed. The writer approved the ad. It is locked in for the scheduled date, and a payout record is created with a "pending" status.
  • Completed. The publication date has passed. Our system marks it as complete and processes the payout to the writer.
  • Rejected. The writer declined the ad. The sponsor receives a full refund, and the payout record is canceled.
  • Expired. The writer did not respond within the time limit, so we automatically refund the sponsor.
  • Cancelled. The sponsor canceled the booking before the writer confirmed it. We process a refund.

Each of these steps triggers different actions—like updating the database, sending email notifications, or processing payouts and refunds. There are rules about which steps can follow others. For example, you can’t go from "completed" back to "draft," and you can’t reject a booking that has already been refunded.

impl BookingStatus {
    pub fn can_transition_to(&self, next: &BookingStatus) -> bool {
        matches!(
            (self, next),
            (Self::Draft, Self::PendingPayment)
                | (Self::PendingPayment, Self::Paid)
                | (Self::PendingPayment, Self::Expired)
                | (Self::Paid, Self::Confirmed)
                | (Self::Paid, Self::Rejected)
                | (Self::Confirmed, Self::Completed)
                | (Self::Confirmed, Self::Cancelled)
                | (Self::Rejected, Self::Refunded)
                | (Self::Cancelled, Self::Refunded)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

I handle this in the backend. Every status update first checks with can_transition_to. If the transition isn’t valid, it shows an error. There are no exceptions. This one function has likely prevented more bugs than anything else I’ve written for this project.

Notifications at every step

Nobody should be confused about their booking status. So, I created email notifications for each important update:

  • When the sponsor pays, they get an email: "Your booking is confirmed, waiting for writer approval."
  • When a writer receives a new request, they get: "New ad request for [newsletter name] — review it now."
  • When the writer approves the ad, the sponsor gets: "Your ad has been approved for [date]."
  • If the writer rejects it, the sponsor gets: "Your ad wasn't approved — a refund is on its way."
  • After the ad completes, the writer gets: "Payout of $X is being processed."
  • When the payout arrives, the writer gets: "You've been paid."
// After writer confirms a booking
async fn confirm_booking(state: &AppState, booking_id: Uuid) -> AppResult<()> {
    let booking = db::booking::find_by_id(&state.db, booking_id).await?;

    // Validate state transition
    if !booking.status.can_transition_to(&BookingStatus::Confirmed) {
        return Err(AppError::BadRequest("Cannot confirm this booking".into()));
    }

    // Update status
    db::booking::update_status(&state.db, booking_id, BookingStatus::Confirmed).await?;

    // Create pending payout
    db::payout::create_payout_tx(
        &mut tx, booking_id, booking.writer_id,
        booking.writer_payout, &booking.currency, "pending",
    ).await?;

    // Notify the sponsor
    send_booking_confirmed_email(state, &booking).await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The emails are straightforward. They explain what happened and what will happen next, without any sales language. When it comes to money, being clear is more important than being clever.

The embeddable widget

I built something this week that I'm really excited about: writers can now add a booking widget directly to their newsletter website.

<!-- Drop this on your site -->
<script
  src="https://adsloty.com/widget.js"
  data-writer-id="your-writer-id"
  data-color="#4F46E5"
  data-button-text="Sponsor this newsletter"
></script>
Enter fullscreen mode Exit fullscreen mode

It creates a button that sponsors can click to view the writer's available dates and pricing. They can start the booking process directly from the writer's site without needing to visit Adsloty. This makes the writer's website a sales page for their ads.

Writers can customize the colors, button text, and placement to ensure it fits seamlessly with their site. I am also tracking impressions, clicks, and conversion rates for each widget, so writers can see how many visitors become sponsors.

What's still rough

I won't pretend it's perfect. The discovery page needs better filters and search options. The user interface works, but it still doesn't feel great. The AI analysis can take a few seconds, so I need to add proper loading states instead of the spinner I added in a hurry.

My analytics are basic. I'm tracking numbers, but I need to present them better. Writers should be able to see trends, compare months, and know which topics generate the most revenue. That's in the works.

But the core loop works

A sponsor can find a newsletter, book a slot, pay, and submit their ad. A writer can review the request using AI analysis, approve it, and get paid automatically after the publication date.

This is the core of the product. Everything else is just added features.

If you run a newsletter and want to try this during the beta phase—or if you are a brand that has found sponsoring newsletters difficult—I would really like to know what you think. What would make this more useful for you?

More updates soon.

Top comments (0)