Skip to content

In-App Integration

This guide covers two ways to collect feedback from your users and route it to your Canviq board. Choose the approach that fits your UX:

Approach Best for Effort
Embedded web form Full board experience — voting, roadmap, auth ~1 hour
API submission Native in-app form, full control over UX ~2 hours

Prerequisites

  • Your Canviq org slug — find it in your board URL: https://{your-slug}.canviq.app/en
  • For API use: an org API key with submissions:write scope — generate one at Settings → API Keys

Option 1: Embedded Web Form

Embed your Canviq board directly in your app. Users get the full board experience — submitting ideas, voting, viewing the roadmap — without leaving your app.

This requires no backend code for basic use.

iOS (SFSafariViewController)

import SafariServices

let ORG_SLUG = "your-slug"  // replace with your board slug

// Open the full feedback board
func openFeedbackBoard() {
    let url = URL(string: "https://\(ORG_SLUG).canviq.app/en")!
    let safari = SFSafariViewController(url: url)
    present(safari, animated: true)
}

// Open with the submit form pre-focused
func openSubmitFeedback() {
    let url = URL(string: "https://\(ORG_SLUG).canviq.app/en/submit")!
    let safari = SFSafariViewController(url: url)
    present(safari, animated: true)
}

// Open the public roadmap
func openRoadmap() {
    let url = URL(string: "https://\(ORG_SLUG).canviq.app/en/roadmap")!
    let safari = SFSafariViewController(url: url)
    present(safari, animated: true)
}

!!! important "Use SFSafariViewController, not WKWebView" WKWebView does not share the Safari cookie jar. Users will not be able to sign in or have their session persist. Always use SFSafariViewController.

Android (Chrome Custom Tabs)

import androidx.browser.customtabs.CustomTabsIntent

val ORG_SLUG = "your-slug"  // replace with your board slug

// Open the full feedback board
fun openFeedbackBoard() {
    val url = Uri.parse("https://$ORG_SLUG.canviq.app/en")
    val intent = CustomTabsIntent.Builder().build()
    intent.launchUrl(this, url)
}

// Open with the submit form pre-focused
fun openSubmitFeedback() {
    val url = Uri.parse("https://$ORG_SLUG.canviq.app/en/submit")
    CustomTabsIntent.Builder().build().launchUrl(this, url)
}

// Open the public roadmap
fun openRoadmap() {
    val url = Uri.parse("https://$ORG_SLUG.canviq.app/en/roadmap")
    CustomTabsIntent.Builder().build().launchUrl(this, url)
}

Web (iframe)

<!-- Full board embed -->
<iframe
  src="https://{your-slug}.canviq.app/en"
  style="width: 100%; height: 600px; border: none;"
  title="Product feedback"
  loading="lazy"
/>

<!-- Submit form pre-opened -->
<iframe
  src="https://{your-slug}.canviq.app/en/submit"
  style="width: 100%; height: 600px; border: none;"
  title="Submit feedback"
/>

If your app uses a Content Security Policy, add *.canviq.app to your frame-src directive:

Content-Security-Policy: frame-src https://*.canviq.app;

Control which screen opens by navigating to the appropriate path:

URL Opens
https://{your-slug}.canviq.app/en Default board (submission list)
https://{your-slug}.canviq.app/en/submit Submission form
https://{your-slug}.canviq.app/en/roadmap Public roadmap
https://{your-slug}.canviq.app/en/submissions/{id} Specific submission detail

Authentication in the Embedded Board

Users authenticate via magic link. Canviq sends a one-time login link to their email. Once clicked, the session persists across future launches (cookies are shared with the system browser).

!!! info "Pre-authentication (coming soon)" Authenticated embed tokens will allow you to pre-authenticate your users so they arrive at the board already signed in. Contact [email protected] if this is blocking your integration.


Option 2: API Submission

Build a native feedback form in your app and send submissions to Canviq from your backend. Users see your native UI; Canviq receives the submission server-side.

This approach gives you full control over the UX and is best when you want feedback collection to feel native to your product.

How It Works

Your app  →  POST /your-backend/feedback  →  POST /api/submissions (Canviq)

The API key lives on your backend. It is never shipped in your app binary.


Step 1 — Generate an API Key

  1. Go to Settings → API Keys on your Canviq board
  2. Click New API Key
  3. Select the submissions:write scope
  4. Copy the key — it is shown only once
  5. Store it in your secrets manager (not in your app or its source code)

Your key will look like:

pk_org_live_a1b2c3d4e5f6...

Step 2 — Add a Feedback Endpoint to Your Backend

Your backend receives the form data from your app and forwards it to Canviq. This keeps the API key server-side.

// POST /feedback — your backend endpoint called by your app
app.post('/feedback', async (req, res) => {
  const { title, description, type, userEmail, userName } = req.body

  const response = await fetch('https://canviq.app/api/submissions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CANVIQ_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title,
      description,
      type,                       // 'idea' or 'problem'
      author_email: userEmail,    // for attribution and status notifications
      author_name: userName,
      source: 'ios-app',          // tag for filtering in the Canviq dashboard
    }),
  })

  if (!response.ok) {
    return res.status(502).json({ error: 'Failed to submit feedback' })
  }

  const data = await response.json()
  res.json({ id: data.id, url: data.url })
})
import httpx, os
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class FeedbackRequest(BaseModel):
    title: str
    description: str
    type: str          # 'idea' or 'problem'
    user_email: str | None = None
    user_name: str | None = None

