Skip to main content
Version: v0.10.2

Integration Fabric

The Integration Fabric lets a sandboxed OpenWA plugin run a bidirectional integration with an external system — a helpdesk inbox, a CRM, a chatbot flow builder — without running its own server. A third party ships an adapter as a normal marketplace plugin; the plugin receives inbound webhooks from the external system and sends normalized replies back through WhatsApp, all inside the OpenWA host.

The base plugin surface is outbound-only: a plugin can send messages, read engine state, call ctx.net.fetch, and observe hooks. What it cannot do on its own is receive an inbound HTTP request — and a real handover integration needs exactly that: an agent replies in an external inbox, the external system fires a webhook, and something must receive it and relay the reply to WhatsApp. The Integration Fabric is that something.

Prerequisites
  • A running OpenWA gateway at v0.8.0 or later, with the plugin runtime enabled.
  • An ADMIN-role API key. Instance provisioning and redrive routes require it. See Authentication.
  • A plugin that declares an ingress route and the webhook:ingress + conversation:send permissions. See Building a plugin and the capability model.

When to use it

Use the Integration Fabric when an external system needs to drive or observe a WhatsApp conversation in both directions:

  • Helpdesk handover (e.g. Chatwoot). A bot auto-replies until a human agent takes over in the helpdesk. The helpdesk fires webhooks into OpenWA; OpenWA relays the agent's replies to WhatsApp and mirrors the customer's inbound messages back to the helpdesk.
  • CRM logging and reply. A CRM records every conversation and lets an agent reply from its own UI.
  • Chatbot flow builder. An external flow builder orchestrates multi-step conversations and needs inbound message events pushed to it, plus the ability to send the next step.

If your integration is one-way — OpenWA pushing events out to a webhook — use webhooks instead. The Fabric is for the case where the external system also talks back.

How it fits together

The Fabric is a two-tier pipeline inside the OpenWA host. An ingress tier receives and verifies the provider's request, persists it, and fast-acks 202. A durable dispatch tier then runs the plugin's handler, which can send replies through the normalized ctx.conversations.send capability. The plugin never binds a port, never touches the queue directly, and never re-implements signature verification or deduplication — the host owns all of that.

One adapter plugin can back many instances — for example, one external account per WhatsApp number. An instance is a first-class instanceId namespaced under a pluginId; each owns a host-minted ingress secret, a resolved session scope, and a config slice. There is still one worker per plugin; instances are a serializable field threaded through payloads, not separate workers.

The capability model, the sandbox, and the permission surface are documented in Plugin architecture. This page covers the Fabric-specific flow.

Mint a plugin instance

An ADMIN mints one instance per external account. The instance carries the secret the provider signs webhooks with, the session scope it may act on, and its config. All routes below require the ADMIN role.

Create an instance under a plugin that declares ingress:

curl -X POST "http://localhost:2785/api/integration/plugins/helpdesk-relay/instances" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instanceId": "acme-helpdesk",
"config": { "helpdeskBaseUrl": "https://helpdesk.acme.example", "autoHandover": true }
}'

The host returns the created instance with its ingress secret shown once — store it, because the provider needs it to sign requests and a later read masks it.

Manage the instance across its lifecycle:

Method & pathPurpose
POST /api/integration/plugins/{pluginId}/instancesCreate an instance (mints its secret)
GET /api/integration/plugins/{pluginId}/instancesList instances for a plugin
GET /api/integration/plugins/{pluginId}/instances/{instanceId}Inspect one instance (secret masked)
PATCH /api/integration/plugins/{pluginId}/instances/{instanceId}Update config, session scope, or enabled
DELETE /api/integration/plugins/{pluginId}/instances/{instanceId}Remove an instance and retire its binding
POST /api/integration/plugins/{pluginId}/instances/{instanceId}/regenerate-secretRotate the ingress secret

The plugin must exist and declare an ingress route plus the webhook:ingress permission to have instances. The exact request and response fields are in the API reference; the create/update DTOs are intentionally minimal — config and session scope are the fields that matter at the guide level.

When you create or patch an instance, the host mirrors its config into the plugin's per-session config and toggles the bound session in the plugin's activation, so an ingress handler resolves the instance's config as ctx.config. This binding is also re-derived on every boot — a binding lost while a plugin was momentarily unloaded is restored on the next restart without an operator re-PATCH.

Receive inbound webhooks

The provider posts to a single public endpoint:

POST /api/ingress/{pluginId}/{instanceId}/{route}

The route is public to the API-key guard — an external provider cannot present the gateway's API key — so it self-validates instead:

  • Per-instance HMAC over the raw body. The host verifies the signature against the instance's secret, using a constant-time comparison over the exact bytes the provider sent. The raw body is preserved by a verify callback on the body parser, because a re-serialized payload is not byte-identical to what the provider signed. The request is intentionally not bound to a DTO, so strict validation never 400s on the provider's unknown fields.
  • Replay protection. A signed-timestamp tolerance rejects stale deliveries.
  • Deduplication. (instanceId, providerDeliveryId) is unique; an insert-or-skip collapses at-least-once delivery to exactly-once processing.
  • Fail-closed. A missing, wrong, or stale signature, or an empty raw body, all reject. The only unconditional-accept path is an explicit scheme: "none" the adapter declares in its manifest.

The global per-IP rate limit still applies as a coarse guard. On top of it, a per-instance fairness limit keys its bucket on (pluginId, instanceId) rather than the client IP — providers like Chatwoot or Meta deliver every tenant's webhooks from the same egress IP, so IP-keying would let one noisy instance rate-limit its neighbors. A misbehaving instance is shed at the edge before it fills the shared ingress queue.

