Field Track 360

Developer guide

Integrate the SDK

Native SDKs for Android and iOS, with bridges for React Native and Flutter. Pick your platform - the setup genuinely differs, so these are not one page.

Generated from docs/api.md in the service repository, so it describes the API this server is actually running.

API integration guide

Everything Field Track 360 exposes over HTTP, what each endpoint is for, and the mistakes that are expensive to make with it.

This is the reference: every endpoint, every field, every status code. For a task-shaped walkthrough - quickstart, runnable code, the order to call things in - read api-integration.md alongside it.

Two audiences, and they need different halves of this document:


Conventions

Base URLhttps://<your-host>/api/v1
Content typeapplication/json on request and response
VersioningThe version is in the path. A breaking change gets a new one; fields are only ever added within a version
AuthNone. See below
TimeISO 8601, UTC, e.g. 2026-08-17T10:45:00.000Z
MoneyInteger minor units450000 is ₹4,500.00. Never a float

There is no API key, and that is deliberate

The only endpoint that carries anything sensitive is /verify, and what it carries is already in the caller's hands: they have the licence token. An API key shipped inside a mobile app is extractable in minutes, so it would add a credential to manage without adding a barrier.

What protects each endpoint instead:

EndpointProtection
/verifyThe token itself. An answer is only ever about the token presented, and the response is signed
/plans, /quotePublic information. This is what the pricing page shows
/trialsRate limited, and one trial per email address
/ordersOrigin-locked to CHECKOUT_ORIGINS, and every price is computed server-side

Rate limits

EndpointLimitWindow
POST /verify120 requestsper minute, per IP
POST /trials5 requestsper hour, per IP
everything elsenot limited

Limits are per IP, which is coarse — a corporate NAT shares one — so /verify is set generously. It is abuse protection, not quota enforcement. Exceeding it returns 429 with {"status": "rate_limited"}, and standard RateLimit-* headers (draft-7) are on every response.

Errors

/verify always returns 200 — see the reasoning under that endpoint. Every other endpoint uses status codes normally:

{ "error": "Plan: required; Billing country: expected string" }
StatusMeaning
400The body did not validate. error names the fields
403origin_not_allowed — only from /orders
404unknown_plan
429Rate limited
500Ours. Retry with backoff

POST /api/v1/verify

The revocation check. The SDK verifies its token offline inside ready() with no network call, and that design is untouched — a background location SDK must not gain a network dependency in order to start. This endpoint answers the one question the offline scheme cannot: has this licence been revoked since it was issued?

Call it opportunistically and never on the startup path.

Request

POST /api/v1/verify
Content-Type: application/json

{
  "access_key":   "TRACKIT-eyJ2IjoxLCJraWQiOjEs…",
  "package_name": "com.acme.app",
  "sdk_type":     "android",
  "sdk_version":  "3.2.1",
  "nonce":        "9f3a1c7b"
}
FieldRequiredMaxNotes
access_keyyes2048The token verbatim, including its prefix
package_nameyes255context.packageName / Bundle.main.bundleIdentifier
sdk_typeyes32android, ios, react-native or flutter — the platform you are. Bridges report themselves, not the native SDK underneath
sdk_versionno32Your build. Observational only; it never affects the verdict
nonceno64Random per request. Echoed into the signed payload, so you can prove the answer was produced for this call and not replayed

Response

{
  "checked_at":   "2026-08-17T10:45:00.000Z",
  "key_id":       "9f2c…",
  "package_name": "com.acme.app",
  "status":       "active",
  "ttl_seconds":  86400,
  "valid":        true,
  "signature":    "base64url-ed25519"
}
statusvalidMeaning
activetrueGood. Refresh your cache
revokedfalseWithdrawn by an admin. A reason may be present
expiredfalseA trial licence past its end date
unknown_keyfalseSignature is valid but there is no record of it
invalid_keyfalseMalformed, tampered, or signed by a key we do not hold
package_mismatchfalseThe token does not cover the package you sent
sdk_mismatchfalseA legacy per-platform token, presented from the wrong platform

Why it is always 200

A non-200 is indistinguishable from a network failure. Your fail-open path treats network failure as "carry on" — correctly — so returning 403 for a revoked licence would cause it to be swallowed by the very code that makes the SDK robust. One shape, one branch, one meaning. The single exception is a malformed body, which is a programming error and returns 400.

Two mandatory checks on the response

1. The signature. Ed25519 over the JSON body with keys sorted and the signature field removed. Without this, anyone can point your SDK at a server that answers "active" forever and the revocation layer is decorative. Fetch the key once from /public-key or pin it in the build.

2. What it is a signature of. key_id is the SHA-256 of the token that was presented. Confirm it matches your own token before trusting the verdict. Otherwise a signed active response is a bearer token for any licence: capture one for a licence you legitimately hold, replay it whenever the SDK checks a revoked one, and the signature still verifies — because nothing in the payload would say which licence it was about. That defeats revocation entirely against the adversary this design actually cares about: the owner of the device.

If you sent a nonce, check that too.

Caching and failure

ttl_seconds is how long you may keep trusting this answer — a cache lifetime, not a heartbeat interval and not a deadline. Nothing is expected of a device that stays offline; verification is fail-open, so a device that cannot reach us keeps working and is never "offline" in any sense we track.

  • Cache the verdict for ttl_seconds (default 86400; 43200 on some plans).
  • On network failure, keep working on the last good verdict.
  • With no verdict ever obtained, keep working. The offline check already passed, which is what licenses the app.

docs/sdk-integration.md §3 has verification code for Kotlin and Swift.


GET /api/v1/public-key

The key that signs /verify responses. Not a secret — it is published so an SDK can pin or fetch it.

{
  "response_signing_public_key": "base64-32-bytes",
  "algorithm": "ed25519",
  "encoding": "base64"
}

This is not the licence minting key. The two are deliberately separate: this one is used on every check-in from every installed app, so its exposure surface is far larger. A leak here lets someone forge "not revoked". A leak of the mint key lets them forge licences outright and costs an SDK release to recover from. Never conflate them.

response_signing_public_key is null when the server has no response key configured, which is a valid first-cut deployment but never valid in production.


GET /api/v1/plans

Every plan on sale. This is what the pricing page renders from.

{
  "plans": [
    {
      "slug": "starter",
      "name": "Starter",
      "description": "One application, perpetual.",
      "price_minor": 4500000,
      "currency": "INR",
      "product_type": "sdk",
      "billing_type": "one_time",
      "billing_interval": null,
      "sdk_platforms": ["android", "ios", "react-native", "flutter"],
      "ttl_seconds": 86400
    }
  ]
}
FieldNotes
price_minorBase price, before tax, in minor units of currency
product_typesdk (perpetual licence, sold per application) or app (subscription to the ready-made app, sold per user)
billing_typeone_time for SDK plans — their tokens are permanent by design, so there is nothing to renew. subscription for app plans
sdk_platformsWhat a licence on this plan covers
ttl_secondsThe cache lifetime this plan's licences get from /verify

Prices here are pre-tax and in the plan's base currency. For what a specific buyer pays, ask /quote — never compute a total from this endpoint.


GET /api/v1/quote

Price and tax for one plan in one country.

GET /api/v1/quote?plan=starter&country=IN
{
  "country": "IN",
  "countryName": "India",
  "currency": "INR",
  "pricedExplicitly": true,
  "subtotalMinor": 4500000,
  "taxMinor": 810000,
  "totalMinor": 5310000,
  "taxName": "GST",
  "taxRateBps": 1800,
  "taxRateLabel": "18%",
  "collectsTaxId": true,
  "taxIdLabel": "GSTIN",
  "formatted": { "subtotal": "₹45,000", "tax": "₹8,100", "total": "₹53,100" }
}
  • taxRateBps is basis points1800 is 18.00%. Tax arithmetic stays in integers so an invoice is never out by a paisa.
  • pricedExplicitly is false when there is no price set in that currency and the base price was used as-is.
  • collectsTaxId tells you whether to show a GSTIN/VAT field, and taxIdLabel what to call it.

