Skip to content
Free with your first app

Developer API

Submit feedback, manage FAQs, and control your support page directly from your mobile app or CI/CD pipeline. One API key per app.

Quick start

Send your first feedback in under a minute.

1

Generate an API key from your app dashboard

2

Install an SDK or use any HTTP client

3

Start sending feedback and managing FAQs

cURL
curl -X POST https://supportdock.io/api/v1/feedback/remote \
  -H "Content-Type: application/json" \
  -H "x-api-key: sdk_your_key" \
  -d '{"type":"bug","message":"App crashes on launch"}'

Authentication

All requests require a per-app API key in the x-api-key header. Keys start with sdk_ and are scoped to a single app.

Header
x-api-key: sdk_your_key

How to get a key

  1. 1Open your app dashboard in the console
  2. 2Click Generate API key
  3. 3Copy the key and add it to your app's configuration

Keys can be regenerated or revoked at any time from the app dashboard. For mobile apps the key can be embedded safely since it is scoped to one app. For web apps, call the API from your server/backend only.

Base URL

https://supportdock.io

All endpoint paths below are relative to this base URL.

Endpoints

The API has two groups: Feedback for user submissions and FAQ for content management.

MethodPath
POST/api/v1/feedback/remote
GET/api/v1/faqs/remote
POST/api/v1/faqs/remote
PATCH/api/v1/faqs/remote/{faqId}
DELETE/api/v1/faqs/remote/{faqId}
GET/api/v1/changelog/remote
POST/api/v1/feedback/remote

Submit user feedback from your app. Supports bug reports, feature requests, questions, and general feedback — with optional image attachments.

Request body

json
{
  "type": "bug",                    // "bug" | "feature" | "question" | "general"
  "message": "App crashes on launch",
  "email": "user@example.com",     // optional
  "name": "Jane Doe",              // optional
  "subject": "Crash on iOS 18",    // optional — auto-generated if omitted
  "metadata": {                    // optional — any key-value pairs
    "appVersion": "2.1.0",
    "platform": "ios"
  },
  "source": "mobile-app",          // optional
  "images": [                      // optional — max 3 base64 data URLs
    "data:image/png;base64,..."    // PNG, JPEG, WebP, or GIF — each ≤ 2 MB
  ],
  "videos": [                      // optional — max 2 base64 data URLs
    "data:video/mp4;base64,..."    // MP4, WebM, or QuickTime — each ≤ 10 MB
  ],
  "attachments": [                 // optional — max 3 PDF files
    { "name": "logs.pdf",          // each PDF ≤ 5 MB
      "data": "data:application/pdf;base64,..." }
  ]
}

Response

201  { "success": true }
GET/api/v1/faqs/remote

Returns all FAQ entries for the app, ordered by sort order.

Response

200  [
  {
    "id": "uuid",
    "question": "How do I reset my password?",
    "answer": "Go to Settings > Account > Reset Password",
    "sortOrder": 0,
    "createdAt": "2026-01-15T10:00:00.000Z",
    "updatedAt": "2026-01-15T10:00:00.000Z"
  }
]
POST/api/v1/faqs/remote

Create a new FAQ entry for your app.

Request body

json
{
  "question": "How do I export data?",
  "answer": "Go to Settings > Export.",
  "sortOrder": 0    // optional
}

Response

201  { "id": "uuid", "question": "...", "answer": "...", "sortOrder": 0 }
PATCH/api/v1/faqs/remote/{faqId}

Update any fields on an existing FAQ entry. All fields are optional.

Request body

json
{
  "question": "Updated question?",   // optional
  "answer": "Updated answer.",       // optional
  "sortOrder": 1                     // optional
}

Response

200  { "id": "uuid", "question": "...", "answer": "...", "sortOrder": 1 }
DELETE/api/v1/faqs/remote/{faqId}

Permanently delete a FAQ entry.

Response

200  { "success": true }
GET/api/v1/changelog/remote

Published release notes, newest first — use it to show a "what's new" sheet after an update. Pass ?since=<version> to get only what the user has not seen yet, and ?limit=<n> to cap the list (default 20, max 50). Requires the changelog to be enabled for the app.

Response

