Shopify Order Calls (Confirmation · Cancellation · Cart-Abandoned)
Places automated voice calls on Shopify order/checkout events, lets the agent write back to the order (address / cancel / tag), and retries no-answer calls — all driven by per-connection settings the merchant edits in the Connecter UI, with no secrets exposed to the agent.
Last verified: 2026-10-30.
Terminology
- Managed Client Tool — a
DynamicToolthe backend provisions automatically and links to an agent. It carries the inter-serviceWEBHOOK_SECRETasx-api-secretso the agent can call an internal endpoint without the operator ever pasting that secret. See DYNAMIC-TOOL-CALLING.md. - Semantic tag — the agent signals an outcome (
confirmed/cancelled), and the backend maps it to a merchant-configured tag string. The LLM never sees the string. - Free-form tag — a tag the agent itself supplies mid-call (
apply_custom_tag), driven by its prompt, sanitized server-side. - No-answer — a call-end
callStatusof"not picked"or"not_answered". - BullMQ — a Redis-backed job queue used here for delayed retry/recovery jobs. See bullmq.io.
Architecture Overview
How It Works (Step-by-Step)
1. Settings (merchant-defined, server-resolved)
- The merchant edits order actions in the Connecter UI →
PUT /api/v2/connecter/shopify/settings→shopifyUpdateSettings(connecter.controller.ts). - The nested
orderActionsobject carries:confirmation/cancellation/abandonment(eachenabled+agentId[+orderTypeFilter/delayMinutes]),tags, and the three retry blocksconfirmationRetry/cancellationRetry/abandonmentRetry. - A single reconciler,
resolveOrderActions(shopifyOrderShared.ts), is the only place effective settings are read. It applies thetags.enabledmaster gate, fills tag defaults, and clamps retry values on read (interval[5,1440], maxRetries[0,10]), so an out-of-range save is accepted and clamped — never a 500. - Backward compatible: a connection with no
orderActions(ortags.enabled !== true) gets all tagsnulland all retries disabled. No migration needed.
2. Auto offline tag (COD at ingestion)
- On
orders/createthe sync path callsmaybeApplyOfflineTag(shopifyOrderTag.service.ts). If the resolvedofflinetag is set and the order classifies as COD, it applies the tag viatagsAddand stampscustomData.appliedTags.offline. Idempotent (re-delivery is a no-op). Never throws (webhook still ACK-200s).
3. Placing the call
- Confirmation (
shopifyOrderConfirm.service.ts), cancellation (shopifyOrderCancel.service.ts), and abandonment (shopifyCartAbandon.service.ts) each have a shared claim → call → record core (place*Core). The atomic Mongo claim + thetriggerCallRedis gate are the single source of truth against double-dialing.
4. Agent write-back (managed tools → internal endpoint)
- The confirmation agent gets
update_order; the cancellation agent getsrecreate_order(shopifyOrderTool.service.ts). Both POST to/internal/connecter/shopify/order-action(guarded byverifyInternalWebhook), handled byshopifyOrderActionHandler(connecterInternal.controller.ts). - Actions:
update_address,cancel_order,apply_tag(semantictagType),apply_custom_tag(free-formtag), andrecreate_order. - The order id is resolved server-side from the agent's own call link (
confirmationContactUID/cancellationContactUID), never from an LLM-supplied value, so an agent can only ever act on its own call's order.
5. Semantic vs free-form tags
apply_tag+tagType(confirmed/cancelled) → resolves the merchant string, applies it, terminates the no-answer retry loop, stampsappliedTags.<type>.apply_custom_tag+tag→ sanitized (validateTagString: trim, no comma, ≤255), capped at 10 custom tags/order, applied additively, stamped underappliedTags.custom.<key>, and does NOT terminate the retry loop or touch semantic markers.
6. No-answer retry (all three actions)
- When a call ends, the LiveKit webhook (
webhook.controller.ts) fires three fire-and-forget evaluators; each no-ops unless the ended call's contact matches its link. - Confirmation (
shopifyCallRetry.service.ts+shopifyConfirmationRetryScheduler.service.ts) and cancellation (shopifyCancellationRetry.service.ts): on no-answer, schedule a delayed BullMQ retry; the job re-reads the order, runs an atomic count-claim (exhaustion-first), releases the prior call claim, and re-dials viaplace*Core. Confirmation applies the terminalunverifiedtag on exhaustion; cancellation just stops. - Abandonment (
shopifyAbandonmentRetry.service.ts) reuses the existing cart-abandon scheduler: it reopens the checkout (reopenForRetry), bumpsretryCount, and reschedules — bounded bymaxRetries, stops if the cart converts, no terminal tag.
File Reference
| File | Role | Key Functions |
|---|---|---|
controllers/connecter.controller.ts | Settings API: parse/validate orderActions incl. tags + 3 retry blocks, carry-forward on omit | shopifyUpdateSettings, parseOrderActionsBody |
controllers/connecterInternal.controller.ts | Internal endpoint: address / cancel / apply_tag / apply_custom_tag / recreate | shopifyOrderActionHandler, sanitizeTagKey |
controllers/webhook.controller.ts | LiveKit call-end hooks → fire the 3 retry evaluators | (call-end block) |
services/connecter/shopifyOrderShared.ts | The single settings reconciler + validators | resolveOrderActions, validateTagString, resolveRetry |
services/connecter/shopifyOrderTag.service.ts | Tag-write core + COD offline auto-tagger | applyOrderTag, maybeApplyOfflineTag |
services/connecter/shopifyOrderTool.service.ts | Provisions the managed update_order / recreate_order tools | ensureConfirmationTool, ensureCancellationTool |
services/connecter/shopify.adapter.ts | Admin GraphQL adapter | addOrderTags (tagsAdd), updateOrderShippingAddress, cancelOrder |
services/connecter/shopifyCallRetry.service.ts | Confirmation retry state machine | evaluateShopifyConfirmationCallEnd, processConfirmationRetryJob, applyTerminalUnverified |
services/connecter/shopifyCancellationRetry.service.ts | Cancellation retry (scheduler + state machine, no terminal tag) | evaluateShopifyCancellationCallEnd, processCancellationRetryJob |
services/connecter/shopifyAbandonmentRetry.service.ts | Abandonment no-answer retry (reuses cart-abandon scheduler) | evaluateShopifyAbandonmentCallEnd |
repositories/CrmAbandonedCheckoutRepository.ts | Checkout persistence | findByContactUID, reopenForRetry |
models/ShopifyConnectionConfig.ts | Config schema incl. orderActions.{tags,*Retry} | orderActionsSchema, retrySettingSchema |
Data Model (config shape)
ShopifyConnectionConfig.orderActions:
jsonc
{
"confirmation": { "enabled": true, "agentId": "agent_abc", "orderTypeFilter": "all" },
"cancellation": { "enabled": false, "agentId": null, "orderTypeFilter": "cod" },
"abandonment": { "enabled": true, "agentId": "agent_xyz", "delayMinutes": 45 },
"tags": {
"enabled": true, // master gate — false ⇒ ALL tags null
"offline": "offline-order",// auto on COD at ingestion
"confirmed": "cod-confirmed",// apply_tag tagType=confirmed
"cancelled": "cod-cancelled",// apply_tag tagType=cancelled / real cancel
"unverified": "cod-unverified"// confirmation retries exhausted
},
"confirmationRetry": { "enabled": true, "intervalMinutes": 30, "maxRetries": 3 },
"cancellationRetry": { "enabled": false, "intervalMinutes": 30, "maxRetries": 3 },
"abandonmentRetry": { "enabled": false, "intervalMinutes": 30, "maxRetries": 3 }
}Retry/outcome state lives on CrmOrder.customData (<action>RetryCount, <action>RetryTerminal, appliedTags.*) for order flows, and on CrmAbandonedCheckout (retryCount, status, contactUID) for abandonment. A free-form tag is stamped at customData.appliedTags.custom.<mongo-safe-key>.
API Endpoints
Update settings
- Method / Path:
PUT /api/v2/connecter/shopify/settings - Auth: customer JWT; Shopify connection must exist.
- Body:
{ "orderActions": { … as above … } }(partial-safe: omitted keys carry forward). - Errors:
400 INVALID_TAGS(comma / >255 / non-string),400 INVALID_RETRY(non-boolean enabled / non-finite number),400 MISSING_AGENT,409 WRITE_SCOPE_MISSING.
Internal order action (agent → backend, not public)
- Method / Path:
POST /internal/connecter/shopify/order-action - Auth:
verifyInternalWebhook(x-api-secret=WEBHOOK_SECRET, injected by the managed tool). - Body (apply_custom_tag):
{ "contactUID": "…", "action": "apply_custom_tag", "tag": "wants-discount" } - Responses:
200tagged ·200already tagged / none configured ·400invalid tag ·422Shopify rejection / tag cap reached ·502transport failure.
External Integrations
- Shopify Admin GraphQL (
shopify.adapter.ts):tagsAdd(additive, idempotent set-union),orderUpdate,orderCancel. Token resolved server-side and never logged/returned. A business rejection →422; a transport/auth failure →502. - LiveKit: call placement via
triggerCall; call-end webhook drives retry evaluation. All evaluators are try/catch-wrapped so the webhook always ACK-200s. - Redis / BullMQ: delayed retry + recovery jobs with deterministic jobIds (a reschedule replaces the standing job; no stacking). A Redis failure degrades to "no retry," never breaks the webhook.
How to Test (End-to-End)
- Connect a Shopify store, enable Order confirmation calls + an agent, turn on Tags and Retry, Save. Re-open settings and confirm the values persisted (GET round-trips
orderActions). - Offline tag: place a COD order → within the ingestion window the order shows the
offline-ordertag in Shopify admin; a prepaid order gets none. - Confirmed/cancelled: run a confirmation call; on confirm the order gets
cod-confirmed, on cancelcod-cancelled. - Free-form tag: add to the agent prompt "tag the order
wants-discountif the customer asks for a discount"; verify the tag appears after such a call. - Retry: set interval = 5, maxRetries = 1; trigger a call that isn't answered → confirm a re-dial after the interval and (confirmation only) the
cod-unverifiedtag after the last unanswered attempt.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Tags never applied | tags.enabled is off (master gate) | Enable tagging and Save. |
400 INVALID_TAGS on save | Tag contains a comma or exceeds 255 chars | Use a single comma-free label. |
| Retry never fires | Retry disabled, or the end status wasn't a no-answer | Enable retry; confirm the call-end callStatus is not picked/not_answered. |
422 on apply_custom_tag | Order already has 10 custom tags, or Shopify rejected the tag | Raise is not configurable; dedupe prompt-driven tags. |
Confirmation re-dials but no unverified tag | unverified tag blank/disabled, or loop closed by a pickup, not exhaustion | The tag applies only on true retry exhaustion. |
| Abandonment keeps calling a recovered cart | — | It stops on conversion (reopenForRetry skips converted rows); verify convertedAt is set. |
Changelog
- 2026-10-30 — Added free-form
apply_custom_tag(prompt-defined tags, sanitized + capped) and extended no-answer retry from confirmation-only to all three actions (cancellation + abandonment; terminalunverifiedtag stays confirmation-only). Added the Connecter-UI controls for tags + per-action retry.