iOS integration guide
Everything an iOS 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 the App Store with StoreKit 2.
01Before you start
You need two things from your Datacove tenant administrator before writing any code.
An IOS API key
A tenant API key whose type is IOS. 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 app bundle 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 supply it through a build configuration file rather than hard-coding it in source.
| 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. |
| App version | X-App-Version: <version> | Required on POST /workflows/v1/execute, from the app and from the Safari extension. Send CFBundleShortVersionString, for example 1.2.0. |
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 14165550199. 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. |
struct TokenPair: Codable { let accessToken: String; let refreshToken: String; let expiresIn: Int }
struct DatacoveClient {
let baseURL: URL // e.g. https://api.example.com/api
let apiKey: String // the IOS tenant API key
func requestMobileOtp(phone: String, name: String? = nil) async throws {
_ = try await post("auth/v1/mobile/request-otp",
body: ["phoneNumber": phone, "name": name, "channel": "sms"], withKey: true)
}
func verifyMobileOtp(phone: String, otp: String) async throws -> TokenPair {
let data = try await post("auth/v1/mobile/verify-otp",
body: ["phoneNumber": phone, "otp": otp, "channel": "sms"], withKey: true)
return try JSONDecoder().decode(TokenPair.self, from: data)
}
func post(_ path: String, body: [String: String?], withKey: Bool = false, token: String? = nil) async throws -> Data {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if withKey { request.setValue(apiKey, forHTTPHeaderField: "x-api-key") }
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
request.httpBody = try JSONSerialization.data(withJSONObject: body.compactMapValues { $0 })
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw APIError.from(data: data, response: response)
}
return data
}
}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": "14165550199",
"country": "CA",
"timezone": "America/Toronto",
"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. App Store rules require in-app account deletion: 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 in the Keychain. If an app extension shares the session, put the item in a shared Keychain access group so both read the same, latest tokens.
- Refresh proactively. Refresh about a minute before
expiresInelapses, and also when any request returns401, then retry that request once. - Refresh one at a time. Route every refresh through a single actor so concurrent requests share one refresh. 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.
- Re-read before refreshing. When an app and an extension share tokens, read the Keychain again just before refreshing. Another process may already have rotated the token.
actor SessionManager {
private let client: DatacoveClient
private let keychain: TokenKeychain // shared access group if an extension uses it
private var inFlight: Task<TokenPair, Error>?
init(client: DatacoveClient, keychain: TokenKeychain) {
self.client = client
self.keychain = keychain
}
/// Returns a usable access token, refreshing it at most once at a time.
func validAccessToken(rejected: String? = nil) async throws -> String {
if let task = inFlight { return try await task.value.accessToken }
guard let current = keychain.read() else { throw APIError.signedOut(code: nil) }
// Another caller (or process) already rotated it: use the newer token.
if let rejected, current.accessToken != rejected { return current.accessToken }
let task = Task { () throws -> TokenPair in
let data = try await client.post("auth/v1/refresh", body: ["refreshToken": current.refreshToken])
let pair = try JSONDecoder().decode(TokenPair.self, from: data)
try keychain.write(pair) // persist before anyone uses it
return pair
}
inFlight = task
defer { inFlight = nil }
do {
return try await task.value.accessToken
} catch let APIError.http(status, code, _) where status == 401 {
keychain.clear()
throw APIError.signedOut(code: code) // SESSION_EVICTED, TOKEN_REUSED, ...
}
}
}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 formats (accept), size limit in MB (maxSizeMb), and whether several files are allowed. The server enforces all three, so read them from here rather than hardcoding them. |
| 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. |
Headers: Authorization: Bearer <token> and X-App-Version: <version>. The server applies plan access (see Trial and entitlement) based on the app version, so every build that sells plans must send it, including from the Safari extension.
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. A missing, empty, unreadable or oversized file, or one whose content isn't an accepted format, is rejected with 400 before anything runs (see file errors).
func executeWithFile(workflowId: String, fileKey: String, fileURL: URL, mimeType: String, token: String) async throws -> Data {
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: baseURL.appendingPathComponent("workflows/v1/execute"))
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(appVersion, forHTTPHeaderField: "X-App-Version") // CFBundleShortVersionString
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
func field(_ name: String, _ value: String) {
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
}
field("id", workflowId) // plain text field
field("input", "{}") // JSON string of the non-file inputs, {} if none
body.append("--\(boundary)\r\n")
// Each file part is named after its input key.
body.append("Content-Disposition: form-data; name=\"\(fileKey)\"; filename=\"\(fileURL.lastPathComponent)\"\r\n")
body.append("Content-Type: \(mimeType)\r\n\r\n")
body.append(try Data(contentsOf: fileURL))
body.append("\r\n--\(boundary)--\r\n")
let (data, _) = try await URLSession.shared.upload(for: request, from: body)
return data
}
private extension Data {
mutating func append(_ string: String) { append(Data(string.utf8)) }
}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 Swift 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. If the app goes to the background during a long run, poll the report when it returns to the foreground.
07App Store purchases
Apps can sell two kinds of product with StoreKit 2. Your tenant administrator creates both in the Datacove catalog, using the same product ids you set up in App Store Connect.
Credit packages
Consumable in-app purchases such as "500 credits". A verified purchase adds the package's credits to wallet.WALLET. Other product types are rejected for credit packages.
Plan (service entitlement)
A plan that unlocks specific workflows: an auto-renewable subscription (monthly or annual) or a one-year plan sold as a non-renewing subscription. The tenant decides which plans are on sale and whether the app offers a free trial.
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". The account is what holds the plan, so it follows the user to every device they sign in on. Read the state at launch, when the app returns to the foreground, and after every purchase:
{
"trial": { "status": "not_started" },
"trialOffer": { "available": false },
"entitlement": {
"status": "active",
"productId": "650f1c2e9b1d4a0012ab34cd",
"platform": "apple",
"currentPeriodEnd": "2026-11-01T08:00:00.000Z"
},
"plans": [
{
"productId": "650f1c2e9b1d4a0012ab34cd",
"platformProductId": "com.example.app.yearly",
"displayName": "1 Year Protection",
"interval": "annual",
"gatedWorkflowId": "wf-sec-001"
}
],
"canExecuteGatedWorkflow": true,
"serverTime": "2026-10-02T09:30:00.000Z"
}trial.status:not_started,activeorexpired.trialOffer.available: whether this app offers the server trial (withdurationDayswhen it does). Show a "start free trial" action only when it'strueandtrial.statusisnot_started. A free-trial offer configured on the App Store product itself is separate: StoreKit shows it on the product, and the server counts that period as an active plan.plans: the plans on sale in this app, oldest first. Load them withProduct.products(for:)using eachplatformProductId, and build the paywall from what StoreKit returns.intervalismonthly,annualorone_time_year.entitlement.status:noneif the user has no current plan; otherwiseactive,in_grace_periodorcancelled.cancelledmeans auto-renew is off; the plan still works untilcurrentPeriodEnd.- Use
canExecuteGatedWorkflowto decide whether to show the paywall. Compare dates againstserverTime, never the device clock.
Starts the user's one free trial, whose length the tenant sets for this app. 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 {}.
Call it only when trialOffer.available is true. Otherwise an account with no trial yet gets 403 with code: "TRIAL_NOT_OFFERED". A trial the user already started, on any device, keeps counting.
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
Sign the user in before they buy, so the purchase is tied to their account.
- Buy with
product.purchase()and keep only.verifiedtransactions. - Confirm it with the backend straight away: call
verify-purchasewith the transaction id and product id. - Finish the transaction after a
200withtransaction.finish(). Don't finish it before the backend confirms, or a failed call can't be retried. - Listen for
Transaction.updatesfrom app launch, and on launch also walkTransaction.unfinished. Both catch purchases that completed while the app wasn't running, such as Ask to Buy approvals and renewals. Send each throughverify-purchase; verifying the same transaction twice is safe. - After sign-in, send each plan in
Transaction.currentEntitlementsthroughverify-purchaseonce, so a plan bought earlier is linked to the account.
| Field | Type | Notes |
|---|---|---|
| platformrequired | "apple" | The store the purchase came from. |
| platformProductIdrequired | string | The App Store product id. The server looks it up in the tenant's catalog to decide what was bought. |
| transactionIdrequired | string | String(transaction.id) from StoreKit 2. |
| transferToCurrentAccountoptional | boolean | Only on the retry after the user agreed to move a plan to this account. See Moving a plan. |
{ "kind": "credit_package", "creditsGranted": 500 }{
"kind": "service_entitlement",
"entitlementStatus": "active",
"currentPeriodEnd": "2026-11-02T09:30:00.000Z"
}A plan response also has "transferred": true when the call moved the plan from another account.
| Status | Meaning |
|---|---|
| 400 | Missing transaction id; the transaction belongs to a different app or product; a credit package that isn't consumable; a one-year plan that isn't a non-renewing subscription; or a refunded transaction. |
| 404 | The product id isn't in the tenant's catalog. |
| 409 | PURCHASE_LINKED_TO_ANOTHER_ACCOUNT: this plan belongs to a different account. If error.canTransfer is true, offer to move it (see Moving a plan). Otherwise ask the user to sign in with that account. Finish the transaction either way, since retrying won't change the answer. |
@MainActor
final class PurchaseManager {
private var updatesTask: Task<Void, Never>?
/// Call once at app launch.
func start() {
updatesTask = Task {
for await result in Transaction.updates { await self.handle(result) }
}
Task {
for await result in Transaction.unfinished { await self.handle(result) }
}
}
func buy(_ product: Product) async throws {
if case .success(let result) = try await product.purchase() {
await handle(result)
}
}
private func handle(_ result: VerificationResult<Transaction>) async {
guard case .verified(let transaction) = result else { return } // ignore unverified
do {
try await api.verifyPurchase(
platform: "apple",
platformProductId: transaction.productID,
transactionId: String(transaction.id)
)
await transaction.finish() // only after the backend has granted it
await refreshWalletAndEntitlement()
} catch {
// Leave it unfinished: Transaction.unfinished will offer it again on next launch.
}
}
}You don't need to report these. The App Store 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. To restore purchases on a new device, call AppStore.sync() and send the resulting transactions through verify-purchase.
Moving a plan to another account
An account is a phone number or email; a plan is paid for by an Apple ID. When someone signs in with a new number on a phone whose Apple ID already pays for a plan on their old account, verify-purchase answers 409 PURCHASE_LINKED_TO_ANOTHER_ACCOUNT. Apple won't sell the same subscription again while it's active, so the user can only get it on the new account by moving it.
- If
error.canTransferistrue, ask the user, for example: "Your Apple ID already has an active plan on another account. Move it to this account? The other account will lose it." - If they agree, call
verify-purchaseagain with the same transaction and"transferToCurrentAccount": true. A200with"transferred": truemeans the plan is now on this account. - If they decline, send nothing more.
canTransfer is false when the plan has ended or belongs to another workspace; don't offer the move then. The server moves a plan without asking when the old account was deleted or shares this account's phone number or email.
Store setup
Done once per app by whoever administers the tenant, before purchases can be verified.
- In App Store Connect under Users and Access → Integrations → In-App Purchase, create a key (requires the Admin role). Note the Key ID and Issuer ID, and download the
.p8file. Apple only lets you download it once. - Find the app's numeric Apple ID under App Information. It's different from the bundle id.
- Save the credentials with
PATCH /tenants/v1/:id/apple-config:bundleId,issuerId,keyId,privateKey(the full contents of the.p8file) andappAppleId. - Under App Information → App Store Server Notifications, set both the production and sandbox URLs to
https://<api-host>/webhooks/appleand choose Version 2. Webhooks live outside the/apiprefix; include any deployment path prefix. - Create the catalog: credit packages through
/pricing-config(providerapple, withiapCreditPackages) and plans through/service-entitlement-products(withplatformProductIds.apple). Each product id must match App Store Connect exactly. A plan with intervalmonthlyorannualmust be an auto-renewable subscription;one_time_yearmust be a non-renewing subscription. - Choose the trial with
PATCH /tenants/v1/:id/trial-config:ios.enabledturns the server trial on or off for this app, andios.durationDayssets its length. If the App Store product has its own free-trial offer, turn the server trial off so users don't get both.
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. A file problem has an error.code from the table below. 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. |
| 413 | execute | PAYLOAD_TOO_LARGE: the upload is over the server's size ceiling. Show error.message. |
| 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. |
File errors on execute are 400. Show error.message as-is; {label} is the input's own label and the limits come from its fileOptions.
| error.code | error.message |
|---|---|
| FILE_REQUIRED | {label} is required |
| FILE_EMPTY | The selected file is empty |
| FILE_TYPE_NOT_ALLOWED | {label} must be one of: .mp4, .webm, .mov, .avi (the input's accepted formats) |
| FILE_UNREADABLE | The selected file is corrupt or could not be read |
| FILE_TOO_LARGE | {label} exceeds the maximum size of 100MB (the input's maxSizeMb) |
| FILE_COUNT_EXCEEDED | {label} does not accept multiple files |
09Launch checklist
- The
IOSAPI key comes from build configuration and is never logged. - Tokens live in the Keychain (in a shared access group if an extension uses them), and every refresh saves the new pair before it's used.
- Refresh goes through one actor, and 401 handling reads
error.codeto show the right sign-out message. - File inputs read
fileOptionsfrom the workflow instead of hardcoding limits, execute uses multipart, and file errors showerror.messageas-is. - Report runs use the realtime channel with a polling fallback that keeps going for at least 20 minutes.
- Credit packages are Consumable products. Transactions are finished only after
verify-purchasesucceeds, andTransaction.updatesandTransaction.unfinishedare handled from launch. - The paywall is driven by
canExecuteGatedWorkflow,plansandtrialOfferfrom the server, andserverTime, not the device clock. - Every execute call, from the app and the Safari extension, sends
X-App-Version. 409 PURCHASE_LINKED_TO_ANOTHER_ACCOUNTwithcanTransfer: trueoffers to move the plan, and only retries withtransferToCurrentAccountafter the user agrees.- In-app account deletion calls
DELETE /users/v1/account.