200  {
  "entries": [
    {
      "id": "uuid",
      "title": "Version 2.1.0",
      "version": "2.1.0",
      "versions": [ { "platform": "ios", "version": "2.1.0" } ],
      "releaseType": "minor",     // major | minor | patch
      "bodyFormat": "markdown",   // markdown | items
      "body": "### Added\n- Dark mode",
      "items": [ { "type": "added", "text": "Dark mode" } ],
      "publishedAt": "2026-01-15T10:00:00.000Z"
    }
  ]
}

Official SDKs

Drop-in SDKs that handle authentication, error handling, retries, and rate limits for you.

JavaScript / React Native

Install

Terminal
npm install @bangkeut-technology/supportdock-sdk

Usage

typescript
import { SupportDockClient } from '@bangkeut-technology/supportdock-sdk';

const sdk = new SupportDockClient({
  apiKey: 'sdk_your_key',
  defaultMetadata: { appVersion: '2.0.0' },
});

// Submit feedback
await sdk.sendFeedback({
  type: 'bug',
  message: 'App crashes on launch',
  images: ['data:image/png;base64,...'],  // optional — up to 3
  videos: ['data:video/mp4;base64,...'],  // optional — up to 2
});

// Manage FAQs
const faqs = await sdk.listFAQs();
await sdk.createFAQ({ question: 'How to export?', answer: 'Go to Settings > Export.' });
await sdk.updateFAQ(faqs[0].id, { answer: 'Updated answer.' });
await sdk.deleteFAQ(faqs[0].id);

React hook (Expo / React Native)

typescript
import { useSupportDock } from '@bangkeut-technology/supportdock-sdk';
import { Platform } from 'react-native';

function FeedbackScreen() {
  const { sendFeedback, loading, error, success, reset } = useSupportDock({
    apiKey: 'sdk_your_key',
    defaultMetadata: { appVersion: '2.0.0', platform: Platform.OS },
  });

  async function handleSubmit() {
    await sendFeedback({
      type: 'bug',
      message: 'Something went wrong',
      email: 'user@example.com',
    });
  }

  // loading, error, success states are managed for you
}

Install

Terminal
composer require bangkeut-technology/supportdock-sdk

Usage

php
use SupportDock\SupportDockClient;

$client = new SupportDockClient([
    'apiKey' => 'sdk_your_key',
    'defaultMetadata' => ['appVersion' => '2.0.0'],
]);

// Submit feedback
$client->sendFeedback([
    'type' => 'bug',
    'message' => 'App crashes on launch',
    'email' => 'user@example.com',
    'images' => ['data:image/png;base64,...'],  // optional — up to 3
]);

// Manage FAQs
$faqs = $client->listFAQs();
$client->createFAQ(['question' => 'How to export?', 'answer' => 'Go to Settings > Export.']);
$client->updateFAQ($faqs[0]['id'], ['answer' => 'Updated answer.']);
$client->deleteFAQ($faqs[0]['id']);

Examples

Works with any language or framework that can make HTTP requests.

cURL — Submit feedback with an image
curl -X POST https://supportdock.io/api/v1/feedback/remote \
  -H "Content-Type: application/json" \
  -H "x-api-key: sdk_your_key" \
  -d '{
    "type": "bug",
    "message": "App crashes on launch",
    "email": "user@example.com",
    "metadata": { "appVersion": "2.1.0", "platform": "ios" },
    "images": ["data:image/png;base64,iVBOR..."]
  }'
Swift (iOS)
var request = URLRequest(url: URL(string: "https://supportdock.io/api/v1/feedback/remote")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("sdk_your_key", forHTTPHeaderField: "x-api-key")

let body: [String: Any] = [
    "type": "bug",
    "message": "App crashes on launch",
    "metadata": ["appVersion": "2.1.0", "platform": "ios"]
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)

let (data, _) = try await URLSession.shared.data(for: request)
Kotlin (Android)
val client = OkHttpClient()
val json = JSONObject().apply {
    put("type", "bug")
    put("message", "App crashes on launch")
    put("metadata", JSONObject().apply {
        put("appVersion", "2.1.0")
        put("platform", "android")
    })
}

val request = Request.Builder()
    .url("https://supportdock.io/api/v1/feedback/remote")
    .addHeader("x-api-key", "sdk_your_key")
    .post(json.toString().toRequestBody("application/json".toMediaType()))
    .build()

val response = client.newCall(request).execute()
Dart (Flutter)
import 'dart:convert';
import 'package:http/http.dart' as http;

final response = await http.post(
  Uri.parse('https://supportdock.io/api/v1/feedback/remote'),
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': 'sdk_your_key',
  },
  body: jsonEncode({
    'type': 'bug',
    'message': 'App crashes on launch',
    'metadata': {'appVersion': '2.1.0', 'platform': 'ios'},
  }),
);
Python
import requests

