Android integration guide
Everything an Android app needs to talk to the Datacove API: signing users in with a one-time code, keeping their session alive, running workflows, receiving results, and selling credits or a subscription through Google Play.
01Before you start
You need two things from your Datacove tenant administrator before writing any code.
An ANDROID API key
A tenant API key whose type is ANDROID. It identifies your app and is sent as the x-api-key header on the sign-in calls and on file uploads. The OTP routes reject keys of any other type.
The API base URL
Every path in this guide is relative to BASE_URL, which already includes the /api prefix and any path prefix your deployment adds in front of it.
Anything shipped inside an APK can be extracted. The key only unlocks the sign-in endpoints and tenant-scoped public data; everything user-specific requires the user's own token. Keep it out of logs and crash reports, and inject it at build time (for example through BuildConfig) rather than committing it.
| Credential | Sent as | Used for |
|---|---|---|
| API key | x-api-key: <key> | Requesting and verifying OTPs, uploading files with /media/v1/upload |
| Access token | Authorization: Bearer <token> | Everything the signed-in user does: profile, workflows, reports, purchases. Valid for 30 minutes. |
| Refresh token | JSON body | Only POST /auth/v1/refresh, to get a new token pair. Valid for 7 days and rotated on every use. |
02How it fits together
The user never sets a password. They prove they own a phone number or email address with a six-digit code, and the app receives a token pair it keeps refreshing for as long as the user stays signed in.
03Sign in with OTP
Offer phone, email, or both. They work the same way: request a code, verify it, and activate the account the first time it signs in. Each phone number or email address maps to one account per tenant, created automatically on the first request.
3 requests per minute and 10 per hour from one IP address. A 429 means wait; show a countdown rather than retrying in a loop.
Phone number
Creates the account if this number is new, then sends a six-digit code by SMS, or by WhatsApp when the tenant has WhatsApp delivery enabled. The response is identical for new and existing numbers, so it never reveals whether an account exists.
| Field | Type | Notes |
|---|---|---|
| phoneNumberrequired | string | International format, digits only, for example 919876543210. A leading + is accepted and stripped. |
| nameoptional | string | Display name. Used only when the account is new; ignored afterwards. |
| channeloptional | "sms" | "whatsapp" | Defaults to sms. Send the same value again on verify. |
| Status | Meaning |
|---|---|
| 200 | Code sent. |
| 400 | Invalid number, or WhatsApp was requested but isn't enabled for this tenant. |
| 429 | Rate limited. |
| 502 | The SMS provider couldn't be reached. Safe to retry. |
Codes expire 10 minutes after they're sent and lock after 5 wrong attempts. A locked or expired code can't be retried; request a new one.
| Field | Type | Notes |
|---|---|---|
| phoneNumberrequired | string | The same number used on request. |
| otprequired | string | Exactly six digits. |
| channeloptional | "sms" | "whatsapp" | Must match the request. |
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 1800
}| Status | Meaning |
|---|---|
| 401 | Wrong code. |
| 403 | Locked after 5 wrong attempts. |
| 404 | No account for this number. Request a code first. |
| 410 | Code expired. |
interface AuthApi {
@POST("auth/v1/mobile/request-otp")
suspend fun requestMobileOtp(@Body body: RequestMobileOtp): Response<Unit>
@POST("auth/v1/mobile/verify-otp")
suspend fun verifyMobileOtp(@Body body: VerifyMobileOtp): TokenPair
@POST("auth/v1/activate")
suspend fun activate(): TokenPair // Bearer token added by the interceptor
@POST("auth/v1/refresh")
suspend fun refresh(@Body body: RefreshRequest): TokenPair
}
data class RequestMobileOtp(val phoneNumber: String, val name: String? = null, val channel: String = "sms")
data class VerifyMobileOtp(val phoneNumber: String, val otp: String, val channel: String = "sms")
data class RefreshRequest(val refreshToken: String)
data class TokenPair(val accessToken: String, val refreshToken: String, val expiresIn: Int)
// Sign-in routes need the app's API key; everything else carries the user's token.
class ApiKeyInterceptor(private val apiKey: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response =
chain.proceed(chain.request().newBuilder().header("x-api-key", apiKey).build())
}Identical to the phone flow, with an email address instead of a number and no channel choice. Requesting a new code within 60 seconds of the last one returns 429.
{ "email": "jane@example.com", "name": "Jane Doe" }{ "email": "jane@example.com", "otp": "123456" }Activate and load the profile
A brand-new account comes back from verify with status: "CREATED" in its access token. Call activate once to move it to ACTIVE; it returns a fresh token pair that replaces the one you just stored. The call is idempotent, so if you're unsure whether it succeeded (a timeout, a dropped connection), just call it again.
Returns the same { accessToken, refreshToken, expiresIn } shape. 400 means the account is in a state that can't be activated from the app.
{
"name": "Jane Doe",
"phone": "919876543210",
"country": "IN",
"timezone": "Asia/Kolkata",
"status": "ACTIVE",
"wallet": { "WALLET": 25, "TRIAL": 0, "SUBSCRIPTION": 0 }
}wallet.WALLET is the user's spendable credit balance. A new account can start with a signup bonus configured by the tenant, so don't assume it starts at zero.
To edit the name or timezone, use PATCH /users/v1/profile. To let a user delete their own account, use DELETE /users/v1/account.
04Keeping the session alive
Access tokens last 30 minutes. Refresh tokens last 7 days and change every time you use one, so a user who opens the app at least once a week stays signed in indefinitely.
{ "refreshToken": "eyJhbGciOiJIUzI1NiIs..." }Returns a new { accessToken, refreshToken, expiresIn }. Always save the new refresh token: the one you sent stops working once the new one is issued.
Rules for a well-behaved client
- Store tokens securely. Use DataStore or SharedPreferences encrypted with a key from the Android Keystore. Never log them.
- Refresh proactively. Refresh about a minute before
expiresInelapses, and also when any request returns401, then retry that request once. - Refresh one at a time. If several requests fail at once, they should all wait for a single refresh rather than each starting their own. Two refreshes that arrive within 30 seconds of each other with the same token both succeed and return the same new token. Anything later with an old token is treated as a stolen token and ends the session.
- Persist before you use. Write the new pair to storage before continuing, so a crash can't leave the app holding a refresh token that's already been replaced.
class TokenAuthenticator(
private val store: TokenStore, // encrypted persistence
private val authApi: AuthApi, // a separate Retrofit instance without this authenticator
) : Authenticator {
private val mutex = Mutex()
override fun authenticate(route: Route?, response: okhttp3.Response): Request? = runBlocking {
val rejected = response.request.header("Authorization")?.removePrefix("Bearer ")
val tokens = mutex.withLock {
val current = store.read() ?: return@withLock null
// Another request already refreshed while we were waiting: reuse its result.
if (current.accessToken != rejected) return@withLock current
try {
authApi.refresh(RefreshRequest(current.refreshToken)).also { store.write(it) }
} catch (e: HttpException) {
if (e.code() == 401) store.clear() // session is over; send the user to sign-in
null
}
} ?: return@runBlocking null
response.request.newBuilder().header("Authorization", "Bearer ${tokens.accessToken}").build()
}
}When a refresh is rejected
A 401 from refresh, or from any authenticated request, carries a machine-readable code inside error. Use it to tell the user why they were signed out.
{
"statusCode": 401,
"timestamp": "2026-10-02T09:30:00.000Z",
"path": "/api/auth/v1/refresh",
"error": {
"statusCode": 401,
"error": "Unauthorized",
"message": "You were signed out because this account signed in on another device.",
"code": "SESSION_EVICTED"
}
}| code | What happened | Suggested message |
|---|---|---|
| SESSION_EVICTED | The account signed in on more than 5 devices, and this was the oldest session. | "You were signed out because your account was used on another device." |
| SESSION_REVOKED | This session was signed out: logout, "sign out of all devices", signed out from another device's session list, or the account was disabled. | "You've been signed out." |
| TOKEN_REUSED | A refresh token that had already been replaced was used again, so the session was ended as a precaution. | "For your security, please sign in again." |
| REFRESH_INVALID | The refresh token is expired (older than 7 days), malformed, or unknown. | "Your session has expired. Please sign in again." |
Devices and signing out
An account can be signed in on up to 5 devices at once. Signing in on a sixth ends the least recently created session. The same endpoints let you build a "signed-in devices" screen:
| Call | Does |
|---|---|
| GET /auth/v1/sessions | Lists the user's sessions with createdAt, lastUsedAt, the app type that created it, and current: true on this one. |
| DELETE /auth/v1/sessions/:sessionId | Signs out one device. Its access token stops working immediately. |
| GET /auth/v1/logout | Signs out this device. |
| DELETE /auth/v1/sessions | Signs out every device, including this one. |
05Running workflows
A workflow is an AI task with a defined set of inputs. Fetch its definition, render a form from its inputs, then execute it. Each run costs the credits configured for that workflow, deducted from wallet.WALLET.
Discover workflows
GET /workflows/v1/:pageNo/:pageSize returns { data, meta }, filtered to what your tenant offers. Optional query parameters: search, category, featured. Then fetch one by its id, for example GET /workflows/v1/wf-sec-001, to get its inputs.
Build the form from inputs
[
{
"key": "url",
"label": "Website address",
"type": "text",
"placeholder": "https://example.com",
"validation": { "required": true }
},
{
"key": "document",
"label": "Document",
"type": "file",
"fileOptions": { "accept": ".pdf,.docx", "maxSizeMb": 10, "multiple": false }
}
]| Field | Use |
|---|---|
| key | The property name to send inside input. |
| type | text, text-area, number, date, time, location, dropdown, select, file, object, array. |
| options | Choices for dropdown and select. |
| fileOptions | Allowed extensions, size limit in MB, and whether several files are allowed. |
| validation | Rules such as required. The server enforces them too. |
| Field | Type | Notes |
|---|---|---|
| idrequired | string | The workflow id, for example wf-sec-001. |
| inputrequired | object | One property per input key. |
| countryoptional | string | ISO country code, for workflows whose output varies by country. |
Text-only inputs: send JSON. File inputs: send multipart/form-data with id (and country, if used) as plain text fields, every non-file workflow input in one text field named input as a JSON string (send {} if there are none), and each file as a part named after its input key. Files over the input's size limit are rejected before anything runs.
interface WorkflowApi {
@POST("workflows/v1/execute")
suspend fun execute(@Body body: ExecuteRequest): JsonObject
@Multipart
@POST("workflows/v1/execute")
suspend fun executeWithFiles(
@Part("id") id: RequestBody, // plain text
@Part("input") input: RequestBody, // JSON string of the non-file inputs
@Part files: List<MultipartBody.Part>, // one part per file input, named by key
): JsonObject
}
data class ExecuteRequest(val id: String, val input: Map<String, Any?>)
// Text input
val result = workflowApi.execute(ExecuteRequest("wf-sec-001", mapOf("url" to "https://example.com")))
// File input: "document" is the input's key
val text = "text/plain".toMediaType()
val filePart = MultipartBody.Part.createFormData(
"document", "contract.pdf", file.asRequestBody("application/pdf".toMediaType()),
)
val started = workflowApi.executeWithFiles(
id = "wf-sec-008".toRequestBody(text),
input = "{}".toRequestBody(text), // no non-file inputs on this workflow
files = listOf(filePart),
)POST /api/workflows/v1/execute HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{ "id": "wf-sec-001", "input": { "url": "https://example.com" } }Two kinds of response
Instant result
Chat and analysis workflows answer in the same request. The 201 body is the result itself.
Report
Longer workflows start a run and return straight away with { "success": true, "eventId": "…", "message": "…" }. Keep the eventId and collect the result as shown in the next section. Reports can take several minutes.
Each workflow always responds the same way, so a client can simply check for eventId in the response.
06Getting report results
Use the realtime channel to be told the moment a report is ready, and keep polling as a fallback. Both use the eventId from execute.
Realtime (recommended) →
Mint a ticket, connect with Socket.IO, join the run's room, and receive workflow_status_update when it finishes. The Realtime guide has a complete Kotlin example.
Polling
Call GET /workflows/reports/v1/:eventId every 5 seconds. A run can take up to 20 minutes, so keep polling at least that long before giving up.
| GET /workflows/reports/v1/:eventId | Meaning |
|---|---|
| 200 { "status": "GENERATING" } | Still running. |
| 200 (report body) | Finished. The body is the report. |
| 404 | The run failed, or the report doesn't exist. These look the same on purpose. Credits are refunded on failure. |
A user's past runs are listed by GET /workflows/reports/v1/history/:page/:limit.
07Google Play billing
Apps can sell two kinds of product through Play Billing. Your tenant administrator creates both in the Datacove catalog, using the same product ids you set up in the Play Console.
Credit packages
One-time, consumable products such as "500 credits". A verified purchase adds the package's credits to wallet.WALLET. Users can buy them again and again.
Plan (service entitlement)
A subscription (monthly or annual) or a one-year prepaid plan that unlocks specific workflows. Users get one free trial before they need it.
Trial and entitlement
Workflows covered by a plan are available while the user has an active trial or an active plan. Without either, executing one returns 403 with code: "ENTITLEMENT_INACTIVE". Read the state once at launch and after every purchase:
{
"trial": { "status": "active", "startedAt": "2026-10-01T08:00:00.000Z", "expiresAt": "2026-10-08T08:00:00.000Z" },
"entitlement": { "status": "none" },
"canExecuteGatedWorkflow": true,
"serverTime": "2026-10-02T09:30:00.000Z"
}trial.status:not_started,activeorexpired.entitlement.status:noneif the user has never bought the plan; otherwiseactive,in_grace_period,cancelled,expiredorrefunded, withproductId,platformandcurrentPeriodEnd.- Use
canExecuteGatedWorkflowto decide whether to show the paywall. Compare dates againstserverTime, never the device clock.
Starts the user's one free trial, whose length is set by the tenant. Calling it again returns the existing trial unchanged, including an expired one, so it's safe to call whenever you're unsure. Send an empty body {}.
If an older version of your app tracked the trial on the device, send that record once as legacyTrial: { startedAt, expiresAt } on the user's first call. The server clamps it to the tenant's trial length, and ignores it if the account already has a trial.
Purchases
- Launch the purchase with Play Billing, setting
setObfuscatedAccountId()to the user's Datacove user id (theuserIdclaim in the access token). This is recommended rather than required: it ties the purchase to the user inside Google's own records. - Confirm it with the backend as soon as Play Billing reports
PURCHASED: callverify-purchasewith the purchase token and product id. - Don't acknowledge it yourself. The backend acknowledges every purchase it verifies. For a credit package, call
consumeAsyncafter a200so the user can buy the same package again. Leave subscriptions alone. - Recover unfinished purchases. On every launch, call
queryPurchasesAsyncand send any purchase you haven't confirmed yet throughverify-purchase. Verifying the same purchase twice is safe; it is only ever granted once.
| Field | Type | Notes |
|---|---|---|
| platformrequired | "google_play" | The store the purchase came from. |
| platformProductIdrequired | string | The Play product id. The server looks it up in the tenant's catalog to decide what was bought. |
| purchaseTokenrequired | string | Purchase.getPurchaseToken(). |
{ "kind": "credit_package", "creditsGranted": 500 }{
"kind": "service_entitlement",
"entitlementStatus": "active",
"currentPeriodEnd": "2026-11-02T09:30:00.000Z"
}| Status | Meaning |
|---|---|
| 400 | Missing purchase token, the purchase isn't complete yet (for example, pending payment), or it belongs to a different app. |
| 404 | The product id isn't in the tenant's catalog. |
| 409 | PURCHASE_LINKED_TO_ANOTHER_ACCOUNT: this subscription belongs to a different Datacove account. Ask the user to sign in with that account. |
// 1. Launch, tagging the purchase with the Datacove user id
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.setObfuscatedAccountId(datacoveUserId)
.build()
billingClient.launchBillingFlow(activity, params)
// 2-3. Confirm each completed purchase with the backend
override fun onPurchasesUpdated(result: BillingResult, purchases: MutableList<Purchase>?) {
purchases.orEmpty()
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.forEach { purchase -> scope.launch { confirm(purchase) } }
}
suspend fun confirm(purchase: Purchase) {
val productId = purchase.products.first()
val granted = paymentApi.verifyPurchase(
VerifyPurchase(platform = "google_play", platformProductId = productId, purchaseToken = purchase.purchaseToken),
)
if (granted.kind == "credit_package") {
// Consume so the same package can be bought again. The server has already acknowledged it.
billingClient.consumePurchase(ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build())
}
refreshWalletAndEntitlement()
}
// 4. On launch, retry anything that never reached the backend
billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build()) { _, list ->
list.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }.forEach { scope.launch { confirm(it) } }
}You don't need to report these. Google notifies the backend directly, and the plan's status updates on its own. Re-read GET /payment/v1/mobile/entitlement when the app returns to the foreground.
Store setup
Done once per app by whoever administers the tenant, before purchases can be verified.
- In Google Cloud, enable the Google Play Android Developer API, create a service account, and download its JSON key.
- In the Play Console under Setup → API access, link that project and grant the service account View financial data and Manage orders and subscriptions.
- Create a Pub/Sub topic, grant
google-play-developer-notifications@system.gserviceaccount.comthe Publisher role on it, and select it under Monetization setup → Real-time developer notifications. - Add a push subscription to that topic that delivers to
https://<api-host>/webhooks/google-play(webhooks live outside the/apiprefix; include any deployment path prefix). Enable authentication on it so the backend can verify that the messages come from Google. - Save the credentials with
PATCH /tenants/v1/:id/google-play-config:packageName,serviceAccountEmail,serviceAccountPrivateKeyandpubSubTopic. - Create the catalog: credit packages through
/pricing-config(providergoogle_play, withiapCreditPackages) and plans through/service-entitlement-products(withplatformProductIds.googlePlay). Each product id must match the Play Console exactly.
08Errors
Every error uses the same envelope. Branch on the HTTP status first, then on error.code when it's present.
{
"statusCode": 403,
"timestamp": "2026-10-02T09:30:00.000Z",
"path": "/api/workflows/v1/execute",
"error": {
"code": "ENTITLEMENT_INACTIVE",
"message": "Your trial has ended and no active subscription was found for this service."
}
}| Status | Where | Meaning and what to do |
|---|---|---|
| 400 | Any | A field failed validation. error.message lists the problems. On execute it can also mean the workflow rejected the input: error.message is The workflow could not process this input, with error.detail (a short reason) or error.fields ([{ field, issue }]) when available. Ask the user to change the input; retrying the same input fails again. |
| 401 | Any authenticated call | Token expired or session ended. Refresh once; if that fails, use error.code to explain and return to sign-in. |
| 403 | execute | Insufficient credit balance: offer a credit package. ENTITLEMENT_INACTIVE: show the plan paywall. |
| 404 | execute, reports | Unknown or unavailable workflow, or a failed or missing report. |
| 409 | execute | The same workflow with identical input is still running from a moment ago. Wait for it instead of retrying. Only returned for workflows that cost credits and for report workflows. |
| 429 | Sign-in, refresh | Rate limited. Back off. |
| 502 | execute | The workflow service failed or couldn't be reached (Workflow service unavailable. Try again later.). Safe to retry with backoff. |
| 5xx | Any | Temporary. Retry with backoff. |
09Launch checklist
- The
ANDROIDAPI key is injected at build time and never logged. - Tokens are stored encrypted, and every refresh saves the new pair before it's used.
- Refresh is single-flight, and 401 handling reads
error.codeto show the right sign-out message. - File inputs respect
fileOptionsbefore upload, and execute uses multipart. - Report runs use the realtime channel with a polling fallback that keeps going for at least 20 minutes.
- Purchases set
setObfuscatedAccountId, are confirmed withverify-purchase, credit packages are consumed after a200, and unfinished purchases are retried on launch. - The paywall is driven by
canExecuteGatedWorkflowandserverTime, not the device clock.