Engineering

Last updated •

A 30% deposit, a held slot, and a webhook that decides

How a booking platform for travelers takes a deposit before the shoot and the balance after it: holds, two Stripe Checkout Sessions, an idempotent webhook, refunds across two Stripe accounts, and the parts that are still done by hand. · 9 min read

The booking platform I built for a Tokyo photo studio has to let someone in another country reserve a photographer's time and pay for it before they have met anyone from the studio. Charging everything up front asks a lot of a stranger. Charging nothing leaves the studio holding a slot for someone who may not come. So the customer pays 30% when they book and the rest after the shoot.

The arithmetic is one line. The work is keeping three things in agreement: whether the time slot is taken, whether the money arrived, and what the booking record says. This is how that is done, in Go and PostgreSQL, with Stripe Checkout.

$19.9k

best month in revenue (Stripe, studio total)

754

transactions, Jan 1 – Sep 13, 2026

20+

countries customers book from

What those numbers cover

They are totals for the studio's Stripe account. The system described here replaced an older booking site at the end of June 2026, so the January to September count spans both. They show the volume the payment design has to carry, not what this code alone processed.

Three records, and who may change them

  • **A hold** is a temporary claim on a time. It is active, booked, expired or cancelled.
  • **A booking** exists only after money has arrived. It is confirmed, completed or cancelled. Nothing else.
  • **A payment** is a deposit or a remaining payment, and is pending, paid or cancelled. Unique indexes allow one of each type per booking.

The rule that holds the design together: the browser never moves a record forward. A paid Stripe event does. The success page the customer lands on only polls the API, up to twenty times a few seconds apart, until the webhook has done its work.

Pressing Pay

The browser sends a plan, a location, a time window and contact details. It never sends a price. The API reads the plan and location prices, adds extra guests and express delivery, applies a discount code if there is one, and computes the deposit. Then one SQL function creates the hold. It locks the availability window row, counts what already overlaps the requested time, and refuses if the window's capacity (two by default) is used up. A shoot occupies its own time plus a thirty-minute buffer on each side.

database/migrations/000055_staff_hold_any_deposit.up.sql
  IF p_subtotal_amount_cents <= 0    OR p_discount_amount_cents < 0    OR p_total_amount_cents <= 0    OR p_subtotal_amount_cents - p_discount_amount_cents <> p_total_amount_cents    -- ...    -- customer holds (condition trimmed)    OR p_deposit_amount_cents <> (p_total_amount_cents * 30 / 100)  THEN    RAISE EXCEPTION 'invalid_booking_amounts';  END IF;  -- ...  v_occupied_start_at := p_requested_start_at - interval '30 minutes';  v_occupied_end_at := p_requested_end_at + interval '30 minutes';  SELECT count(*)::integer INTO v_current_count  FROM (    SELECT id FROM booking_holds    WHERE availability_window_id = p_availability_window_id      AND status = 'active' AND expires_at > now()      AND occupied_start_at < v_occupied_end_at AND occupied_end_at > v_occupied_start_at    UNION ALL    SELECT id FROM bookings    WHERE availability_window_id = p_availability_window_id      AND status IN ('confirmed', 'completed')      AND occupied_start_at < v_occupied_end_at AND occupied_end_at > v_occupied_start_at  ) occupied;  IF v_current_count >= v_window.capacity THEN RAISE EXCEPTION 'availability_window_full'; END IF;
Trimmed from the hold function. The 30% is computed in Go and checked again here, so a hold created from the site cannot store a deposit that disagrees with the total. Bookings that staff enter by hand are exempt.

Active holds count against capacity exactly like bookings, which is the point: two people cannot both be sent to Stripe for the last slot. The cost is that an abandoned checkout blocks a real customer until the hold expires, so the expiry time matters.

The bug that set the expiry rules

In the older design a hold lasted thirty minutes and the Stripe page stayed open for about a day. A few customers came back after the hold had lapsed, paid on the still-open page, and paid for a time that was no longer theirs. The confirming function refuses an expired hold, so the money arrived and no booking was created.

The fix was to make the two clocks one clock. Opening checkout pushes the hold to a full hour from now, and Stripe is told to close its page five minutes before that.