404 unknown_plan if the slug is not an active plan.


POST /api/v1/trials

Mint a 30-day trial licence and email it. One per email address, enforced server-side regardless of the rate limit.

{
  "name": "Asha Rao",
  "email": "asha@acme.test",
  "company": "Acme",
  "package_name": "com.acme.app",
  "also": ["com.acme.app.dev"]
}
FieldRequiredNotes
nameyes
emailyesThe trial is bound to it
companyno
package_nameyesSealed into the token — it cannot be edited later
alsonoUp to 20 variant ids, each of which must extend the primary (com.acme.app.dev, not com.other.app)
{
  "access_key": "TRACKIT-eyJ2IjoxLCJraWQiOjEs…",
  "package_name": "com.acme.app",
  "sdk_types": ["android", "ios", "react-native", "flutter"],
  "trial_days": 30,
  "expires_at": "2026-09-16T10:45:00.000Z",
  "note": "Verified on-device. The trial stops working the next time the app reaches the network after expires_at."
}

A trial's end date is not in the token — the token has no notion of time and never will. Expiry is enforced by the status check, so a trial stops at the next check-in after expires_at, not at a precise moment. Buy a plan before then and the same token keeps working, with no code change.


POST /api/v1/orders

Start a purchase. Called by the checkout page; CORS is locked to CHECKOUT_ORIGINS because this one costs money to abuse.

{
  "plan": "starter",
  "name": "Asha Rao",
  "email": "asha@acme.test",
  "phone": "+91 90000 00000",
  "country": "IN",
  "state": "24",
  "tax_id": "24AAACC1206D1ZM",
  "package_name": "com.acme.app",
  "also": []
}
FieldRequiredNotes
planyesSlug from /plans. Must be active and not an app plan
name, emailyesThe customer record is created or matched on email
phoneyesCashfree rejects an order without one — at least 10 digits
countryyesISO 3166-1 alpha-2. Decides currency and tax
statenoGST state code, e.g. 24. Indian buyers only
tax_idnoGSTIN/VAT number. Appears on the invoice
package_namenoCheckout no longer asks — buyers often do not know their final bundle id on the day they pay, and choose it in the portal afterwards. Supply it and the first key is minted at fulfilment
alsonoVariant ids, as for trials
{
  "order_id": "cmsx…",
  "payment_session_id": "session_…",
  "amount_minor": 5310000,
  "currency": "INR",
  "mode": "sandbox"
}

Hand payment_session_id to the Cashfree SDK. With no gateway credentials configured the response is {"simulation": true, "payment_session_id": null} instead, and the order completes locally — the whole funnel is walkable offline.

The price is never taken from the request. It is resolved server-side from the plan and the declared country. A buyer returning to the site proves only that a browser reached a URL, so fulfilment is driven by the webhook and a server-side confirmation, never by the redirect.

Rejections worth handling

MessageWhy
That plan is not available.Inactive or unknown slug
<package> is already licensed.An active licence exists for that app id
We cannot sell to that country yet.No tax rate configured for it

POST /webhooks/cashfree

Inbound, not for integrators. Cashfree calls this; the signature is verified against CASHFREE_SECRET_KEY over the raw body, so the route is mounted before the JSON parser. Register {BASE_URL}/webhooks/cashfree in the Cashfree dashboard. Delivery is idempotent — a retried webhook cannot mint a second licence. See docs/payments.md.


What is not here

  • No location data. The SDK sends none to us, and there is no endpoint that would accept it. It stays on the device and in whatever backend you send it to. The only thing these servers ever see is a licence check.
  • No licence management API. Issuing, revoking and re-issuing are panel and portal operations. A token is permanent by design; revocation is a deliberate human act with a recorded reason.

Try it before you buy

A 30-day trial licence for one application, issued instantly. Development builds are licence-waived, so you can evaluate the whole SDK first.

Get a trial key

Verification API

The SDK handles licensing for you. This is documented for tooling.

POST https://fieldtrack360-sdk.devstree.in/api/v1/verify