Hand a MeshCore !path hop chain to txmesh and get back a short, shareable map link — no resolver, storage or map page of your own. Read it back as JSON or GeoJSON whenever you want to draw it yourself.
Base URL https://txme.sh. Everything below is server-to-server / device-to-server:
submit a hop chain, get a URL back, paste it into a channel.
curl -X POST https://txme.sh/api/device/path \
-H 'content-type: application/json' \
-H 'x-meshcore-pubkey: 4b19c0de7f1a2b3c' \
-d '{"h":"90e2,fe27","o":"30.267153,-97.743061","n":"TX-Hill Repeater","r":"kk5abc"}'
{"url":"https://txme.sh/p/RiYpPco","resolved":1,"km":41.8}
That url is a hosted map page showing every hop the packet took, which repeaters
they resolved to, and how far the whole path travelled. It stays live and improves on its own
as txmesh learns about more repeaters.
Everything on this page is also an OpenAPI 3.1 document covering exactly this public surface — import it into Postman or Insomnia, point a code generator at it, or read it as a rendered reference:
| What | Where |
|---|---|
| /docs/api/openapi.yaml | The authored contract, application/yaml |
| /docs/api/openapi.json | The same document as JSON, for tools that won't parse YAML |
| /docs/api/reference | Rendered reference — every schema, field and example |
curl -sO https://txme.sh/docs/api/openapi.yaml
The two spec files are the only routes here that send
Access-Control-Allow-Origin: *, so browser tooling can load them by URL. That
is the document, not the API — the endpoints it describes stay CORS-less.
/docs, /redoc and /openapi.json are disabled, because
they described every account-only route as well.Only submission needs an identity; reading a path is public. Every submission is attributed to a sender, and txmesh picks that sender from the request in this order — the first one that matches wins:
| Order | Header | Sender identity | Coordinates stored |
|---|---|---|---|
| 1 | x-device-token | A token label we issued you | Full precision |
| 2 | x-meshcore-pubkey | Your radio's public key | Coarsened to ~1 km, until an admin trusts you |
| 3 | none | Your IP address — open on txmesh, off by default elsewhere | Coarsened to ~1 km |
A wrong x-device-token is not an error — evaluation just falls through to the
next branch — so a typo'd token shows up as a silent downgrade to coarsened coordinates,
never as "invalid token". On a deployment where anonymous submission is off, a request with
no usable identity gets 403.
txmesh accepts anonymous submissions, so a bare POST with no headers at
all will work here. Send your public key anyway: it gives you your own rate-limit budget
instead of one shared with everyone behind your address, keeps your reports grouped as one
identity, and is the only route to storing precise coordinates.
This is the path of least resistance and needs nothing from us: send your node's MeshCore public key in a header on every submission.
x-meshcore-pubkey: 4b19c0de7f1a2b3c9d8e...
! (so pasting !4b19c0de straight out of a client works).
txmesh lowercases it and keeps the first 8 hex characters — the same 4-byte node id
the rest of the archive uses. Sending the full key and sending its 8-character prefix are
equivalent.GET /api/path/{slug}/related. One integrator can never spend another's budget.If you run a bot or a firmware fleet and want your reported coordinates stored at full
precision from the first request, ask this deployment's operator for a device token
([email protected]) and send it
as x-device-token. We issue it from the admin panel and can revoke it the
same way; we only ever see it once, so keep your copy. It is a real shared secret, compared in constant time, and it
is the only identity here that is one. Token senders are pre-trusted and are exempt from the
per-IP ceiling, since a fleet legitimately shares one egress address.
Untrusted senders' endpoint coordinates (the o and q fields)
are rounded to two decimals — roughly a 1 km grid — before they are written. That
is irreversible: the precise value is never stored. Submit a few paths, then email us your
8-hex key and an admin can mark your sender trusted, after which your submissions store
precisely. Per-hop repeater positions come from the public roster and are never coarsened
either way.
All three GET /api/path/… endpoints and the /p/{slug} page are open
to anonymous callers — that is the point of a link you can paste into a LoRa channel. Signing
in with a txmesh account (a browser cookie; there is no bearer token for reads) only changes
one thing: it suppresses the read-time coarsening of the two endpoint coordinates.
The wire format uses single-letter keys on purpose: the reference sender is a
microcontroller building JSON with snprintf into a 256-byte buffer. Content type
may be application/json or absent. Unknown keys are ignored.
| Field | Required | Meaning |
|---|---|---|
| h | yes | The hop-hash chain, comma-joined, in traversal order (requester side first, reporting node last). Each hash is the leading 1–3 bytes of a repeater's public key as hex — exactly 2, 4 or 6 characters — and all hashes must be the same width, since hash size is uniform within one MeshCore packet. 1 to 64 hops. Example: 90e2,fe27. |
| o | no | The reporter's position (the node that answered the !path) as "lat,lon" in decimal degrees. The chain's end anchor. 0,0 is rejected as MeshCore's "position not set" sentinel. |
| q | no | The requester's position (the node that ran !path), same format — typically from that node's advert/wardrive beacon. The chain's start anchor. |
| n | no | Reporter node's display name. Stored truncated to 40 characters. |
| r | no | Requester node's display name. Stored truncated to 40 characters. |
| s | no | Per-hop SNR in dB, comma-joined, traversal order — exactly one value per hop, each within ±40. Example: 11.5,-3.25. |
Only h can fail the request. A malformed or out-of-range o,
q or s is silently dropped: the anchor simply becomes null, and an
s list with the wrong number of values is discarded wholesale. A 200
does not mean every field you sent was accepted.
{"url":"https://txme.sh/p/RiYpPco","resolved":1,"km":41.8}
url is the map page (see short links),
resolved is how many hops matched a known node, and km is the total
path length — omitted entirely, not null, when nothing could be measured. The
"url":"…" shape is a compatibility guarantee: deployed firmware finds it with
strstr, not a JSON parser, so it will never be renamed or nested.
By default url is the full /p/{slug} link. An admin can tick
short links for your sender in the admin panel, which runs your links through
da.gd — worth roughly
six characters on a ~160-character LoRa frame. It is off for every new sender, because
shortening hands the trace URL to a third party. Email us if you want it on.
Shortening is best-effort: a 2-second cap, and any failure falls back to the full URL.
So url is always a working link, never guaranteed to be a short one — read
it from the response rather than assuming a form.
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"bad hashes"} | h was non-hex, empty, over 64 hops, wider than 3 bytes, or mixed widths. |
| 403 | {"error":"submission requires a device token or a mesh pubkey"} | No sender identity — see Authentication. |
| 422 | {"detail":[…]} | Body failed validation: missing h, a field over its length cap, malformed JSON, or an unsupported content type. |
| 429 | {"error":"rate limited"} | Over a per-minute cap. Carries Retry-After: 60. |
Note the two envelopes: the pathbot routes return {"error": …}, while
framework-level validation returns a detail array. Checks run in this
order — body validation, then identity, then rate limit, then hash validation — so an
unauthenticated caller with a bad body sees 422, not 403.
Public while the deployment leaves public path viewing on (the default here). When it is off,
anonymous callers get 401 {"error":"authentication required"} before the slug is
even looked up, so these endpoints can't be used to fish for valid slugs. An unknown or
expired slug is 404 {"error":"not found"}. None of the reads are rate limited.
The richest view, and exactly what the /p/ page itself fetches: every hop with
its resolved node, coordinates and per-hop distance, alternate candidates where a hash was
ambiguous, the two endpoint anchors, SNRs, and path-length totals. Coordinates here are
{"lat": …, "lon": …}. Anonymous responses carry
"coordsRounded": true when read-time coarsening was applied.
The same path as an RFC 7946 FeatureCollection, served as
application/geo+json — one Point per positioned hop, one per known
endpoint, and a LineString tracing requester → hops → reporter. Drops straight
into Leaflet, MapLibre, geojson.io or an existing GeoJSON pipeline. Identifiers (slug, page
URL, names, counts, totalKm) ride along in a metadata member, so you
need no second request.
[longitude, latitude]; the JSON view uses named lat/lon
keys. Unresolved hops have no position, so both the points and the line skip them — a hop
feature's seq is its index into the full hop list and legitimately has gaps.Other reports by the same requester — the "more from this node" rail. Grouped by
public key when the requester is in the roster (so a node that renamed itself still
groups as one identity), falling back to exact name when it isn't; byPubkey tells
you which happened. Results are also scoped to the submitting sender of the reference slug, so
you only ever see reports that arrived through the same submitter. Deliberately returns no
coordinates — slugs and coarse statistics only, newest first, capped at 100 with no paging.
The human-facing map page. The slug is not validated server-side, so an unknown slug still
returns a 200 shell that then renders "Path not found" — use
GET /api/path/{slug} for existence checks, never this.
Seven characters from the URL-safe base64 alphabet, minted at submit time. They are meant to be pasted into LoRa channels: unguessable-ish, but not a capability and not a secret.
403 never reach the counter.429 carries Retry-After: 60, a fixed literal rather than a
computed window position.The read endpoints are not rate limited by the application, but they carry no
Cache-Control either — poll politely.
Access-Control-Allow-* header and no preflight
handling anywhere, so browser JavaScript on another origin cannot call these endpoints. This
is a server-to-server, device-to-server and native-client API — proxy it if you need it in a
page.totalKm tomorrow. Don't treat a
response as immutable and don't cache it indefinitely.404s everywhere and drops out of other reports' related lists.
A /p/ link is meant to be pasted into a channel and read now — treat one as
ephemeral, and keep your own copy of the hop chain if you need a durable record.totalKm.candidates and the alternates list rather than taking the
first plausible node.