Architecture

How the Magento module and the Conviro backend talk to each other in 1.4.0: the event outbox, delivery, signatures in both directions, idempotency, replay protection and the OAuth 1.0a / legacy bearer dual stack. Read against the code on feat/magento-10-10 (d3472ce2), 2026-09-24.

Path shorthands: AiChat/ = integrations/magento2/Conviro/AiChat/, api/magento/ = apps/api/src/modules/magento/.

Parts

 Magento (merchant server)                         Conviro (api.conviro.io)
 ─────────────────────────                         ────────────────────────
 observers ──INSERT──▶ conviro_event_outbox
                           │  cron conviro_deliver_events (every minute)
                           └──POST /v1/api/magento/events (signed v1) ──▶ ingest → KB, commerce_orders, leads, …
 admin: Save Config / setup wizard
        ──GET /api/magento/tenant-info, POST /api/magento/register-store ──▶ verifies OAuth, stores credentials
 sync jobs (cron) ──same outbox──▶
                                                   live reads / order actions (chat, flows, dashboard)
 /rest/V1/…  ◀── OAuth 1.0a signed REST calls ──────┘
 /conviro/webhook/receive ◀── signed webhooks (lead.*, contact.created, session.started)
 storefront widget (browser) ── loads cdn.conviro.io, talks to api.conviro.io

Three independent channels:

  1. Events, Magento → Conviro: asynchronous, from a local outbox.
  2. Live reads and writes, Conviro → Magento REST: synchronous, OAuth 1.0a signed, when a shopper asks, a flow runs, or staff act in the dashboard.
  3. Webhooks, Conviro → Magento: signed notifications the store records and re-dispatches as a Magento event.

The storefront widget is a fourth, separate path (browser ↔ Conviro); see Headless / PWA.

Event outbox

Table conviro_event_outbox (declarative schema, AiChat/etc/db_schema.xml): event_id (UUID, unique), event_type, store_id, subject_id, payload_json (the signed body, sent byte for byte), status, attempt_count, next_attempt_at, claim_token, locked_until, last_http_status, last_error, created_at, sent_at, latency_ms.

Statuses: pending → sending → sent; or blocked (401/403), dead (gave up / refused), dropped (store disconnected), repaired (replaced by a rebuilt event).

There is no message-queue (RabbitMQ) consumer; delivery is cron only, so a store without a working Magento cron queues events and never delivers them (the health card says so).

Magento → Conviro requests