@app.post("/feedback")
async def submit_feedback(body: FeedbackRequest):
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            'https://canviq.app/api/submissions',
            headers={'Authorization': f'Bearer {os.environ["CANVIQ_API_KEY"]}'},
            json={
                'title': body.title,
                'description': body.description,
                'type': body.type,
                'author_email': body.user_email,
                'author_name': body.user_name,
                'source': 'ios-app',
            },
        )
    resp.raise_for_status()
    return resp.json()
curl -X POST https://canviq.app/api/submissions \
  -H "Authorization: Bearer $CANVIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Offline mode support",
    "description": "I want to view my saved items when I lose connectivity and have changes sync when I am back online.",
    "type": "idea",
    "author_email": "[email protected]",
    "author_name": "Jane Smith",
    "source": "ios-app"
  }'

Step 3 — Call Your Endpoint from the App

struct FeedbackPayload: Codable {
    let title: String
    let description: String
    let type: String        // "idea" or "problem"
    let userEmail: String?
    let userName: String?
}

func submitFeedback(
    title: String,
    description: String,
    type: String,
    completion: @escaping (Result<Void, Error>) -> Void
) {
    guard let url = URL(string: "https://api.yourbackend.com/feedback") else { return }

    let payload = FeedbackPayload(
        title: title,
        description: description,
        type: type,
        userEmail: currentUser?.email,
        userName: currentUser?.displayName
    )

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONEncoder().encode(payload)

    URLSession.shared.dataTask(with: request) { _, response, error in
        if let error = error { return completion(.failure(error)) }
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            return completion(.failure(URLError(.badServerResponse)))
        }
        completion(.success(()))
    }.resume()
}
data class FeedbackPayload(
    val title: String,
    val description: String,
    val type: String,           // "idea" or "problem"
    val userEmail: String?,
    val userName: String?
)

suspend fun submitFeedback(
    title: String,
    description: String,
    type: String
) {
    val payload = FeedbackPayload(
        title = title,
        description = description,
        type = type,
        userEmail = currentUser?.email,
        userName = currentUser?.displayName
    )

    val client = OkHttpClient()
    val body = Gson().toJson(payload)
        .toRequestBody("application/json".toMediaType())
    val request = Request.Builder()
        .url("https://api.yourbackend.com/feedback")
        .post(body)
        .build()

    client.newCall(request).execute()
}
async function submitFeedback(params: {
  title: string
  description: string
  type: 'idea' | 'problem'
}) {
  const response = await fetch('/feedback', {   // your backend endpoint
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      title: params.title,
      description: params.description,
      type: params.type,
      userEmail: currentUser?.email,
      userName: currentUser?.displayName,
    }),
  })

  if (!response.ok) throw new Error('Failed to submit feedback')
  return response.json() as Promise<{ id: string; url: string }>
}

Submission Fields Reference

Field Type Required Notes
title string Yes 5–200 characters
description string Yes 20–5000 characters
type "idea" or "problem" Yes Ideas = feature requests, problems = bugs/friction
author_email string No Used for attribution and to notify the user when status changes
author_name string No Display name shown on the submission
source string No Tag for dashboard filtering — e.g. "ios-app", "android", "web"
category_id UUID No Assign to a board category — fetch IDs from GET /api/organizations/{id}/categories

Keep the form minimal. Two fields is the right amount — more than that and completion rates drop sharply.

What kind of feedback?
  ○ Idea — "I wish the app could..."
  ○ Problem — "Something isn't working..."

[Short title]      (required, 5–200 chars)
[More detail]      (required, 20–5000 chars — hint: "What were you trying to do?")

[Submit]

On success, show a brief confirmation and offer an optional "View it →" link that opens the submission on your board.


Choosing Between the Two Approaches

Embedded form API submission
Backend code required No Yes
Native UI No (web) Yes
Users can vote and view roadmap Yes No (submission only)
Author attribution Magic link auth Pass author_email
Content moderation Automatic Automatic
Works offline No Yes (queue + sync)
Time to ship ~1 hour ~2 hours

Start with the embedded form if you want the fastest path to production. It requires no backend code and gives users the full board experience — they can vote on other submissions, view the roadmap, and follow their own items.

Switch to the API approach if:

  • You want feedback collection to feel native to your app's design system
  • You need to add custom fields or contextual metadata (e.g., app version, screen name, subscription tier)
  • You need offline support with a sync-on-connect queue

Rate Limits

Scenario Limit
API submissions per key 100 / minute
Per-user via embedded board 5 submissions / hour

Responses include X-RateLimit-Remaining and X-RateLimit-Reset headers. On 429, back off and retry after the reset timestamp.


Error Handling

Status Meaning Action
400 Validation error — check error field Fix the request body
401 Invalid or missing API key Verify CANVIQ_API_KEY is set correctly
403 Key lacks submissions:write scope Regenerate key with correct scope
429 Rate limited Back off exponentially and retry
402 Plan submission limit reached Upgrade plan or contact support
5xx Canviq server error Retry with exponential backoff

Questions

Contact [email protected].