-
-
Pickup, return, settle: the shop points, the shared pickup photos answer, PayPal captures only lines the renter accepts (recorded run).
-
Live sandbox: one tap authorizes $115.00, captures the $15.00 fee, holds $100.00. The stamp waits for PayPal's read-back.
-
Clean return: the hold is voided and the stamp lands only after PayPal reads back VOIDED.
-
Two phones: the shop's screen in Bahasa Indonesia, the renter's receipt in English with every PayPal id.
-
Every PayPal call for a rental, with HTTP status and debug id, plus verified webhooks. Public, no login.
-
AI-generated test images, not photos. The model read both claims wrong; nothing was charged because no model is benchmarked yet.
-
Architecture: Next.js PWA, FastAPI on Vercel, Qwen vision, PayPal Orders, Payments, Vault, Invoicing and webhooks, Upstash Redis.
Inspiration
In Bali and Jakarta the deposit on a rented scooter is often your passport (or a local's KTP identity card). In Indonesian that deposit is the jaminan. At return the shop points at a dent, the renter says it was already there, and whoever holds the document wins the argument. Neither side has evidence both of them saw, and the "deposit" is a document, not money either side can trust.
Theme fit. The hackathon asks entrants to "build or update an application that integrates the PayPal developer platform (using the free sandbox environment) along with an AI tool, model, or platform of their choice." In Jaminan the deposit is a PayPal authorization, and its whole lifecycle is the product. The AI tool is a vision model (Qwen, through Alibaba Cloud DashScope) that answers narrow questions about shared pickup photos.
What it does
Jaminan replaces the passport with a PayPal authorization, and the argument with a shared walkaround.
- One approval. The renter scans the shop's QR and approves once in PayPal. One Orders v2
AUTHORIZEorder holds the rental fee and the deposit together and saves the wallet (with consent shown up front) for later fines. The fee is captured when both sides confirm the baseline at pickup; the deposit stays held. - Pickup walkaround. A guided 8-angle walkaround. Blurry or glare-washed frames are rejected by arithmetic (Laplacian variance, clipped highlights) before any model sees them. Renter and shop confirm the baseline together; the photos are hashed and the hash prefix is written to PayPal as tracking on the fee capture.
- Return: the owner points, the shared baseline answers. The shop taps a mark and names it from the published tariff. The vision model only answers what the pickup photos show: is there a mark now, is it the kind named, was it visible at pickup, can the pickup photo show that spot. It never sets a price.
- Code decides. No mark, visible at pickup, or not comparable at pickup all mean cannot be charged, and a mark that doesn't look like the kind the shop named can't be paid from the hold. Only clearly new damage becomes a priced line, priced from the shop's tariff.
- The hold only pays lines the renter accepted. Accepted lines become one capture with
final_capture=true, which releases the rest of the hold; PayPal then refuses any further capture. A clean return voids the hold. A contested line leaves as an itemized PayPal invoice (with a QR code) at its tariff price.
What the shared baseline can't show, nobody can charge.
Honest state of the AI piece. On the current deployment no vision model is pinned as benchmarked, because the staged scooter photo set has not been shot and scored. Until a model is pinned, every live claim ends "comparison unavailable — nothing charged from the hold", so a live rental can be released but cannot price a damage line. The priced path is exercised by the unit tests, the 23,040-case claim sweep and real PayPal sandbox tests, and is recorded call by call in evidence/paypal-sandbox-run.json with scripted claim answers (labelled as such). We state no vision accuracy number anywhere.
The model on AI-generated test images (not photographs). To see the vision path on something, we generated a pickup image of a scooter (qwen-image-2.0) and a return image edited from it with both mirrors removed (wan2.6-image), pointed two claims, and recorded what qwen3-vl-plus answered through the same crop, model-call and decision steps as the app's claim path (run from a script), three calls each. It read both wrong: it called the old scuff new, and at the mirror mounts it found no missing mirror, only a smudge elsewhere. Nothing was charged, because no model is benchmarked. Had that model been pinned without a bench, the old scuff would have been priced at the tariff's dent price, $45.00. That is the failure the bench gate exists to stop. This is not a benchmark and not real-world evidence; the model has been shown only on AI-generated test images so far. The images, prompts and every vision-call request id are in evidence/generated-pair/, and /app/judge shows them under the same label.
For booking agents. An MCP server (jaminan/mcp_server.py, stdio) gives a booking agent four tools: list_vehicles, quote_rental, start_rental and get_rental. The agent can price a rental and ask for it. start_rental returns a PayPal approval link that the renter opens and approves themselves. No tool can approve the hold, answer a damage line, capture, void or refund, and the agent routes never return a key that could. On 2026-10-24, Claude Code and Codex each booked through it on the sandbox. The renter approved in PayPal, and each agent read back the authorization (CREATED, USD 130.00 and USD 145.00). The transcripts are in evidence/mcp/.
How we built it
- PayPal is the engine, not decoration. A hand-written REST client (
jaminan/paypal.py) with 19 endpoints: Orders v2 (create, get, authorize, track; buyer-approved and merchant-initiated on a vault id), Payments v2 (capture withfinal_capture, void, reauthorize, get authorization, get capture), Vault v3 (get and delete payment token), Invoicing v2 (create, send, QR code, get), Transaction Search, and Webhooks (verify signature, list, create). The settle screen stamps only after the status read back from PayPal. - Stack. Next.js 16 PWA (React 19, TypeScript; Bahasa on shop screens, English on renter screens) and a FastAPI service (Python, Pydantic, httpx) deployed as Vercel Python functions; Upstash Redis for state; one daily Vercel Cron for the reauthorize sweep, expiry guard and daily close.
- AI. Qwen vision (DashScope, OpenAI-compatible API) with a JSON-schema answer and 0–1000 bounding boxes; numpy + Pillow for the frame gate.
- Correctness evidence. 235 tests (226 offline, 8 against the real PayPal sandbox, 1 live Qwen schema check); a 23,040-case sweep of the claim decision (every chargeable result satisfies every "new since pickup" condition and carries a tariff price); a Hypothesis settle property test (up to 3,000 generated settlements: the hold never pays more than the accepted lines and never exceeds the authorization); 100% line and branch coverage of the API (1,675 statements, 492 branches, enforced in CI) and of the web lib/ (31 Vitest tests); CI runs ruff, mypy, tests, pip-audit, eslint, tsc, build, npm audit and gitleaks.
- Built during the submission period. The first commit is dated 2026-10-14; the repo history is the build log.
- APIMatic Context Plugin. Installed in Claude Code and used while writing the PayPal Transaction Search call through PayPal's APIMatic-generated
paypal-server-sdk: its client-initialization and calling-endpoints skills supplied the pinned sandbox environment and the options-dict call shape, and the request authenticated and PayPal answered 403NOT_AUTHORIZEDbecause Transaction Search is not enabled on our sandbox app, so the daily close reconciles by per-object read-back meanwhile. The rest of the client is hand-written REST. This is the full extent of plugin use; the steps are written up indocs/APIMATIC.mdand reproduced byscripts/apimatic_transaction_search.pyin the repo.
Challenges we ran into
- One approval for three jobs. Getting fee capture, a deposit hold and vault consent out of a single renter approval. Orders v2
AUTHORIZEwithstore_in_vaultdoes it; PayPal returns one vault token per buyer and merchant, so our judge wallet pool holds one token today. - Wallet only. The card-path authorize answered 422 without a stated reason (we believe advanced card processing is not enabled for this Indonesia-region sandbox merchant), so cards are out of scope.
- Reauthorize has a window. PayPal allows it only from Day 4 of an authorization. We waited it out: on day 9 the sandbox reauthorized two holds (one with its fee already captured) and refused a second attempt with 422 "only allowed once". The in-app day-4 trigger rule and its fallback are unit-tested (
cron_daily()is unit-tested; the cron route and schedule have not fired on a real rental); no rental has reached day 4 through the app yet. - A real bug found by the live acceptance run. The read-back panel on
/judgestayedPARTIALLY_CAPTUREDafter a release because it cached on the authorization only. It now re-asks PayPal on every state change, with a regression test. - The model read our AI-generated test pair wrong. On a generated pickup/return pair (AI-generated test images, not photographs),
qwen3-vl-plusread an old scuff as new and found no missing mirror at the mirror mounts, in all three calls each. We left the prompt alone rather than tune it on the only test pair, and recorded both misses. The bench on real photos decides whether any model may price a line. - Python on Vercel. Keeping the FastAPI service as Python functions without Render; in the recorded sandbox run the settle tap to PayPal's read-back took 2.8 s for the accepted settlement and 1.5 s for the clean return.
Accomplishments that we're proud of
- A real merchant flow whose whole lifecycle is one PayPal object: authorize, partial capture with
final_capture, void, reauthorize, vault merchant-initiated charge, tracking, invoice. - Three settlements recorded on the real sandbox, call by call with PayPal debug ids: $18.00 captured, $82.00 released, read-back
CAPTURED, and a second capture refused with HTTP 422AUTHORIZATION_ALREADY_CAPTURED; a clean return voided; a contested line invoiced. - The AI cannot move money. The model only answers narrow questions; code and the published tariff decide, and the claim-decision invariant is tested over 23,040 combinations.
- A live app a judge can open in one tap (
/judge) and release a real sandbox hold, with the PayPal calls listed at/integrations/verify.
What we learned
- PayPal's authorization object does most of the work a deposit needs: one
final_capture=truecapture both takes the money and releases the remainder, and the second-capture refusal is the proof a judge can check. - Constrain the model to questions with checkable answers, and keep prices and decisions in code.
- Read money state back from the processor instead of trusting our own call's response.
What's next for Jaminan
- Shoot the staged scooter set on two scooters, hand-label it, and run the vision benchmark; pin a model only if it gives zero false-"new" answers on the held-out old marks and clean spots, and report beside that how much new damage it finds (target: recall of at least 0.90 on obvious new damage) and every other error, missed new damage included, with its count.
- Run a rental through the app past day 4 so the reauthorize sweep fires end to end.
- Talk to Jakarta rental shops about the deposit, the tariff and whether "no passport" would bring them customers.
Built With
- apimatic-context-plugin
- cron)
- fastapi
- github-action
- httpx
- hypothesis
- invoicing-v2
- next.js
- numpy
- payments-v2-authorizations
- paypal-rest-apis-(orders-v2
- pillow
- pydantic
- pytest
- python
- qwen-vision-(alibaba-cloud-dashscope)
- react
- shipment-tracking
- typescript
- upstash-redis
- vault-v3
- vercel-(python-functions
- webhooks)-in-the-paypal-sandbox
Log in or sign up for Devpost to join the conversation.