Skip to content

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 DynamicTool the backend provisions automatically and links to an agent. It carries the inter-service WEBHOOK_SECRET as x-api-secret so 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 callStatus of "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 orderActions object carries: confirmation / cancellation / abandonment (each enabled + agentId [+ orderTypeFilter / delayMinutes]), tags, and the three retry blocks confirmationRetry / cancellationRetry / abandonmentRetry.
  • A single reconciler, resolveOrderActions (shopifyOrderShared.ts), is the only place effective settings are read. It applies the tags.enabled master 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 (or tags.enabled !== true) gets all tags null and all retries disabled. No migration needed.

2. Auto offline tag (COD at ingestion) ​

  • On orders/create the sync path calls maybeApplyOfflineTag (shopifyOrderTag.service.ts). If the resolved offline tag is set and the order classifies as COD, it applies the tag via tagsAdd and stamps customData.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 + the triggerCall Redis 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 gets recreate_order (shopifyOrderTool.service.ts). Both POST to /internal/connecter/shopify/order-action (guarded by verifyInternalWebhook), handled by shopifyOrderActionHandler (connecterInternal.controller.ts).
  • Actions: update_address, cancel_order, apply_tag (semantic tagType), apply_custom_tag (free-form tag), and recreate_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, stamps appliedTags.<type>.
  • apply_custom_tag + tag → sanitized (validateTagString: trim, no comma, ≤255), capped at 10 custom tags/order, applied additively, stamped under appliedTags.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 via place*Core. Confirmation applies the terminal unverified tag on exhaustion; cancellation just stops.
  • Abandonment (shopifyAbandonmentRetry.service.ts) reuses the existing cart-abandon scheduler: it reopens the checkout (reopenForRetry), bumps retryCount, and reschedules — bounded by maxRetries, stops if the cart converts, no terminal tag.

File Reference ​

FileRoleKey Functions
controllers/connecter.controller.tsSettings API: parse/validate orderActions incl. tags + 3 retry blocks, carry-forward on omitshopifyUpdateSettings, parseOrderActionsBody
controllers/connecterInternal.controller.tsInternal endpoint: address / cancel / apply_tag / apply_custom_tag / recreateshopifyOrderActionHandler, sanitizeTagKey
controllers/webhook.controller.tsLiveKit call-end hooks → fire the 3 retry evaluators(call-end block)
services/connecter/shopifyOrderShared.tsThe single settings reconciler + validatorsresolveOrderActions, validateTagString, resolveRetry
services/connecter/shopifyOrderTag.service.tsTag-write core + COD offline auto-taggerapplyOrderTag, maybeApplyOfflineTag
services/connecter/shopifyOrderTool.service.tsProvisions the managed update_order / recreate_order toolsensureConfirmationTool, ensureCancellationTool
services/connecter/shopify.adapter.tsAdmin GraphQL adapteraddOrderTags (tagsAdd), updateOrderShippingAddress, cancelOrder
services/connecter/shopifyCallRetry.service.tsConfirmation retry state machineevaluateShopifyConfirmationCallEnd, processConfirmationRetryJob, applyTerminalUnverified
services/connecter/shopifyCancellationRetry.service.tsCancellation retry (scheduler + state machine, no terminal tag)evaluateShopifyCancellationCallEnd, processCancellationRetryJob
services/connecter/shopifyAbandonmentRetry.service.tsAbandonment no-answer retry (reuses cart-abandon scheduler)evaluateShopifyAbandonmentCallEnd
repositories/CrmAbandonedCheckoutRepository.tsCheckout persistencefindByContactUID, reopenForRetry
models/ShopifyConnectionConfig.tsConfig 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: 200 tagged · 200 already tagged / none configured · 400 invalid tag · 422 Shopify rejection / tag cap reached · 502 transport 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) ​

  1. 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).
  2. Offline tag: place a COD order → within the ingestion window the order shows the offline-order tag in Shopify admin; a prepaid order gets none.
  3. Confirmed/cancelled: run a confirmation call; on confirm the order gets cod-confirmed, on cancel cod-cancelled.
  4. Free-form tag: add to the agent prompt "tag the order wants-discount if the customer asks for a discount"; verify the tag appears after such a call.
  5. 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-unverified tag after the last unanswered attempt.

Troubleshooting ​

SymptomCauseFix
Tags never appliedtags.enabled is off (master gate)Enable tagging and Save.
400 INVALID_TAGS on saveTag contains a comma or exceeds 255 charsUse a single comma-free label.
Retry never firesRetry disabled, or the end status wasn't a no-answerEnable retry; confirm the call-end callStatus is not picked/not_answered.
422 on apply_custom_tagOrder already has 10 custom tags, or Shopify rejected the tagRaise is not configurable; dedupe prompt-driven tags.
Confirmation re-dials but no unverified tagunverified tag blank/disabled, or loop closed by a pickup, not exhaustionThe 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; terminal unverified tag stays confirmation-only). Added the Connecter-UI controls for tags + per-action retry.

Last updated: