Getting started
There are two ways in, and they are deliberately separate.
Organisation tokens
For your systems. Read-only. Create one in the panel under Integrations. It does not belong to a person, does not take a seat on your plan, and cannot submit anything.
Bearer fwso_…
Device tokens
For the field app. Issued to one phone when someone signs in, and used to submit records. Documented here for completeness — you would only need it to build your own collection client.
Bearer <device token>
Base address
https://fieldworksync.com/v1
HTTPS only. Requests over plain HTTP are redirected and you should never send a token over one. All responses are JSON, UTF-8, unless the endpoint returns a file.
Authentication
Every request carries the token in a header:
curl https://fieldworksync.com/v1/org/records \
-H "Authorization: Bearer fwso_YOUR_TOKEN"
A token is shown once, when it is created, and only its hash is stored here. If you lose it, revoke it and make another — we cannot recover it for you. Revoking takes effect on the next request.
Scopes
An organisation token carries only the permissions you tick when you create it. None of them write.
| Scope | Lets the token |
|---|---|
| forms:read | list your forms and their field definitions |
| records:read | read collected records |
| attachments:read | download photos, signatures and files |
Asking for something a token cannot do returns 403 insufficient_scope, and
the response says which scope was needed.
Identifiers
Every row has a code: a three-letter prefix for the table, then the row.
rec_1041 a record
frm_site-visit a form
att_53 an attachment
usr_9 a person
The prefix is not decoration. Without it, 12 is a valid identifier in every
table, and passing a form where a record was expected reads the wrong row without anyone
noticing. With it, that is rejected at the door:
GET /v1/org/attachments/frm_12
{
"error": {
"code": "bad_identifier",
"message": "attachment must be a att identifier, like att_123.",
"expected_prefix": "att",
"got": "frm_12"
}
}
| Prefix | What it names |
|---|---|
| org_ | organisation |
| usr_ | person |
| dev_ | device |
| frm_ | form |
| rec_ | record |
| att_ | attachment |
| tok_ | API token |
| whk_ | webhook |
| dlv_ | webhook delivery |
Records have two identifiers, and they are not interchangeable
A record has a code (rec_1041) and a uuid. The
uuid is generated on the phone, at the moment of collection, before there is
any network. The code only exists once the record reaches us.
Deduplicate on the uuid. It is the same value whether a record arrives once or is re-sent five times from a patchy connection, and it is what makes re-sending safe. Use the code for cross-references and URLs.
Forms are named, not numbered
A form's code is a name — frm_site-visit, not frm_12. That is
deliberate. A number comes from the order forms were created: delete a form and make it
again and it changes; copy it to another organisation and it is different there. Anything
that stored "form 12" is then either lost or, worse, reading the wrong form.
The code is made from the form's name the moment the form is created — "Site visit"
becomes frm_site-visit — and then stands still. Nobody types it and nobody
edits it. Renaming a form does not change its code, which is what makes
renaming safe: the name is for the people filling the form in, the code is for you.
You will find it on the form's page in the panel, under the title, and in every response that mentions a form. If two forms would produce the same code, the second gets a number on the end.
Fields are the exception to codes entirely: a field is identified by its key,
which the person who built the form chose. That key is what appears in values,
and what ends up as a column in an export.
Relationships
A relation field is a foreign key: a record in one form pointing at a record
in another — a visit that points at the asset it was about. There are three things you
need to mirror that structure, and one endpoint for each.
1. Which forms point at which
GET /v1/org/forms gives every form a relations block. It is the
schema of the links, stated plainly, so you do not have to dig through field configuration
to find it:
{
"code": "frm_site-visit",
"name": "Site visit",
"relations": [
{ "field": "asset", "label": "Asset", "form": "frm_assets",
"display_field": "asset_ref", "required": true }
],
"fields": [ /* … */ ]
}
Read as: site-visit.asset → assets. That is your foreign key definition.
2. Where one record points
Every record carries links, grouped by field:
"links": {
"asset": [
{ "uuid": "a6acb228-…", "record": "rec_1038",
"form": "frm_assets", "resolved": true }
]
}
Then follow it:
GET /v1/org/records/rec_1038
resolved: false is not an error
It means the target record has not reached the server yet. Two records filled in on the same day without a connection sync in whatever order the phones find signal, and the one pointing can easily arrive first.
The uuid is already correct and final — it was generated on the phone. The
link completes itself when the target lands; nothing needs re-sending, and you should not
treat it as a broken reference.
3. What points at one record
The question an asset's history page asks. links_to takes a record code or a
uuid:
GET /v1/org/records?links_to=rec_1038
GET /v1/org/records?links_to=rec_1038&links_field=asset
GET /v1/org/records?links_to=a6acb228-217f-4392-aca6-1cc07d28a70f
Returns the records that point at it, in the same shape and with the same cursor paging as
the main listing. Add links_field when a form points at the same target
through more than one field and you only want one of them.
The uuid form is worth knowing: you hold uuids from the first sync onwards, so you can ask what points at something before that something has itself arrived.
Putting it together
To mirror the whole structure into your own database: read /v1/org/forms once
to build your tables and foreign keys from relations, then page
/v1/org/records with the cursor and write each record's values as
a row and each entry in links as a foreign key. Keep the cursor. You never
need links_to for mirroring — that one is for answering questions live,
without a copy.
Errors
Errors come back with a meaningful HTTP status and a body of the same shape every time:
{
"error": {
"code": "insufficient_scope",
"message": "This token cannot do that.",
"required": "records:read"
}
}
Branch on code, never on message — the wording may improve, the
codes will not change.
| Status | Code | What happened |
|---|---|---|
| 400 | bad_json | The body was not valid JSON. |
| 401 | unauthorized | Missing, malformed or unknown token. |
| 403 | insufficient_scope | Valid token, wrong permission. |
| 403 | token_revoked | This token was revoked. |
| 403 | org_suspended | The organisation is suspended. |
| 404 | not_found | No such thing — or it belongs to someone else. |
| 422 | bad_identifier | An identifier of the wrong type. |
| 429 | rate_limited | Too many requests this minute. |
| 500 | server_error | Our fault. Safe to retry. |
A record that belongs to another organisation returns 404, not
403. Telling you that something exists but is not yours is itself a leak.
Rate limits
Counted per minute, in a sliding window.
| Endpoints | Per minute |
|---|---|
| /v1/org/forms, /v1/org/records | 300 |
| /v1/org/attachments/… | 300 |
| Device endpoints | 120 (60 for submitting) |
Over the limit you get 429. Wait for the next minute rather than retrying
immediately. If you are mirroring a large history, page through with the cursor instead of
raising your request rate — one page of 500 costs the same as one page of 10.
Reading your data
GET/v1/org/forms
Every form in your organisation, with its current field definitions. Needs forms:read.
{
"forms": [
{
"code": "frm_site-visit",
"name": "Water point survey",
"description": "Quarterly condition check",
"version": 3,
"status": "published",
"created_at": "2026-04-02 08:11:40",
"updated_at": "2026-08-19 15:02:07",
"fields": [
{ "key": "reference", "label": "Reference", "type": "text",
"required": true, "position": 1, "config": null },
{ "key": "area_m2", "label": "Area", "type": "calculated",
"required": false, "position": 7,
"config": { "formula": "{length_m} * {width_m}", "decimals": 2 } }
]
}
],
"server_time": "2026-09-08T11:04:22+02:00"
}
Fields come from the form's current version. A record collected under an older version keeps its own version number — read it from the record.
GET/v1/org/records
Collected records, oldest first, paginated by cursor. Needs records:read.
| Parameter | Default | Meaning |
|---|---|---|
| limit | 100 | 1 to 500 per page. |
| cursor | 0 | The next_cursor from your last page. |
| form | all | Only this form, e.g. frm_site-visit. |
| since | — | ISO 8601. Only records received at or after this. |
| links_to | — | Only records pointing at this one. A record code or a uuid. |
| links_field | all | With links_to: only through this field. |
curl "https://fieldworksync.com/v1/org/records?form=frm_site-visit&limit=200" \
-H "Authorization: Bearer fwso_YOUR_TOKEN"
{
"records": [ /* see below */ ],
"next_cursor": 1041,
"has_more": true,
"server_time": "2026-09-08T11:04:22+02:00"
}
Page with the cursor, not with dates
The cursor is a row position, and it is the only safe way to walk the whole set.
received_at is not strictly increasing — two phones syncing in the same
second get the same timestamp — so paging by date silently drops records at the
boundary between pages.
Keep the last next_cursor you saw. Ask again from there and you get only
what is new. since is for a one-off catch-up, not for mirroring.
Deleted records still appear, with "status": "deleted". If
they vanished instead, anything mirroring this data would keep a copy of something the
customer deleted, and never know.
GET/v1/org/records/{code}
One record, by its code. This is how you follow a link. Needs records:read.
curl "https://fieldworksync.com/v1/org/records/rec_1038" \
-H "Authorization: Bearer fwso_YOUR_TOKEN"
{ "record": { /* the record object */ },
"server_time": "2026-09-08T11:04:22+02:00" }
A record belonging to another organisation returns 404, the same as one that
does not exist. Telling you it exists but is not yours would itself be a leak.
GET/v1/org/attachments/{code}
The bytes of one photo, signature or file. Needs attachments:read.
curl -o photo.jpg \
"https://fieldworksync.com/v1/org/attachments/att_53" \
-H "Authorization: Bearer fwso_YOUR_TOKEN"
Returns the file itself, not JSON. An ETag is sent and the content behind a
given code never changes, so send If-None-Match on a re-fetch and you will
get 304 instead of the bytes again.
Images are served inline; everything else is sent as a download. That is a safety measure, not a preference: a document uploaded by someone in the field is untrusted content, and a browser must not interpret it in the context of this domain.
The record object
The same shape whether you fetch it or we push it to you. There is one place in our code that builds it, so the two can never drift apart.
{
"code": "rec_1041",
"uuid": "9f1c4a8e-7d2b-4c19-a5f3-2e8b6d4a1c07",
"status": "active",
"form": { "code": "frm_site-visit", "name": "Water point survey", "version": 3 },
"collected_by": { "code": "usr_9", "name": "A. Mondlane",
"email": "[email protected]" },
"collected_at": "2026-09-07 09:14:02",
"received_at": "2026-09-07 17:40:55",
"location": { "lat": -19.823401, "lng": 34.838502, "accuracy_m": 6.4 },
"values": {
"reference": "WP-233",
"operational": true,
"flow_lmin": 18.4,
"length_m": 12.5,
"width_m": 4,
"area_m2": 50,
"code": "5901234123457"
},
"attachments": [
{ "code": "att_53", "field_key": "photo", "kind": "photo",
"mime": "image/jpeg", "bytes": 284119,
"width": 1600, "height": 1200,
"uploaded_at": "2026-09-07 17:41:02",
"url": "/v1/org/attachments/att_53" }
],
"links": {
"asset": [
{ "uuid": "a6acb228-217f-4392-aca6-1cc07d28a70f",
"record": "rec_1038", "form": "frm_assets", "resolved": true }
]
}
}
Reading it
valuesis keyed by field key. Types follow the field: numbers are numbers, checkboxes are booleans, everything else is a string. A field left empty is absent — not null.collected_atis when the person filled it in, on the phone.received_atis when it reached us. Offline work means these can be days apart, and for anything about what happened in the field, the first is the one you want.locationis null when no position was taken, or when the reading was worse than the accuracy the form demands. A coordinate nobody can trust is worse than none.linksis grouped by field, because the question you ask is "what does this field point at".resolved: falsemeans the target record has not synced yet — the uuid is right, it just is not here yet. It will resolve on its own; nothing needs to be re-sent.- Calculated fields are worked out here, on the server, never on the phone. The same record gives the same number regardless of which device or app version sent it.
Webhooks
Instead of asking, be told. Set a URL under Integrations and we post each record to it as it arrives.
POST https://your-system.example/hooks/fieldwork
Content-Type: application/json; charset=utf-8
User-Agent: FieldWorkSync-Webhook/1
X-FWS-Event: record.created
X-FWS-Delivery: dlv_88231
X-FWS-Timestamp: 1789561460
X-FWS-Signature: sha256=6f2b…
{
"event": "record.created",
"delivery": "dlv_88231",
"org": "org_7",
"sent_at": "2026-09-08T11:04:22+02:00",
"record": { /* exactly the object above */ }
}
Verify the signature
Anyone can post to your URL. The signature is how you know we sent it. It is an HMAC of the timestamp, a full stop, and the raw body — before any JSON parsing, byte for byte as received.
// PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_FWS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_FWS_SIGNATURE'] ?? '';
$mine = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $SECRET);
// hash_equals, não ==: uma comparação normal desiste no primeiro byte
// diferente, e o tempo que demora chega para adivinhar a assinatura.
if (!hash_equals($mine, $sig)) { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }
http_response_code(200); // responda depressa, trate depois
The timestamp is inside the signed string on purpose: without it, a delivery captured by someone could be replayed at you later, signature and all.
What we expect back
Any 2xx, within 15 seconds. Do the minimum — queue it and answer. Anything
else is a failure and we retry, backing off:
1 min · 5 min · 15 min · 1 h · 3 h · 6 h · 12 h · 24 h
After the last one the delivery is marked failed and left alone; you can retry it by hand from the panel. Deliveries for one organisation go in order, so a slow endpoint delays its own queue and nobody else's.
Expect the same record more than once
If your endpoint takes the record and then fails to answer — a timeout on your side, a connection dropped after you committed — we will send it again, because from here that is indistinguishable from never having arrived.
Make your handler idempotent on the record uuid. This is not a rare edge
case; it is the normal cost of at-least-once delivery, and every webhook system has it.
We follow no redirects and require a valid certificate. A redirect would carry your signed payload somewhere you did not choose.
The device API
This is what the Android app speaks. You only need it to build your own collection client — to read data, use the organisation endpoints above.
POST/v1/auth/device
Exchange an email and password for a device token. The token is shown once.
{ "email": "[email protected]", "password": "…",
"device_name": "Nokia G22", "platform": "android", "app_version": "1.0.0" }
→ { "token": "…", "device": "dev_4",
"user": { "code": "usr_9", "name": "A. Mondlane", "role": "field" },
"org": "org_7" }
Only accounts with the field role take a seat on the plan. A
device beyond the plan's limit is refused with 402 plan_limit.
GET/v1/forms
The forms assigned to this person, with their current version numbers.
GET/v1/forms/{code}
The full definition — this is what the app turns into a screen.
{ "code": "frm_site-visit", "name": "Water point survey", "version": 3,
"fields": [
{ "key": "reference", "label": "Reference", "type": "text", "required": true },
{ "key": "flow_lmin", "label": "Flow rate", "type": "number", "unit": "L/min", "decimals": 1 },
{ "key": "asset", "label": "Asset", "type": "relation", "form": "frm_assets",
"display_field": "asset_ref" }
] }
POST/v1/records
Submit a batch. Up to 200 at a time.
{ "records": [
{ "uuid": "9f1c4a8e-7d2b-4c19-a5f3-2e8b6d4a1c07",
"form": "frm_site-visit", "form_version": 3,
"collected_at": "2026-09-07T09:14:02Z",
"lat": -19.823401, "lng": 34.838502, "accuracy_m": 6.4,
"values": { "reference": "WP-233", "flow_lmin": 18.4 } }
] }
→ { "accepted": 1, "duplicates": 0,
"results": [ { "uuid": "9f1c…", "status": "accepted", "record": "rec_1041" } ] }
duplicate is success
Send the same uuid twice and the second returns "status": "duplicate" with
nothing created. Treat it as sent and clear it from your queue.
This is what makes a dropped connection harmless. If the server saved the record but
the reply never arrived, the client re-sends and gets duplicate — instead
of either losing the record or creating it twice.
Each record in a batch succeeds or fails on its own; one bad record does not stop the
other ninety-nine. Values sent for a calculated field are discarded and
recomputed here.
POST/v1/attachments
One photo, signature or file per request, as multipart.
curl -X POST https://fieldworksync.com/v1/attachments \
-H "Authorization: Bearer DEVICE_TOKEN" \
-F "record_uuid=9f1c4a8e-7d2b-4c19-a5f3-2e8b6d4a1c07" \
-F "field_key=photo" \
-F "kind=photo" \
-F "[email protected]"
→ { "status": "stored", "kind": "photo", "attachment": "att_53",
"bytes": 284119, "storage_used": 51219884, "storage_limit": 214748364800 }
kind is photo, signature or file.
The first two must be JPEG, PNG or WebP; a file may also be PDF, Office, OpenDocument,
CSV, text, XML, JSON or GPX. Type is decided by reading the bytes, not by trusting the
filename. Maximum 15 MB.
An attachment may arrive before its record — they are separate requests and either can fail alone. It is held by uuid and attached when the record lands.
GET/v1/records/lookup
Records of one form, as uuid and a readable label, for filling in a relation field offline.
GET /v1/records/lookup?form=frm_assets&q=PUMP&limit=50
→ { "form": "frm_assets", "display_field": "asset_ref",
"records": [ { "uuid": "a6acb228-…", "label": "PUMP-42",
"collected_at": "2026-08-30T10:22:00+02:00" } ] }
GET/v1/sync/status
What the server already has, so a client can clear its queue and notice a form that changed version.
GET /v1/sync/status?uuids=9f1c…,a6ac…
→ { "known_uuids": ["9f1c…"],
"form_versions": { "frm_site-visit": 3, "frm_assets": 1 },
"storage": { "used": 51219884, "limit": 214748364800 } }
Field types
What each type puts in values.
| Type | In values | Notes |
|---|---|---|
| text | string | |
| textarea | string | Several lines. |
| number | number | unit and decimals in config. |
| date | string | ISO 8601. |
| select | string, or array | Array when multiple is set. |
| boolean | true / false | Always present — "no" is an answer. |
| photo | absent | Comes in attachments, kind photo. |
| gps | absent | Comes in location. |
| signature | absent | In attachments, kind signature. |
| barcode | string | The code as read, or typed by hand. |
| file | absent | In attachments, kind file. |
| calculated | number | Computed here. Absent if the formula could not resolve. |
| relation | string (uuid) | Also in links, resolved to a code. |
Something missing?
If you are integrating and the API does not give you what you need, write to [email protected] and say what you are building. It is easier to add the right thing than to guess.