Skip to main content
Version: v0.25.0

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 without an allowedChats restriction (v0.23.6+). 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 answers it (202 accepted unless the route declares its own ack), usually before the plugin runs; see What the provider receives. A durable dispatch tier 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",
"verifyToken": "a1b2c3d4e5f6",
"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.

Config secrets are masked even in the create response — breaking in v0.11.0

The create-instance and regenerate-secret responses are built from the masked view: only the two documented one-time reveal fields — the ingress secret and verifyToken — arrive in plaintext. Every config field flagged secret in the plugin's schema is masked as ***, at any nesting depth. If your provisioning code reads its own config secrets back from the create response, it now receives ***.

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.

A session-scoped ADMIN key manages only the instances inside its allowlist: create, patch, and redrive intersect the requested and persisted session scope with the key's allowedSessions, an out-of-scope instance answers 404 Not Found — indistinguishable from a missing one, so it cannot be probed — and an all-sessions scope (an omitted or '*' sessionScope) is uncreatable by a scoped key. See Authentication.

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. Teardown is sibling-aware: disabling, deleting, or re-scoping one instance does not strip the shared session scope or per-session config while another enabled instance is bound to it, and a wildcard '*' scope is preserved while a wildcard sibling stays enabled.

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. A route that declares timestampHeader without a per-route toleranceSec uses the host-wide INGRESS_TIMESTAMP_TOLERANCE_SEC (default 300 s, the Standard Webhooks convention) rather than rejecting every delivery.
  • Deduplication. (pluginId, instanceId, providerDeliveryId) is unique; an insert-or-skip collapses at-least-once delivery to exactly-once processing. The delivery id comes from the route's dedup header (webhook-id for standard-webhooks, x-delivery otherwise, unless the manifest names another) and falls back to a hash of the raw body when that header is absent or, since v0.23.6, blank. A route whose provider mints a fresh delivery id on every retry attempt can declare dedupOn: "body" (v0.23.6+) to key every delivery on the body hash instead, so byte-identical bodies collapse within INGRESS_DEDUP_RETENTION_DAYS. Keep the default when the provider's retries legitimately differ in the signed body (a fresh timestamp or nonce), since body would then dedup nothing.
  • 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 — and that requires an operator opt-in (below).
Unsigned ingress routes require an explicit opt-in

A signature.scheme: "none" route is an unauthenticated public endpoint: once an instance is provisioned against it, anyone who can reach the host can POST a forged payload that triggers outbound WhatsApp sends on the bound session. The plugin loader rejects a manifest declaring one unless you set ALLOW_UNSIGNED_INGRESS=true; the boot warning still fires when you do, so front the route with a network ACL. Signed schemes (hmac-sha256, standard-webhooks, shared-secret) are unaffected, and both official ingress plugins declare signed schemes.

The route is exempt from the global per-IP rate-limit tiers (v0.21.0+; before, they applied to it as well) and carries two windows of its own, both sized by INGRESS_INSTANCE_TTL: a per-client-IP bound (INGRESS_IP_LIMIT, v0.21.0+, below) and a per-instance fairness limit that 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, and acks the delivery (202 accepted unless the route declares response.ack), and defers the plugin work to a durable queue when QUEUE_ENABLED=true: retries, per-conversation ordering, and dead-lettering then run in the dispatch tier. With the queue disabled, or Redis unreachable, it dispatches inline after persisting, and on a route without a response block the answer then waits for the handler (see What the provider receives). A provider spike or a slow adapter never loses events.

Answer a provider's verification challenge​

