← Back to work
SUPPORT OPS

I Built HelloBible's Premium Triage Queue for $0

HelloBible was migrating support from a tired Gmail inbox to a properly configured Zendesk. Zendesk's native RevenueCat integration showed who paid, but didn't let us prioritise their tickets in the queue. I built an n8n workflow that scans every ticket, cross-references an exported RevenueCat sheet, and auto-tags premium ones. Result: a dedicated 'Premium Clients' Zendesk view, 323 paying customers triagable at a glance, $0 in API/SaaS costs.

Context. For years HelloBible's support ran on Gmail. As we scaled past 100k downloads, we migrated to Zendesk to centralise and professionalise. The migration revealed a gap: paying subscribers were invisible in the new queue. I was solo support at the time, and I needed every premium ticket flagged before I opened it.

Result. In production. 2,687 tickets scanned in the initial run, 383 premium identified (~14%). 323 of them visible right now in a dedicated 'Premium Clients' Zendesk view, refreshed every 12 hours by a delta workflow. Self-hosted on my Mac, $0 in API/SaaS costs. The view is now part of the support team's daily triage.

Hero visual for I Built HelloBible's Premium Triage Queue for $0
Tickets scanned 2,687 Full backlog, initial run
Premium identified 383 (~14%) Auto-tagged priorite_payant
Cost $0/mo API/SaaS costs, self-hosted
n8nZendesk APIRevenueCat APIGoogle SheetsJavaScript

A small system, one outcome: turn HelloBible’s paying subscribers into a visible cohort inside Zendesk. Before the workflow, premium tickets were diluted in a queue of 1.3K. After it, they’re a dedicated view (323 tickets right now), one click away in the sidebar.

The workflow made premium tickets visible directly in the Zendesk queue instead of only inside the user profile.

Two underlying skills made the workflow possible: pragmatic pivot when an API doesn’t do what you expected (RevenueCat API v2 can’t filter by email), and scope discipline (manual sheet refresh once a month, not a complex auto-sync pipeline).


The friction I found

Two layers of friction stacked on top of each other.

Layer 1 - the migration. For years HelloBible’s support ran on Gmail. Tickets, replies, attachments, all in a personal inbox. As we scaled past 100k downloads it stopped working. We migrated to Zendesk to centralise the team, get proper macros, build a queue we could actually triage. I led that migration on the ops side.

Layer 2 - the gap inside Zendesk. Once migrated, I was running support solo. 100% of tickets crossed my desk. The numbers:

  • ~18,000 free users on the app
  • 1,860 active RevenueCat subscribers
  • 2,687 tickets in the cumulative backlog
  • 75% via email, 25% via in-app widget or web form

Zendesk’s native RevenueCat integration exists. It shows total_spent on the user profile, after I’ve clicked into the ticket. There’s no native way to filter the queue by “paid users first” or to spot a premium ticket in the list before opening it. A premium subscriber whose audio playback breaks waits the same amount of time as a free user asking how to unsubscribe.

I needed every premium ticket flagged with a visible marker, in the queue, automatically.


Why the RevenueCat API approach failed

First instinct: call the RevenueCat API per ticket with the requester’s email, get subscription status back, tag accordingly.

The blocker: RevenueCat API v2 doesn’t filter customers by email. To check whether a single email is premium, I’d have to paginate ~18,000 customer records. Per ticket. Not viable.

I tested the direct API path, proved it would not work per ticket (the HTTP Request returned 20 random customers with no email filter, and one ticket from a real $29 paying customer came back unmatched), and switched to a sheet-backed lookup loaded once per run.

The Google Sheet approach:

  1. Export the full customer list manually from RevenueCat once a month (with total_spent per email)
  2. n8n downloads the CSV on the first ticket of each run, builds an in-memory map of email to total_spent, caches it for 24 hours via getWorkflowStaticData
  3. Every subsequent lookup hits the in-memory map, not RevenueCat

Manual refresh ~once a month is the trade-off I made consciously. If someone subscribes today and writes in tomorrow, they might not be tagged on the spot. The cost of missing one ticket is much smaller than the cost of building an auto-refresh pipeline that becomes another thing to monitor.


What this changes operationally

The workflow runs in the background. What changes is the support team’s daily workflow.

Before: I open Zendesk. I see 1.3K tickets. I scan them top-to-bottom. I might miss a paying customer’s urgent ticket because nothing tells me they pay until I open it.

After: I open Zendesk. The left sidebar has a dedicated view called ”🌟 Clients Premium” with 323 tickets. Premium tickets move into the first queue I check instead of being mixed into the general backlog.

The downstream effects:

  • Premium tickets get served first as a structural rule. Paying customers get faster responses than non-paying ones. It’s now enforced by the queue itself rather than by my memory.
  • Expected retention benefit. When a premium subscriber writes in and their complaint gets resolved fast, that should reduce the “I cancelled because nobody responded” class of churn. Effect to be measured over time.
  • A focused listening channel for product. Premium tickets become a high-signal cohort to listen to: these are the people who put money down. The dedicated view doubles as a focused input source for the product team.

The workflow turned paying users from hidden profile data into a queue I could act on before opening any ticket.


The scope choices

