Inspiration

Small pottery and craft studios in Seoul get booking requests from visitors in English, Japanese and Korean, by email and DM, at any hour. The owner answers them by hand between classes, and a lot of slots are held for people who never pay. I wanted a desk that handles the routine part end to end, including the money, and only brings the owner the cases that need a decision.

What it does

A guest writes in, in their own language. Slotkeeper reads the message, finds the class and time, checks the calendar, holds the slot and drafts a reply with a PayPal link for the deposit. When the deposit is paid, the hold becomes a booking. If it is not paid in time, the hold is released and the slot is offered to the waitlist. Cancellations are refunded according to the house rules, partially or in full. When a request does not fit (wrong time, group too big, missing details), the agent asks for what is missing or offers the nearest free times.

The owner sees a schedule board. Dragging a booking to another time makes the agent re-check opening hours, notice and clashes, and rewrite the guest's reply for the new time. The PayPal order is untouched: same amount, same link.

How PayPal is used

Orders v2 and Webhooks, against the sandbox.

  • POST /v2/checkout/orders creates the deposit order when a slot is held. The guest's reply carries the approval link.
  • The deposit is captured (POST /v2/checkout/orders/{id}/capture) from whichever arrives first: the payer's return to the site, the CHECKOUT.ORDER.APPROVED webhook, or the desk's own polling. The other two find it already done.
  • Refunds use POST /v2/payments/captures/{id}/refund, partial or full, with the amount computed from the refund policy and never more than what is left of the capture.
  • Webhook deliveries are verified with POST /v1/notifications/verify-webhook-signature.
  • Every write carries a PayPal-Request-Id, so a retry after a timeout cannot create a second order, capture or refund. 5xx and 429 answers are retried with backoff.

Order, approval, capture, partial refund, full refund and the error cases (capture twice, refund over the remaining amount, refund when nothing is left) were all run against the real sandbox; the recorded answers are in docs/sandbox-recorded/ and the same contract tests run against the built-in mock and against the sandbox.

How AI is used

A language model (Llama 3.3 70B on Workers AI) reads the guest's message into a small structured form (intent, language, class, date, time, party size) and words the reply in the guest's language. It never decides anything about the calendar or the money. Its reading is range-checked against the venue, the e-mail address always comes from the rule-based reader, and if the model's reply drops or changes a pay link, an amount or a time, the template is used instead. Model spend is counted and capped per day; past the cap the desk keeps working on rules and templates.

On 20 test messages in three languages the model got every field right in 17, the rule-based reader in 15 (docs/READER_COMPARISON.md).

How it is built

Cloudflare Worker in TypeScript. One Durable Object per desk holds the state, so a webhook, a payer's return and a hold expiry for the same booking cannot interleave. The schedule board is Bryntum Scheduler. No build step for the front end. 89 tests on the mock, 10 more against the sandbox.

Challenges

  • Three things can complete the same payment (return URL, webhook, polling). Making them all converge on one capture needed a single writer and request ids on every PayPal call.
  • Deciding what the model may and may not do. Letting it read and write text, but keeping every number and link under code, turned out to be the line that made the desk safe to leave running.
  • The sandbox has rules of its own (card processing not enabled on a fresh business account, approval links that expire) that the mock had to mirror so the tests mean something.

What is next

Sending the drafted replies (e-mail and DM) instead of drafting them, several venues per desk, and a licensed schedule component.

Built With

Share this project:

Updates

Submission history