Email is a perfectly reasonable way to receive feedback from an app. It is also very easy for an email to become a dead end.

A useful bug report arrives beside receipts, newsletters, support requests, and everything else in an inbox. I read it, intend to return to it, and eventually copy part of it into GitHub. The issue loses the app version and device context. My reply lives in email. Investigation notes live somewhere else. There is no durable state tying any of it together.

I wanted a tighter loop:

text
Settings → Send Feedback → accepted reference
                  correct per-app Slack channel
                  one thread for the entire report
                       verified GitHub issue

The form turned out to be the least interesting part. The real work was deciding what “sent” means, making retries safe, preserving privacy, surviving a Slack outage, and preventing a single report from fragmenting across channels.

This is part two of Building an Indie App Command Center. Part one covers the App Store Connect side: webhooks, on-demand metrics, ASC CLI, and Slack as a command surface.

The Rules I Started With

Before writing the UI, I wrote down the properties I wanted from the system:

  1. The app talks only to its own backend. A Slack token or portfolio-wide ingest token must never appear in an application binary.
  2. An accepted response means the feedback is durable. It does not merely mean that Slack happened to return HTTP 200.
  3. Slack is the triage surface, not the database.
  4. Retrying an unchanged submission must not create a second record or a second Slack message.
  5. Bugs, ideas, and general feedback belong in different channels, but every individual report must remain one thread.
  6. Analytics can record that feedback was opened or submitted. It cannot receive the message, email address, feedback ID, or installation identifier.

Those rules produce a little more architecture than a webhook directly from the app, but they remove several dangerous shortcuts.

The Architecture

The deployed path uses an app-specific public Worker and a shared internal operations Worker:

text
iOS app
   │ POST /v1/feedback
App API Worker
   │ validate + rate limit + hash installation ID
   │ private service binding + app-specific server token
Indie Ops Worker
   ├── insert feedback into D1
   └── enqueue feedback ID
      Cloudflare Queue
      Slack parent message
            ├── 👀 triage
            ├── investigation
            ├── verified GitHub issue
            └── ✅ complete

The public Worker owns the mobile trust boundary. The shared Worker owns portfolio-wide storage, Slack credentials, app routing, and queue consumption. They communicate through a Cloudflare service binding, so the internal call does not require a public Indie Ops URL.

Splitting the Workers matters. Each app can keep its existing API domain, rate limits, and deployment lifecycle, while the Slack and GitHub machinery stays in one place.

Keeping the iOS Feature Extractable

I kept the feature inside the app for now, but organized it as if it would become a package later:

text
Modules/User Feedback/
├── Model/
├── Service/
├── View/
├── ViewModel/
├── UserFeedbackCoordinator.swift
└── UserFeedbackViewModelDelegate.swift

The host app injects the API URL, anonymous installation ID, diagnostic context, analytics adapter, navigation, and alert presentation. The form should not know where a product stores global configuration.

The payload is intentionally small:

swift
enum FeedbackCategory: String, CaseIterable, Codable, Identifiable, SFKChipItem {
  case feedback
  case bug
  case idea

  var id: String { rawValue }
  var chipLabel: String { rawValue.capitalized }

  var chipIcon: String? {
    switch self {
    case .feedback: "bubble.left.and.text.bubble.right.fill"
    case .bug: "ladybug.fill"
    case .idea: "lightbulb.fill"
    }
  }
}

struct FeedbackContext: Encodable, Equatable {
  let appVersion: String
  let buildNumber: String
  let osVersion: String
  let locale: String
  let deviceFamily: String
}

struct FeedbackSubmission: Encodable, Equatable {
  let idempotencyKey: UUID
  let category: FeedbackCategory
  let message: String
  let replyEmail: String?
  let context: FeedbackContext
}

I attach the app version, build, iOS version, locale, and broad device family. I do not attach location, browsing history, logs, screenshots, analytics IDs, subscription IDs, or the list of things the user viewed in the app.

The form says that explicitly. “Diagnostics may be attached” is too vague to be meaningful privacy copy.

The UI is ordinary SwiftUI using the same components as the rest of the app:

swift
SFKChipFlowLayout(spacing: 8) {
  ForEach(FeedbackCategory.allCases) { category in
    SFKSelectableChip(
      item: category,
      isSelected: viewModel.category == category,
      tintColor: .indigo
    ) {
      viewModel.selectCategory(category)
    }
    .accessibilityIdentifier("feedbackCategory.\(category.rawValue)")
  }
}

