Tools
Tools: I integrated Stripe into a two-sided marketplace. Here's what actually happens
2026-02-23
0 views
admin
Stripe Connect: paying writers directly ## The payment flow ## Webhooks ## The payout lifecycle ## Edge cases ## What I learned this week ## What's next I spent all day making money move. It was harder than I expected. At some point in building a marketplace, you shift from adding features to setting up the behind-the-scenes systems. This includes payment processing. This work often goes unnoticed and rarely gets shared online, but if mistakes happen, real people can lose real money. That’s how I spent my entire week. Adsloty is a marketplace where sponsors pay to reserve ad slots in newsletters. Writers earn money after the ads run. While this sounds simple, there is a complex system behind it that ensures every penny goes to the right place at the right time. Here’s what I built, what went wrong, and what I learned. The first decision was about how writers get paid. I could collect all the money myself and manually send payouts. However, that would be a nightmare to handle and a legal headache right from the start. Instead, I chose Stripe Connect. Each writer links their own Stripe account to Adsloty. When a sponsor books an ad slot, the payment goes through our platform. Stripe automatically splits the payment: the writer's share goes to their connected account, and the platform fee stays with me. The onboarding flow looks like this: The writer clicks a link, enters their banking details on Stripe's secure page, and then returns. I never handle their bank information. Stripe takes care of identity checks and verification. That alone makes the processing fee worth it. However, I did not expect that onboarding would not always be quick. Sometimes, Stripe needs to verify identities, which can take hours or even days. So, I had to keep track of the onboarding status and stop writers from accepting paid bookings until their accounts are fully active. When a sponsor wants to book an ad slot, here's what happens: The platform charges a 10% fee. I carefully considered this amount. If it’s too high, writers will leave. If it’s too low, the business won’t be sustainable. I believe 10% is fair for the services the platform offers, including discovery, booking management, AI analysis, and payment handling. During the beta phase, I will not charge this fee at all for the first three months to attract early users. I made the fee adjustable using an environment variable. I've noticed that hardcoding financial details is a common mistake in many projects. When running a promotion or changing prices, you don't want to have to redeploy. Stripe does not simply tell your frontend that a payment succeeded and leave it at that. The actual confirmation comes through webhooks. Stripe sends an HTTP request to your backend with the event details. This is the accurate source of information, not the redirect URL or the frontend callback. It’s the webhook that matters. However, webhooks can arrive late, arrive twice, or not arrive at all (and then retry). Your code needs to handle all these scenarios. Here are a few important points to keep in mind: First, verify signatures. Every webhook from Stripe is signed. If you skip this step, anyone could send fake "payment succeeded" messages to your endpoint and get free products. In a marketplace that handles money, this check is essential. Second, handle events only once. I store every webhook event ID in the database before processing it. If Stripe sends the same event again, I check the ID, see it's already been handled, and return a 200 response without doing anything. This prevents issues like processing the same payment twice or creating duplicate bookings. Third, save the original event before I process it. If my processing fails partway through, I still have the event saved. I can replay it, debug it, or solve any issues manually. When money is involved, it’s crucial to have a clear record. This is where it gets interesting. A payout isn't just about sending money to the writer. It has a process that involves several stages. When a sponsor makes a payment, we create a payout record that has a status of "pending." This record stays in that state until the ad's publication date arrives. After that date, a background job processes it. Why can't we pay writers right away when the sponsor pays? It’s because the ad hasn’t run yet. If the writer doesn’t publish the ad or the sponsor asks for a refund before the date, we need to cancel the payout. Once we send the money, getting it back can be very difficult. The happy path is straightforward: the sponsor pays, the ad runs, and the writer gets paid. But then reality hits: Sometimes, sponsors change their minds before the ad is published. When that happens, I need to refund the entire amount, cancel the pending payment, and update the booking status. I must do all of this in one transaction so everything stays consistent. Failed Transfers. Sometimes, Stripe cannot send money to a writer's account. This might happen if their bank rejects the transfer or if there are issues with their Stripe account. When this occurs, the payout is marked as failed. I must inform the writer so they can update their account details and try again. Disputes. A sponsor's bank may start a chargeback. This is the worst-case scenario. The money gets taken back, and I need to handle it carefully. This involves updating the booking, notifying both parties, and possibly flagging the account. Each of these situations requires its own solution, state updates, and notifications. It may not be exciting work, but if we skip any steps, someone will eventually lose money and trust. Payments in a marketplace are not just a feature; they are the foundation of the system. You can't simply add them in later—they impact everything. This includes the booking flow, the notification system, the user dashboard, the admin tools, error handling, and background jobs. Everything is connected. If I could give myself one piece of advice, it would be to start with the payment flow first. Design the booking process around how money moves, not the other way around. I followed a similar path, but I had to go back and make changes because I had incorrect assumptions about how payments work. Additionally, read Stripe's documentation thoroughly. Don’t just skim the quickstart guide. Pay attention to the webhook best practices, the Connect account lifecycle, and the dispute handling guide. It may be dense, but every page will save you a lot of time later when you need to debug issues. The booking process is where a sponsor selects a newsletter, picks a date, writes their advertisement, and makes a payment. This is also where our AI fit scoring comes in, analyzing the advertisement in relation to the newsletter's audience before the writer sees it. This is what makes Adsloty unique. Payments are the base, but the booking experience is the core product. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
// Generate a Stripe Connect onboarding link for the writer
let account_link = stripe_client .create_account_link( &writer.stripe_account_id, &format!("{}/dashboard/settings?stripe=return", config.frontend_url), &format!("{}/dashboard/settings?stripe=refresh", config.frontend_url), "account_onboarding", ) .await?; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// Generate a Stripe Connect onboarding link for the writer
let account_link = stripe_client .create_account_link( &writer.stripe_account_id, &format!("{}/dashboard/settings?stripe=return", config.frontend_url), &format!("{}/dashboard/settings?stripe=refresh", config.frontend_url), "account_onboarding", ) .await?; CODE_BLOCK:
// Generate a Stripe Connect onboarding link for the writer
let account_link = stripe_client .create_account_link( &writer.stripe_account_id, &format!("{}/dashboard/settings?stripe=return", config.frontend_url), &format!("{}/dashboard/settings?stripe=refresh", config.frontend_url), "account_onboarding", ) .await?; CODE_BLOCK:
// Creating the checkout session with the platform fee split
let platform_fee = calculate_platform_fee(slot_price); let session = stripe_client .create_checkout_session(CheckoutParams { amount: slot_price, currency: "usd", application_fee_amount: platform_fee, transfer_data_destination: writer.stripe_account_id, metadata: BookingMetadata { booking_id, writer_id, sponsor_id, newsletter_date, }, success_url: format!("{}/bookings/{}?status=success", frontend_url, booking_id), cancel_url: format!("{}/bookings/{}?status=cancelled", frontend_url, booking_id), }) .await?; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// Creating the checkout session with the platform fee split
let platform_fee = calculate_platform_fee(slot_price); let session = stripe_client .create_checkout_session(CheckoutParams { amount: slot_price, currency: "usd", application_fee_amount: platform_fee, transfer_data_destination: writer.stripe_account_id, metadata: BookingMetadata { booking_id, writer_id, sponsor_id, newsletter_date, }, success_url: format!("{}/bookings/{}?status=success", frontend_url, booking_id), cancel_url: format!("{}/bookings/{}?status=cancelled", frontend_url, booking_id), }) .await?; CODE_BLOCK:
// Creating the checkout session with the platform fee split
let platform_fee = calculate_platform_fee(slot_price); let session = stripe_client .create_checkout_session(CheckoutParams { amount: slot_price, currency: "usd", application_fee_amount: platform_fee, transfer_data_destination: writer.stripe_account_id, metadata: BookingMetadata { booking_id, writer_id, sponsor_id, newsletter_date, }, success_url: format!("{}/bookings/{}?status=success", frontend_url, booking_id), cancel_url: format!("{}/bookings/{}?status=cancelled", frontend_url, booking_id), }) .await?; COMMAND_BLOCK:
pub fn calculate_platform_fee(amount: Decimal) -> Decimal { let fee_percentage = Decimal::from_str( &env::var("PLATFORM_FEE_PERCENTAGE").unwrap_or_else(|_| "10.0".to_string()) ).unwrap_or(dec!(10.0)); (amount * fee_percentage / dec!(100)).round_dp(2)
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
pub fn calculate_platform_fee(amount: Decimal) -> Decimal { let fee_percentage = Decimal::from_str( &env::var("PLATFORM_FEE_PERCENTAGE").unwrap_or_else(|_| "10.0".to_string()) ).unwrap_or(dec!(10.0)); (amount * fee_percentage / dec!(100)).round_dp(2)
} COMMAND_BLOCK:
pub fn calculate_platform_fee(amount: Decimal) -> Decimal { let fee_percentage = Decimal::from_str( &env::var("PLATFORM_FEE_PERCENTAGE").unwrap_or_else(|_| "10.0".to_string()) ).unwrap_or(dec!(10.0)); (amount * fee_percentage / dec!(100)).round_dp(2)
} COMMAND_BLOCK:
async fn handle_stripe_webhook( State(state): State<AppState>, headers: HeaderMap, body: Bytes,
) -> AppResult<StatusCode> { // Verify the webhook signature — never trust unverified webhooks let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .ok_or(AppError::BadRequest("Missing stripe signature".to_string()))?; let event = stripe_client .verify_webhook_signature(&body, signature, &state.config.stripe.webhook_secret)?; // Check if we've already processed this event (idempotency) if db::webhook::event_exists(&state.db, &event.id).await? { return Ok(StatusCode::OK); // Already handled, just acknowledge } // Persist the event before processing db::webhook::create_event(&state.db, &event).await?; match event.event_type.as_str() { "checkout.session.completed" => handle_checkout_completed(&state, &event).await?, "charge.refunded" => handle_refund(&state, &event).await?, "charge.dispute.created" => handle_dispute(&state, &event).await?, _ => {} // Ignore events we don't care about } Ok(StatusCode::OK)
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
async fn handle_stripe_webhook( State(state): State<AppState>, headers: HeaderMap, body: Bytes,
) -> AppResult<StatusCode> { // Verify the webhook signature — never trust unverified webhooks let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .ok_or(AppError::BadRequest("Missing stripe signature".to_string()))?; let event = stripe_client .verify_webhook_signature(&body, signature, &state.config.stripe.webhook_secret)?; // Check if we've already processed this event (idempotency) if db::webhook::event_exists(&state.db, &event.id).await? { return Ok(StatusCode::OK); // Already handled, just acknowledge } // Persist the event before processing db::webhook::create_event(&state.db, &event).await?; match event.event_type.as_str() { "checkout.session.completed" => handle_checkout_completed(&state, &event).await?, "charge.refunded" => handle_refund(&state, &event).await?, "charge.dispute.created" => handle_dispute(&state, &event).await?, _ => {} // Ignore events we don't care about } Ok(StatusCode::OK)
} COMMAND_BLOCK:
async fn handle_stripe_webhook( State(state): State<AppState>, headers: HeaderMap, body: Bytes,
) -> AppResult<StatusCode> { // Verify the webhook signature — never trust unverified webhooks let signature = headers .get("stripe-signature") .and_then(|v| v.to_str().ok()) .ok_or(AppError::BadRequest("Missing stripe signature".to_string()))?; let event = stripe_client .verify_webhook_signature(&body, signature, &state.config.stripe.webhook_secret)?; // Check if we've already processed this event (idempotency) if db::webhook::event_exists(&state.db, &event.id).await? { return Ok(StatusCode::OK); // Already handled, just acknowledge } // Persist the event before processing db::webhook::create_event(&state.db, &event).await?; match event.event_type.as_str() { "checkout.session.completed" => handle_checkout_completed(&state, &event).await?, "charge.refunded" => handle_refund(&state, &event).await?, "charge.dispute.created" => handle_dispute(&state, &event).await?, _ => {} // Ignore events we don't care about } Ok(StatusCode::OK)
} CODE_BLOCK:
pending → processing → completed → failed Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
pending → processing → completed → failed CODE_BLOCK:
pending → processing → completed → failed COMMAND_BLOCK:
// Background job: process pending payouts for completed bookings
pub async fn process_pending_payouts(state: &AppState) -> AppResult<()> { let pending = db::payout::find_pending_payouts_for_completed_bookings( &state.db ).await?; for payout in pending { match stripe_client.create_transfer(&payout).await { Ok(_) => { db::payout::mark_completed(&state.db, payout.id).await?; } Err(e) => { tracing::error!(payout_id = %payout.id, error = %e, "Payout transfer failed"); db::payout::mark_failed(&state.db, payout.id).await?; } } } Ok(())
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Background job: process pending payouts for completed bookings
pub async fn process_pending_payouts(state: &AppState) -> AppResult<()> { let pending = db::payout::find_pending_payouts_for_completed_bookings( &state.db ).await?; for payout in pending { match stripe_client.create_transfer(&payout).await { Ok(_) => { db::payout::mark_completed(&state.db, payout.id).await?; } Err(e) => { tracing::error!(payout_id = %payout.id, error = %e, "Payout transfer failed"); db::payout::mark_failed(&state.db, payout.id).await?; } } } Ok(())
} COMMAND_BLOCK:
// Background job: process pending payouts for completed bookings
pub async fn process_pending_payouts(state: &AppState) -> AppResult<()> { let pending = db::payout::find_pending_payouts_for_completed_bookings( &state.db ).await?; for payout in pending { match stripe_client.create_transfer(&payout).await { Ok(_) => { db::payout::mark_completed(&state.db, payout.id).await?; } Err(e) => { tracing::error!(payout_id = %payout.id, error = %e, "Payout transfer failed"); db::payout::mark_failed(&state.db, payout.id).await?; } } } Ok(())
} COMMAND_BLOCK:
"charge.refunded" => { // Cancel the payout if it hasn't been sent yet let cancelled = db::payout::cancel_payout_if_pending( &mut tx, booking_id ).await?; // Update booking status db::booking::mark_refunded(&mut tx, booking_id).await?; tx.commit().await?;
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
"charge.refunded" => { // Cancel the payout if it hasn't been sent yet let cancelled = db::payout::cancel_payout_if_pending( &mut tx, booking_id ).await?; // Update booking status db::booking::mark_refunded(&mut tx, booking_id).await?; tx.commit().await?;
} COMMAND_BLOCK:
"charge.refunded" => { // Cancel the payout if it hasn't been sent yet let cancelled = db::payout::cancel_payout_if_pending( &mut tx, booking_id ).await?; // Update booking status db::booking::mark_refunded(&mut tx, booking_id).await?; tx.commit().await?;
} - The sponsor fills in the ad details and selects a date.
- The frontend creates a checkout session through the backend.
- The backend calculates the platform fee and sets up a Stripe Checkout Session.
- The sponsor pays on Stripe's hosted checkout page.
- Stripe sends a message to confirm the payment.
- The backend creates the booking and adds a pending payout record.
- After the publication date, the payout goes to the writer.
how-totutorialguidedev.toaidatabase