A provider that verifies the endpoint before it starts delivering (Meta's hub.challenge handshake, for example) sends a GET to the same path, and the host answers it itself, without waking the plugin worker. When the plugin's manifest declares a challenge on that route, the host compares the query parameter the route names as its token against the instance's verifyToken in constant time and, on a match, echoes the challenge parameter back with 200. A mismatch, or an instance with no verifyToken, answers 403 challenge failed. A token or challenge parameter repeated in the query string is read as its first value (v0.23.6+; before, a repeated token answered 500, and a repeated challenge parameter was echoed back as a JSON array).

The HMAC, replay and dedup checks above apply to the delivery methods only. Set verifyToken yourself in the create-instance body: unlike the ingress secret the host mints no token for you, so an instance created without one refuses every challenge, and no update route can add it later. Like the secret, it is revealed in the create and regenerate-secret responses and masked on every later read.

What the provider receives​

A delivery is answered 202 with the body accepted unless the route declares response.ack in its manifest. On a route that declares a response block, the answer never waits for the plugin. Any other route waits for the enqueue: with QUEUE_ENABLED=true that is a hand-off to the queue, but with the queue disabled, or Redis unreachable, the host runs the plugin inline and answers once the handler returns or fails.

  • response.ack sets the status, a body that may interpolate {rawBody}, {timestamp}, and {id} from the verified request, and static headers. {id} is the delivery id the host deduplicates on: the dedup header's value, or the body hash when that header is absent or blank. On a dedupOn: "body" route it is always the body hash, so an ack that must echo the provider's own delivery id cannot use that mode.
  • response.preflight with session-alive answers 503 when the instance's bound session has no live engine or has failed. It runs after the signature check and before anything is stored, so the rejected delivery writes no dedup row and the provider's retry counts as a new delivery. Since v0.23.6 the 503 carries Retry-After, so a provider that retries a 503 only when that header is present comes back. The check is skipped for a wildcard session scope.
  • A re-delivery of an event that is already stored is not enqueued again and, since v0.23.6, gets the same ack as the first delivery, with the same status and headers. It still passes the preflight first. The body template renders from the retry, so {timestamp} is the retry's time. Before v0.23.6 a re-delivery was answered with a fixed 200 duplicate that bypassed the declared ack, so a provider that validates the ack failed on exactly the retry that dedup exists for. A repeat is therefore no longer distinguishable by its response: on a route without a response block it gets 202 accepted like a new delivery, and the ingress_events dedup row is the only record of which deliveries were repeats. On a route whose declared ack is not 2xx, a provider that retries on that status now keeps retrying, each attempt deduplicated and none enqueued, where the 200 duplicate used to stop it.
  • Content-Type follows a declared application/json or text/plain (v0.23.6+; v0.20.0 through v0.23.5 sent every ack as text/plain). Only the media type is kept: parameters declared with it are dropped, and the ack goes out as that type with charset=utf-8. Any other declared type, or none, is sent as text/plain, so a reflected body is never served as executable content.
  • Reserved headers in a declared ack are dropped rather than written (v0.23.6+): content-length, transfer-encoding, content-encoding, and trailer, because the host frames the response itself; set-cookie, access-control-allow-origin, and access-control-allow-credentials; and content-security-policy, x-content-type-options, x-frame-options, strict-transport-security, and referrer-policy, which the host sets on every response. Every other declared header goes out verbatim.

The loader validates each ingress declaration when a plugin is installed and again at every boot. Since v0.23.6 it also refuses a route that is not a single URL path segment (no /, \, ?, #, %, or ASCII control character, and not . or ..), a toleranceSec that does not read as a number, such as "5m" (zero or a negative value was already refused), a dedupOn other than header or body, a 1xx ack status (a non-integer status or one outside 100-599 was already refused), an ack body that is not a string, and an ack header value that is not a string or holds a character Node cannot write in a header: an ASCII control character other than a tab, or one above U+00FF (before, only CR and LF were refused). An installed plugin whose manifest breaks one of these rules no longer loads after the upgrade: the boot log reports plugin_load_failed, the plugin drops out of GET /api/plugins, and its ingress route answers 404. Fixing the manifest in place and restarting the gateway loads it again and re-enables it if it was enabled before. A corrected version installed with POST /api/plugins/install or POST /api/plugins/install-url loads as installed and runs only once enabled, with POST /api/plugins/{id}/enable or, if it was enabled before, at the next restart. Until then its ingress route acks deliveries again but cannot dispatch them, so they fail and are dead-lettered; redrive them once the plugin runs. The ingressUrls returned for an instance percent-encode the route (v0.23.6+).

The plugin side​

Inside the plugin, claim the route in onEnable with ctx.registerWebhook. The host renders the synchronous response itself from the route's response.ack block (default 202 accepted), and the handler usually runs after the provider has been answered; only inline dispatch on a route without a response block holds the answer until the handler finishes (see What the provider receives). The handler's return value never reaches the provider, and the status in it is inert. What carries weight is a throw: a handler that throws, or blows the dispatch budget, fails the delivery. A queued delivery is retried and dead-lettered once INGRESS_MAX_ATTEMPTS attempts have failed. An inline delivery is dead-lettered on its first failure, and the ingress reconciler then replays it on later sweeps (INGRESS_RECONCILE_INTERVAL_MS) until a replay succeeds, which retires that dead-letter row, or its replay budget runs out. A handler that returns normally is recorded as delivered whatever status it names.

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);
// Nothing to relay, and a retry cannot invent a mapping. Return without throwing so the delivery
// is not dead-lettered. The provider never sees this return value.
if (!mapping) {
ctx.logger.warn(`no chat mapped to conversation ${event.conversation_id}`);
return;
}

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 },
});
});

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 / filemediaUrlMedia-by-URL; text is an optional caption
audiomediaUrlAudio file
voicemediaUrlPTT voice note (mic bubble, audio/ogg; codecs=opus)
locationlatitude, longitudeLocation message (WGS84 coordinates; text doubles as the description; invalid ranges are rejected)