services/api/internal/payment/checkout.go
	// Pressing Pay restarts the clock: a full hour from now, however much of	// the original hold was left. Stripe's page is told to close five minutes	// before that, so the two can never disagree about whether the time is	// still for sale.	holdExpiresAt := time.Now().UTC().Add(checkoutHoldDuration)	if err := s.db.Pool().QueryRow(ctx, `		UPDATE booking_holds		SET expires_at = GREATEST(expires_at, $2::timestamptz)		WHERE id = $1::uuid AND status = 'active'		RETURNING expires_at	`, bookingHoldID, holdExpiresAt).Scan(&holdExpiresAt); err != nil {		return CheckoutResult{}, err	}	sessionExpiresAt := holdExpiresAt.Add(-checkoutExpiryMargin)	params := &stripe.CheckoutSessionCreateParams{		Mode:      stripe.String(string(stripe.CheckoutSessionModePayment)),		ExpiresAt: stripe.Int64(sessionExpiresAt.Unix()),		// ...		Metadata: map[string]string{			"booking_hold_id": bookingHoldID,			"payment_type":    "deposit",		},	}
Trimmed. checkoutHoldDuration is 60 minutes and checkoutExpiryMargin is 5.

The session is a one-off payment with the amount passed inline, so there are no Stripe products or prices to keep in sync with the database. The hold ID and the payment type go into the metadata of both the session and its payment intent. Thirty minutes after checkout opens, if the hold is still unpaid, the customer gets one email with the same link. It is withdrawn the moment the deposit lands.

The webhook

Stripe delivers an event when a session completes, and it may deliver the same event more than once. The handler verifies the signature, then writes the event down before doing anything else.

