Inspiration

Cash on delivery is the majority payment method across much of South-East Asia, and a well-known share of COD orders are fake, duplicated, or carry an address the driver will never find. The merchant finds out at the door, after paying for the shipping.

The obvious fix is to phone the customer before dispatch. Nobody does it at volume, because at a few hundred orders a day it is a full-time human job.

CALL-E can make that call. The interesting problem is not placing it - it is the part after: turning a messy human conversation into a ship-or-hold decision that a Shopify Flow can execute without a human reading a transcript.

What it does

A Shopify order comes in with payment_method = cod. The plugin:

  1. Places one CALL-E call to the customer with a task and a result_schema.
  2. Waits for the call to reach a terminal status, polling with backoff.
  3. Runs the structured result through a pure decision core that emits exactly one of SHIP, HOLD, or CANCEL, plus a human-readable reason.
  4. Writes the decision back as a Shopify order tag so Flow can branch on it.

The decision core is deliberately boring and total: same input, same output, no I/O, no clock, no network. Every branch is unit-tested. That is the part a merchant is trusting with real inventory.

How we built it

Node with no runtime dependencies, structured as four separable pieces:

  • src/decide.mjs - the pure decision core. Takes a call result, returns a decision. No side effects at all, so it is trivially testable and safe to reason about.
  • src/calle.mjs - the CALL-E client. Idempotency keys, terminal-status polling, backoff, error mapping.
  • src/shopify.mjs - order fetch and tag write.
  • src/gate.mjs - the orchestrator that wires the three together.

Plus a CLI (preview, run) and a webhook server for production use.

Four things the CALL-E maintainers flagged, handled explicitly

The repo's own docs and issues name the failure modes that break naive integrations. Each one is addressed and tested:

  • Idempotency - every call carries an idempotency-key. A retried webhook does not dial the customer twice. There is a regression test for exactly this, and it caught a real bug: the fake server held its call registry at module scope, so idempotency state leaked between server instances. Moved inside the factory.
  • Reconciliation - the order tag is the single source of truth. A crash mid-flight resumes from the tag, not from memory.
  • Ambiguity - when the customer's answer does not clearly confirm or deny, the gate emits HOLD, not a guess. It does not re-dial a real human being to resolve its own uncertainty.
  • Non-terminal states - the client waits for a genuinely terminal status. An HTTP 200 from the call-creation endpoint is a queue acknowledgement, not a result, and treating it as one is the most common way to ship a broken integration.

Runnable with no credentials

CONTRIBUTING.md mandates a dry-run path, and it is the right call: a reviewer should not need a CALL-E key and a Shopify store to evaluate a plugin. Two fake servers ship with the repo, so:

npm test          # 31/31, no network, no credentials
node src/cli.mjs preview --order examples/order-cod.json

A judge can see the whole decision surface in about ten seconds.

Challenges we ran into

The webhook path is a trap. webhook_url is accepted by the API but does not reliably fire, so a plugin built on it looks correct and silently never completes. We poll for terminal status instead. This is documented in the plugin README so the next person does not lose the same day.

Idempotency leaking across instances. The regression test above failed for a subtle reason - module-level state in the fake server, not in the client. Worth stating plainly because it is the kind of bug that passes review and then dials a customer twice in production.

Deciding what "no answer" means. A call that completes but extracts nothing is not a "no". The gate holds the order and surfaces it, rather than cancelling on silence. (The companion entry, confidence-gated-results, generalises exactly this problem.)

Accomplishments that we're proud of

  • 31/31 tests green, no network and no credentials required.
  • The decision core is pure - the piece holding a merchant's inventory decision has no I/O in it at all.
  • It targets plugins/, which had 0 of 19 community contributions at the time of writing. Everyone else went to skills/.
  • scripts/validate_repository.py passes: "Repository validation passed."

What we learned

The hard part of a voice agent integration is not the voice. It is the contract between an unreliable, ambiguous, asynchronous conversation and a system that has to make one irreversible decision. Getting that boundary right - being explicit about ambiguity instead of collapsing it into a boolean - is most of the work.

What's next for Shopify COD Confirmation Gate

Multi-attempt policy (call twice at different hours before holding), per-merchant confidence thresholds, and a small dashboard showing hold reasons so a merchant can tune the gate against their own return rate.

Links

Built With

Share this project:

Updates

Submission history