On every media type, a non-empty mediaUrl must be an absolute http:// or https:// URL with a host (v0.23.6+). Any other non-empty value is refused with url must be an absolute http(s) URL, and a handler that does not catch the error fails the delivery; queue-mode retries only repeat the refusal. Before v0.23.6 such a value reached the engine, which decoded it as base64 and sent a corrupt file. A media envelope whose mediaUrl is missing or empty is not refused: it takes the text path and goes out as a plain text message carrying its text (a quoted reply when replyTo is set). The host downloads the mediaUrl itself, and since v0.23.6 it does so through the session's egress proxy when the session has one; before, the download left from the gateway's own address. A proxy that cannot reach the media host, such as a WhatsApp-only proxy, fails every media reply on that session, which a handler that does not catch it turns into a failed delivery, while text replies still go through. Set SESSION_PROXY_URL_FETCH=false on the gateway (see Configuration) to download directly again.

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. The host verifies the resolved mapping belongs to the envelope's session and fails closed on a mismatch, so a stale mapping after a scope move can no longer send on the wrong WhatsApp session. The one exception: a mapping whose own session was deleted and re-paired is rebound to the envelope's session instead of failing forever.

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.

Since v0.23.6 the gate treats a 1:1 chat's @lid form and its phone (@c.us) form as one chat. A takeover recorded while the chat was known only by its @lid keeps silencing bots once the phone number is learned, ctx.handover.set writes the decision to every mapping the instance holds for that chat, and a new mapping under the other form starts in the state the instance already holds. A human or closed state left on a chat's @lid form before the upgrade therefore also silences bots on its phone form; hand the chat back through the plugin that holds it to release it. A number WhatsApp recycled keeps its earlier owner's @lid mapped to it, so a handover decision on either owner's chat applies to both.

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_IP_LIMIT1200Max requests per client IP per window (v0.21.0+). The per-instance window is keyed on the caller-supplied :pluginId/:instanceId, so varying those segments minted a fresh bucket per request; this second bound closes that. A 429 names the bucket that shed the request in its Retry-After-instance / Retry-After-ingress-ip header, and since v0.23.6 also carries a plain Retry-After with the same delay. Since v0.23.6 an IPv6 client is keyed on its /64, so providers reaching the gateway from one IPv6 network share this bucket
INGRESS_INSTANCE_TTL60000Fairness window length in ms (60 s)
INGRESS_RECONCILE_INTERVAL_MS60000Cadence of the ingress-event reconciler sweep (ms). 0 disables it
INGRESS_DEDUP_RETENTION_DAYS7Days an ingress dedup row is kept before pruning
INGRESS_RETENTION_DAYS90Days a dead-letter row is kept before pruning. 0 disables that prune

Numeric configuration is validated at boot; use plain integers. INGRESS_DEDUP_RETENTION_DAYS<=0 falls back to 7, while INGRESS_RETENTION_DAYS<=0 disables only dead-letter pruning. integration_delivery_failures records failed inbound deliveries only. Outbound HTTP calls through ctx.http.fetch() have no automatic retry or dead-letter record; the adapter owns that policy. Terminal outbound webhook failures are a separate surface at GET /api/webhooks/delivery-failures.

Persisted ingress events are a durability handle, not only a dedup log: each row carries dispatchState, dispatchAttempts, and lastDispatchAt, and the reconciler sweeps for deliveries stranded between the persist and the dispatch — a crash in that window, or an enqueue whose outcome was never recorded. A stuck delivery is replayed with its original delivery id, so the replay is idempotent against a job that did get enqueued, and retires terminally — marked failed, plus a dead-letter row — after five attempts rather than looping forever. Before replaying, the reconciler re-applies the eligibility check the live path makes at the door: an instance disabled or deleted after the persist is never handed the event.

