Entrar Criar conta

Developers

API reference

Everything your field teams collect is available over HTTP: read it into your own systems, or have it pushed to you as it arrives. Version 1.

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.

ScopeLets the token
forms:readlist your forms and their field definitions
records:readread collected records
attachments:readdownload 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"
  }
}
PrefixWhat 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.

StatusCodeWhat happened
400bad_jsonThe body was not valid JSON.
401unauthorizedMissing, malformed or unknown token.
403insufficient_scopeValid token, wrong permission.
403token_revokedThis token was revoked.
403org_suspendedThe organisation is suspended.
404not_foundNo such thing — or it belongs to someone else.
422bad_identifierAn identifier of the wrong type.
429rate_limitedToo many requests this minute.
500server_errorOur 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.

EndpointsPer minute
/v1/org/forms, /v1/org/records300
/v1/org/attachments/…300
Device endpoints120 (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.

ParameterDefaultMeaning
limit1001 to 500 per page.
cursor0The next_cursor from your last page.
formallOnly 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_fieldallWith 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

  • values is 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_at is when the person filled it in, on the phone. received_at is 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.
  • location is 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.
  • links is grouped by field, because the question you ask is "what does this field point at". resolved: false means 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.

TypeIn valuesNotes
textstring
textareastringSeveral lines.
numbernumberunit and decimals in config.
datestringISO 8601.
selectstring, or arrayArray when multiple is set.
booleantrue / falseAlways present — "no" is an answer.
photoabsentComes in attachments, kind photo.
gpsabsentComes in location.
signatureabsentIn attachments, kind signature.
barcodestringThe code as read, or typed by hand.
fileabsentIn attachments, kind file.
calculatednumberComputed here. Absent if the formula could not resolve.
relationstring (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.