Scope choices I made and defended:

  • Manual sheet refresh, not auto-sync (less fragile, lower maintenance overhead)
  • Tag premium only, not free users (I don’t need to know who’s free, I need to know who pays)
  • Acceptable delay of 12h on new premium tickets (workflow runs twice daily, no webhook needed)
  • Tag never gets removed even if a user cancels (a paying customer with an open ticket stays priority)
Zendesk ticket with priorite_payant tag and the auto internal note showing client total spent
The technical architecture

Stack

  • n8n v2.17.7 self-hosted on my Mac at ~/Documents/Dev/n8n-local/n8n_data
  • Zendesk API with cursor-based pagination
  • Google Sheets as the intermediate lookup layer (manual CSV export from RevenueCat)
  • RevenueCat (project_id proj01861a64) used for the export, not the runtime lookup
  • JavaScript in n8n Code nodes via this.helpers.httpRequest for authenticated calls

Two workflows

Workflow 1 - Full backlog scan (manual trigger)

Manual Trigger
  → Get seed ticket (limit=1, to extract subdomain)
  → Fetch All Tickets (HTTP Request, cursor pagination)
  → Split Out on "tickets"
  → Loop Over Items
       → Get user (resolve requester email)
       → Code: lookup against in-memory email map
       → If matched && total_spent > 0:
            → Edit Fields → Update ticket (add tag + internal note)
       → Loop back

Workflow 2 - Delta scan (cron, 2x daily at 08:00 and 20:00)

Same structure, but the initial fetch uses /api/v2/search.json?query=type:ticket+created>{date-24h} with Split Out on results. Max delay before a new premium ticket gets tagged: 12 hours.

The applied state per ticket

  • Tag: priorite_payant
  • Internal note: ✅ Client détecté : {total_spent}$ dépensés.
  • A Zendesk view filtered by this tag gives me the triage queue I needed.

Why a Google Sheet, not RevenueCat direct

RevenueCat API v2 doesn’t filter customers by email. The list endpoint paginates ~18,000 records with no email filter parameter. Per-ticket lookups would mean paginating 18k records per ticket. The intermediate sheet solves this by being a static snapshot n8n loads once per run, in memory.

Caching

The CSV gets downloaded once on the first ticket of each run, stored via $getWorkflowStaticData('global') with a 24h TTL. Every subsequent ticket in the same run hits the in-memory map. Zero re-downloads.

Safety and idempotence

  • continueOnFail: true on Update ticket (closed/archived tickets don’t break the run)
  • retryOnFail: true, waitBetweenTries: 5000 on Update ticket
  • maxRequests: 50 on the HTTP Request pagination
  • 24h cache on the CSV minimises Google Sheets calls
  • Tag is never removed (a paying customer with an open ticket stays priority even if they cancel)

The workflow running


What broke

The bugs worth telling about, the ones that shaped the architecture:

  • n8n Code node sandbox restrictions. Four consecutive errors before finding the right HTTP method: fetch is not defined, $http is not defined, Module 'https' is disallowed, $helpers is not defined. Solution: this.helpers.httpRequest(), not in the docs as the recommended method. The sandbox is more restricted than what most n8n tutorials show.

  • Zendesk node returnAll=true broken. The native n8n Zendesk node failed systematically with “Your request is invalid” when asked for the full ticket list. Worked around it: seed ticket to extract the subdomain, then HTTP Request with manual cursor pagination.

  • The matched: !!match bug. First version flagged any email present in the sheet as premium. The sheet contained all users (60-70k rows), not just paying ones. Fix: check total_spent > 0 explicitly. matched: !!(match && match.total_spent > 0). Caught it by reasoning about what’s actually in the sheet, not by testing.

  • Premature Update ticket failures on closed/archived tickets. Run failed silently. Added continueOnFail: true and retryOnFail to make it resumable end-to-end.

  • Initial run with returnAll=true crashed immediately. This is what forced the seed-ticket + HTTP Request architecture in the first place.

  • n8n folder confusion after a Mac restart. n8n launched on ~/.n8n instead of ~/Documents/Dev/n8n-local/n8n_data, showing an empty account. Fix: explicit env vars at launch, N8N_USER_FOLDER=... N8N_SECURE_COOKIE=false n8n.


Where this lives now

Workflow active in production. Full backlog tagged. Delta workflow runs every 12 hours.

Quality check: I manually verified 15 tagged accounts (all 15 had actually spent money - zero false positives) and spot-checked known paying users in the queue for missed tags - none found in that spot-check.

The discrepancy I observed: 383 tickets tagged by the workflow, 323 visible in the Zendesk view right now (~84%). Cause: Zendesk drops closed tickets from standard views after ~28 days, plus a small number of silent Update failures absorbed by continueOnFail. The trade-off was acceptable given the support volume and queue usage.

Maintenance: refresh the Google Sheet from RevenueCat about once a month. n8n needs to stay running on my Mac, which is the architecture’s main weakness. The day support volume justifies moving to a VPS or n8n.cloud, that risk goes away. For now, the trade-off is fine: $0 in API/SaaS costs, runs without my attention, premium subscribers get served first in the queue.

Want to talk about something like this?

Email me, send a LinkedIn message, or download the CV. Conversations are what this site is built for.

LinkedIn ↗ Download CV
Email copied to clipboard