response = requests.post(
    "https://supportdock.io/api/v1/feedback/remote",
    headers={"x-api-key": "sdk_your_key"},
    json={
        "type": "bug",
        "message": "App crashes on launch",
        "metadata": {"appVersion": "2.1.0", "platform": "ios"},
    },
)
cURL — List FAQs
curl https://supportdock.io/api/v1/faqs/remote \
  -H "x-api-key: sdk_your_key"
cURL — Create FAQ
curl -X POST https://supportdock.io/api/v1/faqs/remote \
  -H "Content-Type: application/json" \
  -H "x-api-key: sdk_your_key" \
  -d '{"question":"How do I export data?","answer":"Go to Settings > Export."}'

Tavern — closed testing

Tavern is SupportDock's closed-testing exchange: developers test each other's apps through Google Play's 12-tester, 14-day requirement, and every day of testing produces evidence that was checked server-side.

Two integrations touch this API. The SDK inside the app being tested reports sessions, which is the endpoint below. The tester's client reads its assignments with a scoped bearer token — see tester client.

What this proves

That a session of real length reached real screens, on the day it is credited to, and that whoever ran it held that seat's key. That is the whole claim. It is not a prediction about Google: nothing in this API makes an application more likely to be approved, and you should not tell your users otherwise.

POST/api/v1/testing/sdk/sessionx-api-key

Report one finished session from inside the app under test. Uses the same per-app API key as the feedback endpoints — there is no SupportDock session inside someone else's app, so the tester is identified by the test key in the payload instead.

Request body

json
{
  "testKey": "b6f1…c2a.Qm9vaw",   // from the tester's app — treat as a secret
  "startedAt": "2026-09-18T09:14:22+07:00",  // ISO 8601 with an offset
  "endedAt": "2026-09-18T09:16:48+07:00",
  "screens": ["home", "library", "settings"], // distinct names, max 50
  "appVersion": "2.1.0"                       // optional
}

Response

json
200  {
  "matched": true,      // false = not a tester, see below
  "counted": true,      // did this session count the day
  "dayIndex": 6,        // which day of the test it counted for
  "reason": null        // set when counted is false
}

matched: false is normal, not an error

Most sessions you send belong to ordinary users of your app, not to testers, and those come back { matched: false, counted: false } with a 200. Never surface it, never retry it, and never treat it as a failure — your telemetry would become a stream of errors for the majority of your users. The response also says nothing about whether an unrecognised key was real, so the seat list cannot be probed.

The qualifying bar: 45 seconds, 2 distinct screens

A launch-and-close is exactly what Google's engagement review rejects, so it does not count a day here either. Sessions under the bar come back counted: false with reason: "session_below_bar". They are recorded and are not held against the tester — they can simply use the app properly later the same day. The numbers stay server-side so they can move when the rules do.

testKey is a bearer credential

It comes from the tester's client, not from you. Keep it in secure storage, never in a log line, an analytics event or a crash report, and re-read it rather than caching it forever — keys rotate. It is an opaque signature over a seat: it tells you nothing about the tester, which is deliberate.

A day counts once per seat

Re-sending sessions does not stack. Once a day is counted, further sessions that day come back counted: false with reason: "already_submitted". Which day a session belongs to is decided server-side against the test's own clock, so a session uploaded late lands on the day it happened, not the day it arrived.