TextEditor(text: $viewModel.message)
  .accessibilityIdentifier("feedbackMessageField")

SFKButton("Send Feedback", isLoading: viewModel.isSubmitting) {
  viewModel.submit()
}
.disabled(!viewModel.canSubmit)
.accessibilityIdentifier("feedbackSubmitButton")

The accessibility identifiers were not added as an afterthought. They are what made the final simulator-to-Slack verification deterministic.

The Mobile Retry Problem

The app can time out after the server has committed the feedback but before the response reaches the device. If pressing Send again creates a new identifier, a harmless network failure becomes two reports.

The idempotency key therefore belongs to the content being attempted, not to an individual HTTP request. The view model reuses it while the category, message, and reply address remain unchanged:

swift
let fingerprint = [
  category.rawValue,
  normalizedMessage,
  normalizedReplyEmail ?? ""
].joined(separator: "\u{001F}")

let idempotencyKey: UUID
if pendingSubmissionFingerprint == fingerprint,
   let pendingIdempotencyKey {
  idempotencyKey = pendingIdempotencyKey
} else {
  idempotencyKey = UUID()
  pendingIdempotencyKey = idempotencyKey
  pendingSubmissionFingerprint = fingerprint
}

I clear the key only after a successful acceptance. Editing the submission creates a new fingerprint and therefore a new key.

The HTTP client treats 202 Accepted as success:

swift
var request = URLRequest(url: baseURL.appending(path: "v1/feedback"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(installationID, forHTTPHeaderField: "X-Installation-ID")
request.httpBody = try encoder.encode(submission)

let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else {
  throw ClientError.invalidResponse
}

switch response.statusCode {
case 202:
  return try decoder.decode(FeedbackReceipt.self, from: data)
case 429:
  throw ClientError.rateLimited
case 400, 413, 415, 422:
  throw ClientError.invalidFeedback
default:
  throw ClientError.unavailable
}

The receipt contains a feedback ID. Showing its short prefix gives the user something concrete without pretending that Slack delivery or GitHub triage has already finished.

The Public Worker Is a Narrow Gate

The app Worker does not know how to post to Slack. It performs five jobs:

  1. Validate an anonymous installation UUID.
  2. Apply a per-installation rate limit.
  3. Parse a small, strict JSON body.
  4. Hash the installation ID before forwarding it.
  5. Call Indie Ops with an app-specific server credential.

The input schema rejects unknown fields as well as invalid values:

typescript
const feedbackSchema = z.object({
  idempotency_key: z.string().uuid(),
  category: z.enum(['feedback', 'bug', 'idea']),
  message: z.string().trim().min(4).max(2_000),
  reply_email: z.string().trim().email().max(254).nullable().optional(),
  context: z.object({
    app_version: z.string().trim().min(1).max(40),
    build_number: z.string().trim().min(1).max(40),
    os_version: z.string().trim().min(1).max(80),
    locale: z.string().trim().min(2).max(80),
    device_family: z.string().trim().min(1).max(80),
  }).strict(),
}).strict()

The rate-limit binding is keyed with a hash rather than the raw UUID:

typescript
const installation = installationSchema.parse(
  context.req.header('X-Installation-ID')
)

const rateLimitKey = await sha256Hex(
  `feedback:${installation.toLowerCase()}`
)

const result = await context.env.FEEDBACK_RATE_LIMITER.limit({
  key: rateLimitKey,
})

if (!result.success) {
  return context.json({ error: 'feedback_rate_limited' }, 429)
}

This identifier is pseudonymous, not magical anonymity. Its purpose is to make basic abuse control and duplicate investigation possible without forwarding the raw installation UUID into the shared system.

The Worker-to-Worker call uses a private binding:

json
{
  "services": [
    {
      "binding": "INDIE_OPS",
      "service": "indie-ops"
    }
  ]
}
typescript
const response = await context.env.INDIE_OPS.fetch(
  'https://indie-ops/v1/feedback/example-app',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${context.env.INDIE_OPS_APP_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      ...parsed.data,
      installation_hash: await sha256Hex(
        `feedback-installation:${installation.toLowerCase()}`
      ),
    }),
    signal: AbortSignal.timeout(5_000),
  }
)

The app-specific token is stored as a Worker secret. It is not the Slack token and it is not reusable by another app backend.

