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:
- Events, Magento → Conviro: asynchronous, from a local outbox.
- 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.
- 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).
- Enqueue (
Service/Event/EventPublisher.php,EventOutbox::enqueue): one INSERT inside the caller's transaction where there is one. Never throws; a failure is logged and the event is lost rather than failing the request. Personal-data events (order / customer / cart / checkout / payment, exceptcustomer.deleted) are not queued while the store holds no signing secret or while Conviro refuses its credentials (flagconviro_delivery_blocked, set by a 401/403, cleared by an accepted delivery or a registration) — they could only wait in the table with the shopper's data (1.4.0, security review R3 M2). Measured enqueue cost on the local stack: p50 0.8 ms / p95 2.0 ms (52fc1635), p95 3.26 ms (90c79bc8), p50 1.39 / p95 3.48 ms (fff8da0a); budget 20 ms. See Performance and SLOs. - Coalescing (
EventOutbox::enqueueLatest): forinventory.updated(per SKU) andsync.job.progress(per job) a still-pendingrow with the same type and subject gets the new payload and a new event id instead of a second row. - Claim (
EventOutbox::claimDue): up to 50 rows,pendingand due, orsendingwhoselocked_untilpassed; ordered by id; claimed with a random 32-hex token and a 300 s lock by a conditional UPDATE, so two workers cannot win the same row. - Deliver (
Cron/DeliverEvents.php→Service/Event/EventDelivery.php): cron jobconviro_deliver_events,* * * * *, guarded by the lockconviro_event_outbox_delivery(a second overlapping run exits at once). Batches of 50 until the queue is empty or 50 s have passed. HTTP timeout 15 s, connect timeout 5 s per event. - Outcome (
Service/Event/RetryPolicy.php): 2xx →sent; network error, 408, 409, 425, 429, 5xx → retry after +30 s, +2 min, +10 min, +30 min, +2 h, +8 h (Retry-After honoured up to 1 h),deadafter 7 attempts; 401/403 →blocked, and the rest of the batch is held back without spending an attempt; other 4xx →dead. Full table in the event reference. - Purge (
Cron/PurgeOutbox.php, daily 03:23):sentrows older than 7 days;dead,dropped,repairedrows older than 30 days; every remainingsentpayload emptied (personal-data payloads are emptied at delivery already);blockedrows older than 30 days becomedroppedwith the payload emptied. - Release: a successful registration (Save Config, wizard, Reconnect) re-queues
blockedrows — only when the key belongs to the same Conviro workspace; with another workspace's key every undelivered row isdroppedand its payload emptied first. Retry failed re-queuesblockedanddeadrows with a fresh attempt budget.
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).
| Request | Who calls it | Timeout (total / connect) |
|---|---|---|
POST /v1/api/magento/events | outbox worker | 15 s / 5 s |
GET /api/magento/tenant-info | registration; Integration Health (cached 60 s) | 10 s / 5 s; health 4 s / 3 s |
POST /api/magento/register-store | Save Config, wizard steps 3/5/6, Reconnect, OAuth migration | 10 s / 5 s |
POST /api/magento/store-mapping | wizard step 4; every re-registration after a selection was saved | 10 s / 3 s |
POST /api/magento/disconnect-store | Disconnect (and its cron retry) | 8 s / 3 s |
GET /api/magento/module-version | version check (cached 12 h; 15 min after a failed check); wizard step 1 reachability probe (anonymous) | 4 s / 3 s |
POST /api/magento/connect/exchange | wizard step 2 callback, no API key (the one-time code + PKCE verifier authenticate it) | 15 s / 3 s |
GET /v1/api/lead-forms | admin lead-form list | 10 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
| Direction | Replay window | Duplicate inside the window |
|---|---|---|
| Magento → Conviro | timestamp in the MAC, ±300 s | Redis 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 → Magento | timestamp in the MAC, ±300 s | conviro_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:
- OAuth 1.0a, HMAC-SHA256, signed for
https://—authMode = oauth1. - 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). Alsooauth1. - The access token as a Bearer token —
authMode = bearer(legacy), withoauthErrorexplaining 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 / key | Written by | Notes |
|---|---|---|
tenant_integrations (provider magento) | register-store, disconnect, store-mapping | Encrypted credentials; metadata.authMode, oauthError, authVerifiedAt, storeUrl, storeMapping, disconnectedAt/By; settings = capabilities and trust policy. |
knowledge_sources (Magento product entries) | product events, inventory events, nightly pull | One per SKU; metadata.magentoModuleSections. |
commerce_orders, commerce_customers, commerce_sync_jobs | order / customer / progress events | Migrations 0220–0222; retention indexes 0228 (24 months / 90 days, purge 30 days after a disconnect). |
commerce_attribution_links, commerce_checkouts, commerce_payment_failures | attribution, checkout.started, payment.failed | Migrations 0225, 0226; retention indexes 0228. |
magento_event_receipts | ingest (side-effecting event types) | Migration 0227; 7 days. |
magento_connect_codes | connect approve / exchange | Migration 0219; code stored as SHA-256. |
leads | customer.created | |
magento_abandoned_carts, contacts | cart.updated | |
tenant_webhooks | register-store (store receive webhook), disconnect | |
chat_message_grounding | chat pipeline | Migration 0223, staff-only. |
ai_action_policies, ai_action_requests, ai_action_events | AI action policy | Migration 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 lookups | failed 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.