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.
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:
- Implementing licensing in an SDK — you need
/api/v1/verifyand/api/v1/public-key.docs/sdk-integration.mdis the companion for that work: it covers the on-device half, which is where the design decisions actually live. - Building a storefront or a portal — you need
/api/v1/plans,/api/v1/quote,/api/v1/ordersand/api/v1/trials.
Conventions
| Base URL | https://<your-host>/api/v1 |
| Content type | application/json on request and response |
| Versioning | The version is in the path. A breaking change gets a new one; fields are only ever added within a version |
| Auth | None. See below |
| Time | ISO 8601, UTC, e.g. 2026-08-17T10:45:00.000Z |
| Money | Integer minor units — 450000 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:
| Endpoint | Protection |
|---|---|
/verify | The token itself. An answer is only ever about the token presented, and the response is signed |
/plans, /quote | Public information. This is what the pricing page shows |
/trials | Rate limited, and one trial per email address |
/orders | Origin-locked to CHECKOUT_ORIGINS, and every price is computed server-side |
Rate limits
| Endpoint | Limit | Window |
|---|---|---|
POST /verify | 120 requests | per minute, per IP |
POST /trials | 5 requests | per hour, per IP |
| everything else | not 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" }
| Status | Meaning |
|---|---|
400 | The body did not validate. error names the fields |
403 | origin_not_allowed — only from /orders |
404 | unknown_plan |
429 | Rate limited |
500 | Ours. 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"
}
| Field | Required | Max | Notes |
|---|---|---|---|
access_key | yes | 2048 | The token verbatim, including its prefix |
package_name | yes | 255 | context.packageName / Bundle.main.bundleIdentifier |
sdk_type | yes | 32 | android, ios, react-native or flutter — the platform you are. Bridges report themselves, not the native SDK underneath |
sdk_version | no | 32 | Your build. Observational only; it never affects the verdict |
nonce | no | 64 | Random 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"
}
status | valid | Meaning |
|---|---|---|
active | true | Good. Refresh your cache |
revoked | false | Withdrawn by an admin. A reason may be present |
expired | false | A trial licence past its end date |
unknown_key | false | Signature is valid but there is no record of it |
invalid_key | false | Malformed, tampered, or signed by a key we do not hold |
package_mismatch | false | The token does not cover the package you sent |
sdk_mismatch | false | A 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
}
]
}
| Field | Notes |
|---|---|
price_minor | Base price, before tax, in minor units of currency |
product_type | sdk (perpetual licence, sold per application) or app (subscription to the ready-made app, sold per user) |
billing_type | one_time for SDK plans — their tokens are permanent by design, so there is nothing to renew. subscription for app plans |
sdk_platforms | What a licence on this plan covers |
ttl_seconds | The 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" }
}
taxRateBpsis basis points —1800is 18.00%. Tax arithmetic stays in integers so an invoice is never out by a paisa.pricedExplicitlyisfalsewhen there is no price set in that currency and the base price was used as-is.collectsTaxIdtells you whether to show a GSTIN/VAT field, andtaxIdLabelwhat 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"]
}
| Field | Required | Notes |
|---|---|---|
name | yes | |
email | yes | The trial is bound to it |
company | no | |
package_name | yes | Sealed into the token — it cannot be edited later |
also | no | Up 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": []
}
| Field | Required | Notes |
|---|---|---|
plan | yes | Slug from /plans. Must be active and not an app plan |
name, email | yes | The customer record is created or matched on email |
phone | yes | Cashfree rejects an order without one — at least 10 digits |
country | yes | ISO 3166-1 alpha-2. Decides currency and tax |
state | no | GST state code, e.g. 24. Indian buyers only |
tax_id | no | GSTIN/VAT number. Appears on the invoice |
package_name | no | Checkout 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 |
also | no | Variant 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
| Message | Why |
|---|---|
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 keyVerification API
The SDK handles licensing for you. This is documented for tooling.
POST https://fieldtrack360-sdk.devstree.in/api/v1/verify