All go to conviro_aichat/general/api_url (default https://api.conviro.io) with Authorization: Bearer <Conviro API key>. A fresh cURL client per call (Service/ApiClient.php).

RequestWho calls itTimeout (total / connect)
POST /v1/api/magento/eventsoutbox worker15 s / 5 s
GET /api/magento/tenant-inforegistration; Integration Health (cached 60 s)10 s / 5 s; health 4 s / 3 s
POST /api/magento/register-storeSave Config, wizard steps 3/5/6, Reconnect, OAuth migration10 s / 5 s
POST /api/magento/store-mappingwizard step 4; every re-registration after a selection was saved10 s / 3 s
POST /api/magento/disconnect-storeDisconnect (and its cron retry)8 s / 3 s
GET /api/magento/module-versionversion check (cached 12 h; 15 min after a failed check); wizard step 1 reachability probe (anonymous)4 s / 3 s
POST /api/magento/connect/exchangewizard step 2 callback, no API key (the one-time code + PKCE verifier authenticate it)15 s / 3 s
GET /v1/api/lead-formsadmin lead-form list10 s / 5 s

Event signature (Magento → Conviro)

POST /v1/api/magento/events
Authorization: Bearer <API key>
Content-Type: application/json
X-Conviro-Event-Id: <envelope id>
X-Conviro-Timestamp: <unix seconds>
X-Conviro-Signature: v1=<hex HMAC-SHA256(event secret, "<timestamp>.<raw body>")>

Conviro (MagentoService.assertWebhookSignature, requireV1: true) refuses with 401: a missing signature, a signature that is not v1= + 64 hex, a missing or malformed timestamp, a timestamp more than 300 s from its clock, a wrong MAC (compared in constant time), or an integration with no signing secret. The raw request body is what is verified. X-Conviro-Event-Id must equal the envelope id (else 400). The endpoint requires the API key scope kb:write.

Whose secret (PR #832, 2026-09-27). The event is verified with the secret of the active Magento integration the calling API key registered: register-store records the key it was called with on the integration (metadata.registeredKeyId), for every module version. A workspace can hold more than one integration (the module's workspace-wide one and chatbot-specific connections from the dashboard, possibly to another store); only the key's own applies — the workspace-wide one first when the key registered several (a module registration refreshes chatbot connections to the same store), then the newest. A key that registered none is refused 401 before the signature is read, signed or not, with a machine-readable error: api_key_not_registered (Save Config in the store that sends the events registers it again with the key it uses), magento_not_connected (no active integration). There is no fallback to another integration's secret or enforcement flag. The same rule applies to the legacy per-event endpoints below.

Signing secrets — one per direction (security review R3, 2026-09-25). A 1.4.0 module sends secretsPerDirection: true on register-store; Conviro answers with eventSecret (signs Magento → Conviro events; Conviro verifies with it only) and receiveSecret (signs Conviro → Magento webhooks). The module stores them encrypted (conviro_aichat/general/event_secret, conviro_aichat/general/receive_secret) and never shows them. A module that does not ask (≤ 1.3.x) gets the single shared webhookSecret (conviro_aichat/general/webhook_secret) for both directions — the compatibility path; a 1.4.0 module talking to an older backend falls back to it too. Conviro keeps the secrets only for the same store URL re-registered by the same API key: another store URL, another key or a switch of secret mode mints fresh ones (an existing secret is never handed to a caller registering a different store), and the previous store's receive webhook is switched off. A disconnect deletes them.

Legacy format (≤ 1.3.x modules only). The per-event endpoints /v1/api/magento/webhook/{product,cart,order,order-status,payment,customer} and /api/magento/webhook also accept X-Conviro-Signature: <base64 HMAC-SHA256 of the raw body> with no timestamp, and accept unsigned requests unless the merchant set Require Signed Webhooks = Yes. A 1.4.0 module never calls them. Their removal is plan open decision 6.

Webhook signature (Conviro → Magento)

POST https://<store base URL>/conviro/webhook/receive
X-Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(receive secret, "<t>.<raw body>")>

AiChat/Service/Security/Signature.php::verifyInbound: ±300 s window; several v1= values are accepted so a sender can overlap old and new secrets during a rotation; constant-time compare. The controller is CsrfAwareActionInterface (Magento's form-key check does not apply to it). Handling and answers: event reference.

Idempotency and replay protection

DirectionReplay windowDuplicate inside the window
Magento → Convirotimestamp in the MAC, ±300 sRedis key per (workspace, event id): done for 7 days → 2xx duplicate; claim 5 min → 409. For the side-effecting types (order.placed, cart.updated, customer.created, checkout.started, payment.failed) the claim is a database row (magento_event_receipts, migration 0227) and does not fail open without Redis; the other types are idempotent by key and keep the Redis gate.
Conviro → Magentotimestamp in the MAC, ±300 sconviro_inbound_event keyed by SHA-256 of the signature header → 200 duplicate. Keys purged after 1 day (cron conviro_purge_inbound_events, hourly at :17).

Delivery is at least once. The seven attempts of the module's retry schedule span about 10.7 hours, inside Conviro's 7-day idempotency window.

Conviro → Magento REST: OAuth 1.0a dual stack

Connecting creates (or repairs) one Magento integration named Conviro AI Chat (AiChat/Service/Auth/IntegrationManager.php) with a least-privilege ACL (Permissions). Its four OAuth credentials (consumer key and secret, access token and secret) are sent to Conviro in register-store, next to the legacy accessToken field that pre-OAuth backends read.

Conviro (MagentoService.verifyConnection) then probes the store, strongest first, and records the first mode that works:

  1. OAuth 1.0a, HMAC-SHA256, signed for https:// — authMode = oauth1.
  2. OAuth 1.0a signed as http:// while still sent over https — for a store behind a TLS terminator that does not tell Magento the request was HTTPS (oauthSignAsScheme). Also oauth1.
  3. The access token as a Bearer token — authMode = bearer (legacy), with oauthError explaining why OAuth was refused.

All credentialed calls — MagentoService, the flow-builder Magento nodes, the chat pipeline's order lookup and the AI action writer — go through one signer (api/magento/magento-http.ts, magento-oauth1.ts, a port of Magento's own Laminas verifier) and one fail-closed SSRF guard (checkMagentoStoreUrl: https only, public DNS; a DNS error refuses the call). Default request timeout 10 s. Measured: Magento 2.4.7-p4 accepted the backend's signatures on every endpoint Conviro calls; a forged signature and the same token sent as Bearer were refused (oauth-roundtrip.ts, local stack).

Legacy bearer window. Stores still on bearer keep working through AiChat/Plugin/WebApiAuthorizationFix.php, a plugin on Magento\Framework\Authorization for REST requests. It is inert once auth_mode = oauth1, acts only on the Conviro integration's own access token (matched by consumer id), and only ever answers with that integration's ACL role. On upgrade from 1.3.x a store with a saved key is re-registered with OAuth automatically by cron (Upgrade guide). When the shim and the legacy endpoints are removed is open decision 6 of the plan.

The module never writes enable_integration_as_bearer, and Save Config no longer regenerates the access token (1.3.x did, which revoked Conviro's token before Conviro had the new one).

Connection lifecycle state

Not settings, so kept in Magento's flag table: conviro_setup_state (wizard progress), conviro_connection_state (connected / disconnected and when), conviro_pending_disconnect (a disconnect Conviro still has to be told about, with the old key encrypted), conviro_oauth_migration. The cron job conviro_connection_maintenance (every minute, idle otherwise) runs the OAuth migration and the disconnect retry.

Sync jobs

AiChat/Service/Sync/SyncJobRunner.php, table conviro_sync_job, cron jobs conviro_catalog_resync and conviro_sync_jobs (every minute each). One active job per type (unique active_key), 200 items per batch, each item's outbox INSERT and the job's cursor/counter UPDATE in one transaction (a killed run resumes with nothing lost or doubled), no new batch while 1,000 events wait for delivery. Progress reaches Conviro as sync.job.progress. Details: Data sync.

Backend storage touched by the integration

Table / keyWritten byNotes
tenant_integrations (provider magento)register-store, disconnect, store-mappingEncrypted credentials; metadata.authMode, oauthError, authVerifiedAt, storeUrl, storeMapping, disconnectedAt/By; settings = capabilities and trust policy.
knowledge_sources (Magento product entries)product events, inventory events, nightly pullOne per SKU; metadata.magentoModuleSections.
commerce_orders, commerce_customers, commerce_sync_jobsorder / customer / progress eventsMigrations 0220–0222; retention indexes 0228 (24 months / 90 days, purge 30 days after a disconnect).
commerce_attribution_links, commerce_checkouts, commerce_payment_failuresattribution, checkout.started, payment.failedMigrations 0225, 0226; retention indexes 0228.
magento_event_receiptsingest (side-effecting event types)Migration 0227; 7 days.
magento_connect_codesconnect approve / exchangeMigration 0219; code stored as SHA-256.
leadscustomer.created
magento_abandoned_carts, contactscart.updated
tenant_webhooksregister-store (store receive webhook), disconnect
chat_message_groundingchat pipelineMigration 0223, staff-only.
ai_action_policies, ai_action_requests, ai_action_eventsAI action policyMigration 0224; 0229 adds the policy version and the one-open-request indexes.
Redis mag:evt:*, mag:evtstats:*, mag:evtcount:*, mag:slo:*, mag:<kind>:<tenant>:*ingest, metrics, live-read caches
Redis ordlk:*chat order lookupsfailed order-number / e-mail pairs, 1 hour (brute-force guard)

Migrations 0219–0229 are idempotent DDL outside the drizzle journal; they reach a database through deploy.sh's replay of migrations/*.sql or a --pre-sql-file on the rolling deploy, before the API code that needs them.