cURL — report a session
curl -X POST https://supportdock.io/api/v1/testing/sdk/session \
  -H "Content-Type: application/json" \
  -H "x-api-key: sdk_your_key" \
  -d '{
    "testKey": "b6f1...c2a.Qm9vaw",
    "startedAt": "2026-09-18T09:14:22+07:00",
    "endedAt": "2026-09-18T09:16:48+07:00",
    "screens": ["home", "library", "settings"],
    "appVersion": "2.1.0"
  }'
TypeScript — the shape to copy
// Call this when a session ends — on background, or on your own timer.
async function reportSession(testKey: string, session: Session) {
  const res = await fetch('https://supportdock.io/api/v1/testing/sdk/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
    body: JSON.stringify({
      testKey,
      startedAt: session.startedAt.toISOString(),
      endedAt: session.endedAt.toISOString(),
      screens: session.screensReached,
      appVersion: APP_VERSION,
    }),
  });

  const result = await res.json();

  // matched: false is the normal case — this user is not one of your testers.
  // Do not show it, do not retry it, do not log it as an error.
  if (!result.matched) return;

  if (result.counted) showToast(`Day ${result.dayIndex} recorded`);
}

Reason codes on counted: false

  • session_below_barShorter than 45s, or fewer than 2 distinct screens.
  • already_submittedThat day is already counted for this seat.
  • cycle_not_runningThe test has not started, or has finished.
  • slot_not_activeThe seat is no longer active.
  • session_staleThe session is from a day the server cannot vouch for.
  • session_in_futureThe session start is ahead of the server clock.
GET/api/v1/testing/sdk/session?testKey=...x-api-key

Has today counted yet? Call it when the app returns to the foreground — reporting a session tells you the outcome once, and that answer is lost if the app is killed or offline as the session ends. The day is recorded either way; this is how the tester finds out.

Response

json
{
  "matched": true,
  "dayIndex": 6,
  "durationDays": 14,
  "countedToday": true,
  "daysCounted": 6,
  "dueBy": "2026-09-19T09:00:00.000Z"
}

It only ever answers about one seat

It reads the seat whose key you hold and nothing else. It never reveals who the tester is, or how anyone else on the test is doing.

cURL — check today
curl "https://supportdock.io/api/v1/testing/sdk/session?testKey=$TEST_KEY" \
  -H "x-api-key: $SUPPORTDOCK_API_KEY"

Agent API — Claude Code

Paste a feedback link into Claude Code and let it investigate. An agent reads the thread — message, details, screenshots, replies and notes — then posts a summary and plan. Nothing changes until you approve that plan on the thread in your console.

Install the Claude Code skill
mkdir -p ~/.claude/skills/supportdock-feedback && \
  curl -fsSL https://supportdock.io/skills/supportdock-feedback/SKILL.md \
  -o ~/.claude/skills/supportdock-feedback/SKILL.md
export SUPPORTDOCK_AGENT_TOKEN=sd_agent_your_token

Create the token under Console → Settings → Agent access. It is shown once, reads only your own apps' feedback, and can be revoked at any time.

GET/api/v1/agent/feedback/:idBearer · agent token

One feedback thread. :id is the last segment of the console link. Image and attachment URLs are absolute so the agent can download them.

Response

json
200  {
  "app": { "id": "uuid", "name": "Habit Tracker" },
  "feedback": {
    "id": "uuid",
    "consoleUrl": "https://console.supportdock.io/feedback/<appId>/<id>",
    "subject": "Crash on iOS 18",
    "message": "App crashes when I open Statistics",
    "category": "bug",
    "status": "open",
    "metadata": { "appVersion": "2.1.0", "platform": "ios" },
    "images": ["https://supportdock.io/uploads/…png"],
    "attachments": [],
    "githubIssueUrl": "https://github.com/acme/habits/issues/42"
  },
  "messages": [ { "direction": "note", "body": "…", "createdAt": "…" } ],
  "latestProposal": null
}
POST/api/v1/agent/feedback/:id/proposalBearer · agent token

Propose what to change. Posted to the thread as an internal note and to the app's Telegram chat, and held as awaiting_approval.

Request body

json
{
  "summary": "Statistics crashes when a habit has no entries — division by zero in streak.ts.",
  "plan": "Guard the empty case, add a test, open a PR. No release."
}

Response

json
201  { "runId": "uuid", "status": "awaiting_approval", "pollUrl": "/api/v1/agent/runs/uuid" }