Store First, Notify Second

The shared Worker validates the payload again. Trust boundaries should validate their own inputs even when the caller is another service I control.

It then writes the transaction to D1 before touching Slack:

sql
CREATE TABLE feedback (
  id TEXT PRIMARY KEY,
  app_slug TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  category TEXT NOT NULL
    CHECK (category IN ('feedback', 'bug', 'idea')),
  message TEXT NOT NULL,
  reply_email TEXT,
  installation_hash TEXT NOT NULL,
  app_version TEXT NOT NULL,
  build_number TEXT NOT NULL,
  os_version TEXT NOT NULL,
  locale TEXT NOT NULL,
  device_family TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'new'
    CHECK (status IN ('new', 'triaged', 'closed')),
  delivery_status TEXT NOT NULL DEFAULT 'pending'
    CHECK (delivery_status IN ('pending', 'delivering', 'delivered', 'failed')),
  delivery_attempts INTEGER NOT NULL DEFAULT 0,
  slack_channel_id TEXT,
  slack_thread_ts TEXT,
  last_delivery_error TEXT,
  last_attempted_at TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  UNIQUE (app_slug, idempotency_key)
);

The unique constraint is the backend half of the mobile idempotency contract. Insertion uses INSERT OR IGNORE, then reads the canonical record by (app_slug, idempotency_key):

typescript
const result = await database.prepare(`
  INSERT OR IGNORE INTO feedback (
    id, app_slug, idempotency_key, category, message,
    reply_email, installation_hash, app_version,
    build_number, os_version, locale, device_family,
    created_at, updated_at
  ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?13)
`).bind(
  id, appSlug, idempotencyKey, category, message,
  replyEmail, installationHash, appVersion,
  buildNumber, osVersion, locale, deviceFamily, now
).run()

const record = await readByIdempotencyKey(
  database,
  appSlug,
  idempotencyKey
)

return {
  record,
  inserted: (result.meta.changes ?? 0) > 0,
}

Only after that succeeds does the Worker enqueue the ID on Cloudflare Queues:

typescript
if (record.deliveryStatus !== 'delivered') {
  await env.FEEDBACK_QUEUE.send({ feedbackId: record.id })
}

return {
  feedbackId: record.id,
  duplicate: !inserted,
  deliveryStatus: record.deliveryStatus,
}

If queue submission fails after the database commit, the client may retry. The same idempotency key finds the existing row and attempts to enqueue it again. The report is not lost and a second report is not created.

Making Queue Delivery Idempotent Too

Cloudflare Queues can retry a message, and an app retry can enqueue the same feedback ID again. The consumer therefore claims delivery atomically:

sql
UPDATE feedback
SET delivery_status = 'delivering',
    delivery_attempts = delivery_attempts + 1,
    last_attempted_at = ?2,
    updated_at = ?2
WHERE id = ?1
  AND delivery_status != 'delivered'
  AND (
    delivery_status != 'delivering'
    OR last_attempted_at < ?3
  )
RETURNING *;

Only the consumer that receives a row may post. A claim older than the timeout is considered stale, allowing recovery if a Worker died between claiming the record and updating the result.

Successful delivery stores Slack's channel and timestamp:

typescript
await database.prepare(`
  UPDATE feedback
  SET delivery_status = 'delivered',
      slack_channel_id = ?2,
      slack_thread_ts = ?3,
      last_delivery_error = NULL,
      updated_at = ?4
  WHERE id = ?1
`).bind(feedbackId, channelId, messageTimestamp, now).run()

That timestamp is more than delivery metadata. It is the permanent thread anchor for everything that happens next.

Routing by App and Category

My Slack workspace already has per-app channels for bug reports, ideas, user feedback, releases, metrics, ratings, and revenue. The destinations live in data rather than a switch statement:

sql
CREATE TABLE slack_routes (
  app_slug TEXT NOT NULL,
  topic TEXT NOT NULL,
  channel_id TEXT NOT NULL,
  enabled INTEGER NOT NULL DEFAULT 1,
  UNIQUE (app_slug, topic, channel_id)
);

Feedback category maps to a topic:

typescript
function feedbackSlackTopic(
  category: string
): 'bug-reports' | 'ideas' | 'user-feedback' {
  switch (category) {
    case 'bug':
      return 'bug-reports'
    case 'idea':
      return 'ideas'
    default:
      return 'user-feedback'
  }
}

