Channel Architecture
How channel plugins work end-to-end — webhook ingress, message normalization, delivery, and outbound sending
Channel Plugin Architecture#
How channel plugins (Instagram, WhatsApp, Chatwoot, …) move messages between external platforms and Hay — end-to-end, as actually implemented.
Overview#
A channel plugin connects an external messaging platform to Hay's orchestrator. The full loop:
- Inbound: the provider POSTs a webhook to core → core routes it to the plugin's per-org worker → the worker verifies/filters/normalizes → the worker calls back into core with
messages.receive→ core creates the customer/conversation/message → the orchestrator responds. - Outbound: the orchestrator (or a human agent) produces a reply → the
ChannelDeliveryServicePOSTs it to the worker's/deliverroute → the plugin calls the provider's send API. - Escalation: when a conversation flips to
pending-human, core POSTs the worker's/escalateroute (optional — plugins may not implement it).
Channel plugins run under the same worker model as every other plugin: a separate per-(org, plugin) HTTP process spawned from the SDK runner, idle-killed after inactivity. Core talks to workers over localhost HTTP; workers talk back to core over a tRPC-over-HTTP Plugin API authenticated with a scoped JWT.
This document uses plugins/core/instagram (plugin ID hay-channel-instagram-meta) as the running example.
What Makes a Plugin a Channel Plugin#
There is no manifest.json. All metadata lives in the plugin's package.json under the hay-plugin key. A channel plugin declares:
{
"name": "hay-channel-instagram-meta",
"type": "module",
"hay-plugin": {
"entry": "./dist/index.js",
"displayName": "Instagram",
"category": "channel",
"channel": "instagram",
"capabilities": ["messages", "customers"],
"env": ["META_APP_ID", "META_APP_SECRET", "META_VERIFY_TOKEN"]
}
}
(verbatim from plugins/core/instagram/package.json)
The load-bearing fields:
category: "channel"— classifies the plugin in the marketplace.channel: "<slug>"— the channel slug (e.g."instagram"). This is the join key for outbound delivery:Conversation.channelis a free-form string, and core resolves which plugin owns a channel viapluginManagerService.findPluginIdByChannel(channel), which matches this field.capabilities: ["messages", "customers"]— scopes the worker's Plugin API JWT so it may callmessages.*andcustomers.*procedures.
The entry module registers Express-style routes on the worker in onInitialize:
// plugins/core/instagram/src/index.ts
register.route("POST", "/webhook", async (req, res) => {
/* inbound */
});
register.route("POST", "/deliver", async (req, res) => {
/* outbound */
});
/webhook and /deliver are conventions, not registry magic: /webhook is where core's proxy and shared-webhook router forward provider payloads, and /deliver is the route ChannelDeliveryService POSTs to.
System Flow#
sequenceDiagram
participant P as Provider (Meta)
participant C as Hay Core
participant W as Plugin Worker
participant O as Orchestrator
P->>C: POST /v1/plugins/:pluginId/webhook
C->>C: Verify HMAC / resolve org
C->>W: POST /webhook (verified, per-org payload)
W->>W: Filter + normalize
W->>C: messages.receive (Plugin API, tRPC)
C->>C: Upsert customer, find/create conversation, dedupe
C->>O: New customer message → process
O->>C: Bot reply (deliveryState "sent")
C->>W: POST /deliver { to, content, ... }
W->>P: Provider send API
W-->>C: { success, providerMessageId }
Inbound: Webhook Ingress#
The ingress route#
All plugin worker traffic enters through one catch-all Express router mounted at /v1/plugins (server/main.ts → server/routes/v1/plugins/proxy.ts):
ALL /v1/plugins/:pluginId/*
The plugin ID is the npm package name (e.g. hay-channel-instagram-meta), so a webhook URL looks like:
POST https://<host>/v1/plugins/hay-channel-instagram-meta/webhook
The proxy decides between two paths:
- Shared-webhook routing — if the path is exactly
/webhook, the request carries no org identifier, and the plugin declares awebhookRoutingdescriptor in its metadata, the request is diverted towebhookRouterService.handle()(see below). - Per-org proxy — otherwise, core resolves the organization from the
?organizationId=<uuid>query param or thex-organization-idheader (both validated as UUIDs and checked against the DB), starts or reuses the org's worker, and forwards the request tohttp://localhost:<workerPort><path>.
When forwarding, the proxy:
- Strips credential-bearing headers:
authorization,cookie,x-forwarded-for,x-real-ip,proxy-authorization,content-length. - Preserves signature-verification material: since Express has already JSON-parsed the body, the proxy re-encodes it as JSON (moving the original
Content-Typeintox-original-content-type) and addsx-original-url(the external-facing URL, honoringX-Forwarded-Proto) andx-original-body-base64(the exact raw request bytes, captured by a body-parserverifyhook inserver/main.tsasreq.rawBody). Plugins that verify per-instance HMAC signatures (e.g. Chatwoot) recompute the digest over these raw bytes.
If no org can be resolved (and there's no routing descriptor), the proxy returns 401.
Shared webhooks: register.webhookRouting#
Some providers (Meta being the canonical case) deliver all tenants' events to a single app-level webhook URL with no org identifier anywhere in the request. Hay solves this generically: the plugin declares a data-only routing strategy, and core executes it blindly without ever learning which provider it is.
Instagram's declaration (plugins/core/instagram/src/index.ts):
register.webhookRouting({
signature: {
header: "x-hub-signature-256",
format: "sha256-hmac", // the only supported format
secretEnv: "META_APP_SECRET",
},
verificationChallenge: {
modeParam: "hub.mode",
verifyTokenParam: "hub.verify_token",
challengeParam: "hub.challenge",
verifyTokenEnv: "META_VERIFY_TOKEN",
},
routeKeyPath: {
itemsPath: "entry", // dot-path to the array of items
keyPath: "id", // dot-path within each item to the routing key
},
});
Core's side (server/services/webhook-router.service.ts) then handles the shared URL:
- GET → verification handshake. Core checks
hub.mode === "subscribe"and the verify token against the declared env var, then echoeshub.challenge. The worker never sees GET requests — Instagram registers no GET route. - POST → core verifies the HMAC-SHA256 signature once over the exact raw bytes (timing-safe compare,
sha256=prefix tolerated —verifyHmacSha256inserver/services/plugin-route.service.ts), responds200 { received: true }immediately, then fans out asynchronously. - Fan-out — for each item under
itemsPath, core extracts the value atkeyPath(a safe dot-path getter, no eval), looks up which org owns that routing key (plugin-webhook-route.repository), groups items per org, reconstructs a per-org body containing only that org's items, and POSTs it to each org worker's/webhookwith the internal headerx-hay-webhook-verified: true. Unknown routing keys are logged and dropped.
Where routing keys come from: the onConnected lifecycle hook. Right after OAuth tokens are stored, core calls the worker's /on-connected; the plugin returns opaque routing keys that core persists. Instagram resolves the connected IG business account ID(s) via the Graph API and returns them — those IDs are exactly what appears in entry[].id on inbound webhooks (condensed from plugins/core/instagram/src/index.ts):
async onConnected(ctx) {
const token = ctx.auth.get()?.credentials.accessToken;
if (!token) return { routingKeys: [] };
const client = new GraphClient({ logger: ctx.logger });
// (the real handler also refreshes the worker's closure-held token/client
// and re-subscribes the account to the app's webhooks — see below)
const routingKeys = await client.getConnectedAccountIds(token);
return { routingKeys };
}
onConnected never fails the OAuth flow — throwing is tolerated and core reconciles later.
One provider-side prerequisite Instagram handles here (and in onStart): app-level webhook field subscription alone does not deliver messages — the connected account must also be subscribed via /me/subscribed_apps. The plugin calls graphClient.subscribeToWebhooks() idempotently on both hooks and treats failures as non-fatal.
Legacy per-instance webhook path#
A separate route family, ALL /plugins/webhooks/:pluginName/:webhookPath plus GET /plugins/webhooks/:pluginName for verification challenges (handled by pluginRouteService.handleWebhook / handleWebhookVerification in server/services/plugin-route.service.ts), predates the worker proxy and is still mounted in server/main.ts. New channel plugins should use the /v1/plugins/:pluginId/webhook proxy path.
Inbound: Normalization in the Worker#
The worker's /webhook handler is where all provider-specific knowledge lives. Instagram's handler (plugins/core/instagram/src/webhook.ts) illustrates the pattern:
- Trust check — reject any request missing
x-hay-webhook-verified: truewith403. The worker is localhost-only behind core; core already verified the shared HMAC, so the worker does not re-verify. - Availability check — return
503if credentials aren't loaded yet (org hasn't completed OAuth). - Filter — Instagram log-and-ignores: echoes of its own outbound (
message.is_echo), non-message events (postbacks/reactions), and attachment-only or empty-text messages (text-only MVP). It also normalizes both delivery shapes Meta uses (entry[].messaging[]andentry[].changes[].valuewherefield === "messages"). - Enrich — best-effort sender profile lookup via the Graph API; failures degrade gracefully rather than dropping the message.
- Hand off to core — call
messages.receiveon the Plugin API:
await apiClient.mutation("messages.receive", {
from: `instagram:${senderPsid}`, // channel-scoped external identity
content: text,
channel: "instagram",
senderType: "customer",
metadata: {
username,
profileName,
mid: message.mid, // provider message id → core-side dedupe
igAccountId,
timestamp: event.timestamp,
},
});
- Response semantics — return
200for anything handled (including ignored events); return500only for genuine processing failures, since providers like Meta retry on 5xx.
The Plugin API client is plain tRPC-over-HTTP: POST ${HAY_API_URL}/v1/pluginApi.messages.receive with Authorization: Bearer ${HAY_API_TOKEN} (both env vars injected by core when it spawns the worker). Note: each channel plugin currently ships its own copy of this small client (e.g. plugins/core/instagram/src/plugin-api.ts) — it is not yet an SDK export.
Inbound: What Core Does with messages.receive#
Server side: server/routes/v1/plugin-api/trpc.ts, procedure messages.receive (capability-gated on messages). Input schema:
{
from: string, // channel-scoped external id, e.g. "instagram:<psid>"
content: string,
channel: string, // channel slug, max 64 chars
metadata?: Record<string, any>,
senderType?: "customer" | "human_agent", // default "customer"
externalConversationId?: string, // provider-side conversation id (e.g. Chatwoot)
}
Processing steps, in order:
- Customer — look up by
external_idwithin the org (customerRepository.findByExternalId). Create if missing, storing channel data underexternal_metadata[channel]; on subsequent messages, backfillname/phoneif empty and merge the latest metadata. Thefromvalue is a channel-scoped identity and lives inexternal_id— never in the phone field (a real phone number only lands inphoneif the channel passesmetadata.phone). - Agent resolution —
getAgentForChannel(orgId, channel): (1) agents whosechannelsarray (theagent.channelstext-array column on the Agent entity) includes this channel — preferring the org default agent if it's among them, otherwise the earliest-created; (2) the org default agent; (3) the first available agent. Only when the org has no agents at all does the mutation fail (an internalNOT_FOUNDthat the procedure's catch-all re-throws asINTERNAL_SERVER_ERRORwith message "No agent configured for this channel"). - Conversation —
findActiveByCustomerAndChannel(customerId, channel, orgId); create a newopenconversation if none is active. If the plugin passedexternalConversationId, it is stored underconversation.metadata[channel].conversationIdso outbound delivery can reuse it. - Dedupe — if
metadata.midis set, core claims the keydedupe:msg:<orgId>:<channel>:<mid>atomically via RedisSETNXwith a 24h TTL. Duplicates return{ processed: false, deduped: true }without creating a message. Redis being down fails open (at-least-once beats dropping). Channels that don't passmidget no dedupe at all — of the shipped plugins, only Instagram currently sets it (WhatsApp passesmessageSidand ChatwootchatwootMessageIdin metadata, but neither under themidkey core checks). - Message — added as
MessageType.CUSTOMER, orMessageType.HUMAN_AGENTwhensenderType === "human_agent"(e.g. a Chatwoot agent replied on the provider side). Human-agent messages are taggedmetadata.externalOrigin: trueso outbound delivery never echoes them back to the same channel, and the conversation is flipped tohuman-took-overso the orchestrator stops responding.
The new customer message then flows through the normal orchestrator pipeline exactly as a webchat message would.
Outbound: ChannelDeliveryService → POST /deliver#
Outbound delivery is push-based and lives in server/services/channel-delivery.service.ts. It subscribes to the Redis websocket:events channel (the same event stream the WebSocket service uses) and reacts to two event types.
Message delivery (message_received events)#
A message is delivered to the channel plugin only when all of these hold:
payload.typeisBotAgentorHumanAgent(never customer/system messages),payload.metadata.externalOrigin !== true(don't echo messages that came from the channel),payload.deliveryState === "sent"(messagesqueuedby test-mode approval are skipped until approved),- the conversation's channel is not
web(the dashboard/WebSocket path handles web).
The service then:
- Resolves the plugin by slug:
pluginManagerService.findPluginIdByChannel(conversation.channel)— matching thehay-plugin.channelfield. No plugin registered for the channel → warn and drop. - Gets or starts the org's worker.
- POSTs to
http://localhost:<port>/deliver:
{
to: string, // customer.external_id, e.g. "instagram:<psid>"
content: string,
messageId: string, // Hay message UUID
conversationId: string,
conversationMetadata: Record<string, unknown> | null,
messageMetadata: Record<string, unknown> | null,
}
The plugin responds with:
{ success: boolean, providerMessageId?: string, error?: string }
On success: true with a providerMessageId, core stores it on the message (messageRepository.updateProviderMessageId).
Error semantics: don't retry permanent failures#
The convention (established by Chatwoot, followed by Instagram in plugins/core/instagram/src/deliver.ts): non-retryable provider errors return HTTP 200 with success: false and a machine-readable error string, so core logs the failure instead of triggering a retry storm. Reserve HTTP 5xx for genuinely transient failures.
Instagram's mapping:
| Condition | Response |
|---|---|
| Sent OK | 200 { success: true, providerMessageId } |
| 24h messaging window expired (Graph code 10 / subcode 2534022) | 200 { success: false, error: "24h_window_expired" } |
| Other Graph 4xx | 200 { success: false, error: "graph_<status>" } |
| Network error / Graph 5xx | 500 { success: false, error } |
| Credentials not loaded | 503 { success: false, error } |
Core specifically recognizes "24h_window_expired" and logs it as a warning rather than an error.
The Instagram handler also strips the channel namespace before calling the provider — to arrives as instagram:<psid> (as set on inbound) and the Graph API wants the bare PSID.
Escalation (conversation_status_changed events)#
When a non-web conversation flips to pending-human, core POSTs the worker's /escalate route:
{ conversationId, conversationMetadata, reason: "orchestrator_handoff" }
This is best-effort: a 404 from plugins that don't register /escalate is silently tolerated, so implementing it is optional. Chatwoot uses it to perform provider-side handoff (reopen the ticket, assign a team); Instagram does not implement it.
The Plugin → Core Callback Surface#
Channel plugins call back into core via the Plugin API (server/routes/v1/plugin-api/trpc.ts), authenticated by the worker JWT and gated per procedure on declared capabilities:
| Procedure | Capability | Purpose |
|---|---|---|
messages.receive |
messages |
Ingest an inbound message (the main channel entry point) |
messages.send |
messages |
Create an outbound BotAgent message in a conversation |
messages.getByConversation |
messages |
Read a conversation's messages |
conversations.updateStatusByExternalId |
messages |
Mirror provider-side lifecycle changes (resolve/reopen) into Hay, keyed on metadata[channel].conversationId |
customers.get / customers.findByExternalId / customers.upsert |
customers |
Customer lookup and enrichment |
sources.register |
sources |
Stub — accepts input and returns { success: true }, but persistence is a TODO in the code |
(The router also exposes mcp.registerLocal, mcp.registerRemote, products.upsertMany, and products.delete for non-channel capabilities.)
Note the router self-describes as a "simplified initial implementation" — full conversation management is planned for a later phase.
Auth in the Shared-App Model (Instagram)#
Instagram demonstrates the shared-app pattern: one Hay-owned Meta app serves every org, so per-org config is empty. App credentials resolve from the core process environment through hidden config fields with env fallbacks (META_APP_ID, META_APP_SECRET — allow-listed in hay-plugin.env), and the org just clicks "Connect Instagram":
register.auth.oauth2({
id: "instagram-oauth",
authorizationUrl: "https://www.instagram.com/oauth/authorize",
tokenUrl: "https://api.instagram.com/oauth/access_token",
scopes: ["instagram_business_basic", "instagram_business_manage_messages"],
scopeSeparator: ",", // Instagram requires comma-separated scopes
clientId: config.field("clientId"),
clientSecret: config.field("clientSecret"),
tokenExchange: {
// short-lived → long-lived (~60d) token
url: "https://graph.instagram.com/access_token",
grantType: "ig_exchange_token",
tokenParam: "access_token",
includeClientSecret: true,
},
tokenRefresh: {
// non-standard refresh (no refresh_token)
url: "https://graph.instagram.com/refresh_access_token",
grantType: "ig_refresh_token",
tokenParam: "access_token",
},
});
The platform runs the entire OAuth dance — authorization, token exchange, and auto-refresh (including these declarative non-standard flows, executed core-side in server/services/oauth.service.ts). The plugin only ever reads the current token via ctx.auth.get().
One subtlety worth copying: onStart does not re-run on reconnect, so Instagram's onConnected also refreshes the closure-held token and Graph client used by the /webhook and /deliver handlers — otherwise they'd keep a stale token until the next worker restart.
Building a New Channel Plugin — Checklist#
package.json:category: "channel",channel: "<slug>",capabilities: ["messages", "customers"].onInitialize:register.config+register.auth.*;register.route("POST", "/webhook", …)andregister.route("POST", "/deliver", …).- If the provider uses one shared webhook URL with no tenant identifier:
register.webhookRouting(...)+ return routing keys fromonConnected. Otherwise, verify per-instance signatures yourself using the raw bytes fromx-original-body-base64/x-original-url(seeplugins/core/chatwootfor the reference implementation: HMAC-SHA256 withtimingSafeEqualand a replay window). - Inbound: filter echoes and out-of-scope events, then call
messages.receivewith a channel-scopedfromand — always — the provider message ID inmetadata.midso core can dedupe redeliveries. - Outbound: implement
/deliverwith the 200-success:falseconvention for permanent provider errors. - Optionally implement
/escalatefor provider-side human handoff.
Reference implementations: plugins/core/instagram (shared-app model, declarative webhook routing, declarative token exchange/refresh, and the only plugin that passes metadata.mid) and plugins/core/chatwoot (per-instance signature verification, escalation, human-agent forwarding, externalConversationId). Avoid copying the inbound handlers of plugins/core/whatsapp or plugins/core/chatwoot verbatim — neither passes metadata.mid, so their inbound paths have no dedupe. WhatsApp also uses a bare phone number as from rather than a channel-scoped identity (whatsapp: is stripped); prefer Instagram's <channel>:<id> convention for new plugins.
Known gaps and stubs (as of this writing)#
sources.registeraccepts calls but does not persist anything (TODO inserver/routes/v1/plugin-api/trpc.ts).- Instagram's
onValidateAuthonly checks that auth state exists (throwing if none) — it returnstruewithout a Graph API round-trip. - The
PluginApiClientis copy-pasted into each channel plugin rather than exported from@hay/plugin-sdk. - The shared-webhook router supports exactly one signature format (
sha256-hmac) and env-based verify tokens (verifyTokenConfigFieldexists in the type but is not resolvable on a no-org shared URL).
References#
- Getting Started — plugin anatomy, SDK contract, lifecycle hooks
- Quick Reference —
register.*API signatures - Channel Registration — the sources table and source registration
- Core code:
server/routes/v1/plugins/proxy.ts(ingress),server/services/webhook-router.service.ts(shared-webhook fan-out),server/services/channel-delivery.service.ts(outbound + escalation),server/routes/v1/plugin-api/trpc.ts(plugin → core callbacks) - Example plugin:
plugins/core/instagram