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.
Generate an API key from your app dashboard
Install an SDK or use any HTTP client
Start sending feedback and managing FAQs
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.
x-api-key: sdk_your_keyHow to get a key
- 1Open your app dashboard in the console
- 2Click Generate API key
- 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.ioAll endpoint paths below are relative to this base URL.
Endpoints
The API has two groups: Feedback for user submissions and FAQ for content management.
| Method | Path |
|---|---|
| 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 |
/api/v1/feedback/remoteSubmit user feedback from your app. Supports bug reports, feature requests, questions, and general feedback — with optional image attachments.
Request body
{
"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 }/api/v1/faqs/remoteReturns 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"
}
]/api/v1/faqs/remoteCreate a new FAQ entry for your app.
Request body
{
"question": "How do I export data?",
"answer": "Go to Settings > Export.",
"sortOrder": 0 // optional
}Response
201 { "id": "uuid", "question": "...", "answer": "...", "sortOrder": 0 }/api/v1/faqs/remote/{faqId}Update any fields on an existing FAQ entry. All fields are optional.
Request body
{
"question": "Updated question?", // optional
"answer": "Updated answer.", // optional
"sortOrder": 1 // optional
}Response
200 { "id": "uuid", "question": "...", "answer": "...", "sortOrder": 1 }/api/v1/faqs/remote/{faqId}Permanently delete a FAQ entry.
Response
200 { "success": true }/api/v1/changelog/remotePublished 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.
Install
npm install @bangkeut-technology/supportdock-sdkUsage
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)
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
composer require bangkeut-technology/supportdock-sdkUsage
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 -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..."]
}'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)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()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'},
}),
);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 https://supportdock.io/api/v1/faqs/remote \
-H "x-api-key: sdk_your_key"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.
/api/v1/testing/sdk/sessionx-api-keyReport 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
{
"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
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 -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"
}'// 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.
/api/v1/testing/sdk/session?testKey=...x-api-keyHas 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
{
"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 "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.
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_tokenCreate the token under Console → Settings → Agent access. It is shown once, reads only your own apps' feedback, and can be revoked at any time.
/api/v1/agent/feedback/:idBearer · agent tokenOne feedback thread. :id is the last segment of the console link. Image and attachment URLs are absolute so the agent can download them.
Response
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
}/api/v1/agent/feedback/:id/proposalBearer · agent tokenPropose 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
{
"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
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.
/api/v1/agent/runs/:runIdBearer · agent tokenPoll for your decision. Proceed only on approved — and follow decisionNote where it differs from the plan. Stop on rejected.
Response
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
Open the system browser at /tavern/authorize
Generate a high-entropy
verifier, keep it in memory, and send only its SHA-256 as thechallenge. - 2
The person signs in to SupportDock
Google, GitHub, Apple or a password — whatever they already use. Then they approve this device.
- 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
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
Use the token for 90 days
Authorization: Bearer sdt_…on every tester endpoint.
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…/api/v1/testing/auth/exchangeOne-time code + verifierTrades 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
{
"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
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 -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
| Status | Code |
|---|---|
400 | bad_request |
400 | invalid_code |
403 | integrity_failed |
409 | device_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 {
"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.
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.
Authorization: Bearer sdt_your_tokenTokens come from the browser sign-in flow — see Tavern sign-in — and expire after 90 days.
/api/v1/testing/me/tasksBearer · testing:readToday'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
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.
/api/v1/testing/me/slotsBearer · testing:readEvery 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
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 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 -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 -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".
422 {
"error": "This quest runs for 14 days and day 16 is outside that window.",
"code": "out_of_window",
"dayIndex": 16
}| Status | Code |
|---|---|
422 | out_of_window |
422 | cycle_not_running |
409 | already_submitted |
403 | slot_not_active |
400 | no_images |
400 | bad_image |
429 | rate_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
| Status | Meaning |
|---|---|
201 | Created successfully |
200 | Success (GET, PATCH, DELETE) |
400 | Validation error |
401 | Missing or invalid API key |
403 | Forbidden, or feedback is turned off for the app |
404 | Resource not found |
429 | Rate limited — wait and retry |
Rate-limited responses include a Retry-After header with the number of seconds to wait.
Works with any platform
Frequently asked questions
Start building
Generate your API key and start sending feedback in under a minute.