createSupport: storage, channels, notifications, automatic replies and every option.
Create the service
createSupport returns the object your webhook route, scheduled job and API call. It holds no state of its own, so create it once per request (or once per worker) with your database client.
import { createSupport } from "@/lib/support/service";
import { createPostgresStore } from "@/lib/support/postgres/store";
import { resendChannel } from "@/lib/support/channels/resend";
import { directory } from "./directory";
import { storage } from "./storage";
export const support = () =>
createSupport({
store: createPostgresStore(db),
storage,
channels: [
resendChannel({
id: "email",
apiKey: process.env.RESEND_API_KEY!,
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
address: "support@acme.com",
replyAddress: "support@in.acme.com",
}),
],
directory,
notify: async (n) => {
if (n.type === "ticket.created") await emailTeam("New ticket", n.ticket.subject);
},
acknowledgement: () => ({
text: "Thanks for writing to Acme. We've received your message and will reply to this email.",
}),
fromName: "Acme Support",
portalChannelId: "email",
ignoreFrom: ["noreply@acme.com"],
});Options
| Name | Type | Description |
|---|---|---|
store* | SupportStore | createPostgresStore(db) or createD1Store(db). |
storage* | AttachmentStorage | Where attachment bytes live. See below. |
channels* | Channel[] | The adapters you use. Each needs a unique, permanent id. |
directory* | SupportDirectory | Who people are. See Your directory. |
notify | (n) => Promise<void> | Alerts to your team. Errors are logged, never thrown into ingestion. |
acknowledgement | (ctx) => { text } | null | The automatic first reply to new tickets. Return null to skip one. |
signature | (agent) => string | Appended to email replies only; the stored text stays what the agent wrote.Default: the agent's first name |
fromName | string | The display name replies are sent as, such as "Acme Support". |
portalChannelId | string | The email channel that portal requests and contact forms reply on. |
ignoreFrom | string[] | Senders whose mail never becomes a ticket, such as your app's no-reply address. |
reopenWindowDays | number | A reply within this many days reopens a solved ticket; later replies open a new one that links back.Default: 14 |
maxAttachmentBytes | number | Larger inbound files are skipped; larger uploads are refused.Default: 25 MB |
now | () => Date | For tests. |
log | (level, message, data) => void | Where warnings and errors go.Default: console |
Attachment storage
Inbound attachments are copied into your storage as they arrive, and agents upload files to replies and notes. Download links are created on demand and expire after five minutes.
import type { AttachmentStorage } from "@/lib/support/service";
// S3, R2, Supabase Storage, a disk: anything that can store bytes and hand out a download link.
export const storage: AttachmentStorage = {
async put(key, body, contentType) {
await bucket.put(key, body, { contentType });
},
async get(key) {
return new Uint8Array(await bucket.get(key).arrayBuffer());
},
async url(key, { filename, expiresInSeconds }) {
// A short-lived link that downloads (Content-Disposition: attachment).
return bucket.signedUrl(key, { expiresIn: expiresInSeconds, downloadAs: filename });
},
};Serve files as downloads
url return links that download rather than display, ideally from a different origin than your app, so an HTML or SVG attachment can never run as a page on your domain.Notifications
notify receives one of four events. Send them however your team works: email, Slack, push.
| Name | Type | Description |
|---|---|---|
ticket.created | { ticket, message, contact } | A new ticket, including ones opened from the portal. Not sent for automatic mail or tickets marked as spam. |
ticket.customer_replied | { ticket, message, contact } | The customer wrote again. Usually goes to the assignee, or everyone when unassigned. |
ticket.assigned | { ticket, assigneeId, actorId } | Someone assigned the ticket. |
message.failed | { ticket, message } | A reply bounced or couldn't be delivered; message.deliveryError says why. |
Automatic replies
acknowledgement runs for new tickets from senders your directory matched to a customer. Strangers, spam and spoofed senders never get one, so the inbox can't be used to send mail to arbitrary addresses. The reply carries Auto-Submitted: auto-replied so other systems don't answer it.
Methods
What the service returns. Expose the agent methods and the portal through your own API, behind your own auth.
| Name | Type | Description |
|---|---|---|
handleWebhook | (channelId, request) => Response | For your webhook route. See Webhooks. |
runScheduled | () => Promise<summary> | Polls channels, renews subscriptions, fails sends that never finished. |
ingest | (channelId, event) | Feed an event directly, for tests or custom transports. |
listTickets / getTicket / counts | | What the inbox and ticket screens read. |
reply / retry / addNote | | Send a reply (status and attachments included), retry a failed one, or add an internal note. |
assign / setStatus / linkCustomer | | Assignment, status (open, waiting, solved, spam) and linking a sender to a customer. |
uploadAttachment / attachmentUrl | | Agent uploads, and short-lived download links with access checks. |
searchCustomers / listAgents / markRead | | Pickers and read state per agent. |
portal.list / get / create / reply | | The customer portal. See Customer portal. |
Errors
Methods throw SupportError with a code of not_found, forbidden, invalid, reply_window_closed or too_large, and a message written for the person using the screen. Map it to your API's error type and show the message as is.