The agent's only write

An agent token cannot reply to the sender, archive a thread or change its status. A newer proposal replaces one still waiting, and nothing is ever sent to the person who wrote the feedback.

GET/api/v1/agent/runs/:runIdBearer · agent token

Poll for your decision. Proceed only on approved — and follow decisionNote where it differs from the plan. Stop on rejected.

Response

json
200  {
  "id": "uuid",
  "status": "approved",         // awaiting_approval | approved | rejected
  "decisionNote": "Also cover the weekly view",
  "decidedAt": "2026-10-02T08:14:00.000Z"
}

Tavern — sign in

Tavern sign-in is SupportDock sign-in. The tester app has no password field of its own. Whatever someone already uses on supportdock.io — Google, GitHub, Apple or a password — signs them in here, which is the entire reason for the browser round-trip below. It is OAuth-style authorization with PKCE, so if you have done that before, none of this will surprise you.

  1. 1

    Open the system browser at /tavern/authorize

    Generate a high-entropy verifier, keep it in memory, and send only its SHA-256 as the challenge.

  2. 2

    The person signs in to SupportDock

    Google, GitHub, Apple or a password — whatever they already use. Then they approve this device.

  3. 3

    The browser returns to your deep link

    Carrying ?code=…, which is single-use, lives 5 minutes, and is bound to the device that asked for it.

  4. 4

    Exchange the code for a token

    Over HTTPS, with the verifier. The token never travels in a redirect URL, where it could be logged.

  5. 5

    Use the token for 90 days

    Authorization: Bearer sdt_… on every tester endpoint.

The authorize URL
https://supportdock.io/tavern/authorize
  ?device=<deviceId>            # stable per install, yours to generate
  &name=<device name>           # shown on the approval screen: "Pixel 8"
  &challenge=<base64url(sha256(verifier))>
  &redirect=<allowlisted target> # your App Link, or supportdock-tavern:// / tavern://

# The browser comes back to your deep link with a one-time code:
tavern://auth?code=ac_7f1c9e4b…
POST/api/v1/testing/auth/exchangeOne-time code + verifier

Trades the code from the deep link for a scoped device token. Single-use: a replayed code is refused, so an intercepted redirect is worth nothing on its own.

Request body

json
{
  "code": "ac_7f1c9e4b…",       // from the deep link, single-use
  "verifier": "the string you hashed",
  "deviceId": "same device you sent to /authorize",
  "integrityToken": "…",        // Play Integrity, optional in dev
  "pushToken": "ExponentPushToken[…]",
  "timezone": "Asia/Phnom_Penh"
}

Response

json
201  {
  "token": "sdt_…",             // Bearer token, store it securely
  "expiresAt": "2026-12-17T09:00:00.000Z",
  "scopes": [
    "testing:read",
    "testing:profile:write",
    "testing:slots:write",
    "testing:proofs:write"
  ],
  "profile": { "timezone": "Asia/Phnom_Penh", "isTester": true }
}

The system browser, never an embedded web view

A web view lets the surrounding app watch what someone types into a sign-in form, which is exactly what this flow exists to prevent — and Google, GitHub and Apple all refuse to authenticate inside one. Use Custom Tabs on Android, not a WebView.

The verifier never leaves the device

Only its SHA-256 goes in the authorize URL. A code lifted from the redirect — off a shared log, a malicious app claiming the same scheme — cannot be exchanged without the original string, which only your process has. This is PKCE.

Approving the device does not hand over the account

The token carries testing:* scopes and nothing else. Revenue and billing endpoints require a SupportDock session, which a device token cannot produce, so a tester-only account is structurally unable to reach a paying customer's financial data — even if the device is lost.

One active account per device

A device already linked to another testing account is refused: "This device is already linked to another testing account." That is the anti-farming defence, not a bug — ten accounts on one handset is what would make "real humans, real devices" untrue. Re-linking the same account after a reinstall is fine.

Play Integrity runs here

Attestation is checked at exchange time, not at authorize time, because this is the request that mints a credential. An emulator or a tampered build is refused.

