Support callbacks: "Request a callback"
A customer asks to be called back from the portal, the chat widget, or your own backend through the API. The request lands on a ticket, so the desk sees it, and carries a promised time that the…
A customer asks to be called back from the portal, the chat widget, or your own backend through the API. The request lands on a ticket, so the desk sees it, and carries a promised time that the project's hours honour. This discharges ledger rows D207 and D211's route half: Tier 3 is back on the portal and in the Slip.
- Server:
apps/api/src/support/callbacks/ - Table:
support_callbacks(migration 0238) - Types:
packages/types/src/support-callbacks.ts
1. Turning it on
A project offers callbacks when voice_enabled OR callback_enabled is true. callback_enabled (0238)
turns callbacks on with browser calling off. A sandbox project may turn it on to exercise the integration
(see §5). With both off, every surface shows nothing and the routes answer 409 callbacks_off.
2. The window and the promise
Every time a customer sees comes from the server, worked out on the project's own clock: the
SlaClock from resolveSlaClock, the same one behind the ticket SLA and the chat's "we'll reply by".
| Window | Promise (promisedBy) | Rules |
|---|---|---|
{kind:"asap"} | 30 open minutes from now, or from the next opening when closed | Refused (422 slot_unavailable) when no opening is in reach. The server never invents a time. |
{kind:"slot", start} | the slot's end (30 minutes after start) | start must be on the half hour, ≥ 1 h out, ≤ 7 days, and wholly inside open hours. The server re-checks every slot the client sends. |
GET …/callbacks/options returns {enabled, prefillE164, open, nextOpenAt, timezone, asapBy, slots[≤12], live}.
The form renders from it alone. Times are shown in the customer's own timezone.
SLA. promisedBy feeds the ticket's first-response deadline: sla_first_due_at becomes the earlier of
the two while nobody has replied. An answered callback sets first_replied_at, because a phone call is a reply.
3. The doors
| Door | Route | Who | Edge limit |
|---|---|---|---|
Portal (home, /chat, a case) | GET /support/portal/callbacks/options · GET /support/portal/callbacks · POST /support/portal/callbacks · POST /support/portal/callbacks/:id/cancel | customer session (CustomerSessionGuard); deactivated = read-only, blocked = no session | 3 / hour / customer (Redis, before any DB work) |
| Widget (Slip) | GET /support/chat/:sessionId/callbacks/options · POST /support/chat/:sessionId/callbacks | signed chat token; hmac or authenticated only (anonymous gets 403, and the chunk is never fetched) | 3 / hour / customer |
| API | POST /v1/support/callbacks · GET /v1/support/callbacks · GET /v1/support/callbacks/:id · POST /v1/support/callbacks/:id/cancel | API key, support-tickets:rw / :read; project pins enforced; test keys reach only the sandbox; Idempotency-Key on create | key limits |
| Desk | GET /support/callbacks · GET :id · POST :id/call-now · POST :id/outcome · POST :id/cancel | member session, support-tickets view/edit | — |
Refusals, in this order, before anything is written (a refused request costs no ticket number):
- 409
callbacks_off: the project offers no callbacks. - 403
customer_inactive: the customer is blocked or deactivated, or the grant on this project is blocked or missing. - The customer already has a live callback on this project. That one is returned (200), not a second. This is the dedupe every door meets, enforced by a unique index.
- 429
daily_cap: 5 requests per customer per project in 24 h, counted across every door. - 422
slot_unavailable: the window, per §2.
A live key never reaches a sandbox callback (read, cancel or list): 404, as for an unknown id. A pinned API key naming another project gets 404, the same answer as an unknown id, so a pin is never an existence oracle.
The ticket. ticketId (portal and API) attaches the callback to the customer's own case. Someone else's
case gets a 404. In the widget, the chat's existing case is reused. Otherwise a case "Callback requested" is opened
through the one creation family (createFromPortal / createFromChat / createFromApi).
4. Dispatch: who actually dials
Decided server-side per request and stored as dispatch:
dispatch | When | What happens |
|---|---|---|
voice | voice_enabled, not sandbox, the contact-centre platform on (PULSE_FLAG_CALLBACKS + PULSE_FLAG_CONTACT_CENTRE) and active for the workspace, a voice_queues row whose team is the project's L1 tier team, a live line in support_project_numbers, and the queue's number policy accepts the number | A voice_callbacks row is booked (via 'api', created_by NULL: "the caller asked", 0152). The existing contact-centre dispatcher rings an agent, dials, retries and expires it. The sweep support.callback_reconcile (60 s) and every desk read carry its ending back. |
manual | anything else | The agent dials from their own phone (tel:) and records the outcome. |
sandbox | sandbox project | Never dialled. No voice row is ever booked, the desk offers no Call now and no tel:, and outcomes are recorded only to test the integration. |
The contact centre's caps hold too ([review]): under one per-number lock, a number already live on the L1 queue,
a number with 3 live callbacks in the workspace, or a queue whose daily cap is spent is not booked again. The callback
stays manual, so a typed number (many customers, one victim) is never dialled past the operator's limits.
The number policy is the operator's. The contact-centre default is ani_only: call back only a number that
called in. A typed number has no inbound call, so on such a queue the callback stays manual instead of
overriding the operator. Set the L1 queue to same_country or any to let Gauge dial typed numbers.
The seam. VoiceModule does not export CallbacksService. The support pillar's @Global
SupportCallbackVoiceSeam is bound at boot by voice/callbacks/support-callback-port.ts (the D198 seam, run in reverse).
With no voice pillar the seam is empty, and every callback is manual.
5. The desk
A case shows its callbacks above its history. The queue shows the project's waiting callbacks above the rack, soonest promise first, with "Overdue" in words as well as in red.
- Call now (voice only): the row becomes the agent's personal callback, and the shipped personal ring dials them first. If the agent isn't routable, the refusal says to call from their phone.
- Call from your phone (manual only): a
tel:link. - Record what happened: answered · no answer · busy · voicemail · wrong number, plus an optional note
(≤ 500). Retry policy: answered → completed. Wrong number → failed. A miss → the next try 30 open
minutes later, up to 3 tries, then failed. Recording by hand on a voice row cancels the idle voice row,
and the callback continues as manual. A call that is ringing right now can't be overwritten (409
in_progress). - Cancel: by the customer, an agent or the API. Any of them cancels the voice row too.
5a. Events and webhooks
support.callback.requested · .scheduled (a chosen slot, or a retry) · .completed · .failed · .cancelled.
They carry ids only: {version, callbackId, projectId, customerId, ticketId}. Never the number, the reason or a
note. They are written to the outbox in the same transaction, published in the OpenAPI webhooks block, and audience
members. Read the callback itself with GET /v1/support/callbacks/{callbackId}.
6. Privacy
e164, reason and outcome_note are REST-only and never logged. CustomerErasureService deletes the
customer's callbacks, and the contact-centre rows they booked, except one whose call is ringing at that
moment. The customer FK cascades as well. The PII census lists every text column.
7. Bundles
The widget arm is a lazy, SRI-pinned chunk, support/callback.js (10.2 KB gz, budget 15 KB). The base is
30.9 KB gz (budget 50, +0.9 KB for the strings, the loader and the type roles). size.mjs fails the build if the
chunk is inlined. That guard was proven by inlining the chunk.
8. What this does not do (recorded, not hidden)
- The overflow chain's callback arm OFFERS; it never books. When a browser call's queue overflows on a
queue configured
callback(orvoicemail, which degrades to it), andSupportCallbacksService.optionssays this customer could book (project offers callbacks, customer active and granted), overflow recordscallback, opens the call's ticket, and closes the room. The caller's status read then carriescallbackOffer: { ticketId }: the portal shows "Nobody could take your call this time. We can call you back instead." with its Tier 3 arm booking against that ticket, and the widget call slip opens the callback form for it. The booking goes through the normal door (edge limit, daily cap, one live callback, sandbox dispatch). Nothing is ever dialled on overflow's say-so: the caller confirms the number. Phone (PSTN) callers and anonymous widget visitors have no customer row, so they get no offer and the chain degrades as before./chat?embed=1shows the sentence without a key; the native app books through/v1(mobile.md), and thesupport.voice.abandonedevent names the action taken. - Browser calls are separate from callbacks. In-app calls (portal,
/chat?embed=1, the widget's own call key) are LiveKit rooms and need nothing from Twilio (docs/ops/support-embed.md§5). Callbacks are the PSTN path and stay on Twilio. The widget Slip shows both when the project has both on. - Desk reads follow the ticket's data scope (S5). An own/team-scoped agent sees and works a callback only when its
ticket passes the
support-ticketsscope predicate (ticket-scope.ts): the list and every:idroute, 404 otherwise. There is still no per-project membership, matchingGET /support/tickets.
Keeping your users in sync with Gauge Support
This is the integrator's guide to the customer sync API. It's written for a SaaS backend (Salonvira is the first one) that owns its users and wants Gauge Support to mirror them: who exists, what…
Native Help & Support screens
How a native app (iOS, Android, Flutter, React Native) builds its Help & Support screens on Gauge. The rule for every section: Gauge is the source of truth. Hours, staffing and who answers are…