The queue consumer resolves the actual channel at delivery time:

typescript
const channel = await resolveSlackTopicChannel(
  env.DB,
  feedback.appSlug,
  feedbackSlackTopic(feedback.category)
)

if (!channel) {
  throw new Error('feedback_slack_route_missing')
}

This is useful beyond feedback. A new app can be onboarded through registry and route data without copying Slack IDs into its mobile client or API source.

User Text Is Untrusted in Slack Too

Slack message formatting has control syntax. A feedback message containing <!channel> or a mass mention should not interrupt an entire workspace.

I escape Slack's special characters and neutralize @ before constructing the message:

typescript
function escapeSlackText(value: string): string {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('@', '@\u200B')
}

The API request disables name linking and unfurls:

typescript
await fetch('https://slack.com/api/chat.postMessage', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${env.SLACK_BOT_TOKEN}`,
    'Content-Type': 'application/json; charset=utf-8',
  },
  body: JSON.stringify({
    channel,
    text,
    thread_ts: threadTimestamp,
    link_names: false,
    unfurl_links: false,
    unfurl_media: false,
  }),
})

The optional reply email is stored for follow-up, but the Slack message says only Reply requested: yes. It does not print the address. It also never appears in analytics or ordinary logs.

The Mistake: One Report in Two Channels

The first production test worked end to end. The app returned a reference. D1 contained one record. The queue posted once. The Slack message had the correct context. A GitHub issue was created and linked.

The workflow was still wrong.

I had routed all three categories into the app's user-feedback channel. Separately, the bug-reports channel was subscribed to GitHub issue notifications. A bug therefore looked like this:

text
#app-user-feedback
└── feedback parent
    ├── 👀
    ├── request to create issue
    └── verified issue link + ✅

#app-bug-reports
└── unrelated top-level “issue created” notification

Every component had behaved correctly, but the human workflow had lost its identity. Someone opening the bug channel saw an issue notification without the discussion that produced it. Someone opening the feedback channel saw the report but not the channel where bugs were supposed to live.

The fix was not more automation. It was choosing one canonical parent.

Now the original submission is posted directly to the category channel:

text
#app-bug-reports
└── original user report
    ├── 👀 looking into it
    ├── investigation notes
    ├── verified GitHub issue link
    └── ✅ complete

I removed the broad GitHub subscription from the category channel. GitHub's Slack integration supports repository subscriptions, but those are activity feeds; they are not continuations of an arbitrary feedback thread. If I want a full repository feed later, it can have a dedicated activity channel.

Slack accepts a thread_ts in chat.postMessage to post a reply under a parent. The system already stored that timestamp, so every automated follow-up has an unambiguous destination.

The invariant is now simple:

One user report creates one Slack parent. Everything about that report is a reply to that parent.

GitHub Creation Needs Independent Verification

The GitHub app can be invited to a Slack channel and can create issues through /github open owner/repository. During testing, I also tried a conversational mention in the feedback thread. The app added an 👀 reaction but did not create the issue within the verification window.

That reaction was acknowledgement, not evidence of completion.

The fallback was to create the issue through the authenticated GitHub integration, verify that it existed independently, and then post the URL into the original Slack thread. Only then did I add ✅.

This distinction is important for any bot workflow:

typescript
async function finishIssueHandoff(env: Env, input: {
  channel: string
  threadTimestamp: string
  parentTimestamp: string
  issueURL: string
}) {
  await postSlackMessageToChannel(
    env,
    input.channel,
    `Created and verified: ${input.issueURL}`,
    input.threadTimestamp
  )

  await addSlackReaction(
    env,
    input.channel,
    input.parentTimestamp,
    'white_check_mark'
  )
}

The function should receive a verified issue URL, not an optimistic “request sent” result.

What Failure Looks Like

Writing down the failure behavior made the implementation much easier to reason about:

FailureResult
Invalid or oversized bodyReject before storage
Installation exceeds rate limitReturn 429 without forwarding
App Worker cannot reach Indie OpsReturn a retryable unavailable response
D1 insert failsDo not report acceptance
Queue send fails after D1 commitClient retry finds the same row and re-enqueues it
Queue delivers twiceAtomic claim allows only one Slack post
Slack is unavailableKeep the D1 record, mark delivery failed, retry later
Slack route is missingKeep the record and surface an operational delivery error
GitHub creation is uncertainDo not mark complete until the issue is independently verified

“Slack is down” is therefore a notification failure, not a data-loss event.

Testing the Boundaries

The unit tests target the parts most likely to silently regress.

The public Worker test asserts that the raw installation ID is never forwarded:

typescript
expect(forwarded.category).toBe('bug')
expect(forwarded.installation_hash).toEqual(
  expect.stringMatching(/^[a-f0-9]{64}$/)
)
expect(forwarded).not.toHaveProperty('installation_id')

Routing is table-tested:

typescript
it.each([
  ['bug', 'bug-reports'],
  ['idea', 'ideas'],
  ['feedback', 'user-feedback'],
])('routes %s submissions to %s', (category, topic) => {
  expect(feedbackSlackTopic(category)).toBe(topic)
})

Slack formatting tests use hostile input:

typescript
const message = formatFeedbackSlackMessage({
  ...feedback,
  message: '<!channel> @everyone & hello',
})

expect(message.text).toContain('&lt;!channel&gt;')
expect(message.text).toContain('@\u200Beveryone')
expect(message.text).toContain('&amp; hello')
expect(message.text).not.toContain(feedback.replyEmail)

Component tests were not enough. The final check used a real Simulator submission:

  1. Launch the app with XcodeBuildMCP.
  2. Open Settings → Send Feedback.
  3. Submit a uniquely searchable bug marked safe to close.
  4. Record the short reference shown by the app.
  5. Query D1 using the full feedback ID.
  6. Confirm delivery_status = 'delivered', one attempt, and the expected channel ID.
  7. Match the Slack parent by feedback ID.
  8. Add 👀.
  9. Create and independently verify the GitHub issue.
  10. Post the link in the same thread and add ✅.
  11. Confirm that no duplicate issue notification appeared as another parent.

The database query is deliberately boring:

sql
SELECT
  id,
  app_slug,
  category,
  status,
  delivery_status,
  delivery_attempts,
  slack_channel_id,
  slack_thread_ts,
  created_at
FROM feedback
WHERE id = ?1;

That one row is the evidence connecting the app receipt, queue delivery, Slack thread, and later triage state.

Onboarding the Next App

The first implementation is only valuable to me if the second app does not require reconstructing it from memory. The per-app work is now a checklist:

  1. Create app-bug-reports, app-ideas, and app-user-feedback.
  2. Invite the operations bot to all three channels.
  3. Add the GitHub app to bug reports and ideas, but do not add a broad issue subscription.
  4. Insert the three channel IDs into the Slack route table through a committed migration.
  5. Add the app to the Indie Ops registry.
  6. Create one app-specific server token on Indie Ops and the app Worker.
  7. Add the private service binding and a unique feedback rate limiter.
  8. Copy the iOS module and inject the host app's URL, context, analytics, navigation, and alerts.
  9. Deploy Indie Ops before deploying the caller.
  10. Run one complete Simulator → D1 → Queue → Slack → GitHub proof.

The shared D1 schema, queue, dead-letter queue, Slack formatter, delivery claims, and bot token are portfolio-wide. They should not be recreated per app.

What I Would Add Next

There are obvious ways to extend the system, but each one expands the privacy or operational surface.

Slack actions could mark a report triaged or closed in D1. That would make the database state match the reactions instead of treating reactions as the only signal.

Screenshot attachment would be useful for visual bugs, but it needs explicit consent, short-lived upload URLs, content limits, retention rules, and access controls. I would rather ship text-only feedback than casually build a permanent screenshot archive.

A small replay command for failed Slack deliveries would be more useful than a dashboard. The system already records attempts and the last error; operations only needs a safe way to retry a specific ID.

Finally, issue creation can become a verified button or command. The important word is verified. The automation should update the feedback row and Slack thread only after GitHub returns the created issue.

The Result

The app now has a feedback form, but the useful product is the loop around it.

A user receives an acceptance reference immediately. The report survives Slack failure. Mobile and queue retries do not duplicate it. Bugs and ideas arrive where I already work. The original Slack message remains the canonical parent. GitHub is a linked execution record rather than a second, disconnected conversation.

The architecture is also modest. SwiftUI collects the report. The app Worker protects the public boundary. D1 holds durable truth. Queues separate acceptance from notification. Slack provides context. GitHub tracks the work.

None of those pieces needs to pretend it owns the whole workflow. The system became reliable when I made the boundaries explicit—and it became usable when I treated the thread, not the channel, as the unit of work.