Skip to content

Support (Zendesk) — how it works

Everything about customer support in Toborverse: what the customer sees, how requests reach Zendesk, how replies and files come back, what is stored in Postgres and S3, the Zendesk setup the code relies on, configuration, limits and security.

One rule explains most of the design: Zendesk is the source of truth. The platform writes to Zendesk (new requests, customer replies) and then copies the ticket back. Nothing is written to support.* tables or the support S3 bucket except by the sync, which always re-reads the ticket from the Zendesk API. Running the sync again, twice, or out of order is harmless.


1. Big picture

 Customer (browser)                    Backend (Express)                       Zendesk
 ───────────────────                   ─────────────────                       ───────
 Support page ── POST /api/support/requests ──► upload files ──► /uploads.json
   (form + files)                                 create ticket ─► /tickets.json  (tag toborverse_app)
                                                  syncTicket() in background
 My requests  ── GET  /api/support/tickets ────► read support.tickets
 Conversation ── GET  /api/support/tickets/:id ► syncTicket() then read messages
 Reply box    ── POST /api/support/tickets/:id/replies
   (text + files)                                 upload files ──► /uploads.json
                                                  add comment ───► PUT /tickets/:id.json (as requester)
                                                  syncTicket()
                                                                         agent replies in Zendesk
                                                                                  │
               POST /api/support/zendesk-webhook ◄── webhook (HMAC signed) ◄──────┘
                                                  syncTicket() in background
               every 60 s: syncChangedTickets() ── search tickets tagged toborverse_app

 syncTicket(): Zendesk API ──► support.tickets / support.messages (Postgres)
                           ──► files: Zendesk ──► S3 bucket ──► support.attachments
                           ──► malware files: support.unusual_attachments (no file)
                           ──► clears the "Handled By" field in Zendesk (if enabled)

2. Files

Area File What it does
Backend backend/src/routes/support.js POST /api/support/requests — new request from the Support page
Backend backend/src/routes/supportHistory.js My requests list, conversation, customer replies (with files), Zendesk webhook
Backend backend/src/services/zendeskService.js Zendesk API client (OAuth or API token), file upload, ticket creation
Backend backend/src/services/supportSync.js Copies a ticket from Zendesk into the database and S3; background sync; Handled By
Backend backend/src/services/supportStore.js All SQL for the support.* tables
Backend backend/src/services/supportAttachments.js S3 paths, copying files from Zendesk to S3, file types, download links
Backend backend/src/app.js Mounts the webhook (before the JSON parser) and the authenticated /api/support routers
Backend backend/server.js Starts the background sync after the server starts listening
Frontend frontend/src/pages/SupportPage.jsx /support: "New request" form and "My requests" tab
Frontend frontend/src/pages/SupportRequests.jsx Request list, conversation view, reply box with attachments
Frontend frontend/src/service/dataEpisodeService.js createSupportRequestApi, getSupportTicketsApi, getSupportTicketApi, replySupportTicketApi
Frontend frontend/src/apiList.js SupportRequestCreate, SupportTickets URLs
Database database/schemas/support.sql Full current schema of the support schema
Infra infra/terraform/environments/dev/variables.tf extra_bucket_arns includes toborverse-dev-support-attachments

3. Customer experience (frontend)

The Support item in the sidebar opens /support, which has two tabs.

3.1 New request

Field Required Notes
Your email address yes Prefilled from the logged-in user (/users/me). Becomes the Zendesk requester.
Name no Prefilled from firstName lastName (also accepts first_name/last_name).
Robot yes Dropdown of the org's robots plus "Other". Sent as a readable label name (model).
Robot Serial Number no Filled automatically from the selected robot's robot_serial_no; editable.
Subject yes
Description yes Small rich-text editor (bold, italic, code, link, lists, headings). Sent as HTML.
Attachments no Drag and drop or click. Max 20 MB per file, 10 files. Duplicates (same name + size) are ignored.

On success: toast "Your request #N has been submitted…", the form is cleared and the view switches to My requests. The form is hidden, not unmounted, when switching tabs, so a half-written request survives.

3.2 My requests

  • List of the user's requests: #ticket subject, robot, last update, message count, status.
  • Status shown to customers:
