Fernscout API
The agent is the editor here: it writes, it publishes, it corrects. Everything created arrives as a draft first, so the person can read it back; putting it on the site is a second call, POST .../days/{slug}/publish, and that call is not how you edit a day — PATCH the same URL as the day itself for that. The prose guide is at https://fernscout.ch/agent.md.
/api/auth/request
postAsk for a one-time codeno token
Always answers 202, whether or not the address owns anything — so it cannot be used to discover which addresses exist. Two exceptions, and neither of them varies with the address: an agent code for an address that neither owns the journal nor is on the trip you named answers 403 rather than leaving you waiting for a code that was never coming, and a server with mail switched off answers 503 mail_disabled rather than issuing a code it has no way to deliver.
A new request invalidates the previous code. Two of these mails look identical apart from the time in them, and only the newest code works — so if you ask twice, make sure the person reads out the newest one.
Request body
application/json
{
"type": "object",
"required": [
"user",
"email"
],
"properties": {
"user": {
"type": "string",
"description": "The journal's address — the same segment that appears in its URLs. Called `username` when a journal is created; the same value."
},
"email": {
"type": "string",
"format": "email"
},
"kind": {
"type": "string",
"enum": [
"agent",
"guest"
],
"default": "guest",
"description": "`agent` for a token that can write."
},
"trip": {
"type": "string",
"description": "For somebody who is on a trip but does not own the journal. The token then writes to that trip and nothing else."
},
"destination": {
"type": "string",
"description": "Where the one-tap link in the mail should land, for the browser sign-in form: the path the reader was on. Guest codes only — an agent code has no link. It is stored with the code and never appears in the mailed URL, and anything that is not a path inside `/{user}/` is ignored, landing the reader on the journal instead."
}
}
}Responses
- 202
Accepted
- 403
That address may not have an agent code for this journal
- 404
Authentication is off on this server
- 429
Too many attempts
- 503
mail_disabled— this server cannot send mail at all, so nothing was issued and any code you already hold is still live. Ormail_failed— the send was attempted and broke, so no code is live for this address and retrying is the remedy.
/api/auth/verify
postExchange a code for a tokenno token
Request body
application/json
{
"type": "object",
"required": [
"user",
"email",
"code"
],
"properties": {
"user": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"code": {
"type": "string",
"description": "Six digits. Ten minutes, single use."
},
"kind": {
"type": "string",
"enum": [
"agent",
"guest"
],
"default": "guest"
},
"trip": {
"type": "string",
"description": "Optional, and only ever the same trip named at /api/auth/request: the trip travels on the code, and the token is scoped to it whether or not this is sent. Naming a different one is refused with 401. The journal's owner may name one here to narrow a code they asked for unqualified."
}
}
}Responses
- 200
A token, its expiry and its scope
- 401
Invalid code
/api/v1/{user}
deleteAsk to delete a journal (deletes nothing; mails the owner)
This deletes nothing. It answers 202 and mails the address that owns the journal a link to a page with a button; only that button deletes. The link is single-use, expires in an hour, and the caller cannot follow it — that is the point, because the confirmation for something irreversible must not be completable by the same agent that asked for it. Report that a mail is waiting, never that the journal is gone. Owner only: a trip-scoped token is refused.
Parameters
- user (path, required)
Responses
- 202
A confirmation was mailed. The body names the address and what would go, and carries
"deleted": false.- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to one trip. Writing to a trip and deleting the journal around it are different authorities.
- 404
No such journal, or this server cannot send mail
- 409
The journal's config.json has no owner.email to mail
- 410
This journal was already deleted
/api/v1/{user}/trips/{trip}
getOne trip, whole — everything the create call accepts
Added because five fields POST .../trips invites you to set — accent, costsVisibility, intro, translations, test — could be written and read back nowhere: the trips list is a summary and the dedicated doors cover only visibility, rates, people, travellers and tracks. Read your own work back with this before telling somebody a trip is ready. "It was accepted" is not the same claim as "it is there", and this API has been wrong about the difference. Gated as a write is rather than as a read, because it carries people, which is addresses.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The trip, its party, its rates and what it tracks
- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip, or none this token may write to — the two answer alike, so this cannot be used to ask which trips a journal has.
patchRename a trip, move its dates, or set its cover
Five fields of a trip nothing could write until B622 (four) and B245 (cover): title, tagline, start, end and cover. Send only what is changing. A title cannot be cleared — a trip.md without one does not load — while an emptied tagline or a cover sent as null/"" removes the key rather than storing an empty one. Dates are YYYY-MM-DD, and end may not precede start: the check is against the result, so either date may arrive on its own. cover must be a src this trip's own gallery already carries — read GET .../trips/{trip}/media for the list — since a value naming a photo the trip does not have would render as a broken image on the trips index and the OG card.
Only the frontmatter lines you name are rewritten. The prose under it, the key order, and every other key are left byte for byte, so this is safe on a trip.md somebody wrote by hand.
Owner only. A trip-scoped token belongs to somebody who was on the journey, and adding a day to it is not the same authority as saying what it is called.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "One line. Cannot be cleared."
},
"tagline": {
"type": "string",
"description": "One line. Empty string removes it rather than writing one."
},
"start": {
"type": "string",
"description": "YYYY-MM-DD."
},
"end": {
"type": "string",
"description": "YYYY-MM-DD, and not before `start`."
},
"cover": {
"type": "string",
"description": "A `src` from this trip's own gallery (`GET .../trips/{trip}/media`). `null` or empty string clears it. A value naming a photo the trip does not have is refused rather than written."
}
}
}Responses
- 200
The fields named, as they now stand on disk
- 400
A body naming none of the five (
nothing_to_change), a cleared or multi-line title (invalid_title), a date that is not one — anendbefore thestartis the sameinvalid_date— or acovernaming a photo not in this trip's gallery (invalid_cover) — and nothing is written in any of those cases- 401
Missing or invalid token
- 403
A trip-scoped token: it writes days into the trip and cannot rename it
- 404
No such trip, or none this token may write to — the two answer alike
deleteAsk to delete a trip (deletes nothing; mails the owner)
This deletes nothing — same flow as deleting a journal. One difference worth repeating to the person: deleting a day leaves its photographs on disk, and deleting a trip takes them with it. Owner only; somebody listed in the trip's people: may write days into it and may not delete it.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 202
A confirmation was mailed; nothing is deleted yet
- 401
Missing or invalid token
- 403
Not this journal's owner
- 404
No such trip, or this server cannot send mail
- 410
This trip was already deleted
/api/v1/{user}/deletions/{token}
postConfirm a deletion (from the mailed page, not from an agent)
The button on the confirmation page. There is deliberately no GET: mail scanners and link previewers follow links, and a GET that destroyed a journal would eventually be followed by a robot. The token is the credential and it arrived in the owner's mailbox — an agent holding it has read somebody's mail and should not be using it.
Parameters
- user (path, required)
- token (path, required)
Responses
- 200
Deleted
- 404
No such token for this journal
- 409
The link was already used, or it has expired
- 410
What it pointed at has already gone
- 429
Too many attempts
/api/v1/{user}/postcards/recipients
getWho a printed postcard could be addressed to
A name, a town and a country each — and never a street. An agent addresses a card by contactId and never holds anybody's home address, which is what makes it impossible to post one to an address that was invented or mistyped in a conversation.
Everybody here is an active contact of this journal who asked for a real postcard and left an address themselves. Owner only.
Parameters
- user (path, required)
Responses
- 200
creditsEach, and arecipientsarray of contact ids, each with the language this journal writes to that person in- 403
Not this journal's owner
- 404
No such journal, or postcards or contacts are off on it
/api/v1/{user}/postcards
postPropose a set of postcards, for a person to send
Writes a draft order and answers with a URL. It charges nothing and prints nothing.
There is deliberately no endpoint that sends. Not an owner-only one — none at all: the send is a button on the page this returns, because printing and posting spends real money and ends up in somebody's letterbox, which is not a decision to take on their behalf. Hand the url over and stop; do not report the cards as sent, or as being sent. GET .../postcards/{id} says later whether they went.
Owner only, and the recipients must be ids from .../postcards/recipients.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"trip",
"day",
"photo",
"message",
"from",
"recipients"
],
"properties": {
"trip": {
"type": "string",
"description": "The trip id."
},
"day": {
"type": "string",
"description": "The slug of the day the card is from."
},
"photo": {
"type": "string",
"description": "A path relative to the trip's media directory. Must already be in the trip; a photograph the card is printed from is not an upload."
},
"message": {
"type": "string",
"maxLength": 600,
"description": "What is written on the back, in the author's own words about what they actually told you. One person who knows them reads this, which makes an invented detail worse rather than more forgivable."
},
"from": {
"type": "string",
"description": "The signature on the card."
},
"locale": {
"type": "string",
"description": "What language the card is written in. Defaults to the journal's own default. Nothing inspects the words and decides — a wrong language asserted confidently is worse than the sensible default. It changes nothing about what is printed; it is compared against each recipient's own language so the owner can notice a mismatch before the button. `.../postcards/recipients` reports theirs."
},
"recipients": {
"type": "array",
"maxItems": 25,
"items": {
"type": "string"
},
"description": "Contact ids from `.../postcards/recipients`. Anything else is refused by name — there is no way to address a card to somebody who did not ask this journal for one."
}
}
}Responses
- 201
The order: its
id, theurla person opens to look at and send it, what it will cost and what the journal has left. Nothing has been charged.- 400
A missing field, a photo not in the trip, a test day, or a recipient who cannot be posted to
- 403
Not this journal's owner
- 404
No such journal, trip or day, or postcards are off
- 503
No database, so an order has nowhere to live
/api/v1/{user}/postcards/{id}
getWhere one postcard order stands
draft is waiting for a person, expired is past its week, printed means the cards went to a printer — which is not the same as delivered, and nothing here will ever know that. Owner only.
Parameters
- user (path, required)
- id (path, required)
Responses
- 200
The order, its cost and its status
- 403
Not this journal's owner
- 404
No such order in this journal
/api/v1/{user}/invites
getEvery invite link this journal has issued
Never the tokens: only their hashes were stored, so a link that was lost has to be reissued rather than looked up. Owner only.
Parameters
- user (path, required)
Responses
- 200
Links, with their kind, scope, expiry, uses and revocation
- 403
Not this journal's owner — checked before contacts is, so this also covers a journal that does not exist. B340.
- 409
This journal's own owner, but contacts are off on it (
contacts_disabled)
postIssue a guest link or a buddy link
Neither link grants anything. Whoever opens one proves their own address and lands in the owner's approval queue; the owner lets each person in by hand. So a link is an invitation to ask, and reporting one as "they now have access" is false.
guest leads to reading the journal — every trip marked visibility: guest, and never one marked private. It is journal-wide; there is no per-trip guest link. Safe to forward.
buddy needs a trip and leads to write access to that trip, plus the journal's guest trips once approved. It is for the people who were actually on the trip and is not the one to paste into a group chat — say which kind you are handing over.
The token appears in this response once and is stored only hashed. Owner only: a trip-scoped token may write days into its trip and may not invite people to it.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"kind"
],
"properties": {
"kind": {
"type": "string",
"enum": [
"guest",
"buddy"
],
"description": "`buddy` grants write access to one trip once approved."
},
"trip": {
"type": "string",
"description": "Required for `buddy`, and refused for `guest` — being let into a journal is never narrowed to one trip. Hold a trip back from the people you have let in by marking it `private`."
},
"name": {
"type": "string",
"description": "Whom it is for. Prefill for the greeting on the landing page, never identity: whoever opens the link types their own address."
},
"locale": {
"type": "string",
"description": "The language the page opens in."
},
"days": {
"type": "integer",
"default": 30,
"description": "How long the link stays live. There is no never — a link that does not expire is the shared password again, wearing a URL."
},
"email": {
"type": "string",
"description": "Mail the link to this address, in the recipient's own language, and pre-approve it — B319. Whoever proves this exact address at the landing page is admitted with no queue and no second decision from the owner. Proof still happens: a wrong or forwarded address grants nothing to anybody. Optional; omit it to get back a link to send yourself."
}
}
}Responses
- 201
The link.
urlis present exactly once, in this response;scopeis the journal, or a<user>/<trip>ref for a buddy link.sentsays whether anemailgiven above actually left —falsestill means the link and its pre-approval both exist, so handinvite.urlover another way rather than reading a failed send as a failed invitation.- 400
No kind, a guest link with a trip, or a buddy link without
- 403
Not this journal's owner — checked before contacts is, so this also covers a journal that does not exist. B340.
- 404
No such trip, for a buddy link
- 409
This journal's own owner, but contacts are off on it (
contacts_disabled)
/api/v1/{user}/invites/{id}
deleteRevoke one link
The link stops working and everybody already approved stays in — which is the whole reason these exist rather than a shared password, which could only be changed for everyone at once. Nothing anybody wrote is removed, so unlike deleting a journal or a trip this needs no mailed confirmation.
Parameters
- user (path, required)
- id (path, required)
Responses
- 200
Revoked
- 403
Not this journal's owner — checked before contacts is, so this also covers a journal that does not exist. B340.
- 404
No such link in this journal
- 409
This journal's own owner, but contacts are off on it
/api/v1/{user}/trips
getEvery trip in this journal
Parameters
- user (path, required)
Responses
- 200
Trips
- 401
Missing or invalid token
- 403
The token belongs to a different journal
postCreate a trip (owner only; defaults to this journal's own visibility)
public (anyone, and listed) · guest (the journal's approved guests, plus the trip's own people) · private (only the trip's own people, not approved guests). Omitted means this journal's own answer — public in a public journal, guest in a guest one — so a forgotten field is never wider than the journal already is; a value this server does not recognise falls back to private instead. Ask rather than relying on either, and recommend public or guest.
A private trip stays shut to approved guests too — approving somebody into the journal does not open it, and the owner has no way to grant it per person. If the plan is to share with family, guest is the value, and approving them is the other half of it.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"id",
"title",
"start",
"end"
],
"properties": {
"id": {
"type": "string",
"description": "URL segment: lowercase, digits, dashes."
},
"title": {
"type": "string",
"description": "What the trip is called. One line."
},
"start": {
"type": "string",
"description": "2027-04-01. Required — a trip without dates is never read."
},
"end": {
"type": "string",
"description": "2027-05-15. Required."
},
"tagline": {
"type": "string",
"description": "One line under the trip's title."
},
"status": {
"type": "string",
"enum": [
"upcoming",
"current",
"past"
],
"description": "Optional, and usually omitted: `past`/`upcoming` are derived from `start` when the trip is read. Set `current` for the trip served at the bare /{user} URL."
},
"accent": {
"type": "string",
"enum": [
"sky",
"yellow",
"green",
"coral",
"navy"
],
"description": "The trip's colour, through its pages and its map."
},
"visibility": {
"type": "string",
"enum": [
"public",
"guest",
"private"
],
"description": "public (anyone, and listed) · guest (the journal's approved guests, plus the trip's own people) · private (only the trip's own people, not approved guests). Omitted means this journal's own answer — public in a public journal, guest in a guest one — so a forgotten field is never wider than the journal already is; a value this server does not recognise falls back to private instead. Ask rather than relying on either, and recommend public or guest."
},
"listed": {
"type": "boolean",
"description": "Only ever narrows. `false` on a public trip is the old `unlisted`: readable by anybody holding the link, and in no sitemap, feed or switcher. `true` alongside a visibility that advertises nothing is refused with `invalid_listed` rather than written, since the reader would refuse it too."
},
"teaser": {
"type": "boolean",
"description": "Names a closed trip on `/<user>/trips` without opening it — a locked card carrying the title and the dates, linking to the trip's own sign-in gate. Only on a `guest` or `private` trip; `true` alongside `visibility: public` is refused with `invalid_teaser`, since a public trip is already advertised by `listed`."
},
"costsVisibility": {
"type": "string",
"enum": [
"public",
"guests"
],
"description": "Who may see what the trip cost, once they can read the trip at all — a different question from `visibility`, which decides who gets in. `public` is the default and means anybody who can read the trip can read its money; `guests` narrows that to somebody who was on the trip or whom the owner has approved into the journal. There is no editing interface anywhere in this product, so this call is the only way an owner can reach it (B178)."
},
"tracks": {
"type": "object",
"description": "What this trip keeps track of, and therefore what every day written into it is asked for. **Absent means all of them on**, which is the default an owner should not have to find: a day missing one is refused with 422 `incomplete_day` and told both how to send it and how to decline it. Turn one off here for a trip where the question does not apply — a city weekend nobody is costing, say. Changed later at PATCH .../tracks.",
"properties": {
"costs": {
"type": "boolean"
},
"coordinates": {
"type": "boolean"
},
"photos": {
"type": "boolean"
}
}
},
"travellers": {
"type": "array",
"maxItems": 10,
"description": "How the party is drawn — the walking figures on the trip's map and story. Cosmetic, and therefore not owner-only the way `people` is. Ask GET /api/v1/{user}/travellers/presets for the vocabulary and twelve starting points, and GET …/travellers/preview to show somebody the figure before it is written. An unknown key inside a figure is refused with `invalid_travellers` rather than dropped.",
"items": {
"$ref": "#/components/schemas/Traveller"
}
},
"test": {
"type": "boolean",
"description": "This trip did not happen — it exists to check that the software works. Every day of it gets a banner saying so, and none of it reaches the feed, the search index or the sitemap."
},
"intro": {
"type": "string",
"description": "The prose under the trip's own heading — what this journey is, in the person's words rather than a summary you write."
},
"people": {
"type": "array",
"maxItems": 10,
"description": "Who took the trip. It is the byline AND it is write access: everyone named may write to the whole trip and may obtain a token scoped to it, using the address given. A malformed entry is refused by name (`invalid_people`) rather than dropped, which is what the reader does with one. Correctable afterwards at PATCH .../trips/{trip}/people, which replaces the whole list.\n\n**Never infer an address.** An agent moving a journal onto a server found a person with a name and no email and filled in the owner's, which is a reasonable-looking guess that hands somebody write access to a trip. If you do not have the address, ask for it; a person listed with the wrong one is worse than a person not listed yet.",
"items": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"nickname": {
"type": "string"
}
}
}
},
"rates": {
"type": "object",
"additionalProperties": {
"type": "number"
},
"description": "This trip's frozen rates: units of the journal's BASE currency for one unit of the keyed currency. `{\"THB\": 0.0245}` is \"1 THB = 0.0245 CHF\", so a currency worth less than the base one has a small number — `site/rates/ecb.json` points the other way. Omitting a currency is supported: its costs are reported as unconverted rather than converted at a guess."
},
"translations": {
"type": "object",
"description": "Title and tagline in the journal's other languages, keyed by locale: `{\"de\": {\"title\": \"Japan\"}}`. A locale the journal does not declare is refused rather than written, since nothing would render it.",
"additionalProperties": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"tagline": {
"type": "string"
}
}
}
}
}
}Responses
- 201
Created
- 400
The id, title, dates, people, rates or translations are not usable
- 401
Missing or invalid token
- 403
Another journal's token, or one scoped to a single trip
- 409
A trip with that id already exists
/api/auth/signup/request
postAsk for a code to create a journal (no journal needed yet)
Request body
application/json
{
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"format": "email"
}
}
}Responses
- 202
Accepted — a code is mailed if the address is usable
- 404
Signing up is not enabled on this server
- 429
Too many attempts
- 503
This server cannot send mail, so signing up cannot finish
/api/auth/signup/verify
postExchange the code for a token that can create one journal
Request body
application/json
{
"type": "object",
"required": [
"email",
"code"
],
"properties": {
"email": {
"type": "string",
"format": "email"
},
"code": {
"type": "string"
}
}
}Responses
- 200
A signup token. It creates exactly one journal and is spent by doing so; unused, it expires in twenty minutes. A refused creation — a taken or malformed username — does not spend it, so a correctable mistake can be corrected without another emailed code.
- 401
The code is wrong, expired or already used
/api/v1/journals
postCreate a journal
Takes the signup token. Answers with an agent token for the journal it just created, so the caller can go straight on to creating a trip.
Request body
application/json
{
"type": "object",
"required": [
"username",
"title",
"ownerName",
"ownerNickname",
"visibility",
"defaultLocale",
"locales"
],
"properties": {
"username": {
"type": "string",
"description": "The journal's address. Permanent."
},
"title": {
"type": "string",
"description": "What the journal is called — the heading on its front page. Ask; do not invent one from the username."
},
"tagline": {
"type": "string",
"description": "One line under the title. Theirs, not a description you write."
},
"ownerName": {
"type": "string",
"description": "Whose journal it is, as they would write it. It is the byline."
},
"ownerNickname": {
"type": "string",
"description": "What the site calls them, in its own voice. Never guessed from ownerName — a first-word split mangles any name whose given name is not first, so there is no safe guess. Ask. That includes the case where the owner is the person you are talking to and has just given you their name: ask them \"what should the site call you?\" rather than inferring it. There is no default, and that is deliberate."
},
"visibility": {
"type": "string",
"enum": [
"public",
"guest"
],
"description": "Required — there is no default. Whether this server advertises the journal: public is listed on this server's own index, on its landing page and in its sitemap; guest is on none of them and asks search engines not to index it — anyone sent the address can still open it. It is also this journal's own answer for a new trip's default, unless the create call says otherwise. Neither decides who may read a particular journey: that is the trip's own visibility — guest means the people the owner has let into this journal, private means only the people who were there, public means anyone — and a new trip's default follows the journal's own answer, unless the call that creates it says otherwise. Ask which they want."
},
"startLocation": {
"type": "string",
"description": "Where the maps open before a trip has begun — the place they set off from."
},
"defaultLocale": {
"type": "string",
"enum": [
"en",
"de",
"hu"
],
"description": "Required — there is no default. The language the owner writes in, English (`en`), Deutsch (`de`), Magyar (`hu`). Sets the language of the site's own chrome and of the welcome mail sent the moment the journal is created."
},
"locales": {
"type": "array",
"items": {
"type": "string",
"enum": [
"en",
"de",
"hu"
]
},
"description": "Required — there is no default. Which languages a reader may switch the journal into, as distinct from defaultLocale, the owner's own. Must include defaultLocale. Each entry must be one of English (`en`), Deutsch (`de`), Magyar (`hu`)."
},
"baseCurrency": {
"type": "string",
"description": "ISO-4217. What totals are converted into for display; what was actually paid is never converted on the way in."
},
"displayCurrencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "Shown beside the base currency, so a reader sees both."
},
"units": {
"type": "string",
"enum": [
"metric",
"imperial"
],
"description": "metric or imperial — distances and temperatures."
}
}
}Responses
- 201
Created, with an agent token for it. Also
signIn— a one-time sign-in URL for the owner, not for you: put it in your reply so they can open their journal without going to their inbox, and it lets them see drafts and private trips. Single use, expires in 15 minutes, and never to be handed over as the journal's address (that isurl). Do not follow it yourself — opening it spends it. Hand it over straight away: asking for a sign-in code for that address invalidates an unused one early. The owner's welcome mail carries a second, standing link to the same place — a different token with no expiry, not this one.signInNotecarries the same instruction as one sentence, for pasting into a reply. Both are absent when this server has auth off.- 400
The username, title or owner name/nickname is not usable, or visibility, defaultLocale or locales is missing or not a value this server accepts, or locales does not contain defaultLocale.
- 401
Missing or invalid signup token
- 403
This address already owns as many journals as it may
- 404
Signing up is not enabled on this server
- 409
That username is taken
/api/v1/{user}/trips/{trip}/days
getEvery day in a trip, drafts included
Not only the published ones, whatever this summary said until B540: a draft comes back flagged draft: true, because an agent reading a trip back needs to see what it has written and not yet put on the site.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
Days
- 404
No such trip
postAdd a day, as a draft
Always creates a draft. A retry that finds its own earlier write gets 409 rather than overwriting it — send an idempotency_key to get the first answer back instead. Photographs are not part of this body: POST them to the media endpoint, which adds them to the day.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"$ref": "#/components/schemas/Draft"
}Responses
- 200
Replayed: this idempotency_key had already been used for this exact call, and nothing was written again.
- 201
Created as a draft
- 400
Invalid entry. The body carries a
problemslist — every problem at once, each naming the field, what arrived and what was expected. Alsoweather_disabled:weather: trueon a journal whose weather capability is off, refused with nothing written rather than accepted and never looked up (B778).- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip, or none this token may write to — the two answer alike, so a trip-scoped token cannot enumerate the journal's others.
auth_disabledinstead means this server has authentication off entirely.- 409
An entry already exists for that date and title, or an idempotency_key was reused for a different day.
- 422
incomplete_day— the day says nothing about something this trip keeps track of. Not a malformed request:missingnames each field with what to send and, equally, how to decline it ("costs": false). Never invent a value to satisfy this; ask the person, or decline.
deleteDelete a day
Refused the first time on purpose. The first call answers 409 with a signed confirm code and a question; repeat the call with that code to go through. The code is bound to the journal, trip, day and verb, and lasts five minutes. A published day is a different verb from a draft, so a code issued for one will not verify against the other. The entry file is removed; its photographs are left on disk.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"slug"
],
"properties": {
"slug": {
"type": "string"
},
"confirm": {
"type": "string",
"description": "The code from the 409. Omit it to be issued one."
}
}
}Responses
- 200
Deleted
- 400
No such day
- 409
Confirmation required — the body carries the code
/api/v1/{user}/trips/{trip}/days/{slug}/publish
postPublish a draft
Puts a draft on the site, in one call. Ask the person first, in words, and wait for an answer. Nothing here can check that you did, so that sentence is the whole of the safeguard: publishing is your work to do, and deciding is theirs.
Owner only: a token scoped to a single trip writes days into it and cannot put them on the site. Nothing sent to the days POST can publish — writing and publishing are two calls, which is what gives them a moment to read the day back, and it is the only part that is structural.
Not an update. This does exactly one thing — remove the line holding a day back — and it is not how you correct a day, before or after it is on the site: that is PATCH .../days/{slug}. A day already published answers 409 here rather than accepting new content under the name "publish".
It does not really come back. Taking a day down removes it from the journal, the feed and the search index, not from the people who have read it.
send_mail: true sends a letter about this day to every reader who may see it, in their own language — B345. Its absence means no letter, and that default never changes: publishing several days must not mail one letter per day to everybody the owner knows. The response's mail field reports how many went, never who to. A failed send never fails the publish; it shows up in mail instead. Owner only, same as the publish itself. See /api/v1/{user}/trips/{trip}/days/{slug}/send-mail to send it again afterwards.
send_whatsapp: true does the same on WhatsApp — B365 — and obeys the same default: absent means nothing is sent. Both flags may be given at once, and each reports separately (mail, whatsapp) so one channel failing tells you nothing false about the other. Its readers are a narrower set: only contacts who ticked the WhatsApp box and left a usable number, because Meta requires opt-in to WhatsApp specifically and the digest's consent does not carry over.
Both flags must be the JSON boolean true — B400. "send_mail": "true" (a string) or 1 is not read as yes; it is ignored the same as if it had never been sent, and nothing goes out for it. That case is reported rather than left silent: the response carries flagsIgnored (e.g. ["send_mail"]) and a flagsIgnoredMessage naming which key was present but not a boolean. Absence of the key stays silent — that is the honest "did not ask" — only a present, wrong-typed value is called out.
Sending costs credits where this server charges for them — B366, one per email and one per WhatsApp message. Both requested channels are priced together against one balance before anything is published: if the journal cannot cover the whole send, this answers 402 with needed and balance, the day stays a draft and nothing is sent. It is all-or-nothing, so a partial delivery is never the outcome. A publish with neither flag is never charged and never refused for credits. GET /api/v1/{user}/status carries the balance; read it first rather than discovering an empty account here. Only the journal's owner can add credits, and only from a shell on the server — there is no purchase call, so a 402 is a message to pass on, never something to retry around.
Parameters
- user (path, required)
- trip (path, required)
- slug (path, required)
Request body (optional)
application/json
{
"type": "object",
"properties": {
"send_mail": {
"type": "boolean",
"description": "Mail every entitled reader about this day once it is published. Absent or false sends nothing."
},
"send_whatsapp": {
"type": "boolean",
"description": "Message every entitled reader who opted in to WhatsApp and left a usable number. Absent or false sends nothing."
}
}
}Responses
- 200
Published; the body carries the day's public URL, and
notesays who can now read it. That answer is the trip's, not the publish's: a public trip's day is in the feed and the search index and readable by anyone with the link, aguesttrip's by the people the owner has approved into the journal, and aprivatetrip's by the people on the trip — B775. Read the note out; do not paraphrase it into "it is live".- 400
The day could not be published — the body says why
- 401
Missing or invalid token
- 402
Not enough credits for the send this call asked for. Nothing was published and nothing was charged;
neededandbalancesay by how much.- 403
Another journal's token, or one scoped to a single trip — which may write days but not publish them
- 404
No such trip, or no such day
- 409
That day is already on the site
/api/v1/{user}/trips/{trip}/days/{slug}/send-mail
postSend the letter for a published day, again
B345's second trigger: mail every entitled reader about a day that is already on the site, whether this is the first attempt or a repeat. Owner only, for the same reason /publish is — a token scoped to one trip may write days into it and must not be able to mail the journal's whole readership.
Not idempotent, on purpose. Every call sends to everybody who currently qualifies, whatever an earlier attempt sent — the owner asking again is the whole of the safeguard, so ask in words before calling it a second time, the same discipline as /publish itself. The response says resend: true and how many letters went; never who to. A test: true day, or one still a draft, refuses outright rather than sending nothing quietly.
Parameters
- user (path, required)
- trip (path, required)
- slug (path, required)
Responses
- 200
Sent (or attempted) — the body carries the count
- 400
Content nobody lived, or mail/contacts is not enabled here — the body says which
- 401
Missing or invalid token
- 402
Not enough credits for this send, where the server charges for them (B366). Nothing was sent and nothing was charged;
neededandbalancesay by how much. Only the owner can add credits, from a shell on the server — there is no purchase call, so this is a message to pass on rather than something to retry around.- 403
Another journal's token, or one scoped to a single trip — which may write days but not mail readers about them
- 404
No such trip, or no such day
- 409
That day is still a draft — nothing to send a letter about
/api/v1/{user}/trips/{trip}/days/{slug}/send-whatsapp
postAnnounce a published day on WhatsApp, again
B365, and the exact counterpart of /send-mail beside it: message every entitled reader who opted in to WhatsApp about a day already on the site. Owner only, and that reasoning is stronger here than for mail — a letter waits in an inbox, this buzzes in somebody's pocket, and a reader who did not want it reports the number rather than unsubscribing. Meta bans the number and the journal loses the channel for everyone.
Not idempotent, on purpose, exactly like /send-mail. The response says resend: true and how many went; a failure names its reason against a masked number, never the number itself. A test: true day, or one still a draft, refuses outright.
The words are fixed: WhatsApp permits only a template approved by Meta in advance, so this fills variables in sentences already written. no_template means readers opted in but no approved template exists for any language they could be written in.
Parameters
- user (path, required)
- trip (path, required)
- slug (path, required)
Responses
- 200
Sent (or attempted) — the body carries the count
- 400
Content nobody lived, WhatsApp or contacts not enabled here, or no approved template for any reader's language — the body says which
- 401
Missing or invalid token
- 402
Not enough credits for this send, where the server charges for them (B366). Nothing was sent and nothing was charged;
neededandbalancesay by how much. Only the owner can add credits, from a shell on the server — there is no purchase call, so this is a message to pass on rather than something to retry around.- 403
Another journal's token, or one scoped to a single trip — which may write days but not message readers about them
- 404
No such trip, or no such day
- 409
That day is still a draft — nothing to announce
/api/v1/{user}/trips/{trip}/days/{slug}
getOne day in full, drafts included
The whole entry — content, gallery, costs, tags, translations, and without for anything the day deliberately has none of — and a status of draft or published. This is how you read back something you have just written, before telling a person it is ready — translations included, in the same shape they were written in. Scoped like the writes on this path: a draft is what somebody has not decided to publish, so it needs the same token.
Parameters
- user (path, required)
- trip (path, required)
- slug (path, required)
Responses
- 200
The day
- 404
No such trip or day
patchEdit a day that already exists
Change one or more fields of a day already written — a coordinate that was missing, a misspelled place, a date that was wrong. This is a textual edit: a field this omits, and the file's own formatting, are left exactly as they were — the same discipline POST .../media and the publish call already keep.
Not how you publish or unpublish. There is no status in the body (components.schemas.DayEdit), and sending one is refused (400) with nothing written — a day moves between draft and published only through POST .../publish, never through this call. The response's status says which one the day was left in, so it can be reported truthfully rather than assumed: an earlier agent had no way to edit a day, reached for /publish because it was the only verb that touched an existing file, and put fifteen unreviewed days on somebody's site while reporting them as drafts (B266).
Same authority as writing the day: whoever may POST a day into this trip may PATCH one, trip-scoped tokens included.
Parameters
- user (path, required)
- trip (path, required)
- slug (path, required)
Request body
application/json
{
"$ref": "#/components/schemas/DayEdit"
}Responses
- 200
Edited.
statusisdraftorpublished— the day's actual state, not this call's intention — andchangedlists the fields that were sent.- 400
Invalid entry (a
problemslist, same shape as creation's), an empty body, or a field this endpoint does not write —statusincluded, named inunsupported_fieldrather than silently dropped.weather_disabledwhenweather: trueis sent to a journal whose weather capability is off: nothing is written, because the lookup that would answer it will never run (B778).- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip, or no such day — the two answer alike, so a trip-scoped token cannot enumerate the journal's others.
/api/v1/{user}/trips/{trip}/costs
getA trip's budget and preparation costs, as stored
The whole of costs.md — the budget, the preparation costs, the base currency they default into, and the trip's own prose about the money. exists: false means there is no costs.md yet, which is not an error: it is the same answer an empty drafts list gives. This is how you read back what PUT or PATCH just wrote, before telling the owner it is there.
Same authority as writing a day: whoever may write to this trip may read its budget, trip-scoped tokens included.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The trip's costs.md, parsed
- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip
patchAmend part of costs.md without resending the whole thing
Textual, like PATCH .../days/{slug} (B266): a field this omits, and the file's own formatting — comments, key order, flow or block YAML style — are left exactly as they were, because this may well be a file the owner wrote by hand.
budget, costs and body each replace their own block wholesale when sent. budget: null clears the budget alone and leaves costs and body untouched; costs: [] clears the preparation-costs list the same way. Neither removes costs.md itself — that is DELETE, below, and it is the only call that makes the costs page disappear.
Same authority as writing a day: whoever may POST a day into this trip may PATCH its costs, trip-scoped tokens included.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"$ref": "#/components/schemas/Costs"
}Responses
- 200
Amended.
changedlists the fields that were sent.- 400
Invalid costs, an empty body, or a field this endpoint does not write — named in
unsupported_fieldrather than silently dropped.- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip, or this trip has no costs.md yet — PUT to this same URL to create one first.
deleteRemove costs.md — not always how the costs page goes away
Whole file, not just the budget: line: the costs page is presence-driven (B293) and hasCostsData (B267, widened B328) is what decides it exists, by asking whether costs.md is there or any day carries its own costs: block — so removing the file takes the budget away but leaves the page standing if a day still logs spend (B332). The response's costsPageGone says whether the page actually went, rather than leaving that to be inferred.
Not idempotent in status: calling this on a trip with no costs.md answers 404, since there was nothing here to remove.
Same authority as writing a day: whoever may write to this trip may remove its budget, trip-scoped tokens included.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
Removed.
costsPageGonesays whether the trip's costs page is actually gone — false if a day still carries its owncosts:block.- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip, or this trip has no costs.md
putWrite the whole costs.md
Creates or wholly replaces a trip's budget, preparation costs and prose about the money, in one call — the write half of B295: before it, a budget could only be written by hand, over SSH or with the add-a-trip skill on a local checkout, and there was no way over the network to give a trip its costs page at all.
budget is required. A zero or missing total is refused here with a problems entry, rather than written and read back as no budget at all — lib/costFormat.ts's parseBudget drops one silently for a page render, and a door cannot repeat that (B263).
Same authority as writing a day: whoever may POST a day into this trip may PUT its costs, trip-scoped tokens included — a budget is trip content, and the people on a trip are the people who spent the money.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"$ref": "#/components/schemas/Costs"
}Responses
- 200
Written. GET this same URL to read it back.
- 400
Invalid costs (a
problemslist, same shape as a day's — field, what arrived, what was expected), invalid JSON, or a field this endpoint does not write.- 401
Missing or invalid token
- 403
The token belongs to a different journal
- 404
No such trip
/api/v1/{user}/trips/{trip}/rates
getA trip's frozen exchange rates, as stored
The rates: table on this trip's trip.md — units of the journal's base currency for one unit of each keyed currency, so {"THB": 0.0245} reads "1 THB = 0.0245" of the base. A currency with no rate is simply absent; its costs are reported as unconverted rather than guessed at.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The trip's rates table
- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to a trip
- 404
No such trip
patchAmend a trip's exchange rates after it was created — B352
createTrip could only ever write rates: once, at the moment a trip is made (B207); this is the door to fix or fill in a rate afterwards, for a hosted instance where nobody has a shell to edit trip.md by hand.
Merges, does not replace. Naming one currency fills in or corrects that one and leaves every other rate already on the trip untouched — send the one rate a trip is missing, not the whole table. Costs already recorded in a currency you just add convert the next time the costs page, or any total drawn from it, is read.
Owner only, like rates at creation — a trip-scoped token is refused with out_of_scope: a rate table is metadata about the trip, the same shelf visibility and people sit on, and not content a traveller logs. That is unlike the trip's budget, which anyone on the trip may write.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"rates"
],
"properties": {
"rates": {
"type": "object",
"additionalProperties": {
"type": "number"
},
"description": "Currency code to rate, e.g. {\"EUR\": 0.94} — units of the base currency for one unit of the keyed currency."
}
}
}Responses
- 200
Merged and written.
ratesis the trip's full table after the merge.- 400
Invalid JSON, an empty or malformed rates object, or an unrecognisable currency code.
- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to a trip rather than the journal's owner (
out_of_scope).- 404
No such trip
/api/v1/{user}/trips/{trip}/visibility
getWho may read this trip, as stored
visibility: and listed: on this trip's trip.md — private (the people who were there, and the owner), public (everyone) or guest (everyone the owner has approved into the journal, and the people who were there), and whether the trip is advertised in the sitemap, the feed and the trip switcher — plus teaser:, whether a closed trip is named on the trips page without being opened.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The trip's visibility, listed and teaser flags
- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to a trip
- 404
No such trip
patchChange who may read this trip after it was created — B396
createTrip could only ever write visibility: once, at the moment a trip is made (B207); this is the door to change it afterwards, for a hosted instance where nobody has a shell to edit trip.md by hand — the contacts page's own advice, "set a trip's visibility to guest", had nowhere else to send an owner.
Send only what changes — visibility, listed, teaser, or any of them. An unrecognised visibility is refused rather than written and read back as private later, the same rule the file's own reader already follows. listed: true is refused on a trip whose visibility does not already advertise it (B51) — only public does, and teaser: true is refused on a public trip for the mirror reason: it names a trip nobody may read, and a public trip is readable.
Widening is said out loud. Moving towards public, or from private to guest, exposes every day already published on this trip to a wider audience the instant this call returns; the response's note says so. Narrowing needs no such warning: it can only take readers away.
Owner only, like visibility at creation — a trip-scoped token is refused with out_of_scope: this is metadata about the trip, the same shelf rates and people sit on, and deciding who else may read the whole journey is not the authority writing a day into it grants.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"properties": {
"visibility": {
"type": "string",
"enum": [
"public",
"guest",
"private"
]
},
"listed": {
"type": "boolean"
},
"teaser": {
"type": "boolean",
"description": "Whether a *closed* trip says that it exists: a `guest` or `private` trip carrying this gets a locked card on `/<user>/trips` with its title, its dates and nothing else — no cover, no counts, no route — linking to its own sign-in gate. Refused with `invalid_teaser` on a public trip, where `listed` is the key that decides. Never a reading right: who may open the trip is `visibility` alone."
}
}
}Responses
- 200
Written and read back.
notesays whether this widened who may read the trip.- 400
Invalid JSON, no field named, an unrecognised visibility, a listed: true this trip's visibility does not advertise, or a teaser: true on a public trip.
- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to a trip rather than the journal's owner (
out_of_scope).- 404
No such trip
/api/v1/{user}/trips/{trip}/tracks
getWhat this trip keeps track of, and therefore asks every day for
costs, coordinates and photos, each true unless the owner has turned it off. A day that says nothing about a tracked row is refused with 422 incomplete_day — send the thing, or say in the same call that the day does not have it. Read this before writing days rather than after the first refusal.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The rows, and what each one asks for
- 401
Missing or invalid token
- 404
No such trip, or none this token may read
patchTurn a row off, or back on. Owner only
Send {"tracks": {"costs": false}} — only the rows you name change. Turning a row off is the owner deciding this journey is not keeping that; it is not a way to quieten one awkward write, which is what the per-day decline is for. Nothing already written changes either way.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"tracks"
],
"properties": {
"tracks": {
"type": "object",
"properties": {
"costs": {
"type": "boolean"
},
"coordinates": {
"type": "boolean"
},
"photos": {
"type": "boolean"
}
}
}
}
}Responses
- 200
What the trip asks for now, and what changed
- 403
A trip-scoped token cannot lower the bar it is measured against
/api/v1/{user}/inbox
getEverything staged, and what was said about it
The journal's inbox: files that have been uploaded and belong to no day yet. Grouped by kind — media, files, photobook, postcards — with each file's id, the name it arrived under, its size, and whatever the uploader said about it (description, caption, lat, lon, takenAt, tags; absent means nobody said).
Make this call before writing days for a trip somebody has just come back from: the pictures are usually here already. Filing one into a day is POST /api/v1/{user}/trips/{trip}/media with inbox.
A trip-scoped token is refused — the bucket belongs to the journal, and showing it would show files staged for trips you are not on.
Parameters
- user (path, required)
Responses
- 200
What is staged, by kind
- 401
No live token — authenticate
- 403
A different journal's token, or one scoped to a trip
postStage files that belong to no day yet
The one upload door that does not ask which day a file is for. That is what it is for: a camera emptied on the evening it happened, when the days that will hold the pictures are still unwritten.
multipart/form-data. files may repeat; meta and kind may repeat alongside it, one per file and in the same order. Everything on meta is optional and every field of it is what you were told — never what you concluded from looking at the file. A file with no description is normal; an invented one is not recoverable.
Duplicates are free. A file is named by a hash of its own bytes, so the same file sent twice is stored once and the second call answers with the first one's id and duplicate: true. Two different files sharing a name both survive.
Counts against the journal's storage ceiling like everything else — see storage in GET /api/v1/{user}/status.
Parameters
- user (path, required)
Request body
multipart/form-data
{
"type": "object",
"required": [
"files"
],
"properties": {
"files": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
},
"description": "Repeatable. Images and video as the media route takes them, plus .csv, .pdf, .json, .txt, .gpx, .md for the documents nothing reads yet."
},
"kind": {
"type": "array",
"items": {
"type": "string",
"enum": [
"media",
"files",
"photobook",
"postcards"
]
},
"description": "Optional, one per file and in the same order. Which folder it goes in. Left out, it is worked out from the extension: a picture or a clip is `media`, a document is `files`. Say `photobook` or `postcards` for artwork meant for a printed thing."
},
"meta": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional, one JSON object per file and in the same order: `description`, `caption`, `lat`, `lon`, `takenAt`, `tags`. All optional. Only what somebody told you."
}
}
}Responses
- 201
Staged. Each item carries its id and its sidecar
- 400
A file was refused — kind, size, or no room left
- 401
No live token — authenticate
- 403
A different journal's token, or one scoped to a trip
- 413
The whole request is too big to buffer
/api/v1/{user}/inbox/{id}
deleteTake one staged file back out
No confirmation code: nothing staged has ever been on the site and nobody has read it. A photograph already filed into a day is a different route, and that one does ask. The file and its sidecar go together.
Parameters
- user (path, required)
- id (path, required)
Responses
- 200
Gone
- 403
A different journal's token, or one scoped to a trip
- 404
Nothing staged under that id
/api/v1/{user}/import
getWhat can be imported, and in which formats
The kinds of data this instance can read, and who wrote each format it understands: gps, costs. A kind is what the data is — a location history, a bank statement — and a format is who wrote it.
This is the only GET in the import feature, and it describes the door rather than what is behind it. Nothing anywhere hands back a position. A location history is every address somebody sleeps at and every place they work; what a reader ever sees is the derived line for one trip, drawn behind that trip's own gate.
The two kinds end differently. gps is stored as it is read — a coordinate is a measurement and there is nothing to decide about it. costs writes nothing at all: a statement covers the trip and the fortnight either side of it, and what each line was for is an editorial decision. It reports, a person agrees, and POST /api/v1/{user}/trips/{trip}/costs/import writes.
A trip-scoped token is refused on all of these — the history belongs to the journal, not to the trip you came on.
Parameters
- user (path, required)
Responses
- 200
The kinds, their formats, and the size limit
- 401
No live token — authenticate
- 403
A different journal's token, or one scoped to a trip
postRead an export into the journal's own data
Takes a file somebody exported from somewhere else — Google Maps Timeline, a Takeout Records.json, a GPX track, plain JSON Lines from a tool of your own, or a Revolut statement — and reads it into the journal.
Say the kind. With two of them an absent one is refused rather than guessed at: reading a bank statement as positions, or a location history as money, is not a mistake to make quietly.
Three ways to hand over the bytes. inbox names a file already staged with POST /api/v1/{user}/inbox and is the normal path for a real export; multipart file is a one-shot; text is for a handful of lines pasted in. The import leaves the staged file where it is — deleting it is DELETE /api/v1/{user}/inbox/{id}, and worth doing, because it is the unthinned original.
Leave format out and the file is recognised from its contents. Name one only when detection gets it wrong, or when you wrote the importer.
Positions are thinned on the way in — one kept per five minutes or 250 metres, whichever comes first — so importing the same export twice changes nothing, and importing overlapping exports does not double anything.
Nothing is drawn by this call. POST /api/v1/{user}/trips/{trip}/track is what turns what is now stored into one trip's line.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"gps",
"costs"
],
"description": "What the data is. **Required**: with two kinds, an absent one is refused rather than guessed at."
},
"format": {
"type": "string",
"enum": [
"google-timeline",
"google-records",
"gpx",
"fixes",
"revolut"
],
"description": "Who wrote the file, within its kind. Left out, it is detected from the contents."
},
"from": {
"type": "string",
"description": "`costs` only: ignore rows before this ISO date. Usually the trip's start — a statement holds the fortnight either side of it too."
},
"to": {
"type": "string",
"description": "`costs` only: ignore rows after this ISO date."
},
"inbox": {
"type": "string",
"description": "The id of a file already staged in the inbox. The normal path."
},
"text": {
"type": "string",
"description": "A small export inline, instead of `inbox`. JSON Lines of `[t, lat, lon]` is the format for anything you generated yourself."
},
"dryRun": {
"type": "boolean",
"description": "`gps` only: parse, check and report without writing anything. This is how you test an importer you wrote — it runs the same contract check the format's own `schema.ts` exports. A `costs` import never writes in the first place, so the flag changes nothing there; it is accepted, and the answer says so rather than leaving you to wonder whether the read happened."
}
}
}multipart/form-data
{
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
},
"kind": {
"type": "string",
"enum": [
"gps",
"costs"
]
},
"format": {
"type": "string",
"enum": [
"google-timeline",
"google-records",
"gpx",
"fixes",
"revolut"
]
},
"dryRun": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
}Responses
- 200
Read. Says which format, whether it was detected, how many rows and over what span, and — unless
dryRun— how many the store holds now- 400
unknown_kind,unknown_format,unreadable,contract(what came out does not hold up — the problems are listed in words),no_file, orstorage_full- 401
No live token — authenticate
- 403
A different journal's token, or one scoped to a trip
- 404
No such journal, or no such file in the inbox
- 413
The whole request is too big to buffer
/api/v1/{user}/trips/{trip}/costs/import
postPut agreed statement rows onto the days they happened
The second half of a costs import. POST /api/v1/{user}/import read the statement and wrote nothing; this takes back the rows a person has agreed and records them as costs on the days.
Two decisions happen in between, and neither is yours. Which rows — a statement covers the trip, the rent and the phone bill. Which category — a statement says what was paid, never what it was for. Agree them against the import's merchants list, which is sorted biggest first because one decision about a merchant covers every payment to it. other is a real answer; a guess dressed as a category is not.
It adds, and never replaces. Costs somebody wrote by hand stay. Sending the same rows twice writes them twice — visible on the day and correctable there, which is the honest behaviour for an append.
A date whose day has not been written yet is reported back in orphaned and nothing is recorded for it. The cost is never moved to a neighbouring day.
Writable by anybody who may write the trip, trip-scoped tokens included: nothing here reads the owner's statement or reaches outside this trip.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"rows"
],
"properties": {
"rows": {
"type": "array",
"items": {
"type": "object",
"required": [
"date",
"label",
"amount",
"currency",
"category"
],
"properties": {
"date": {
"type": "string",
"description": "ISO date — which day it goes on"
},
"label": {
"type": "string",
"description": "What it is called on the day"
},
"amount": {
"type": "number",
"description": "Positive: what it cost. A statement's minus sign belongs to the statement; a negative cost renders as a negative total."
},
"currency": {
"type": "string"
},
"category": {
"type": "string",
"enum": [
"preparation",
"flights",
"accommodation",
"food",
"transport",
"activities",
"other"
]
}
}
}
}
}
}Responses
- 200
What was written, per day, with how many existing costs were kept — and
orphanedfor dates with no day- 400
invalid_costs— every bad field of every row at once- 401
No live token — authenticate
- 403
A token that may not write this trip
- 404
No such trip
/api/v1/{user}/trips/{trip}/track
postDraw this trip's line from the imported history
Derives the ground actually covered on this trip and writes it into the trip, where the map draws it faintly under the day markers.
Four things happen, and three are about what does not come out: the line is clipped to the trip's dates (everything outside is the rest of somebody's life), the owner's private zones are cut out and the line broken there, a gap of more than two hours is left as a gap rather than joined — a flight is a hole in the data, not a straight line across a continent — and the rest is simplified to a few thousand points.
Answers with counts and never with a coordinate. Safe to run again whenever more history has been imported; it rewrites one file. A trip with nothing stored for its dates writes nothing and leaves any existing line alone.
Owner only, and a trip-scoped token is refused even for its own trip: deriving reads the owner's whole history across those dates.
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
Segments, points and how many private zones were applied.
written: falsemeans nothing was stored for these dates- 401
No live token — authenticate
- 403
A different journal's token, or one scoped to a trip
- 404
No such trip
/api/v1/{user}/trips/{trip}/media
postUpload photographs or video to a day, and add them to it
The files are put into the day's entry for you. There is nothing to paste, and it does not matter whether you write the day before or after sending its pictures — only that the day exists.
Two ways in. multipart/form-data carries the bytes: day is the slug of a day in this trip, and files may repeat. application/json carries urls for this server to fetch — https only, public hosts only, refused after a redirect to a private address.
Two files are kept for each one sent: a resized copy for the browser and the original for print. Send the largest you have — for a URL upload the original is whatever the remote host served, so a 2000px source is what a photobook will be printed from, and there is no way to get the pixels back later.
The whole request may carry 512 MB, which is a different limit from the per-file one and is the one a batch of phone originals meets first. Over it the answer is 413 body_too_large, naming the cap and what arrived; nothing is written, and a day may be filled by as many calls as you like.
Parameters
- user (path, required)
- trip (path, required)
Request body
multipart/form-data
{
"type": "object",
"required": [
"day",
"files"
],
"properties": {
"day": {
"type": "string",
"description": "A day that already exists in this trip. Write the day first."
},
"files": {
"type": "array",
"items": {
"type": "string",
"format": "binary"
},
"description": "Repeatable. Images: jpeg, png, heic, heif, webp — no other format is taken, and note that .jpg and .jpeg are the same thing here. Video: mp4, mov, webm. At most 50 MB and 8000 px on the long edge for a picture, 500 MB and 300 seconds for a clip — though about 60s is the clip a reader watches, and a longer one is taken whole and mentioned in `advice` rather than cut — 40 items on one day, and 512 MB in one request — which is the limit a batch of phone originals meets first, so send them in batches rather than all at once. Send the largest you have: the original is what a photobook is printed from and there is no way to get the pixels back later."
},
"captions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional, one per file and in the same order — `captions` may repeat like `files`. Fewer captions than files is fine; more is refused rather than misaligned, since a caption on the wrong photograph is worse than none. Write what you were told about the picture, never what it looks like to you."
},
"visibility": {
"type": "array",
"items": {
"type": "string",
"enum": [
"guest",
"private"
]
},
"description": "Optional, one per file and in the same order — same alignment rule as `captions`, and an empty value for a picture nobody is holding back. `guest` shows it to everybody the owner has let into the journal and to the people who were on the trip; `private` to the people who were there, and the owner. It narrows what the trip's own visibility already allows and can never widen it, so there is no `public`. Only ever what the owner asked for — a picture nobody said anything about is not held back on a hunch."
}
}
}application/json
{
"type": "object",
"required": [
"day"
],
"properties": {
"day": {
"type": "string"
},
"urls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"description": "https URLs on public hosts, to photographs or to clips — the same formats and the same per-file ceilings as the multipart door, and a `content-type` that is neither an image nor a video is refused. All or nothing: if any is refused, nothing is written and the reply names which and why. One of `urls` or `inbox` is required."
},
"inbox": {
"type": "array",
"items": {
"type": "string"
},
"description": "Ids from `GET /api/v1/{user}/inbox` — files already staged in this journal, which is how photographs get in before the days that will hold them exist. They are **moved**: the file goes into the day and leaves the inbox, so a written day owns its pictures. A caption on the staged file is used unless `captions` gives one here. An id that names nothing staged, or names something that is not a photograph, refuses the whole call and writes nothing."
},
"captions": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional, one per URL and in the same order. Same rules as the multipart door."
},
"visibility": {
"type": "array",
"items": {
"type": "string",
"enum": [
"guest",
"private"
]
},
"description": "Optional, one per URL and in the same order. Same rules as the multipart door."
}
}
}Responses
- 201
Written and added to the day.
itemsis the resized copy the site serves, and its width/height are that copy's.keptis the original stored untouched for print, with the dimensions you sent — the two differ on purpose, andkeptis how you confirm the full-resolution file survived rather than taking it on trust — a clip is inkeptlike a photograph, and its dimensions are absent because for a video they describe the transcode, whichitemsalready carries.adviceis present only when there is something worth saying about a batch that succeeded, today that a clip is long; it changed nothing and asks for nothing.skippedis the photographs this day already had: sending the same batch twice adds nothing the second time, and each entry names the file you sent and thesrcof the picture it matched, so feweritemsthan files is not a loss. Clips are not compared this way and a resent clip lands twice.attachedis false only if the entry has no frontmatter to write into, in which case the files are still on disk anditemsis what to add by hand.notesays plainly when the day is already published, so anyone reading it can now see the addition.- 400
A file, a URL, or the day was rejected — the response says which and why
- 413
Over a size limit this instance sets —
body_too_largewhen the request itself is too big to buffer, which is the limit a batch meets first
deleteTake a photograph off a day, for good
This route could put photographs on and never take one off, so the only remedy for a duplicate or a wrong upload was a shell on the server — B605. src — one or more — has to be exactly what GET .../days/<slug> handed back for this day; a name the day does not carry refuses the whole call rather than removing the rest and leaving you to notice which one silently did not land.
The files are actually deleted: the derivative, the poster if it was a clip, and the kept original — not merely detached from the day. A photobook or postcard order that already named one of these files is untouched; both resolve the photograph live, at send or print time, so deleting one a pending order names will make that order fail the same way it would if you had deleted the file by hand. A completed order already has its copy at the printer and is not affected either way.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"day",
"src"
],
"properties": {
"day": {
"type": "string",
"description": "The day these photographs are on."
},
"src": {
"type": "array",
"items": {
"type": "string"
},
"description": "One or more, exactly as GET .../days/<slug> hands them back in `gallery`. Every one has to already be on this day, or nothing is removed."
}
}
}Responses
- 200
Removed.
removedlists thesrcof every photograph actually deleted, andnotesays plainly when the day is already published, so anyone reading it can now see the removal.- 400
No
srcat all, or one naming a photograph this day does not have — the response says which- 404
No such day in this trip
/api/auth/handover
postSpend a handover credential for your own 7-day token
The first call an agent makes when the owner pasted a prompt instead of reading out a code. Send the handover credential as Authorization: Bearer. It lasts 20 minutes, is spent by succeeding here, and is refused on every other route. A 401 means expired or already used — ask the person for a fresh one rather than retrying. The answer carries the 7-day token and the status URL to read next.
Responses
- 200
A 7-day agent token
- 401
No credential, or one that is expired, spent or not a handover
- 404
This server has authentication off
/api/v1/{user}/handover
postIssue a handover credential (owner only)
What the owner's own access page calls so it can print a pasteable prompt. Owner only, cookie or bearer. The credential it answers with lasts 20 minutes and can only be exchanged at POST /api/auth/handover — never used to read or write. An agent has no reason to call this; it is here so the contract is complete.
Parameters
- user (path, required)
Responses
- 200
A 20-minute handover credential
- 403
Not this journal's owner
- 404
No such journal, or sign-in is off for it
/api/health
getIs this server well, what can it do, and what will it acceptno token
Public and unauthenticated. Read it before you do anything expensive: capabilities says which optional features are on and, when one is off, why — so an agent can tell "this server cannot send mail" from "this call was wrong". media says what an upload may be, which is the one limit worth knowing before rather than after sending 60 MB of photographs. status is error and the code 503 when the config or the content root is unreadable.
Responses
- 200
Healthy
- 503
The config or the content root cannot be read
/api/v1/{user}/trips/{trip}/people
getWho is on this trip
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The people block
- 403
Owner only
- 404
No such trip
patchReplace who is on this trip (owner only)
The whole list at once, not a merge: send everyone who is on the trip, including the ones already there. It is the byline and it is write access — everyone named may write to the trip and may hold a token scoped to it — so this is owner-only and a trip-scoped token cannot widen its own reach.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"people"
],
"properties": {
"people": {
"type": "array",
"maxItems": 10,
"items": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}Responses
- 200
The list as it now stands
- 400
An entry is not usable
- 403
Owner only
- 404
No such trip
/api/v1/{user}/trips/{trip}/travellers
getHow this trip's party is drawn
Parameters
- user (path, required)
- trip (path, required)
Responses
- 200
The travellers block
- 404
No such trip
patchReplace how this trip's party is drawn
The whole list at once. Cosmetic — it changes the walking figures and nothing about who may read or write anything, which is why it is not owner-only the way people is. Ask …/travellers/presets for the vocabulary first; an unknown key inside a figure is refused rather than dropped.
Parameters
- user (path, required)
- trip (path, required)
Request body
application/json
{
"type": "object",
"required": [
"travellers"
],
"properties": {
"travellers": {
"type": "array",
"maxItems": 10,
"items": {
"$ref": "#/components/schemas/Traveller"
}
}
}
}Responses
- 200
The list as it now stands
- 400
A figure is not usable
- 404
No such trip
/api/v1/{user}/travellers/presets
getThe vocabulary the walking figures are described inno token
Open, because it describes nothing about anybody: it is the list of hair, skin, clothing and pack values a figure may use, and twelve worked starting points. Read it before writing a travellers block rather than guessing at value names.
Parameters
- user (path, required)
Responses
- 200
The vocabulary and the presets
- 404
No such journal
/api/v1/{user}/travellers/preview
getA figure, drawn, so a person can see themselves before it is writtenno token
Answers SVG, not JSON. Give it figure (one figure as JSON) or party (a list), and optionally size in pixels. Nothing is stored. This is the call that makes "is this you?" a question somebody can answer.
Parameters
- user (path, required)
- figure (query)
- party (query)
- size (query)
Responses
- 200
image/svg+xml
- 400
Nothing to draw, or the JSON did not parse
- 404
No such journal
/api/v1/{user}/keys
getThe tokens and sessions that can write here — the owner sees every row, anybody else only their own (B323)
The owner gets one row per live credential in the journal, each with email. Anybody else who has proved an address — a guest cookie, a year-long identity, or a trip-scoped bearer token — gets only the rows issued to that address, with no email field (the list is already implicitly theirs). There is no parameter that widens this: the filter is the caller's own proven address, never anything the request sends. Every row carries scope, in tripWriteScope's vocabulary, so a trip-bound key can be told apart from a journal-wide one.
Parameters
- user (path, required)
Responses
- 200
One row per live credential this caller may see, with its kind, scope and expiry
- 403
No proven address at all — no cookie, no identity, no bearer token for this journal. For an address that owns the journal this also covers a journal that does not exist, checked before the capability is (B340).
- 409
A proven address on this journal, but sign-in is off on it (
auth_disabled)
postRevoke one of them — the owner may revoke any row, anybody else only their own
{"revoke": "<key id>"}, with an id from the GET above. It ends that credential immediately — the way to answer "an agent has a token I want back". An id that is not this journal's, or — for a non-owner — not this caller's own row, answers the same 404 as an id that does not exist at all, so a guess learns nothing.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"revoke"
],
"properties": {
"revoke": {
"type": "string"
}
}
}Responses
- 200
Revoked
- 400
No key id sent
- 403
No proven address at all. For an address that owns the journal this also covers a journal that does not exist, checked before the capability is (B340).
- 404
No such key — either it does not exist, or (for a non-owner) it belongs to somebody else's address
- 409
A proven address on this journal, but sign-in is off on it (
auth_disabled)
/api/v1/{user}/channels
postTurn this journal's mail or WhatsApp on or off (owner only)
The narrow door for the two channels a published day can go out on. The wider one is PATCH …/config with features; this exists so a person can say "stop mailing me" without a call that could change anything else.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"channel",
"enabled"
],
"properties": {
"channel": {
"type": "string",
"enum": [
"mail",
"whatsapp"
]
},
"enabled": {
"type": "boolean"
}
}
}Responses
- 200
The channel as it now stands
- 400
Unknown channel, or
enabledis not a boolean- 403
Owner only
- 404
No such journal
- 429
Too many changes too quickly;
retryAftersays when
/api/v1/{user}/postcards/texts
getWhat each day of a trip would say on the back of a card
Per day, in the journal's languages, so a person can choose rather than have an agent write one. trip is required.
Parameters
- user (path, required)
- trip (query, required)
Responses
- 200
The trip's days and their texts
- 403
Owner only
- 404
No such trip, or postcards are off on this server
/api/v1/{user}/credits/purchase
postStart a credit purchase and get a link to it (buys nothing)
Nothing is bought and nothing is granted. It records a pending payment, mails the owner, and answers with paymentUrl — an absolute link to the page where a person chooses how to pay. The money and the credits happen there and at the payment provider, deliberately, so that no token can spend anything. Hand the URL over and report it as a request, never as a purchase.
tier is one of the fixed tiers; the response repeats credits and priceRappen so you can quote what was started. Owner-only.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"required": [
"tier"
],
"properties": {
"tier": {
"type": [
"string",
"integer"
]
}
}
}Responses
- 200
A pending payment.
paymentUrlis the absolute link to hand over;transactionId,creditsandpriceRappensay what was started, andmailedTois the owner address the same link went to.- 400
Unknown tier
- 403
Owner only
- 404
Credits are off on this server
/api/v1/{user}/storage
getWhere this journal's space is going
What is used, what is allowed, what is left, and a breakdown — one row per trip, plus the inbox, the generated photobooks and postcards, and everything else. The rows sum to usedBytes exactly.
reclaimable is what a cleanup would take back without touching a photograph: generated PDFs and postcard sheets. Only the owner, in their own browser, can run one — report the number and let them decide.
Parameters
- user (path, required)
Responses
- 200
Usage, the breakdown, and what could be reclaimed
- 401
No live token — authenticate
- 403
This token belongs to a different journal
postBuy this journal 5 GB more room
Spends 50 credits and raises this journal's storage ceiling by 5 GB, immediately and for good — unlike /credits/purchase, this one really does charge. It cannot be undone, it does not expire, and buying twice adds twice. No request body: there is one thing to buy and one price.
The owner's own browser session, and nothing else. A bearer token is refused here whatever it is scoped to, the same way ordering a photobook or posting a card is: an agent that has just been refused an upload reports that the journal is full and lets the owner decide whether to delete something or buy more room. GET /api/v1/{user}/status is where the bytes held and the bytes allowed are read back.
Parameters
- user (path, required)
Responses
- 200
Bought. The new ceiling and what is used against it
- 402
The balance does not cover it — nothing was charged
- 403
Owner only, and never a bearer token —
not_for_agents- 404
Credits are off on this server
- 429
Too many purchases in a minute
/api/v1/{user}/storage/cleanup
getWhat a cleanup would remove (removes nothing)
The plan behind the confirmation the owner reads: bytes and file counts, split into photobooks, postcards and staged documents. ?staged=1 includes the documents in inbox/files/. Nothing is deleted.
Parameters
- user (path, required)
- staged (query)
Responses
- 200
What would go
- 403
Owner only, and never a bearer token —
not_for_agents- 404
No such journal
postDelete generated photobooks and postcard sheets
Deletes files. Generated photobook PDFs for orders that finished printing, and dry-run postcard sheets. ?staged=1 also removes the documents staged in inbox/files/; staged photographs are never in scope, and are removed one at a time through DELETE /api/v1/{user}/inbox/{id} where a person is looking at what they are removing.
Nothing else is touched: every photograph, day and trip stays, and a printed book keeps its record, its price and its date — only the PDF goes, and it can be built again from photographs that are still there. A book still building is never touched.
The owner's own browser session, and never a token, for the same reason as buying storage: an agent reports what is taking the space and does not decide which of somebody's files to delete.
Parameters
- user (path, required)
- staged (query)
Responses
- 200
Removed, with what was taken
- 403
Owner only, and never a bearer token —
not_for_agents- 404
No such journal
- 429
Too many cleanups in a minute
/api/v1/{user}/payments/{id}/pay
postStart paying a pending payment (grants nothing)no token
Authenticated by the payment id in the path, which is an unguessable capability reached from a link in the owner's own mail — not by a session and not by a token in the body. It adds no credits on any path; creditsAdded is zero and always will be.
What it does depends on whether this instance has a payment provider configured (/api/health says, under capabilities.credits.note). With one, the response carries url — a hosted checkout page to send the buyer to — and method in the request is ignored, because the provider's own page is where TWINT, a wallet or a card is chosen. Without one, it files a request and mails the instance operator an approval link; approver is the address it went to, and method is then required.
Parameters
- user (path, required)
- id (path, required)
Request body
application/json
{
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": [
"twint",
"card"
],
"description": "Required when no payment provider is configured; ignored when one is."
}
}
}Responses
- 200
Recorded.
statusisrequested;creditsAddedis0;urlis present when a provider is configured and the buyer should be sent there.- 400
Unknown method, and no provider configured
- 404
No such payment
- 502
The payment provider could not be reached
/api/v1/{user}/payments/{id}/approve
postApprove a payment — the one call that grants creditsno token
Authenticated by the single-use token in the body. It is reached from a link in the operator's mail rather than by anything an agent holds, and it is one of only two HTTP paths that add credits to a journal — the other is the payment provider's own signed webhook, which no client calls.
Parameters
- user (path, required)
- id (path, required)
Request body
application/json
{
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string"
}
}
}Responses
- 200
Approved, and the credits added
- 401
The token does not verify
- 404
No such payment
/api/v1/{user}/status
getWhere you stand, in one call
The first call to make, and the cheapest credential check there is: 401 means go and get a code, 200 means you are in. Carries the journal, the drafts waiting for a person to approve them — each with the call that publishes it — the trips this token may write to, which capabilities are on for this journal and why any is off, and a next saying what to do.
features here is deliberately only the four an agent can act on — mail, push, postcards, photobook — plus credits where this server bills. It is not the journal's whole capability list, and a name missing from it is not a name that is off: GET .../config carries all of them and /api/health says what this server can offer at all. The two fields share a name and answer different questions, which has misled a reader of this document before.
scope says whether you are holding the whole journal or one trip's slice; do not report a slice as the journal's total.
credits is here when this server charges for sends (B366): balance, and what each channel costs. Read it before publishing with send_mail or send_whatsapp — a balance too small refuses the whole publish with 402 and writes nothing. Absent means this server does not bill, not that the account is empty; it is also absent for a trip-scoped token, which can neither publish nor send.
storage is how full the journal is — usedBytes, limitBytes, remainingBytes and purchasedBytes. Read it before uploading a batch: a batch that would go past limitBytes is refused whole and nothing is written. It counts every byte under the journal's folder, photobook PDFs included, not only its photographs. A limitBytes of null means this instance sets no ceiling — never that the answer is unknown. Present for a trip-scoped token too, because the whole journal's ceiling is what refuses a trip's photographs. POST /api/v1/{user}/storage is how the owner raises it.
inbox counts what is staged and belongs to no day yet (B663), with the call that lists it. A non-zero count is the first thing to act on for a trip somebody has just come back from — the photographs are already here. Absent for a trip-scoped token, which the inbox route refuses.
malformed names a trip on disk with a trip.md too broken to parse (B83) — the same list GET .../trips carries, so writing a trip and reading this back cannot disagree about whether it took. next puts fixing one ahead of the draft queue: a broken file is a thing you may have just caused and can fix yourself, where the drafts are a person's decision. Present for an owner token only — a trip-scoped token learns nothing about the rest of the journal, malformed or not — and absent entirely when there is nothing broken.
Parameters
- user (path, required)
Responses
- 200
Status
- 401
No live token — authenticate
- 403
This token belongs to a different journal
/api/v1/{user}/drafts
getEverything written and not yet on the site
Each draft carries where to publish it, and test: true if it is content nobody lived — including a day that inherits the flag from its trip and says nothing itself. Read that out with the rest: this is the list somebody is looking at when they decide what goes on the site (B134). Absent means real.
Parameters
- user (path, required)
Responses
- 200
Every draft in the journal, each with the trip it belongs to and the
publishcall that would put it on the site.- 401
Missing or invalid token
- 403
The token belongs to a different journal
/api/v1/{user}/config
getWhat this journal asks for, and what it says about itself
Parameters
- user (path, required)
Responses
- 200
One boolean per capability under
features, and the writable half of config.json underjournal— including thebaseCurrencyadisplayCurrenciesmust contain- 401
Missing or invalid token
- 403
The token belongs to a different journal
patchChange a capability, or what this journal says about itself
Send only what you are changing: {"features": {"contacts": true}}, or one or more of title, tagline, visibility, startLocation, units, locales, defaultLocale, displayCurrencies, manualRates, ownerTel. Before this there was no endpoint, tool or page that wrote a journal's config at all, so it was fixed at creation and only an operator with a shell could change it — which left journals unable to invite anybody (B182) and a title typoed at signup permanent (B220).
Capabilities can only ask for what the server already provides: the server's own config is a ceiling, and asking to exceed it is refused with the reason rather than written and silently ignored. Switching a capability off always works, except for the four the server decides alone: photobook and postcards cost the operator money at a printer, and logging and credits are the instance's own. All four are whatever the server says for every journal on it, and either direction is refused with capability_not_yours.
Capabilities and the rest are two calls. A body naming features alongside another field is 400 mixed_change and writes nothing: each call rewrites config.json whole, reads it back, and restores the previous bytes if it does not load, so a request doing that twice is one that can succeed halfway.
Three keys are never writable, each with its own reason in the refusal. The owner block is not writable as a whole, and owner.email in particular never is: it decides who can obtain a token for this journal, so a token must not be able to move it. The telephone number inside it is the exception, reached as the flat field ownerTel. baseCurrency is not a display setting — a cost written without a currency IS a cost in the base currency, so changing it re-reads every amount already recorded rather than reconverting it. media is the operator's, and the server's limits are already a ceiling over it. Owner only.
Parameters
- user (path, required)
Request body
application/json
{
"type": "object",
"properties": {
"features": {
"type": "object",
"properties": {
"reactions": {
"type": "boolean"
},
"costs": {
"type": "boolean"
},
"push": {
"type": "boolean"
},
"mail": {
"type": "boolean"
},
"whatsapp": {
"type": "boolean"
},
"auth": {
"type": "boolean"
},
"signup": {
"type": "boolean"
},
"contacts": {
"type": "boolean"
},
"postcards": {
"type": "boolean"
},
"photobook": {
"type": "boolean"
},
"logging": {
"type": "boolean"
},
"credits": {
"type": "boolean"
},
"addressLookup": {
"type": "boolean"
},
"weather": {
"type": "boolean"
},
"analytics": {
"type": "boolean"
},
"helper": {
"type": "boolean"
},
"transcription": {
"type": "boolean"
}
},
"additionalProperties": false,
"description": "Capability name to true or false — one of reactions, costs, push, mail, whatsapp, auth, signup, contacts, postcards, photobook, logging, credits, addressLookup, weather, analytics, helper, transcription. Omitted ones are left alone, and an unknown name is refused rather than ignored. A journal can only ever switch on what this server already offers: /api/health says which those are, and asking for one it cannot do is refused. `logging` and `credits` are never a journal's own opt-in — they are the operator's alone, for the whole server — so the response echoes the server's own answer for those two regardless of what is sent here. Not combinable with the fields below — send it in a call of its own."
},
"title": {
"type": "string"
},
"tagline": {
"type": "string",
"description": "Empty string removes it rather than writing one."
},
"visibility": {
"type": "string",
"enum": [
"public",
"guest"
],
"description": "Whether this server advertises the journal: public is listed on this server's own index, on its landing page and in its sitemap; guest is on none of them and asks search engines not to index it — anyone sent the address can still open it. It is also this journal's own answer for a new trip's default, unless the create call says otherwise."
},
"startLocation": {
"type": "string",
"description": "Empty string removes it rather than writing one."
},
"units": {
"type": "string",
"enum": [
"metric",
"imperial"
]
},
"locales": {
"type": "array",
"items": {
"type": "string",
"enum": [
"en",
"de",
"hu"
]
},
"description": "Language codes, most preferred first. Must contain `defaultLocale`; a pair that disagrees is refused rather than written, because the resulting config would take the journal off the site entirely. Each entry must be one of English (`en`), Deutsch (`de`), Magyar (`hu`), the same set creation refuses outside of — B777: a field checked when a journal is made and unchecked when it is corrected is the same field with two meanings."
},
"defaultLocale": {
"type": "string",
"enum": [
"en",
"de",
"hu"
],
"description": "One of English (`en`), Deutsch (`de`), Magyar (`hu`). The language the site's own chrome is in; a code this build ships no strings for is refused here exactly as it is at creation."
},
"displayCurrencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "Which currencies a reader may see totals in. Must include the journal's `baseCurrency`, which this endpoint cannot change — `GET` returns it under `journal`."
},
"ownerTel": {
"type": "string",
"description": "The owner's own telephone number — `owner.tel` in config.json, and the only part of the `owner` block a token may write. It is where the owner's own WhatsApp copy of a published day goes, and that copy costs no credits; without it the owner is the one person the channel cannot reach. Include the country code — `+41 76 561 31 50`, `0041 76 561 31 50` or `41765613150`. A national number like `076 561 31 50` is refused rather than guessed at, because it means a different telephone in every country. Stored and returned as E.164 digits, whatever form it was sent in. Empty string removes it, which is also how the owner stops their own messages."
},
"manualRates": {
"type": "object",
"additionalProperties": {
"type": [
"number",
"null"
]
},
"description": "Rates for what the ECB does not publish, MERGED into what is there. The ECB's direction: `{\"VND\": 30500}` is \"1 EUR = 30 500 VND\", the opposite of a trip's own `rates`. `null` removes a code."
}
}
}Responses
- 200
The journal's features or profile afterwards, and what changed
- 400
An unknown capability, a non-boolean, an unwritable field (
owner,baseCurrency,media), a capability this server does not provide, one the server decides for every journal (capability_not_yours:photobook,postcards,logging,credits), orfeaturessent together with a profile field (mixed_change)- 401
Missing or invalid token
- 403
The token belongs to a different journal, or is scoped to one trip
- 404
No such journal
/example/trips/{trip}/day/{slug}.md
getA day's markdown sourceno token
Any day, in any trip. The content is markdown, so this is the source rather than a conversion of it, and it is gated exactly like the HTML page — a private trip answers 404 here too. This is the .md twin of the day's own URL, and the form to use when you have a trip id: the search index identifies entries as {trip}/{slug}.
Parameters
- trip (path, required)
- slug (path, required)
Responses
- 200
text/markdown
- 404
text/plain — never an HTML error page
/example/day/{slug}.md
getA day's markdown source, in the current tripno token
The short form, mirroring /{user}/day/{slug} — the current trip's day pages. If the current trip has no such slug, the journal's other readable trips are searched before this gives up, so a slug alone usually resolves.
Parameters
- slug (path, required)
Responses
- 200
text/markdown
- 404
text/plain — never an HTML error page