cURL — exchange the code
curl -X POST https://supportdock.io/api/v1/testing/auth/exchange \
  -H "Content-Type: application/json" \
  -d '{
    "code": "ac_7f1c9e4b…",
    "verifier": "the string you hashed",
    "deviceId": "a3f9…",
    "integrityToken": "…",
    "timezone": "Asia/Phnom_Penh"
  }'

Exchange failures

StatusCode
400bad_request
400invalid_code
403integrity_failed
409device_taken

invalid_code is deliberately vague

A code that is unknown, expired, already used, presented from a different device, or paired with a verifier that does not match the challenge all return the same code and the same message. That is on purpose, not an oversight we have yet to get to: naming which of the five it was would tell a caller what to change and try next, and the only caller who needs that is one guessing. Start the flow again.

Codes live 5 minutes and die on first use

Exchange immediately; do not stash a code to use later. Approving again invalidates any earlier outstanding code for that device, so if you opened the browser twice, only the code from the second round-trip works — a client that keeps the first one will fail with invalid_code and look, from the inside, like the server is wrong.

403 — carries the verdicts that failed
403  {
  "error": "…",
  "code": "integrity_failed",
  "reasons": ["device_integrity_failed"]   // or app_not_recognized
}

Redirect targets

The redirect you send to /tavern/authorize is checked against an allowlist. An open redirect here would hand live auth codes to whoever controls the URL, so arbitrary https:// targets are refused.

Allowed
supportdock-tavern://…     # custom scheme, allowlisted
tavern://…                 # custom scheme, allowlisted
https://…                  # exactly one, set as TAVERN_APP_LINK_REDIRECT

# Any other https:// target is refused before a code is ever minted:
400  { "code": "bad_redirect" }

Ship with an App Link, develop with the custom scheme

A custom scheme can be registered by any app on the device, so a hostile app can claim tavern:// and receive the redirect. PKCE is what makes that survivable — the code it catches is useless without the verifier that never left your process — but it is not what prevents it. An Android App Link is a real https:// URL verified against your signing certificate through assetlinks.json, so no other app can claim it. Use the App Link in the shipping client and keep the custom scheme for development.

Deprecated

POST /api/v1/testing/auth/register and POST /api/v1/testing/auth/token still work for accounts that have a password, and existing clients will keep running. Do not build anything new on them: they cannot sign in an account that uses Google, GitHub or Apple, which is most of them, and they put a password field in an app that has no business holding one.

Tavern — tester client

Endpoints for the app a tester holds. These authenticate with a scoped bearer token, not an app API key: the token carries testing:* scopes and nothing else, so a tester account can never reach revenue or billing data.

Header
Authorization: Bearer sdt_your_token

Tokens come from the browser sign-in flow — see Tavern sign-in — and expire after 90 days.

GET/api/v1/testing/me/tasksBearer · testing:read

Today's assignment for every active seat, with the screen the tester has to reach. Pass ?day=N to look back at an earlier day.

Response

json
200  {
  "tasks": [
    {
      "slotId": "uuid",
      "cycleId": "uuid",
      "listingId": "uuid",
      "appName": "Habit Tracker",
      "iconUrl": "https://…",
      "packageName": "com.acme.habits",
      "optInUrl": "https://play.google.com/apps/testing/com.acme.habits",
      "testBrief": "Demo login: demo@acme.io / test1234",
      "dayIndex": 6,              // server-computed — never compute this
      "durationDays": 14,
      "targetScreen": "Statistics", // today's screen for this seat
      "dueBy": "2026-09-19T02:14:00.000Z",
      "submitted": false,
      "slotStatus": "active",     // active | at_risk
      "missedDays": 0,
      "joinCheckPending": false,
      "verificationTier": "sdk",  // sdk | screenshot
      "testKey": "b6f1…c2a.Qm9vaw" // sdk tier only, else null
    }
  ],
  "outstanding": 1
}

The client never computes dayIndex or targetScreen

Both come from the server. A day index worked out on the device drifts with the device clock — which the user can set — and a locally chosen target screen would let yesterday's screenshot through. Render what you are given.

verificationTier steers the UI, it does not restrict the tester

On sdk you can tell the tester to simply use the app and hand testKey to that app's SDK. On screenshot you ask for the target screen. Keep the screenshot path reachable on both tiers — an SDK session that never fires, because the tester is on an old build or the app crashed, must not leave them unable to prove their day.

