mapto.it documentation
mapto.it tells your app when a person's phone arrives at and leaves one of your places — a home, a job
site, a store, an office. Phones report their location to mapto.it — from your own app, or from the free
OwnTracks app if you'd rather not build one — and you get stop.arrived and stop.departed events by webhook or from the API. You never handle raw locations.
- Send your places with their coordinates (Places).
- Add a device for each person and show them its setup link once (Devices).
- Receive events by webhook, or pull them (Events).
Every API call carries a key's secret: Authorization: Bearer ms_…. A key has scopes —
devices, places, events — and only works for what they allow. Keep the secret on your
server: never in a web page or an app people download.
The API lives at https://mapto.it/api/v1. Bodies are JSON; times are ISO 8601 in UTC.
Places
A place is a point and a radius. You name each place by your own id, its external_ref (letters, digits and
. _ : @ -, up to 191 characters), so you don't need to keep ours.
PUT /api/v1/places/job-1042
{"name": "Smith residence", "latitude": 40.6200123, "longitude": -111.8001234, "radius_m": 150, "kind": "property"}
radius_m is optional (default 150 m, 25 to 2000). Use the building's own coordinates, not a ZIP-code centroid:
a stop is matched by its distance from this point. A store's parking lot may need 250 m.
To keep a whole list in step, send it at once — up to 1000 places, with prune: true to delete any not in it:
POST /api/v1/places/sync
{"places": [{"external_ref": "job-1042", "name": "…", "latitude": …, "longitude": …}, …], "prune": true}
→ {"created": 3, "updated": 120, "deleted": 1}
Groups. If you serve several companies from one account, give each device and place a group (your id for
the company). A device only matches places in its own group, and a sync with group set prunes only that group.
Without a group, a device matches only places without one.
Also GET /api/v1/places (paged with after={external_ref}, filtered with group),
GET and DELETE /api/v1/places/{external_ref}.
Devices
A device is one phone. Give it a label and, optionally, your id for the person:
POST /api/v1/devices
{"label": "Sam's iPhone", "external_ref": "user-77", "group": "acme", "paused": false}
→ 201 {"device": {"id": "Hx3kP9qa", …},
"setup": {"link": "owntracks:///config?inline=…", "url": "https://mapto.it/owntracks", "username": "mt-…", "password": "…", "config": {…}}}
The setup is in this response only — we keep only a hash of the password. Show the link to the person on their
phone (or as a QR code): with OwnTracks installed, opening it imports the settings. They must allow location access
Always. If the link is lost — or was shown on a computer instead of the phone — the device's owner gets a new one
with POST /api/v1/devices/{id}/setup; the old password stops working at once.
Also GET /api/v1/devices (filter with external_ref), GET /api/v1/devices/{id},
and POST /api/v1/devices/{id}/revoke — from then on its reports are refused, and a visit in progress ends.
From your own app
Your app can report instead of OwnTracks, using the same device credentials (setup.username and
setup.password; ignore the link). POST each location as JSON with HTTP Basic authentication:
POST https://mapto.it/owntracks
Authorization: Basic base64(username:password)
{"_type": "location", "lat": 40.6200123, "lon": -111.8001234, "tst": 1762095600, "acc": 12, "vel": 0}
tst is when the location was taken (Unix seconds), acc its accuracy in meters, vel
the speed in km/h (both optional). Any 2xx means it was received. Report at least every couple of minutes while the phone is
still, and every few hundred meters while moving, so stops can be confirmed; queue reports while offline and send them later in
the order they were taken. This is the format OwnTracks uses, so an existing OwnTracks integration works unchanged.
Pausing
When someone is off the clock, pause their device. Reports taken while paused are dropped on arrival — never stored, including ones the phone queued while offline and sends late. Pausing ends a visit in progress at the pause time.
POST /api/v1/devices/{id}/pause {"at": "2026-11-01T23:30:00Z"}
POST /api/v1/devices/{id}/resume {"at": "2026-11-02T14:00:00Z"}
at is optional (default now) and may be up to 7 days back, so a clock-out recorded late still pauses
from the right moment. Both calls are safe to repeat. Create a device with "paused": true if the person isn't working yet.
Home and work on one phone
People carry one phone. A device can have at most one home profile and one work profile, each belonging to a different account, and the phone is set up once. The split is by location:
- A report inside one of the home profile's places belongs to home alone:
place.enteredas the phone crosses into the place's radius,place.exitedonce it is beyond the radius plus 50 m — no waiting, so a house can react as someone arrives. - A home place is exclusive unless you send
"exclusive": false. Only exclusive places hide the phone from work; usefalsefor a wide "near home" circle, so job sites down the street still count. - Everywhere else belongs to work: the usual stops. If a work visit is in progress when the phone reaches home, it ends there as if the phone had simply moved on. The employer never learns where home is or when the phone is there — not even at a place that is also one of its own; home wins.
The person's own app sets the phone up and owns it ("kind": "home" when creating it). To let an employer
use it for work, the owner asks for a one-time code — valid 10 minutes, once — and the person enters it at the employer:
POST /api/v1/devices/{id}/work-codes (owner) → {"code": "7KQM-3XPA", "expires_at": "…"}
POST /api/v1/work-profiles (employer)
{"code": "7KQM-3XPA", "external_ref": "user-77", "group": "acme", "name": "Acme Plumbing", "paused": false} → 201 {"device": {…}}
name is optional: what the owner sees you as. Set it if one account of yours serves several companies; otherwise your account's name is shown.
The employer gets a work profile and the device id; it never gets the phone's credentials. It sees "also_home": true,
and nothing else about home. The owner sees "work": {"account": "Acme", …} and receives profile.attached.
Either side can leave at any time: the employer with POST /api/v1/devices/{id}/revoke (which detaches its profile),
the owner with DELETE /api/v1/devices/{id}/work-profile or by revoking the whole phone. The side that didn't act
receives profile.detached. Attaching replaces the employer's older work-only phone for the same
external_ref and group.
Pausing is per profile. While work is paused (someone off the clock) and home is on, reports are still kept — for home only, which the owner agreed to by setting the phone up — and work sees nothing from that time.
How stops are found
- A phone that stays within 100 m for 5 minutes has stopped. The arrival is stamped when it came to rest, not 5 minutes later.
- The stop is matched to the nearest place whose radius covers it, or none.
- A visit at a place lasts while the phone is anywhere within the place's radius plus 50 m — the whole lot, not just where it parked. It ends on the second report in a row outside, so one stray GPS fix doesn't split a visit.
- The departure is stamped back from the first report outside by the time it took to get there, because a parked phone reports only every few minutes.
- Four hours without a report ends a visit at its last report. Reports less accurate than 150 m are ignored.
Events
Work profiles get stop.arrived when a stop is confirmed and stop.departed when it ends. place is
null for a stop at none of your places — ignore those, or use their coordinates to add a place. Home profiles get
place.entered / place.exited (data: device, place, at), and either side
gets profile.attached / profile.detached when the other joins or leaves.
{"id": "evt_9mQ…", "type": "stop.departed", "created_at": "2026-11-02T16:05:12+00:00",
"data": {"stop": {
"id": "Ab3dEf7h", "status": "closed",
"device": {"id": "Hx3kP9qa", "external_ref": "user-77", "group": "acme"},
"place": {"id": "Qw8eRt2y", "external_ref": "job-1042", "name": "Smith residence"},
"distance_m": 34, "latitude": 40.620101, "longitude": -111.800212, "accuracy_m": 12,
"arrived_at": "2026-11-02T15:00:00+00:00", "departed_at": "2026-11-02T16:03:20+00:00",
"ended_by": "moved"}}}
ended_by is moved, gap (no reports for 4 hours), pause or revoked (the device or your profile on it ended).
Pull them in order with GET /api/v1/events?after={last id you processed} (up to 500 per page, kept 30 days).
GET /api/v1/stops lists recent stops (device, since, unmatched=1).
An event id is unique and stable: process each once.
Webhooks
Optional: pulling always works. With a webhook set on your account, each event is also POSTed to it, in order:
POST https://your-app.example/hooks/mapto
webhook-id: evt_9mQ…
webhook-timestamp: 1762099512
webhook-signature: v1,K5o…=
{"id": "evt_9mQ…", "type": "stop.arrived", "created_at": "…", "data": {"stop": {…}}}
They follow the Standard Webhooks scheme, so its libraries verify them.
By hand: base64-decode your secret after whsec_, compute HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body},
base64 it, and compare with the value after v1,. Reject timestamps more than 5 minutes old.
Answer with any 2xx. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stop; later
events for your account wait behind a failing one within each round. Webhook URLs must be HTTPS on a public address; redirects
aren't followed. webhook-id is the event id on every attempt: use it to ignore repeats.
Errors
Errors are {"error": {"code": "…", "message": "…"}}, with details for invalid fields. Program against the code;
messages may be reworded. Codes are only ever added.
| Code | HTTP | Meaning |
|---|---|---|
invalid_key | 401 | The key is unknown, revoked, expired, or lacks the scope for this. |
account_suspended | 403 | This account is suspended. |
rate_limited | 429 | Too many requests in the last minute for this account's plan. |
not_found | 404 | No such item on this account. |
invalid_request | 422 | The request is not valid. |
device_limit | 422 | This account's plan allows no more devices. |
place_limit | 422 | This account's plan allows no more places. |
device_revoked | 409 | This device has been revoked. |
not_owner | 403 | Only the account that set this device up can do this. |
invalid_code | 422 | The code is wrong, already used, or older than 10 minutes. |
work_profile_exists | 409 | This device is already used for work by an account; it must be detached first. |
invalid_url | 422 | The URL is not a valid https address. |
blocked_address | 422 | The URL points at a private or reserved network address. |
Plans and limits
| Plan | Devices | Places | API calls a minute |
|---|---|---|---|
| Trial | 3 | 100 | 60 |
| Starter | 25 | 2,000 | 120 |
| Business | 250 | 20,000 | 600 |
A revoked device no longer counts. Phones' own reports don't count toward API calls.
Privacy and retention
- Raw location reports are never given to you or anyone else, and are deleted after 14 days.
- Reports from a paused device are not stored at all.
- Stops are kept 180 days; stops at none of your places 21 days. Events are kept 30 days.
- You must tell the people you track, and get their consent where the law requires it — several US states require notice before an employer tracks employees by GPS. Pause devices outside working hours.
- Questions or requests: contact@scaledapps.com. See also the terms and privacy policy.