Zendesk status Shown as
new Received
open In progress
pending Awaiting your reply
hold On hold
solved Solved
closed Closed
  • Conversation view: robot, serial number, "Handled by" (shows the Zendesk assignee name, or "Waiting for an agent"), then every public message in order. Customer messages show the requester's name; agent messages show the agent's display name from support.agents (never their code), or "Toborverse Support". Each message lists its files as links (see 6.4).
  • Reply box: text plus Attach files (same limits: 10 files, 20 MB each). A reply can be text only, files only, or both. Closed requests show "This request is closed. Submit a new request if you still need help." instead of the box.
  • Refresh buttons reload the list / conversation. Opening a conversation always refreshes it from Zendesk first.
  • Email links: /support?request=<zendesk ticket number> opens My requests and then that conversation. If it isn't found: "We couldn't find request #N. A new request can take a minute to appear." The ?request= is then removed from the URL.

4. Backend API

All /api/support/* routes except the webhook require the normal user login (authenticateUser → req.auth with user_id, org_id, email) and go through the app's rate limiter.

4.1 POST /api/support/requests — new request

Multipart form: email, name, robot, serial_number, subject, description, attachments[].

  1. Files are read into memory by multer (20 MB / 10 files; errors → 400 with a readable message).
  2. Validation (400 on failure): valid email; robot, subject and description (with HTML tags removed) not empty; description at most 50,000 characters.
  3. zendeskService.createSupportTicket:
  4. each file → POST /uploads.json?filename=… → upload token;
  5. POST /tickets.json with subject, comment.html_body = description, comment.uploads = [tokens], requester {email, name}, tags: ['toborverse_app'], custom fields Robot and Serial (if their IDs are configured), ticket_form_id (if configured).
  6. Responds { success, data: { ticket_id } } straight away.
  7. In the background: syncTicket(ticket_id, { created_by: user_id, org_id }). This is the only path that tells the database which org and user the ticket belongs to.

Zendesk failure → 502 "Could not submit your request to support…".

4.2 GET /api/support/tickets — My requests

Runs inside withOrg(org_id). Returns tickets that are not deleted and were either created by this user or have this user's email as requester, newest Zendesk update first.

4.3 GET /api/support/tickets/:id — conversation

  1. Loads the ticket (:id is our UUID, not the Zendesk number); canView must pass (creator, or requester email matches) → otherwise 404.
  2. Calls syncTicket to refresh from Zendesk. If the ticket was deleted in Zendesk → 404. If Zendesk is unreachable, the stored copy is shown.
  3. Returns ticket fields + messages (public only), each with attachments: [{id, file_name, url}].

4.4 POST /api/support/tickets/:id/replies — customer reply

Accepts JSON { message } or multipart message + attachments[].

  1. canView check → 404. Need a message or at least one file → 400. Message at most 10,000 characters → 400. Ticket closed → 409.
  2. Each file → /uploads.json → token.
  3. PUT /tickets/:zendesk_id.json with a public comment authored as the requester (author_id = requester's Zendesk id), body as plain text (so < > stay as typed), plus uploads. A files-only reply gets the body Attached: <file names> because Zendesk requires a body.
  4. syncTicket and returns the refreshed conversation.

Zendesk 404 → ticket marked deleted, 404 "This request no longer exists". Other errors → 502.

4.5 POST /api/support/zendesk-webhook — Zendesk webhook

  • Mounted before express.json() and without user login; reads the raw body (≤ 1 MB).
  • Returns 503 if ZENDESK_WEBHOOK_SECRET isn't set; 401 if the signature is wrong.
  • Signature check: base64(HMAC-SHA256(secret, timestamp + raw body)) compared (timing-safe) with header x-zendesk-webhook-signature; timestamp from x-zendesk-webhook-signature-timestamp.
  • Body must be JSON with a positive integer ticket_id → otherwise 400.
  • Answers 200 immediately, then runs syncTicket(ticket_id) in the background and logs [Support webhook] Ticket N synced (k new messages) or … failed: ….

5. The sync (supportSync.js)

5.1 syncTicket(zendeskTicketId, local) step by step

  1. GET /tickets/:id.json?include=users.
  2. 404 → ticket deleted in Zendesk → deleted_at set in the database, returns null.
  3. Ticket without the toborverse_app tag → ignored, returns null.
  4. GET /tickets/:id/comments.json?include=users (all pages).
  5. GET /tickets/:id/audits.json (all pages) → which Handled By code was entered with which reply (5.3).
  6. Builds the message list from public comments only (internal notes are never stored) and decides who wrote each one (5.2).
  7. upsertTicket (by zendesk_ticket_id). Org is taken from: the existing row → else local.org_id (new request from the platform) → else the requester's email if it belongs to exactly one org (users + organization_members). Otherwise the ticket is skipped ([Support store] Ticket N skipped: no org for <email>) and the sync stops here.
  8. For each message: upsertMessage (by ticket_id + zendesk_comment_id, updates the ticket's message_count and last_message_at), then copyAttachments (5.4).
  9. If the Handled By field has a value → clearHandledBy (5.3).

5.2 Who wrote a message (author_type)

  • The reply has a Handled By code entered in the same update → agent (customers can't see or fill that field).
  • Author is the ticket's requester → customer (even if that Zendesk user is an agent/admin).
  • Author's Zendesk role is end-user → customer; any other role → agent.
  • Unknown author → system.

5.3 Handled By (agent codes)

Agents type their personal code (e.g. CN01) into the agent-only Handled By field when they reply ("code every reply").

  • A reply is credited to the code changed in the same Zendesk update (audit) as the reply. Changes made by triggers are ignored. Codes are trimmed and upper-cased.
  • messages.agent_code / agent_id = that code and the matching support.agents row.
  • tickets.handled_by_code / handled_by_id = code of the latest coded agent reply, else the field's current value.
  • A code that isn't in support.agents is still stored as typed; the name just stays empty.
  • Clearing: Zendesk triggers can't empty a custom field, so when ZENDESK_CLEAR_HANDLED_BY=true the sync sends PUT /tickets/:id.json with custom_fields: [{ id: <Handled By>, value: null }] after reading it. No comment is added, so no email goes out. The next reply then needs its own code, even from the same agent. In the ticket's Zendesk events this shows as an update by the API user setting Handled By to empty. Failures are logged as Could not clear Handled By on ticket N: ….
  • It runs at the end of syncTicket, so it happens only when the ticket was synced successfully and has an org (see 5.1 step 5).

5.4 Attachments (copyAttachments)

For every file on every public message (customer or agent), in this order:

Zendesk malware_scan_result What happens
malware_found Never copied. A row in support.unusual_attachments (once per Zendesk file; user_id = ticket creator if the customer sent it, else NULL). Logged as a warning. Recorded even when the bucket isn't configured.
bucket not configured Nothing else happens (messages show no files).
file already stored Skipped (matched by Zendesk attachment id from the S3 path).
not_scanned, failed_to_scan, missing Not copied yet; warning logged; tried again on a later sync of the ticket.
malware_not_found Downloaded from Zendesk (with the API auth header), uploaded to S3, row added to support.attachments.

A failed copy is logged (Attachment N on ticket M failed: …) and retried on a later sync.

5.5 Background sync

Started by server.js (only if Zendesk is configured). Every SUPPORT_SYNC_INTERVAL_SEC seconds (default 60), skipped if the previous run is still going:

  1. Search Zendesk type:ticket tags:toborverse_app (all pages).
  2. For each result whose updated_at differs from the stored zendesk_updated_at (or that isn't stored) → syncTicket.
  3. Deletions: every stored active ticket missing from the search is checked directly; a 404 marks it deleted. (The search can lag behind new tickets, so it isn't trusted alone.)
  4. Logs [Support sync] Checked X, synced Y, deleted Z when something changed.

It is the safety net for missed webhooks; with a working webhook, changes arrive within seconds.


6. Files in S3

6.1 Bucket and credentials

  • Bucket: SUPPORT_S3_BUCKET_NAME (dev: toborverse-dev-support-attachments), private, separate from the data bucket. Region: SUPPORT_S3_REGION, else AWS_REGION.
  • Uses the app's AWS keys (config.aws). That IAM identity needs s3:PutObject and s3:GetObject on the bucket. Without the bucket variable, file copying is off.

6.2 Path

s3://<bucket>/<org_id>/<ticket uuid>/<zendesk attachment id>/<YYYY-MM-DD>/<file name>
  • One object per Zendesk attachment, so a re-sync builds the same path and doesn't copy twice.
  • The date is the day the reply was posted in Zendesk (UTC); undated if unknown.
  • File name: invisible / direction-changing Unicode characters removed (so invoice\u202Egpj.exe can't pose as invoiceexe.jpg), control characters → _, / and \ → _, empty → file.

6.3 File type (file_type)

From the extension; if unknown, from Zendesk's content type (video/mp4 → video).

Type Extensions
image jpg jpeg png gif bmp webp tif tiff svg heic heif ico avif
audio mp3 wav m4a aac ogg oga flac wma opus aiff
video mp4 mov avi mkv webm m4v wmv flv 3gp mpeg mpg
text txt csv log md
unknown everything else

The list in supportAttachments.js and in migration 123 must be kept in step.

  • The S3 path never leaves the server. Each time a conversation is loaded, every file gets a presigned link valid for 15 minutes.
  • Opens in the browser only for: jpg jpeg png gif webp bmp avif, mp4 m4v webm mov, mp3 wav m4a aac ogg oga flac opus, txt csv log md (as plain text), pdf. The content type served comes from the extension, never from what Zendesk reported.
  • Everything else (HTML, SVG, Office files, archives, …) is downloaded (attachment, application/octet-stream), so an uploaded file can't run as a web page.

7. Database (support schema)

Full SQL: database/schemas/support.sql.

7.1 Tables

support.agents — support staff and their codes. Filled by hand; the app never writes it.

Column Type Notes
id UUID PK
code VARCHAR UNIQUE What agents type into Handled By, e.g. CN01 (stored upper-case)
full_name VARCHAR
display_name VARCHAR Shown to customers on agent replies (falls back to full_name)
is_active BOOLEAN Only checked by findAgentByCode; the stored handled_by_id / agent_id match the code regardless
created_at TIMESTAMPTZ
INSERT INTO support.agents (id, code, full_name, display_name)
VALUES (gen_random_uuid(), 'CN01', 'Full Name', 'First name');

support.tickets — one row per Zendesk ticket tagged toborverse_app.

Column Notes
id UUID PK (UUIDv7), used in platform URLs/API
org_id FK organizations, NOT NULL — which org owns it (never overwritten once set)
created_by FK users — platform user who filed it (kept once set)
robot_id FK robots — currently not filled by the code
zendesk_ticket_id UNIQUE — Zendesk ticket number
requester_email / requester_name / zendesk_requester_id From the Zendesk requester
robot_label / serial_number From the Zendesk custom fields
subject / status / priority From Zendesk
assignee_name Zendesk assignee (shown as "Handled by" to customers)
handled_by_code / handled_by_id Agent code of the latest coded reply / FK agents
message_count / last_message_at Public messages, kept up to date by upsertMessage
zendesk_updated_at Zendesk updated_at; the background sync compares this
last_synced_at Last successful sync
deleted_at Set when the ticket is deleted in Zendesk (hidden from customers)
created_at / updated_at created_at = Zendesk creation time

Indexes: org_id, created_by, lower(requester_email).

support.messages — public Zendesk comments.

Column Notes
id UUID PK; (id, org_id) also UNIQUE (for the attachments FK)
ticket_id FK tickets ON DELETE CASCADE
org_id FK organizations
zendesk_comment_id UNIQUE with ticket_id
author_type customer / agent / system
agent_code / agent_id Only for agent replies with a code
body_text Plain text (HTML entities decoded) — the only body the app shows
body_html Zendesk HTML as written — untrusted, never rendered
is_public Always true today (internal notes aren't stored)
created_at Zendesk comment time

support.attachments — files copied to S3.

Column Notes
id UUID PK
message_id + org_id FK → messages(id, org_id) ON DELETE CASCADE (a file can't point at another org's message)
file_name Cleaned name shown in the chat
s3_path UNIQUE, must match ^s3://[^/]+/.+
size_bytes BIGINT, as reported by Zendesk (NULL for files copied before migration 122)
file_type image / audio / video / text / unknown (CHECK)
created_at

support.unusual_attachments — files Zendesk flagged as malware. No file is stored.

Column Notes
id UUID PK
ticket_id FK tickets ON DELETE CASCADE
org_id FK organizations
user_id FK users — the customer who sent it; NULL if an agent sent it
zendesk_attachment_id UNIQUE — one row per Zendesk file
scan_result e.g. malware_found
detected_at

7.2 Relationships

agents ◄── tickets.handled_by_id
agents ◄── messages.agent_id
tickets ◄── messages.ticket_id            (cascade)
tickets ◄── unusual_attachments.ticket_id (cascade)
messages(id, org_id) ◄── attachments(message_id, org_id) (cascade)
organizations / users / robots ◄── tickets, messages, attachments, unusual_attachments

7.3 Row-level security and database users

  • tickets, messages, attachments and unusual_attachments have RLS enabled with a policy org_id::text = current_setting('app.current_org', true).
  • Customer routes run inside withOrg(org_id): a transaction with set_config('app.current_org', <org>, true).
  • The sync and webhook work across all orgs and use syncPool, which connects as SUPPORT_SYNC_DB_USER (meant to be a BYPASSRLS role). If that variable isn't set they use the main DB user, with the startup warning SUPPORT_SYNC_DB_USER not set: sync and webhook use the main DB user.
  • Caveat: RLS is ENABLEd, not FORCEd, and the tables are owned by the app's DB user (toborverse), so for that user the policies don't apply. What a customer can see is in practice decided by the code (canView, listTicketsForUser). The comment at the top of supportStore.js says FORCE; the schema doesn't.

8. Zendesk setup the code relies on

Item Where in Zendesk Needed for
Tag toborverse_app added automatically on create Only tagged tickets are synced; removing the tag hides the ticket from the sync
API access Admin Center → Apps and integrations → APIs OAuth client (client credentials, scope read write) or API token; the account must be an agent/admin that can edit tickets
Ticket form Admin Center → Objects and rules → Tickets → Forms Optional ZENDESK_FORM_ID for new requests
Custom field Robot (text) … → Tickets → Fields ZENDESK_ROBOT_FIELD_ID
Custom field Serial number (text) … → Tickets → Fields ZENDESK_SERIAL_FIELD_ID
Custom field Handled By (agent-only) … → Tickets → Fields ZENDESK_HANDLED_BY_FIELD_ID; agents enter their code with each reply
Webhook Admin Center → Apps and integrations → Webhooks URL https://<api host>/api/support/zendesk-webhook, POST, JSON body containing "ticket_id": "{{ticket.id}}", signing secret = ZENDESK_WEBHOOK_SECRET
Trigger calling the webhook Objects and rules → Business rules → Triggers On ticket created/updated with tag toborverse_app, notify the webhook. Without it, changes arrive only through the 60-second background sync
Requester notification trigger Triggers (e.g. "Notify requester of comment update – Toborverse") Emails the customer on agent replies. A link to https://<app host>/support?request={{ticket.id}} in the email opens that conversation on the platform
Malware scanning built into Zendesk Provides malware_scan_result on each attachment

Notes: * No trigger or automation should set Handled By, or it will be refilled after the sync clears it. * Closed tickets can't be edited in Zendesk, through the API either: our reply API refuses them with 409, and clearing Handled By on them fails (logged). * The webhook check uses the signing secret shown on the webhook's page in Zendesk.


9. Configuration (backend/.env)

Names only; values live in the server's .env (never commit them).

Variable Meaning
ZENDESK_SUBDOMAIN <sub>.zendesk.com
ZENDESK_CLIENT_ID, ZENDESK_CLIENT_SECRET OAuth client credentials (preferred)
ZENDESK_OAUTH_SCOPE Default read write; must allow writes
ZENDESK_EMAIL, ZENDESK_API_TOKEN Fallback auth (email/token:api_token) when OAuth isn't set
ZENDESK_FORM_ID Optional ticket form for new requests
ZENDESK_ROBOT_FIELD_ID, ZENDESK_SERIAL_FIELD_ID Custom field IDs
ZENDESK_HANDLED_BY_FIELD_ID Handled By field ID
ZENDESK_CLEAR_HANDLED_BY true (exactly) to clear Handled By after each sync; anything else = off
ZENDESK_WEBHOOK_SECRET Webhook signing secret; webhook answers 503 without it
SUPPORT_SYNC_INTERVAL_SEC Background sync interval, default 60
SUPPORT_SYNC_DB_USER, SUPPORT_SYNC_DB_PASSWORD DB role for the sync (BYPASSRLS); falls back to the main user
SUPPORT_S3_BUCKET_NAME Support attachments bucket; empty = file copying off
SUPPORT_S3_REGION Defaults to AWS_REGION

Zendesk is "configured" when ZENDESK_SUBDOMAIN is set plus either OAuth or email+token. If not, new requests return 500 "Support is not configured on the server" and the background sync logs that it is off.

OAuth tokens are cached until one minute before they expire; a 401 drops the cache so the next call gets a new token.


10. Limits and validation summary

What Limit Where enforced
File size 20 MB each frontend + multer
Files per request / reply 10 frontend (reply) + multer
Description (new request) 50,000 chars (HTML) backend
Reply text 10,000 chars backend
Webhook body 1 MB backend
Reply JSON body 100 KB backend
Download link lifetime 15 minutes S3 presign

11. Security summary

  • Webhook: HMAC signature with timing-safe comparison; only the ticket id is taken from the body — everything else is re-read from the Zendesk API.
  • Message bodies are shown as plain text (body_text); body_html is never rendered. If it ever is, clean it first (e.g. DOMPurify).
  • Malware-flagged files are never downloaded or stored; unscanned files wait for a clean scan.
  • S3 paths stay on the server; links are short-lived; risky types are forced to download; content type comes from the extension.
  • File names are cleaned of direction-changing and control characters.
  • Customer replies are sent to Zendesk as plain text, authored as the requester.
  • Treat stored message text and file names as untrusted input if they are ever fed to other systems (exports, AI tools, emails).