Card, comic and retro game sellers run "claim sales" in X, Instagram and Discord replies. Their pinned rules are always the same: "Drop a 'Claim' or 'Mine' + card name", "The timestamp on my screen is the absolute final judge", "Payment is due within 24 hours of receiving your invoice DM", "cards immediately go to the backup claimer". Every step is done by hand, late at night, and most of it is paid as Friends and Family to dodge the fee, which strips PayPal protection from both sides.

Dibs runs that whole night on PayPal Goods and Services. Here is one sale, told by the clock.

Try it: live app https://dibs-claims.fly.dev · code https://github.com/kamalbuilds/dibs

Dibs home: one photo of the table becomes a claim sale that bills itself The seller starts from one photo of the table.

8:00 PM: one photo becomes a binder

The seller lays out a page of Conan Doyle cigarette cards and takes one photo. A vision model returns every distinct item with a position number, a title, the set, visible condition notes, a confidence score and the exact label text it read off the card. Each pocket shows that evidence ("AI read PAPERBOY on the item"). A title the read text does not support drops below the review line, and an unreadable label comes back as "No legible label" instead of a guess.

Each pocket also gets price comps from Channel3 product search: listings whose title names the item, priced in the sale currency, outliers fenced out with Tukey fences, shown as low, median and high with the source listings. The seller confirms titles, sets prices from the market, and picks the rules they already use: a claim deadline, shipping tiers (plain envelope under a value cap, tracked mailer, free over a threshold) and a fee policy of absorb, split or buyer pays. The fee rate defaults to PayPal's published US invoicing rate.

On the hosted app, a photo of a stack of NES carts becomes five priced pockets in one pass: Marble Madness, Super Mario Bros./Duck Hunt, Mario Bros., Paperboy and Q*bert.

The public binder buyers open The public binder at /s/:slug, one pocket per card, live for every viewer.

8:03 PM: the first "mine"

Buyers open the public binder, tap a card or type "mine 3, 7". The server timestamp, stored to the millisecond, is the judge, exactly as the pinned rules promise. A later claim on a taken card joins that card's backup queue. Every change streams to every viewer over Server-Sent Events, so nobody refreshes to find out they lost.

Most claims still happen in the replies. The seller pastes the thread exactly as copied from X, Instagram or Discord, with the name, @handle, "·" and "3m" header lines in between. The model returns the line number of each comment; Dibs quotes the raw text from the paste itself and rejects any event whose line is out of range, out of order, or whose handle is not visible above it. A deterministic resolver maps "3", "#3", "no. 3" or "marble madness" to an item and leaves "the mario bros" to the seller when two items fit. Every imported claim links back to the exact line it came from.

On the hosted app a pasted X thread becomes five claim events, each traceable to its comment.

8:30 PM: claims close, invoices go out

At the deadline Dibs bundles everything each buyer won into one PayPal Goods and Services invoice, applies the shipping tier and the fee math in integer cents, sends it, and reads it back SENT from PayPal. When the buyer shares the fee it is its own line, "G&S fee (split)", so the fee argument is settled on the invoice itself. PayPal reads back invoice INV2-RE8V-MRTT-C3VR-PEZ5 with lines #1 Charizard ex holo=60.00; G&S fee (split)=1.38.

On the NES sale, closing sends three bundled invoices, INV2-8TR4-C2KK-L9BQ-PW25, INV2-GAPZ-VULF-TLB9-4UYL and INV2-PB2M-35X3-BUMK-ZWTZ, all read back SENT by PayPal GET.

A buyer who wants a hold claims with "Hold with 20% deposit". The invoice accepts partial payment with the seller's minimum: INV2-ASG3-A2WE-CJKV-GD8B reads back allow_partial_payment true, minimum_amount_due 13.28 on a $66.38 total. When PayPal reports PARTIALLY_PAID, Dibs marks the invoice HELD and the deadline engine leaves it alone.

The seller docket in AG Grid The AG Grid docket: every buyer, item, fee line, invoice status and deadline countdown in one table.

Next day: the deadline nobody had to enforce

The seller sleeps. A reconciler reads every open invoice every 15 seconds, and verified INVOICING.INVOICE.PAID webhooks land the same transition. Paid items flip to PAID. Past the deadline, Dibs cancels the invoice on PayPal, reads back CANCELLED, and invoices the next backup in timestamp order. With no backup the item returns to OPEN. Every transition is an append-only event in the sale timeline.

