Trips API
A public HTML pastebin for trip itineraries at /trips. Mounted at /api/trips. Wire fields are camelCase.
Trips#
| Method | Path | Auth | Body | Notes |
|---|---|---|---|---|
| POST | / | optional + trips:write (PATs) | JSON {html (1..10MB), title?, filename?, force?} OR multipart file (.html, 10MB) + title? + force? | 201 {trip:{id, slug, title, ...}}; authed upload stamps ownerId, anonymous is ownerless. 409 duplicate_trip when it repeats an existing trip |
| GET | / | public | ?limit=1..100&cursor=<opaque, from nextCursor> | metadata only, no html |
| GET | /:slug | public | none | full row including html |
| PATCH | /:slug | optional + trips:write (PATs) | any of html/title/filename, or multipart | 403 if owned by someone else; anonymous trips are editable by anyone; slug never changes |
| DELETE | /:slug | optional + trips:write (PATs) | none | 204; same ownership rule |
Gotchas:
- Title precedence on create: body title, then the html
<title>tag, then first h1, then cleaned filename. trips:writeonly gates PATs; browser JWTs and anonymous callers pass the scope check. The real protection is per-trip ownership.- Anonymous means no Authorization header. A PAT that does not resolve (revoked, mistyped) is a 401
Invalid or revoked tokenon every write, never a silent anonymous upload: an ownerless trip is editable and deletable by anyone, so falling back would publish your itinerary unprotected. An invalid browser JWT still uploads anonymously. - Slug is kebab-cased from the title (max 60 chars) with a random suffix on collision.
- POST refuses a duplicate with
409 {error, code:"duplicate_trip", existing:{slug, title, createdAt}}. Two uploads are the same trip when their titles match after trimming, collapsing whitespace and lowercasing, or when their HTML is byte-identical (an override title hides the first test but not the second).existingis the OLDEST match, so it points at the original. Sendforce(JSONtrue, multipart"true"/"1") to create the copy on purpose. A null or whitespace-only title never matches on the title arm. PATCH is not checked: it edits a row in place and cannot mint a second URL. - No rate limiting on this module. The cursor is the compound keyset form
<iso-timestamp>_<row-id>, not the journal's date cursor. ReadnextCursoroff a response and send it back verbatim; do not rebuild it from a row'screatedAt, because the id half is what keeps a tie or a sub-millisecond gap from silently dropping rows. See Pagination.
Trip Groups (/api/trip-groups)#
Group trip planning. Everything requires a browser session, except raw photo bytes which are public. There is no PAT scope for this module: any PAT, whatever its scopes, gets 403 {"error":"Trip groups requires a browser session, not a PAT"}, reads included. Membership is join-by-slug: POST /:slug/join.
Highlights (all under /api/trip-groups/:slug unless noted):
POST /api/trip-groups{title}: create, caller becomes owner.GET /api/trip-groups: your groups.- Ideas:
POST /ideas{body}(1..4000), delete is author-only. POST /itinerary/consolidate: LLM call turning ideas into a structured itinerary, refining the stored one if there is one. Owner writes directly (200); a non-owner member creates a pending suggestion (201). The owner's result is merged like aPUTwithbase: only the days (and summary) the model changed from the itinerary it was given are applied onto the itinerary as stored when the call finishes, so an approve or edit that lands while the model runs is kept, and the response carries the mergeditinerary. 409 when the merge would leave no valid itinerary. 502 when LLM providers are down.PUT /itinerary{itinerary, base?}: manual structured edit (same owner/member fork). Sendbase, the itinerary your edit started from (nullif there was none): the edit then counts as only the days (and summary) that differ frombase. For the owner those are applied onto the itinerary as stored at that moment, under a row lock, so days another member changed after you loaded it are kept; the response carries the mergeditinerary, and 409 means the merge would leave no valid itinerary. For a member, the suggestion'schangedDaysis computed againstbase, so approving it applies only what they touched. Withoutbase,itineraryreplaces the whole stored itinerary.DELETE /itinerary: owner-only reset.- Suggestions:
GET /itinerary/suggestions(with conflict detection), owner-only approve/reject, race-safe 409 when already resolved. Approve applies only the days listed in the suggestion'schangedDays(plus its summary, if the suggestion changed it) onto the itinerary as stored at that moment, under a row lock, so edits made after the suggestion was filed are kept; a changed day missing from the suggestion is removed. The response carries the mergeditinerary. 409 when the merge would leave no valid itinerary (for example, no days); the suggestion stays pending. - Photos: multipart upload per day (5MB, sniffed types), public raw bytes at
.../photos/:photoId/raw, owner-onlyunsplash-fill. - Notes and todo sections: member CRUD with creator-or-owner deletes.
PUT /sections/:sectionIdtakestitle?plus either a fullitemslist, which replaces whatever is stored, or item deltas:addItems(items shaped like the list's),removeItemIds,setItemsDone([{id, done}]). Deltas are applied to the section as stored at that moment under a row lock, so a check, add or removal another member made after you loaded the list is kept. Prefer them for every item edit. Removing or toggling an id that is already gone does nothing, re-adding an existing id is skipped, and removal wins when one body names the same id twice. Sendingitemstogether with any delta is a 400, and so is a merge that would leave more than 200 items. The response is the merged{section}. POST /itinerary/export-calendar: exports to the CALLER's Google Calendar; 409calendar_not_connecteduntil they connect. Only dated days export. An activity whosetimeis a 24hH:MMorHH:MMbecomes a 1-hour event (one starting after 23:00 ends the next day); any othertimetext exports as untimed, listed with that text in the day's all-day event.
Agents without a browser session cannot use trip-groups; use the plain trips API instead.