Since v0.25.0 an undispatched ingress event that ages past the dedup window is handed to the dead-letter table before its dedup row can be removed. If that handoff fails, the pending row is kept. Some aged deliveries may already have reached the plugin, so check before redriving.

Retention is split by table. Settled dedup rows retire after 7 days (INGRESS_DEDUP_RETENTION_DAYS), while dead-letter rows keep the 90-day window (INGRESS_RETENTION_DAYS). Provider retries normally arrive within minutes, so the shorter window bounds the dedup table. The stored payload is nulled the moment an outcome is recorded; a sha256 payloadHash remains as the permanent fingerprint. While a row is still live, its credential and signature headers are persisted redacted (since v0.20.0).

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>, "remaining": <count>, "batchSize": 100 }. Redrive replays the retained payload with the original delivery id and a fresh queue job id. The plugin must deduplicate the delivery id to avoid repeating a downstream send. A row counts as redriven once accepted by the queue or delivered inline, not when queued processing finishes. A failed inline replay stays available for another redrive. A disabled or deleted instance answers 409 (v0.24.0+); a session-restricted ADMIN key sees only retained failures from its authorized session binding.

One call attempts at most 100 dead-lettered deliveries. Shutdown stops it at the next row and leaves unreached rows unchanged. Malformed stored payloads are skipped so they do not block later rows (v0.25.0+). Read remaining before assuming the queue is empty, and call again while it is above zero.

Troubleshooting​

SymptomCauseFix
401 on instance routesNo X-API-Key header, or the key is invalid, expired, or revokedSend a valid key. See Authentication.
403 on instance routesThe key is valid but its role is below ADMIN. Both instance controllers carry the requirement at class level, so it applies to every route on themUse an ADMIN-role key. A role that is too low never surfaces as 401.
403 API key is restricted to selected chats on instance or redrive routesThe ADMIN key carries an allowedChats list (v0.23.6+). A chat-restricted key is refused on every route not marked chat-scoped, and the instance and redrive routes are notUse an ADMIN key without allowedChats, and narrow it with allowedSessions instead.
404 on an instance you know existsThe instance's session scope is outside your key's allowedSessions — out-of-scope instances answer 404 so they cannot be probedUse an unrestricted ADMIN key, or add the instance's session to the key's allowlist.
Provider gets 401 on ingressSignature missing, stale, or made with the 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.
Provider gets 403 on ingressThe GET verification challenge failed: the instance carries no verifyToken, or the token the provider sends does not match itRead verifyToken from the create or regenerate-secret response and re-enter it at the provider. It can only be set at create time, so an instance created without one has to be recreated. Rotating the ingress secret does not affect this.
Provider gets 413 on ingressThe body exceeds the route's maxBodyBytesRaise maxBodyBytes in the plugin manifest, or BODY_SIZE_LIMIT when the route declares none.
Provider gets 503 on ingressThe route declares a session-alive preflight and the instance's bound session has no live engine or has failedReconnect the session. The delivery was not stored; since v0.23.6 the 503 carries Retry-After, so a provider that honours it retries.
A provider retry sends the WhatsApp message a second timeThe provider mints a fresh delivery id on every retry attempt, so the dedup header never matchesDeclare dedupOn: "body" on the route (v0.23.6+) so retries are keyed on the raw body.
After upgrading to v0.23.6 a plugin is missing from GET /api/plugins and the dashboard, and its ingress route answers 404Its ingress manifest breaks a rule the loader now enforces (see What the provider receives); the boot log reports plugin_load_failedCheck the gateway log for the rule the manifest breaks, then fix the manifest in place and restart the gateway, or install a plugin release whose manifest passes and enable it.
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, check which window shed them: Retry-After-ingress-ip names the per-client-IP bound, which every tenant behind one provider IP (or, since v0.23.6, one IPv6 /64) shares. Raise INGRESS_IP_LIMIT.
Replies loop back into the external systemThe adapter mirrors outbound messages from its own message:sending or message:sent hook. A ctx.conversations.send from an ingress handler runs every plugin's message:sending, message:sent, and message:failed hooks, the adapter's own included: the host's re-entrancy guard only stops a hook handler's send from re-firing an event already on that handler's chain, such as a message:sending handler that sends. Before v0.23.6 a send from an ingress handler also skipped, for every plugin, the hooks of any event the adapter was handling at that momentFilter the replies the adapter relayed out of its outbound hook, for example by recording what the ingress handler sent and skipping those messages.
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.