Getting started
You can be sending your first report in about five minutes.
- Sign in with Google to create your account.
- Create a project and choose your platform.
- Copy your Project ID and publishable key.
- Add the SDK or call the REST API.
- Send a test report.
- See it in your dashboard inbox.
Authentication
Two kinds of project keys exist with completely different permissions.
| Key | Header | Use it for |
|---|---|---|
pk_live_… | X-Project-Key | Submit feedback. Safe to embed in apps and websites. |
sk_live_… | Authorization: Bearer | Read project data from your backend. Never ship it to a client. |
Create a project
Each project has an internal ID (never exposed) and a public Project ID (prj_…). A publishable key is generated automatically.
API keys
Create, name, rotate and revoke keys from the Integrate tab. Secret keys are shown once and only their hash is stored. Publishable keys can be rotated with no downtime.
REST API
All endpoints are versioned under /v1, use JSON over HTTPS (UTF-8) and return a consistent success/error envelope.
POST /v1/feedback | Submit a report (publishable key) |
POST /v1/projects/{project_id}/feedback | Submit a report (publishable key) |
POST /v1/feedback/{id}/attachments | Upload an attachment (token from submit response) |
GET /v1/config | Public SDK configuration (publishable key) |
GET /v1/projects/{project_id} | Project details (secret key, server only) |
GET /v1/projects/{project_id}/feedback | List reports, cursor-paginated (secret key, server only) |
curl -X POST https://relay-wishkit.reza-rzny.workers.dev/v1/feedback \
-H "Content-Type: application/json" \
-H "X-Project-Key: pk_live_YOUR_PUBLISHABLE_KEY" \
-d '{
"type": "bug",
"title": "Login button does not work",
"message": "Nothing happens when I tap the login button.",
"test": true,
"context": { "platform": "ios", "app_version": "4.2.0", "os": "iOS", "os_version": "26.0", "locale": "en-US" },
"metadata": { "screen": "login" }
}'
# 201 → { "success": true, "data": { "id": "fbk_…", "status": "new", "created_at": "…" } }// Response envelope — always the same shape
{ "success": true, "data": { } }
{ "success": false, "error": { "code": "PROJECT_QUOTA_EXCEEDED", "message": "…", "request_id": "…" } }
// Request fields (unknown fields are rejected)
type "bug" | "feature_request" | "feedback" | "issue" (default "feedback")
message string, 1–5000 chars (required)
title string, ≤160 chars
user { id?: string ≤128, name?: string ≤80, email?: string }
installation_id "install_…" random, resettable
context { platform, app_version, build, os, os_version, device, device_type, locale,
language, timezone, sdk_version, browser, browser_version, viewport{width,height}, page_url }
metadata ≤20 keys; key [A-Za-z0-9_.-]{1,40}; value string ≤200 | number | boolean; ≤4 KB total
test boolean — marks the report as test dataSwift / SwiftUI
The Swift examples call the same REST API as every other language.
Official Swift Package
Install RelaySDK 1.3.0+ with Swift Package Manager. It includes a unified tabbed UI for feedback submission, developer replies, public board, voting and changelog.
github.com/reza-rezaeian/RelaySDK// Step 1 — Xcode ▸ File ▸ Add Package Dependencies
// https://github.com/reza-rezaeian/RelaySDK.git (version 1.3.0+)
// Step 2 — Configure once at launch
import SwiftUI
import RelaySDK
@main
struct MyApp: App {
init() {
Relay.configure(
projectKey: "pk_live_YOUR_PUBLISHABLE_KEY",
baseURL: URL(string: "https://relay-wishkit.reza-rzny.workers.dev")!
)
}
var body: some Scene { WindowGroup { ContentView() } }
}
// Step 3 — Show the complete customer center.
// Feedback opens first; Messages, Public Board and What's New appear at the top.
struct ContentView: View {
@State private var showFeedback = false
var body: some View {
Button("Send feedback") { showFeedback = true }
.sheet(isPresented: $showFeedback) { RelayFeedbackCenterView() }
}
}
// Optional direct submission
Task { try await Relay.submit(type: .feedback, message: "Hello from SwiftUI") }import RelaySDK
Relay.configure(projectKey: "pk_live_YOUR_PUBLISHABLE_KEY", baseURL: URL(string: "https://relay-wishkit.reza-rzny.workers.dev")!)
// Submit directly
Task {
try await Relay.submit(type: .bug, title: "Login button does not work",
message: "Nothing happens when I tap login.")
}
let board = try await Relay.board()
let changelog = try await Relay.changelog()
let messages = try await Relay.refreshAllThreads()
try await Relay.setVote(feedbackId: board.items[0].id, voted: true)
// Ready-made SwiftUI views:
// RelayFeedbackCenterView, RelayFeedbackView, RelayMyMessagesView,
// RelayBoardView and RelayChangelogViewJavaScript
The JavaScript examples call the same REST API as every other language.
<script src="https://relay-wishkit.reza-rzny.workers.dev/sdk/feedbackkit.js"></script>
<script>
FeedbackKit.init({ projectKey: "pk_live_YOUR_PUBLISHABLE_KEY" /*, locale: "nl" */ });
// Open the built-in dialog (en, ar, fa, nl, fr, es — RTL aware)
document.querySelector("#feedback-btn").addEventListener("click", () => FeedbackKit.open());
// …or submit directly
FeedbackKit.submit({ type: "bug", message: "Checkout button is not responding." });
</script>Kotlin
// Dependency-free example using HttpURLConnection (run off the main thread).
import java.net.HttpURLConnection
import java.net.URL
fun sendFeedback(message: String) {
val body = """{"type":"bug","message":"""$message""","context":{"platform":"android","sdk_version":"rest-1"}}"""
val c = URL("https://relay-wishkit.reza-rzny.workers.dev/v1/feedback").openConnection() as HttpURLConnection
c.requestMethod = "POST"
c.setRequestProperty("Content-Type", "application/json")
c.setRequestProperty("X-Project-Key", "pk_live_YOUR_PUBLISHABLE_KEY")
c.doOutput = true
c.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
check(c.responseCode == 201) { "Feedback failed: ${c.responseCode}" }
}Flutter
// pubspec.yaml → dependencies: http: ^1.2.0
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> sendFeedback(String message) async {
final res = await http.post(
Uri.parse('https://relay-wishkit.reza-rzny.workers.dev/v1/feedback'),
headers: {'Content-Type': 'application/json', 'X-Project-Key': 'pk_live_YOUR_PUBLISHABLE_KEY'},
body: jsonEncode({'type': 'feedback', 'message': message, 'context': {'platform': 'flutter'}}),
);
if (res.statusCode != 201) throw Exception('Feedback failed: ${res.statusCode}');
}Python
# Server-side example. Reading feedback needs a SECRET key — keep it in an environment variable.
import os, requests
r = requests.post("https://relay-wishkit.reza-rzny.workers.dev/v1/feedback",
headers={"X-Project-Key": "pk_live_YOUR_PUBLISHABLE_KEY"},
json={"type": "bug", "message": "Something went wrong.", "context": {"platform": "backend"}},
timeout=10)
r.raise_for_status()
# List reports (secret key, server only)
res = requests.get("https://relay-wishkit.reza-rzny.workers.dev/v1/projects/prj_YOUR_PROJECT_ID/feedback?status=new",
headers={"Authorization": "Bearer " + os.environ["SUGKIT_SECRET_KEY"]}, timeout=10)PHP
<?php
$ch = curl_init("https://relay-wishkit.reza-rzny.workers.dev/v1/feedback");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "X-Project-Key: pk_live_YOUR_PUBLISHABLE_KEY"],
CURLOPT_POSTFIELDS => json_encode(["type" => "bug", "message" => "Something went wrong."]),
]);
$response = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 201) { error_log("Feedback failed"); }C#
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://relay-wishkit.reza-rzny.workers.dev") };
http.DefaultRequestHeaders.Add("X-Project-Key", "pk_live_YOUR_PUBLISHABLE_KEY");
var res = await http.PostAsJsonAsync("/v1/feedback", new {
type = "bug",
message = "Something went wrong.",
context = new { platform = "dotnet" }
});
res.EnsureSuccessStatusCode(); // 201 CreatedErrors
Errors always contain a machine-readable code. Never parse the message.
AUTH_REQUIRED | 401 |
ACCESS_DENIED | 403 |
CSRF_FAILED | 403 |
ORIGIN_NOT_ALLOWED | 403 |
ACCOUNT_SUSPENDED | 403 |
VALIDATION_FAILED | 400 |
INVALID_JSON | 400 |
PAYLOAD_TOO_LARGE | 413 |
UNSUPPORTED_MEDIA_TYPE | 415 |
NOT_FOUND | 404 |
PROJECT_NOT_FOUND | 404 |
FEEDBACK_NOT_FOUND | 404 |
PROJECT_LIMIT_REACHED | 403 |
PROJECT_QUOTA_EXCEEDED | 429 |
STORAGE_QUOTA_EXCEEDED | 403 |
INVALID_API_KEY | 401 |
INSUFFICIENT_SCOPE | 403 |
SECRET_KEY_IN_BROWSER | 403 |
RATE_LIMITED | 429 |
PLAN_REQUIRED | 402 |
BILLING_UNAVAILABLE | 503 |
TURNSTILE_FAILED | 403 |
FEATURE_DISABLED | 403 |
INVALID_UPLOAD_TOKEN | 401 |
UNSUPPORTED_FILE_TYPE | 415 |
KEY_LIMIT_REACHED | 403 |
METHOD_NOT_ALLOWED | 405 |
INTERNAL_ERROR | 500 |
Rate limits
Limits apply per IP, API key and project. When exceeded you receive HTTP 429 with a Retry-After header. Back off and retry.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
{ "success": false, "error": { "code": "RATE_LIMITED", "message": "…" } }Security best practices
- Never embed secret keys (sk_…) in apps, websites or public repositories.
- Treat publishable keys as public: rely on quotas, allowed origins and Turnstile, not secrecy.
- Identify users with your own opaque ID instead of names or emails.
- Rotate keys if you suspect misuse; revoked keys stop working immediately.
- Validate webhooks and never log API keys.
- Use allowed origins for websites; native apps have no trustworthy Origin.
Privacy & data collection
SDKs collect only what is needed to debug. You control every category per project and the server discards anything disabled.
| Field | Why it is collected |
|---|---|
platform, os, os_version | Reproduce platform-specific bugs. |
app_version, build | Know which release a bug affects. |
device, device_type | Hardware-specific issues (off by default). |
locale, language, timezone | Localisation and date/time issues. |
sdk_version | Diagnose SDK versions. |
browser, viewport, page_url (no query string) | Reproduce web issues. Query strings and fragments are removed. |
user.id / name / email (only if YOU send them) | Only if your app sends them; optional. |
installation_id (random, resettable) | Group reports from one installation without personal data. |
We never collect contacts, precise location, advertising identifiers, passwords, tokens, cookies, clipboard contents or files. Screenshots are only sent when the end user chooses to attach them.
Reports are kept according to your plan's retention period. You can delete a report, a project or your account data at any time.