{"openapi":"3.1.0","info":{"title":"lct.me","version":"1.0.0","summary":"Share a location as a link that expires.","description":"Create a link to a position — a fixed one, or a live session a device keeps updating —\nand share the URL. The link stops working when it expires or when it is revoked.\n\nEverything under `/v1` is a promise and will not break under you. Everything under\n`/api` is the browser app's own surface: undocumented on purpose, and free to move."},"servers":[{"url":"https://lct.me"}],"components":{"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-lctme-api-key","description":"Your key, as `x-lctme-api-key: lctme_sk_…`.\n\n**Not `Authorization: Bearer`, and that is a limitation rather than a preference.**\nThe API is served through CloudFront with origin access control, which signs each\nrequest to the origin and OVERWRITES the `Authorization` header with its own\nsignature. A key sent conventionally would be discarded in transit and every call\nwould read as unauthenticated. The conventional form needs a different front door,\nwhich is planned; when it arrives both headers will be accepted for long enough\nthat nothing has to change on a schedule we set."}},"schemas":{"Position":{"type":"object","required":["latitude","longitude","accuracy"],"properties":{"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy":{"type":"number","minimum":0,"description":"Radius in metres, as the browser's Geolocation API reports it."},"altitude":{"type":"number","description":"Metres above the WGS 84 ellipsoid."},"altitudeAccuracy":{"type":"number"},"heading":{"type":"number","description":"Degrees clockwise from true north."},"speed":{"type":"number","description":"Metres per second."}},"description":"Optional fields are omitted rather than sent as null. A device that cannot measure altitude should leave the key out; null is refused so that 'unknown' has one representation instead of two."},"Link":{"type":"object","required":["id","kind","createdAt","expiresAt"],"properties":{"id":{"type":"string","description":"The path segment of the shareable URL."},"kind":{"type":"string","enum":["snapshot","live"]},"position":{"type":"object","required":["latitude","longitude","accuracy"],"properties":{"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy":{"type":"number","minimum":0,"description":"Radius in metres, as the browser's Geolocation API reports it."},"altitude":{"type":"number","description":"Metres above the WGS 84 ellipsoid."},"altitudeAccuracy":{"type":"number"},"heading":{"type":"number","description":"Degrees clockwise from true north."},"speed":{"type":"number","description":"Metres per second."}},"description":"Optional fields are omitted rather than sent as null. A device that cannot measure altitude should leave the key out; null is refused so that 'unknown' has one representation instead of two."},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"After this the link returns 404. Expiry is checked on every read."},"positionAt":{"type":"string","format":"date-time","description":"Live links only. When the position was last updated — a viewer needs it to tell a moving dot from one that stopped reporting, which look identical without it."},"sessionEndsAt":{"type":"string","format":"date-time","description":"Live links only."},"name":{"type":"string","description":"Group links only, and optional: what the organiser is called on their own roster. Validated by the same rule a joiner's name is, because they end up in one list. Refused on a link that is not a group — a name with no roster to show it on would be collected and discarded."},"join":{"type":"boolean","description":"Present and true only on a live link other people may share into (a group). The creation response also carries a one-time `joinToken`. NOTE: the same gap as `trail` — joining and reading the members is /api only, which is not part of this contract, so a link created with `join` here cannot be completed through /v1. Do not depend on this field until that is closed."},"members":{"type":"array","description":"Present only on a group link. The first entry is the link's own position, marked `self`; the rest are people who joined. One uniform list on purpose — the organiser is stored on the link record and the joiners as separate items, and a client drawing the group should not have to know that.","items":{"type":"object","properties":{"id":{"type":"string","description":"Absent on the `self` entry, which is the link."},"self":{"type":"boolean"},"name":{"type":"string","description":"Optional, and chosen by the member at join."},"position":{"type":"object","required":["latitude","longitude","accuracy"],"properties":{"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy":{"type":"number","minimum":0,"description":"Radius in metres, as the browser's Geolocation API reports it."},"altitude":{"type":"number","description":"Metres above the WGS 84 ellipsoid."},"altitudeAccuracy":{"type":"number"},"heading":{"type":"number","description":"Degrees clockwise from true north."},"speed":{"type":"number","description":"Metres per second."}},"description":"Optional fields are omitted rather than sent as null. A device that cannot measure altitude should leave the key out; null is refused so that 'unknown' has one representation instead of two."},"positionAt":{"type":"string","format":"date-time"}}}},"trail":{"type":"boolean","description":"Present and true only on a live link that is keeping its path. Off unless asked for at creation, and it cannot be turned on for a session already running — the person sharing agreed to what the link was when they started it. NOTE: the route itself is not readable through this contract yet, only through /api, which is not part of it. Creating a link with `trail` here and having no documented way to read it back is a gap and is being closed; until it is, do not depend on this field."},"session":{"type":"string","enum":["pending","open","ended","revoked"],"description":"Live links only. `pending` means the link exists but carries no position yet — a request nobody has answered. It is reachable through /v1 only by reading a link created elsewhere; there is no business endpoint that creates one (see /api, which is not part of this contract)."},"revokedAt":{"type":"string","format":"date-time","description":"Present only on a revoked live link, which carries no position at all: revoking DELETES the coordinates from the record rather than withholding them here."},"url":{"type":"string","description":"The shareable URL, so there is nothing to assemble. Present only when the deployment has been told its own origin."},"writeToken":{"type":"string","description":"Live creates only, and RETURNED EXACTLY ONCE — it is never in any later read of the link, because those go to viewers. Whoever holds it can update the position. Store it when you get it; there is no way to ask for it again."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable and safe to branch on. The message is for a person and may be reworded."},"message":{"type":"string"}}}}}},"responses":{"Unauthorized":{"description":"No key, or a key that is not valid. The two are distinguished (`no_credentials` and `invalid_key`) because the difference is about YOUR request rather than about our data — neither answer tells an attacker whether a guessed key exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Revoked":{"description":"The key was revoked. Codes: `key_revoked`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"This deployment does not serve the business API. Codes: `not_found`. A 404 rather than a 500, because it is true and reveals nothing about why.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"MethodNotAllowed":{"description":"Codes: `method_not_allowed`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"security":[{"apiKey":[]}],"paths":{"/v1/me":{"get":{"operationId":"whoami","summary":"What this key is.","description":"The smallest call that proves a key works. Worth making first from a new integration: the alternative is finding out from whichever endpoint you actually wanted, and guessing at the failure.","responses":{"200":{"description":"The key is live.","content":{"application/json":{"schema":{"type":"object","required":["name","createdAt"],"properties":{"name":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Revoked"},"404":{"$ref":"#/components/responses/NotFound"},"405":{"$ref":"#/components/responses/MethodNotAllowed"}}}},"/v1/links":{"post":{"operationId":"createLink","summary":"Create a link.","description":"A `snapshot` is one fixed position. A `live` link is a session a device keeps\nupdating with the returned `writeToken`, until `sessionEndsAt` or until it is\nrevoked.\n\nAuthentication is checked BEFORE the body is read, so a bad key costs you a 401\nwhatever you sent — a malformed payload will not tell you that it was otherwise\nacceptable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["position"],"properties":{"position":{"$ref":"#/components/schemas/Position"},"kind":{"type":"string","enum":["snapshot","live"],"default":"snapshot","description":"An unrecognised value is refused rather than defaulted: a client asking for a kind this server does not have will misread whatever it gets back."},"lifetimeSeconds":{"type":"number","description":"Snapshots. Capped by the deployment's maximum."},"sessionSeconds":{"type":"number","description":"Live links. Capped by the business maximum, which is higher than the one anonymous browser creates get."}}}}}},"responses":{"201":{"description":"Created. `writeToken` is present for live links and will never appear again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Link"}}}},"400":{"description":"Codes: `invalid_json`, `invalid_body`, `invalid_position`, `invalid_kind`, `invalid_lifetime`, `invalid_session`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Revoked"},"404":{"$ref":"#/components/responses/NotFound"},"405":{"$ref":"#/components/responses/MethodNotAllowed"}}}},"/v1/usage":{"get":{"operationId":"readUsage","summary":"What you have used.","description":"Defaults to the current UTC calendar month, which is the period an invoice covers.\n\n**Every boundary is UTC.** A day that starts when a server happens to think it does\nis a total you cannot reproduce.\n\nThere is no parameter naming a customer, and that is structural rather than a check\nwe remembered to write: the key you authenticate with is the only one you can read.","parameters":[{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"YYYY-MM-DD, inclusive. A date that does not exist is refused."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"YYYY-MM-DD, inclusive."}],"responses":{"200":{"description":"Totals for the period, and a line per day — reconciling an invoice means finding the day that surprised you, and one number gives you nowhere to look.","content":{"application/json":{"schema":{"type":"object","required":["from","to","snapshots","sessions","days"],"properties":{"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"},"snapshots":{"type":"integer"},"sessions":{"type":"integer"},"days":{"type":"array","items":{"type":"object","required":["date","snapshots","sessions"],"properties":{"date":{"type":"string","format":"date"},"snapshots":{"type":"integer"},"sessions":{"type":"integer"}}},"description":"Only days with usage appear."}}}}}},"400":{"description":"Codes: `invalid_period`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Revoked"},"404":{"$ref":"#/components/responses/NotFound"},"405":{"$ref":"#/components/responses/MethodNotAllowed"}}}},"/v1/openapi.json":{"get":{"operationId":"openapi","summary":"This document.","security":[],"responses":{"200":{"description":"The OpenAPI document.","content":{"application/json":{"schema":{"type":"object"}}}},"405":{"$ref":"#/components/responses/MethodNotAllowed"}}}}}}