GET/api/v1/testing/me/slotsBearer · testing:read

Every seat the caller holds, running or finished, with standing next to the bad news — a tester who can see how many days they have missed can still act on it.

Response

json
200  {
  "slots": [
    {
      "slotId": "uuid",
      "cycleId": "uuid",
      "status": "active",
      "app": {
        "name": "Habit Tracker",
        "iconUrl": "https://…",
        "packageName": "com.acme.habits",
        "optInUrl": "https://play.google.com/apps/testing/com.acme.habits"
      },
      "dayIndex": 6,
      "durationDays": 14,
      "cycleStatus": "running",
      "missedDays": 0,
      "lastProofDayIndex": 5,
      "joinCheckPending": false,
      "requestExpiresAt": null,
      "verificationTier": "sdk",
      "testKey": "b6f1…c2a.Qm9vaw"
    }
  ]
}

Keys are minted only where they can be used

This list includes finished and removed seats. testKey is present only on a seat that can still earn a day, so fewer live credentials exist for the same ability.

cURL — read today's tasks
curl https://supportdock.io/api/v1/testing/me/tasks \
  -H "Authorization: Bearer sdt_your_token"

# Look back at a day you missed (the day lock still refuses a submission for it)
curl "https://supportdock.io/api/v1/testing/me/tasks?day=4" \
  -H "Authorization: Bearer sdt_your_token"
cURL — submit a screenshot proof
curl -X POST https://supportdock.io/api/v1/testing/proofs \
  -H "Authorization: Bearer sdt_your_token" \
  -H "Idempotency-Key: 7c9e-4f11-a0b2" \
  -F "slotId=uuid" \
  -F "capturedAt=2026-09-18T23:50:12+07:00" \
  -F "queuedAt=2026-09-18T23:50:20+07:00" \
  -F "note=Stats screen took a moment to load" \
  -F "images=@day6-stats.png"

Tavern conventions

These apply to every Tavern endpoint. They matter most on the connection a tester actually has, not the one you develop on.

Idempotency

Every mutating endpoint accepts an Idempotency-Key header and replays the stored response instead of acting twice. Send one on every write: retries on flaky mobile data are the norm, not the exception, and without a key three taps in a basement are three requests.

cURL
curl -X POST https://supportdock.io/api/v1/testing/slots/{slotId}/request \
  -H "Authorization: Bearer sdt_your_token" \
  -H "Idempotency-Key: 7c9e-4f11-a0b2"

# Replay the same key and you get the stored response, not a second seat request.
# Replays are marked with an Idempotent-Replay: true header.

Typed failures on proof submission

Day-lock and window failures return 422, not 400: the request was well formed, it just arrived for a day that cannot accept it. Branch on code and show the tester what actually happened — "you are on day 7, this is day 6's evidence" is a very different message from "upload failed".

json
422  {
  "error": "This quest runs for 14 days and day 16 is outside that window.",
  "code": "out_of_window",
  "dayIndex": 16
}
StatusCode
422out_of_window
422cycle_not_running
409already_submitted
403slot_not_active
400no_images
400bad_image
429rate_limited

Rate limits

  • Proof uploads — capped per seat and per account per hour. A tester holding sixteen seats submits sixteen proofs in a sitting, so the per-account ceiling sits well above ordinary use and only bounds a loop.
  • SDK sessions — 60 per test key per hour. Keyed per key rather than per app, so one looping device cannot silence the other eleven testers on your listing.

Rate-limited responses carry a Retry-After header in seconds. Respect it rather than backing off on your own schedule.

Error codes

StatusMeaning
201Created successfully
200Success (GET, PATCH, DELETE)
400Validation error
401Missing or invalid API key
403Forbidden, or feedback is turned off for the app
404Resource not found
429Rate limited — wait and retry

Rate-limited responses include a Retry-After header with the number of seconds to wait.

Works with any platform

Swift / iOS
Kotlin / Android
Flutter / Dart
React Native
PHP
Node.js
Python
Any HTTP client

Frequently asked questions

Start building

Generate your API key and start sending feedback in under a minute.