The host verifies, persists, fast-acks 202, and defers the plugin work to a durable queue (when the queue is disabled, it falls back to inline dispatch after persisting). A provider spike or a slow adapter never loses events.

The plugin side

Inside the plugin, claim the route in onEnable with ctx.registerWebhook. The handler receives the already-verified request and returns an HTTP response:

ctx.registerWebhook('webhook', async (req) => {
// req.verified === true: the host checked the HMAC, replay window, and dedup.
// req.body is the raw provider payload (a string); req.instanceId and req.sessionId are set.
const event = JSON.parse(req.body ?? '{}');

const mapping = await ctx.mappings.getByProvider(req.instanceId, event.conversation_id);
if (!mapping) return { status: 404 };

await ctx.conversations.send({
sessionId: mapping.sessionId,
instanceId: req.instanceId,
chatId: mapping.chatId,
type: 'text',
text: event.body,
source: { provider: 'helpdesk', externalConversationId: event.conversation_id },
});
return { status: 200 };
});

ctx.registerWebhook is available only to sandboxed plugins that declared webhook:ingress; the host refuses to route ingress to a plugin whose declared SDK major differs from the host's supported major.

Send replies

ctx.conversations.send takes a normalized envelope and translates it host-side through MessageService, so persistence and the message hook chain are preserved. The type selects the send path:

typeRequired fieldsBehavior
textchatId (or instanceId + source), textPlain text or quoted reply (replyTo)
image / video / filemediaUrl, text (as caption)Media-by-URL
audiomediaUrlAudio file
voicemediaUrlPTT voice note (mic bubble, audio/ogg; codecs=opus)
locationlocation fieldsLocation message

If you pass instanceId + source instead of chatId, the host resolves the chat from the mapping — so an adapter that only knows the external conversation id can reply without tracking the WhatsApp chat id itself.

Map conversations and hand over

ctx.mappings is the chat↔external-conversation identity map. Use upsert to record a link when a new conversation starts, get to read it by WhatsApp chat, and getByProvider to read it by external conversation id. Each mapping carries a handoverState of bot, human, or closed.

ctx.handover.set flips that state. The handover gate reads it before dispatching message:received: once a conversation is human or closed, the plugin that owns the mapping keeps receiving inbound messages (so it can mirror them to the external system), while other plugins are silenced — a human-handled chat is not echoed into every bot. The gate is scoped by session+chat, fail-open on a lookup failure, and confined to message:received; every other hook event is unaffected.

Reliability: retries, dead-letter, and redrive

Ingress jobs retry with exponential backoff before landing in a dead-letter table (integration_delivery_failures). A worker crash mid-request resolves the pending ingress call to 502 instead of hanging the HTTP request forever. Tune the retry policy with environment variables:

Env varDefaultEffect
INGRESS_MAX_ATTEMPTS3Number of dispatch attempts before a job is dead-lettered
INGRESS_RETRY_DELAY_MS5000Base delay for exponential backoff (ms)
INGRESS_INSTANCE_LIMIT120Max requests per instance per fairness window
INGRESS_INSTANCE_TTL60000Fairness window length in ms (60 s)

An invalid value falls back to the default. The dead-letter table is a sibling of the outbound webhook dead-letter — terminally-failed webhook deliveries are listed at GET /api/webhooks/delivery-failures.

Re-dispatch a dead-lettered instance's failed deliveries with the redrive endpoint (ADMIN-only, because it can cause real downstream sends):

curl -X POST "http://localhost:2785/api/integration/instances/helpdesk-relay/acme-helpdesk/redrive" \
-H "X-API-Key: YOUR_API_KEY"

It returns { "redriven": <count> }. Redrive replays the persisted raw payload through the plugin's handler; deduplication still applies, so an already-processed delivery is skipped.

Troubleshooting

SymptomCauseFix
401 on instance routesKey missing or not ADMINUse an ADMIN-role key in X-API-Key. See Authentication.
Provider gets 401 / 403 on ingressHMAC mismatch, stale timestamp, or wrong secretConfirm the instance secret matches what the provider signs with; regenerate with regenerate-secret if exposed. Check the provider's clock against the toleranceSec window.
404 from the ingress routePlugin not loaded, instance disabled, or route not declared in the manifestVerify the plugin is enabled and its manifest ingress declares the route.
Ingress returns 202 but no reply lands in WhatsAppHandler ran in the dispatch tier and threw, or the mapping is missingCheck the gateway logs for the dispatch error; confirm ctx.mappings has a link for the conversation.
One noisy tenant rate-limits othersNot possible per-instance — the fairness bucket is keyed on (pluginId, instanceId), not IPIf you still see cross-tenant 429s, confirm InstanceThrottlerGuard is active (it runs alongside the global IP guard).
Replies loop back into the external systemRe-entrancy guard suppressed the plugin's own outbound hookExpected: a reply issued inside an ingress handler is deliberately not re-relayed. The guard prevents echo loops.
Instance binding lost after a restartPlugin was unloaded when the binding was provisionedIt self-heals on the next boot. If it does not, confirm the instance is enabled and the plugin loads cleanly.

Next steps

  • Plugin architecture — the capability model, the sandbox, and the handover gate in depth.
  • Building a plugin — the authoring workflow, including the Integration Fabric adapter section.
  • API reference — the exact request and response fields for instance management and ingress.
  • Webhooks — the outbound-only counterpart for pushing OpenWA events to your own systems.