On the NES sale, @retrorick_ leaves #2 unpaid. Dibs cancels INV2-8TR4-C2KK-L9BQ-PW25 (PayPal: CANCELLED) and sends the backup, @jules_plays, INV2-2365-T4SG-KY33-YYUE (PayPal: SENT). The npm run smoke run repeats the same pass on sandbox: INV2-JHLQ-2EN7-JFWD-JFLB reads back CANCELLED after its deadline and the backup's INV2-AAGN-7L6X-HPD8-SBK5 reads back SENT.

The poller and the webhook can race for the same payment. The invoice state machine is a pure function with tests for that race, and every PayPal mutation carries a persisted PayPal-Request-Id, so the transition lands exactly once.

The buyer who sends an AI assistant

Some buyers no longer browse; their assistant does. Dibs ships a Model Context Protocol server with four tools: list_sale, claim, invoice_status and await_payment. An assistant claims "Irene Adler" from the "Conan Doyle singles" sale and gets a 402-style reply: payment.status payment_required, httpStatusEquivalent 402, amount 27.72 USD, a real PayPal invoice INV2-CRMH-ZF3V-9F63-WD3T and its pay link.

The buyer pays on PayPal ("Payment successful! You paid $27.72 for the invoice from Test Store."). await_payment polls PayPal and returns outcome paid, finalStatus PAID after 27 PayPal checks. A direct GET /v2/invoicing/invoices/INV2-CRMH-ZF3V-9F63-WD3T returns status PAID with transaction 2YT41011W5893515R, and the docket row flips to SOLD · PAID with $27.72 collected. An unpaid invoice gets a clean outcome timeout, finalStatus SENT, so an assistant never reports a sale that did not happen.

Agent checkout landed: row #01 SOLD · PAID The assistant's claim, paid on PayPal and read back: SOLD · PAID, $27.72 collected.

Shipped, and protected

The seller presses Add tracking on the paid row and enters carrier and number. Dibs reads the payment's transaction id from the PayPal invoice, attaches the tracker to that transaction with Shipment Tracking, and reads it back. Seller Protection asks for tracking on the transaction, so the protection the Goods and Services invoice promised is complete. The docket marks the row "On PayPal" and the buyer's receipt shows "Tracking on PayPal".

Tracker 0FL39582BL8172154-9400111899223197428513 reads back status SHIPPED, carrier USPS, shipment_uploader MERCHANT.

Under the hood

PayPal Invoicing v2 and Shipment Tracking, all in src/paypal.ts: OAuth client credentials; create the bundled invoice with PayPal-Request-Id idempotency; send; read back SENT, PAID, PARTIALLY_PAID and CANCELLED; cancel before re-routing to a backup; remind from the docket; partial payment configuration for holds; POST /v1/shipping/trackers and the tracker GET; and webhook signature verification for INVOICING.INVOICE.PAID.

AG Grid Community powers the seller docket: grouped item, claim and invoice columns, status filter chips, column filters, CSV export, row selection and row actions to remind, cancel and pass to backup, or add tracking.

Channel3 product search (/v1/search) supplies the price comps, aggregated per item with a title match and Tukey fences.

Gemini via OpenRouter, JSON Schema output, temperature 0, provider data collection denied. Two jobs: itemizing sale photos and turning comment threads into claim events tied to numbered source lines. Every response is validated by Zod, and both are suggestions the seller confirms. Money moves only from confirmed, deterministic state.

Stack: TypeScript on Node 24, Hono, a SQLite event store, React 19, Tailwind CSS, Vite, Vitest and Zod, deployed on Fly.io. The MCP server uses @modelcontextprotocol/sdk over stdio and Streamable HTTP.

Fee math runs in integer cents for absorb, split and buyer pays. A mutation check proves the tests bite: removing the fixed fee turns the suite red (expected 84 cents, received 35), restoring it turns it green.

Proof, read back from PayPal

What PayPal id Read back
Agent checkout invoice INV2-CRMH-ZF3V-9F63-WD3T PAID, 27.72 USD, txn 2YT41011W5893515R
Guest card payment INV2-7BHP-MB86-QZCF-UJKX PAID, $13.50
Tracking on the transaction 0FL39582BL8172154-9400111899223197428513 SHIPPED, USPS, MERCHANT
Hold with deposit INV2-ASG3-A2WE-CJKV-GD8B PARTIALLY_PAID, paid 13.28, due 53.10
Fee as its own line INV2-RE8V-MRTT-C3VR-PEZ5 G&S fee (split) = 1.38
Expired, cancelled INV2-8TR4-C2KK-L9BQ-PW25 CANCELLED
Backup invoiced INV2-2365-T4SG-KY33-YYUE SENT

Every status above comes from a PayPal GET, not from Dibs' own database. The protected path is now the easy path: one photo, one rule set, and the clock does the rest.

Built With

Share this project:

Updates

Submission history