services/api/internal/payment/webhook.go
func (s Service) recordWebhookEvent(ctx context.Context, event stripe.Event, payload []byte, stripeAccountID string) (bool, error) {	var shouldProcess bool	err := s.db.Pool().QueryRow(ctx, `		INSERT INTO webhook_events (provider, provider_event_id, event_type, payload, processed, stripe_account_id)		VALUES ('stripe', $1::text, $2::text, $3::jsonb, false, $4::text)		ON CONFLICT (provider, provider_event_id)		DO UPDATE SET			event_type = EXCLUDED.event_type,			payload = EXCLUDED.payload,			stripe_account_id = EXCLUDED.stripe_account_id		WHERE webhook_events.processed = false		RETURNING true	`, event.ID, string(event.Type), payload, stripeAccountID).Scan(&shouldProcess)	if err != nil {		// ...		if errors.Is(err, pgx.ErrNoRows) {			return false, nil		}		return false, err	}	return shouldProcess, nil}
An event already marked processed returns no row and is skipped. One that failed earlier is taken again.

That table is the first layer of idempotency, and it is deliberately not the only one. It lets a failed event be retried, which means two deliveries of an unprocessed event can both get through. So every step behind it is safe to repeat. The function that turns a hold into a booking locks the hold row and, if the hold is already booked, returns the existing booking. The balance update only touches a payment that is not yet paid. Every notification insert is guarded by a NOT EXISTS on the same booking and subject.

For a deposit, one database function inserts the booking, marks the hold booked and marks the payment paid, in a single transaction. Then the handler queues the confirmation email, a reminder for 24 hours before the shoot, and staff alerts by push and Slack, and writes the booking to a shared Google Calendar. A calendar failure is logged and recorded on the booking, and a worker retries it. It never fails the payment.

If any required step fails, the event is stored as failed with the reason and the endpoint answers with an error, so Stripe sends it again. A worker runs every five minutes. An event still unprocessed after five minutes becomes a push notification to owners and managers and a Slack message. That alert is the safety net for the worst case in this design: money taken, booking not created.

The balance, after the shoot

The balance is not charged automatically and no card is stored. When the assigned photographer (or an owner or manager) taps "Shoot done" in the staff app, the API creates a second Checkout Session for the total minus the paid deposit, and emails the customer the link. The email says the balance is the one step left before the photos are delivered. When that payment's webhook arrives, the payment is marked paid and the booking becomes completed. Nobody sets that status by hand, except for a booking that was already paid in full before the shoot.

Staff can add guests to a booking before the shoot, which changes the total. That races with creating the balance link. So the link is created first, then the booking row is locked and its total compared with the one the link was built from. If it changed, the session is expired at Stripe and staff are asked to try again.

Two separate payments cost the customer a second checkout. They also kept a later change simple. On October 1 the studio moved from a sole-proprietor Stripe account to a corporate one. Because the balance is a fresh session, a booking whose deposit went to the old account can pay its balance to the new one.

Two Stripe accounts at once

Every payment row records which account took it. New sessions go to whichever account a cutover timestamp says is current. Each account posts to its own webhook endpoint with its own signing secret, so an event signed by one account cannot be processed as the other's. If an unpaid balance link exists on the old account, it is expired before a new one is made. If that old session turns out to be already paid, with its webhook still on the way, the API refuses to create a second link rather than risk charging twice.

Cancellations, refunds, failures, disputes

  • **Customer cancels.** The deposit is not refunded and the balance is never charged. That is the stated policy, and the default in code is no refund.
  • **Studio cancels** (weather, safety, photographer). An owner chooses deposit only or everything paid. Managers can cancel but cannot refund. Photographers can do neither.
  • **Refund mechanics.** The booking is marked cancelled with the refund pending, and committed, before Stripe is called. If Stripe fails, the booking stays cancelled, the refund is marked failed, and the same action can be retried.
  • **Refund made in the Stripe Dashboard instead.** The refund event comes back through the webhook, finds the payment by its payment intent, cancels the booking and frees the slot.
  • **Card declined.** No completed event is sent, so no booking exists. The hold expires and the time goes back on sale.
  • **Disputes.** There is no dispute handling in the code. A dispute event is recorded and acknowledged without action, so a dispute is answered by hand in Stripe.
services/api/internal/payment/cancellation.go
		// A refund can only come from the account that took the payment. A		// booking whose deposit went to the old account and remaining fee to		// the new one is refunded from both.		account, err := s.accounts.ByID(payment.AccountID)		if err != nil || !account.HasKey() {			failedPaymentIDs[payment.ID] = true			// ...			continue		}		params := &stripe.RefundCreateParams{			PaymentIntent: stripe.String(payment.PaymentIntentID),			Amount:        stripe.Int64(refundableAmount),			// ...		}		params.SetIdempotencyKey(fmt.Sprintf("booking_refund_%s_%s_%s", bookingID, payment.ID, refundPolicy))		stripeRefund, err := s.createRefund(ctx, account, params)		if err != nil {			failedPaymentIDs[payment.ID] = true			refundErrors = append(refundErrors, fmt.Sprintf("%s refund failed: %v", payment.PaymentType, err))			continue		}
Trimmed from the refund loop. The idempotency key makes a retried refund the same refund, not a second one.

Currency

Everything is priced in US dollars, and every Checkout Session is created in US dollars, whatever country the customer is in. The currency is a constant in the booking service, not a setting, and it is copied onto every hold, booking and payment row. There is no per-country pricing and no currency selector. The studio is paid out in yen, so the revenue dashboard reads Stripe's settled yen amounts.

How it is tested, and what is missing

The payment package has integration tests that run against a real PostgreSQL and a fake Stripe server: a deposit confirming a booking, a duplicate session not duplicating notifications, a failed event being handled when it is sent again, a wrong signature, refunds that fail halfway. CI fails if coverage of that package drops below 80%. A browser test books a shoot end to end and stops at the handoff to Stripe.

  • Disputes are manual, as above.
  • Inside Stripe, a deposit carries the hold ID and a balance payment carries the booking ID. The two cannot be matched there without the database. That makes reconciliation harder than it should be.
  • The webhook alert exists, but there are no infrastructure alarms on the API itself yet.
  • The 30% is integer division on cents, so it rounds down by less than a cent. The balance is always total minus what was actually paid, which absorbs it.

The decision I would make again first is the smallest one: the browser reports nothing. Every state change that involves money starts from a signed event and passes through steps that can be run twice. The expiry bug was what happened at the edge of that rule, where a second clock disagreed with mine.

Read more

Have something that needs to be built?

Tell me the workflow and what a good outcome looks like. I'll reply with a clear next step.

  • 1,200+ clients handled
  • Published at Mercari
  • 30K followers