Skip to content

ContactPigeon / Menura Order History API ​

Flow ID: IN-26 | Module(s): ecommercen/api/controllers/AdvApiContactpigeonOrders.php, application/modules/api/controllers/Api_contactpigeon_orders.php, ecommercen/helpers/phone_helper.php | Complexity: Medium Last Updated: 2026-09-29

Business Overview ​

Ecommercen exposes a read-only, partner-facing REST endpoint that lets the ContactPigeon / Menura marketing-automation platform look up a customer's own order history by mobile phone number. ContactPigeon uses this to power behavioral marketing (purchase-based segments, win-back campaigns, post-purchase flows) without Ecommercen having to push a data export.

The endpoint is intentionally narrow: it takes a phone number and an optional token, and returns up to the 50 most recent orders placed by that person as the billing party — never the delivery recipient. This distinction matters because gift orders and "deliver to a different address" checkouts mean the person paying and the person receiving are often not the same, and the receiving party never consented to having their number handed to a marketing platform.

This is a separate integration from the existing ContactPigeon product XML feed (see IN-01 Feed Generation) — that feed exports the product catalog outbound on a schedule; this endpoint answers inbound, on-demand lookups about orders. The two share a partner name but nothing else: separate registry group, separate token, separate code path.

API Reference ​

MethodPathAuthDescription
GET/api/contact_pigeon/orders?mobphone=…&token=…IP allowlist + optional shared tokenOrder history, default site language
GET/{lang}/api/contact_pigeon/orders?mobphone=…&token=…IP allowlist + optional shared tokenOrder history, response localized to {lang}

Both routes point at the same controller action; the {lang} prefix only changes which language the item names in the response are rendered in (see Code Flow).

Query parameters ​

ParamRequiredDescription
mobphoneYes (functionally)The phone number to look up. Any format accepted by phone matching; an unparseable value yields {"orders": []}, not an error.
tokenOnly when CONTACTPIGEON.IS_PROTECTED is enabledCompared to CONTACTPIGEON.TOKEN with hash_equals().

Response shape ​

json
{
  "orders": [
    {
      "order_id": "ORD-2026-00123",
      "order_date": "2026-07-30 14:22:10",
      "order_value": 61.50,
      "status": "PAID_SENT",
      "status_label": "Paid & Sent",
      "tracking_number": "GR123456789",
      "pricing_name": "Maria",
      "pricing_surname": "Papadopoulou",
      "pricing_address": "Ermou 12",
      "pricing_city": "Athens",
      "pricing_postal": "10563",
      "items": [
        { "sku": "5201234567890", "name": "Product name", "quantity": 2, "price": 12.50 }
      ]
    }
  ]
}

Every guard rejection (see Business Rules) returns the same shape, regardless of cause:

json
{ "error": "Not found" }

with HTTP status 404 Not Found — never 401/403, and never the HTML storefront 404 page (Adv_base_controller::error_404()); see the note under Business Rules.

Code Flow ​

GET /api/contact_pigeon/orders?mobphone=…&token=…
  or
GET /{lang}/api/contact_pigeon/orders?...
  -> MY_Lang::__construct() (application/core/MY_Lang.php:55-60) reads URI segment 1
     BEFORE any controller runs, and sets $CFG['language'] / $CFG['language_abbr']
     from it if it matches a configured lang_uri_abbr. This is what makes the {lang}
     prefix drive the response language further down the stack.
  -> application/config/routes.php
     $route['api/contact_pigeon/orders']        = 'api/api_contactpigeon_orders/index';
     $route['(\w{2})/api/contact_pigeon/orders'] = 'api/api_contactpigeon_orders/index';
     The partner-facing URL is unchanged — only the routing TARGET moved. Both routes
     are still explicit because `api` is also an HMVC module name, and both must sit
     above the storefront catch-all routes or the URL resolves into eshop/... instead.
     The target is now module-qualified (`api/api_contactpigeon_orders/index`) rather
     than a bare controller name, because the stub controller lives inside the HMVC
     `api` module rather than as a top-level `application/controllers/` file.
  -> application/modules/api/controllers/Api_contactpigeon_orders.php   <- the ROUTE TARGET
     One-line stub: `class Api_contactpigeon_orders extends AdvApiContactpigeonOrders {}`.
     Routing targets the STUB, not the ecommercen/ implementation directly — omitting
     this file 404s the route even though the real logic lives elsewhere.
  -> ecommercen/api/controllers/AdvApiContactpigeonOrders.php (extends Base_c)
     __construct():
       - loads the 'admin' and 'phone' ecomn helpers
       - explicitly loads the adv_advisable lang file, because Adv_base_controller
         does NOT load it (only Adv_admin_controller does) — without this, t() would
         return the raw dotted status-label key instead of localized text
       - loads eshop/order_model
     index():
       1. isFeatureEnabled() -- guard 1, see Business Rules
       2. isIpAllowed()      -- guard 2, see Business Rules
       3. isTokenValid()     -- guard 3, see Business Rules
       4. normalizePhoneForMatching($_GET['mobphone'])  (ecommercen/helpers/phone_helper.php)
          -- empty result => {"orders": []}, not a guard failure
       5. Adv_order_model::getOrdersForContactPigeonByMobile($mobile, $langAbbr, 50)
       6. presentOrders() / presentItems() map DB rows onto the wire shape
       7. sendOutput() (ApiEndpointTrait) writes application/json

Before any of this runs, Adv_base_controller::__construct() (which Base_c chains into) has already called maintenanceModeGuard() (ecommercen/core/Adv_base_controller.php:68) and, on the very next line, siteModeGuard() (:69). Both can redirect, and both run ahead of every guard listed above:

  • maintenanceModeGuard() — in MAINTENANCE_MODE=offline, issues a redirect() to /maintenance, so during a maintenance window this endpoint returns an HTML 302, not a JSON 404 or JSON error body. This is pre-existing platform behavior, not specific to this endpoint, but worth knowing when interpreting a partner's integration test during a deploy window. This guard is NOT exempted for this endpoint — the 302 during maintenance still applies.
  • siteModeGuard() — has the same redirecting effect under GLOBAL.SITE_MODE values other than the normal operating mode (e.g. AdminOnly/AdminFrontend during a catalogue migration). AdvSiteModeMiddleware::redirectTo() (ecommercen/libraries/AdvSiteModeMiddleware.php:28-57) checks the caller's controller/namespace/route against application/config/app.php:557-559 (siteModeAllowedControllers / siteModeAllowedNamespaces / siteModeAllowedRoutes) and returns a redirect URI, e.g. to /soon, for anything not on those lists. This is now resolved for this endpoint: Api_contactpigeon_orders::class is registered in siteModeAllowedControllers, alongside the pre-existing Api_services::class entry, so the endpoint is exempt from site-mode redirects. Before this fix, an operator setting site mode during routine maintenance would have silently 302'd ContactPigeon's poller to /soon with an HTML body — no error status, no log, the sync just stops.

The Three Guards ​

All three run, in this fixed order, before any database query — a rejection at any step short-circuits and never reaches the DB:

#GuardSourceFailure behavior
1IS_ENABLEDCONTACTPIGEON.IS_ENABLED registry keyJSON 404
2IP allowlistCONTACTPIGEON_IP_ALLOWLIST env varJSON 404
3TokenCONTACTPIGEON.TOKEN, hash_equals(), only when IS_PROTECTEDJSON 404

Every failure path returns the identical JSON 404 — never 401/403. A distinguishable status code would let a prober confirm the endpoint exists (feature disabled vs. wrong IP vs. wrong token); a uniform 404 gives no such signal.

Guard 3 is intentionally fail-open when IS_PROTECTED is off ​

When CONTACTPIGEON.IS_PROTECTED is falsy, the token check is skipped entirely — this is a product-owner-accepted design decision, mirroring the existing behavior of the XML-feed admin settings. It is not a bug and should not be "fixed" without a product conversation. With the token check skipped, the IP allowlist becomes the only always-on control on this PII-bearing endpoint.

GDPR / PII Posture ​

State this explicitly for support and DPA purposes: the response contains the billing party only.

  • shipping_name, shipping_surname, shipping_address, shipping_city, shipping_postal, and shipping_mobile are never emitted.
  • shop_order.shipping_mobile and shop_customer.sendto_mobilephone are never matched — a lookup by a delivery recipient's phone number will not surface the order.
  • The governing rule: query by a person's phone, get that person's own purchases.

This matters because the delivery recipient on an order is frequently a non-consenting third party — gift purchases, deliveries to another household member, office deliveries, etc. Exposing their number or address to a marketing platform because someone else bought them something would be a PII leak with no legal basis. Only the pricing_* (billing) columns are read or returned.

Phone Matching ​

The inbound mobphone query parameter and the stored database values are reduced to the same canonical form before comparison, so that different spellings of the same number match:

  1. Drop every character that is not a digit, except a leading +.
  2. A leading 00 becomes + (the international dialing prefix is equivalent to +).
  3. A leading +30 (Greece) is dropped.

So 6912345678, 694 123 4567, +30 691 2345678, and 00306912345678 all reduce to the same national form; a non-Greek +CC… number keeps its country code.

  1. Minimum-length floor: if the canonical form has fewer than 6 digits, the lookup returns the same 200 {"orders": []} response as a blank/unparseable mobphone — not a guard rejection. This closes a defect where a degenerate needle such as ?mobphone=00 normalized to '+' and matched stored placeholder values like '0 0', '0-0', or '(0)0', returning unrelated customers' billing PII in a single response. Greek mobiles are 10 digits, so a 6-digit floor rejects this kind of junk input while still tolerating legitimately short international numbers. This is a distinct concern from the SQL/PHP twin-sync note below: the floor guards against an unrelated-match false positive; twin drift would only ever cause a false negative (a real number failing to match).
  • PHP side: normalizePhoneForMatching() in ecommercen/helpers/phone_helper.php. Framework-free by design (no get_instance(), no config_item()) so it can be unit-tested without booting CodeIgniter — see tests/Unit/Helpers/PhoneMatchingNormalizationTest.php.
  • SQL side: Adv_order_model::normalizePhoneSqlExpression() — a CASE expression ported from the same three rules, applied to the stored column inside the query. These two implementations must be kept in sync; they are documented as twins in both files' docblocks.

Matching covers two columns via a LEFT JOIN:

sql
FROM shop_order AS so
LEFT JOIN shop_customer AS sc ON sc.id = so.customer_id
WHERE so.status NOT IN ('PENDING', 'CANCELED')
  AND (normalize(so.pricing_mobile) = ? OR normalize(sc.mobilephone) = ?)

The join is deliberately LEFT, not INNER: guest checkout stores shop_order.customer_id = NULL, so an INNER JOIN against shop_customer would structurally exclude every guest order. LEFT JOIN plus matching against so.pricing_mobile directly means guest orders are included in results.

Because the stored side of the predicate is a SQL expression rather than a bare column, the pricing_mobile / mobilephone indexes are unusable for this query and a table scan is expected. This is an accepted tradeoff for a low-volume, partner-triggered lookup endpoint, not an oversight.

Order Visibility ​

StatusIncluded?Why
PENDINGNoNot yet an accepted order
CANCELEDNoNot a completed purchase
PENDING_ACCEPTEDYesAccepted COD order — despite the "PENDING" name, this is not pending
PENDING_ACCEPTED_VOUCHERYesAccepted voucher-payment order, same reasoning
every other statusYes—

Results are capped at MAX_ORDERS = 50, ordered entry_datetime DESC, id DESC (newest first).

Payload Field Mapping ​

FieldSourceNotes
order_idshop_order.order_serial
order_dateshop_order.entry_datetime
order_valueshop_order.total_vatVAT-inclusive grand total — the amount actually charged. Deliberately not shop_order.total, which is the net, items-only accounting column (no shipping/coupon/points/gift adjustments). Same gross-vs-net column contract as the rest of checkout (see docs/changelog/unreleased/563-checkout-vat-inclusive-totals.md while unreleased).
statusshop_order.statusRaw status code, always present
status_labelderived, see Status LabelsLocalized short label; falls back to the raw status on any miss
tracking_numbershop_order.gtcodenull until the order is handed to the courier
pricing_name, pricing_surname, pricing_address, pricing_city, pricing_postalshop_order.pricing_*Billing party only — see GDPR / PII Posture
items[].skuresolved barcode, see SKU Resolutionnull when no barcode row exists
items[].nameshop_product_mui.name for the requested language
items[].quantityshop_order_basket.qty
items[].priceshop_order_basket.priceVAT-inclusive, per the same gross basis as the rest of the checkout basket. Gift basket rows are included, at their stored (often discounted-to-near-zero) price — not filtered by gift_id.

SKU Resolution ​

items[].sku is the product's barcode, resolved through this chain:

shop_order_basket.product_code_id
  -> product_codes.id
     -> product_codes.product_id
        -> shop_product_barcodes.product_id   (JOINED ON product_id ONLY)

The join to shop_product_barcodes is on product_id alone, not product_code — product_codes.product_code is frequently empty, and joining on it would silently drop rows that have a valid product but no code string.

shop_product_barcodes is 1:N per product, so exactly one barcode is emitted, chosen deterministically as the row with the lowest id (ORDER BY spb.id ASC LIMIT 1 in a correlated subquery).

This mirrors the existing ContactPigeon product XML feed's mpn field (ecommercen/feeds/core/AdvXml.php:139, 'mpn' => $product->barcode), which sources the same shop_product_barcodes table via the same product_id-only join. That feed's barcode resolution is not deterministic for multi-barcode products in the same way this endpoint's is, so the two surfaces may occasionally disagree on which barcode they report for the same product. This divergence is accepted, not a bug to fix here.

Status Labels ​

status_label is the admin panel's existing short-label vocabulary (eshop.admin.order.status.* language keys), produced by orderStatusPresentationData() (ecommercen/helpers/admin_helper.php:54) called with the 'text' selector. This is the same function the admin order list uses to render status badges — no new label vocabulary was introduced.

One status has a store-pickup variant: SENT orders with a non-null store_id map to eshop.admin.order.status.sent.store instead of the plain eshop.admin.order.status.sent.

Because Adv_base_controller does not load the adv_advisable language file (only Adv_admin_controller does, and this controller extends Base_c/ Adv_base_controller, not the admin hierarchy), the controller's constructor loads it explicitly:

php
$this->lang->load('adv_advisable', $this->language, false, true, FCPATH . '../ecommercen/');
$this->load->language('advisable', $this->language);

Both status (the raw DB code) and status_label (the localized text) are always emitted. If a status has no entry in orderStatusPresentationData()'s list — e.g. a client-custom status a fork has added — the label resolution falls back to the raw status code rather than leaking a language key: CI's t() returns the dotted key verbatim on a miss (MY_Lang.php), and the controller detects that shape (strncmp($label, 'eshop.admin.order.status.', ...)) and substitutes the raw status instead of forwarding the unresolved key to the partner.

Architecture ​

ComponentPathPurpose
AdvApiContactpigeonOrdersecommercen/api/controllers/AdvApiContactpigeonOrders.phpThe real implementation. Extends Base_c, uses ApiEndpointTrait.
Api_contactpigeon_ordersapplication/modules/api/controllers/Api_contactpigeon_orders.phpOne-line routing stub: extends AdvApiContactpigeonOrders {}. The route target — required for the route to resolve at all.
phone_helper.phpecommercen/helpers/phone_helper.phpnormalizePhoneForMatching() — framework-free phone canonicalization, shared contract with the SQL-side twin.
Adv_order_model::getOrdersForContactPigeonByMobile()ecommercen/eshop/models/Adv_order_model.phpThe order lookup query (LEFT JOIN, status filter, phone normalization).
Adv_order_model::getContactPigeonBasketItems()ecommercen/eshop/models/Adv_order_model.phpBasket line lookup + barcode resolution, batched by order id.
Adv_order_model::normalizePhoneSqlExpression()ecommercen/eshop/models/Adv_order_model.phpSQL-side twin of the PHP phone normalization rule.

Class hierarchy ​

CI_Controller
  -> Base_c
     -> AdvApiContactpigeonOrders (uses ApiEndpointTrait)
        -> Api_contactpigeon_orders   [route target, application/modules/api/controllers/]

Data Model ​

No new tables or columns. The endpoint reads existing columns on:

TableColumns readRole
shop_orderid, order_serial, entry_datetime, total_vat, status, gtcode, store_id, pricing_mobile, pricing_name, pricing_surname, pricing_address, pricing_city, pricing_postalOrder header, billing party
shop_customerid, mobilephoneLEFT-joined for registered-customer phone matching
shop_order_basketorder_id, price, qty, product_code_idBasket lines
product_codesid, product_idBridges basket row to product
shop_product_barcodesproduct_id, barcode, idBarcode resolution (lowest id wins)
shop_product_muiproduct_id, lang, nameLocalized item name

Configuration ​

Registry (CONTACTPIGEON group) ​

KeyTypeDescription
IS_ENABLEDbool (0/1)Guard 1 — master switch. Endpoint returns 404 for everything when falsy.
IS_PROTECTEDbool (0/1)Whether guard 3 (token check) is enforced.
TOKENstringShared secret compared via hash_equals(). Self-seeds with bin2hex(random_bytes(32)) on first visit to settings/third_party_providers; rotated via a "Regenerate token on save" checkbox on that page.

No migration or seed is needed for these keys — Registry::setValue() calls registry_model::set_regval(), which upserts. Deliberately a separate registry group from XML_FEEDS: the pre-existing ContactPigeon product XML feed keeps its own independent enable/token flags, entirely unaffected by this feature.

.env ​

KeyDescription
CONTACTPIGEON_IP_ALLOWLISTComma-separated bare IPv4/IPv6 addresses and/or CIDR ranges, e.g. 203.0.113.10,198.51.100.0/24. Shipped commented-out in .env.example. Fails closed: unset or empty denies every request, regardless of the registry flags. A /0 prefix (0.0.0.0/0, ::/0) is refused as a match-everything misconfiguration — see AdvApiContactpigeonOrders::ipMatchesAllowlistEntry().

The feature is inert until both are set: CONTACTPIGEON.IS_ENABLED must be checked on the settings page, and CONTACTPIGEON_IP_ALLOWLIST must be populated in .env. Either alone leaves the endpoint 404-ing everything.

Client Extension Points ​

Every guard predicate is its own protected method (isFeatureEnabled(), isIpAllowed(), ipAllowlist(), ipMatchesAllowlistEntry(), isTokenValid()), as are the payload builders (presentOrders(), presentItems(), orderStatusLabel()). A client fork can override any one of these on a subclass without touching the rest of the flow.

A fork that overrides any of the following files will not automatically pick up this feature and must merge it in by hand:

  • application/views/admin/settings/third_party_providers.php — the new panel (enable/protect checkboxes, token field, regenerate checkbox) is added here.
  • application/config/routes.php — the two new routes.
  • application/config/app.php — the siteModeAllowedControllers entry (Api_contactpigeon_orders::class) that exempts this endpoint from site-mode redirects (see Code Flow).
  • ecommercen/settings/controllers/Adv_settings.php — the token self-seed, the contactpigeon_* $_POST handling in third_party_providers(), and the corresponding form_validation rules.

Note that relocating the stub into application/modules/api/controllers/ (rather than top-level application/controllers/) slightly reduces fork friction overall for this feature: forks own top-level application/controllers/ far more often than they shadow the api HMVC module, so fewer forks are likely to already have a conflicting file at the new path than at the old one.

Business Rules ​

  1. Guard order is fixed and all three run before any DB query. IS_ENABLED → IP allowlist → token. A rejection at any step short-circuits.
  2. Every rejection is an indistinguishable JSON 404, never 401/403 and never the HTML storefront 404 page — a prober cannot use the response to learn whether the endpoint is disabled, the caller's IP is unlisted, or the token is wrong.
  3. The IP allowlist fails closed. Empty/unset CONTACTPIGEON_IP_ALLOWLIST denies every caller unconditionally — a fresh deployment cannot accidentally serve PII before the allowlist is configured.
  4. IS_PROTECTED off is an intentional fail-open toggle, matching existing XML-feed admin UX, not a defect. When off, the IP allowlist is the only always-on control.
  5. An empty/unparseable phone number is not a guard failure. It reaches the guards, passes them, and then yields {"orders": []} — because it structurally cannot match any stored value, not because access was denied. This includes a canonical form under the 6-digit floor (see Phone Matching).
  6. Billing party only, always. No shipping-side field is ever read or emitted.
  7. Guest orders are included via the LEFT JOIN — customer_id IS NULL does not exclude a matching order.
  8. PENDING and CANCELED orders are excluded; PENDING_ACCEPTED and PENDING_ACCEPTED_VOUCHER are included despite the "PENDING" name — they are accepted orders.
  9. Results are capped at 50, newest first.
  10. An unknown/custom order status never leaks a raw language key to the partner; it falls back to the raw status code.
  11. Maintenance mode pre-empts everything above and is NOT exempted.Adv_base_controller's constructor-time maintenanceModeGuard() redirects to /maintenance before this controller's own guards run, so a maintenance-window response is an HTML 302, not a JSON body.
  12. Site mode also pre-empts everything above, but IS exempted for this endpoint. The very next line of the same constructor calls siteModeGuard(), which has the same redirecting effect under non-default GLOBAL.SITE_MODE values. Unlike maintenance mode, this endpoint is registered in siteModeAllowedControllers (application/config/app.php), so site mode alone does not redirect it — see Code Flow.

Known, pre-existing risks (not introduced by this feature) ​

  • The IP allowlist is spoofable via forwarding headers. MY_Input::ip_address() (application/core/MY_Input.php:111-128) trusts HTTP_CF_CONNECTING_IP and the first X-Forwarded-For entry unconditionally, and $config['proxy_ips'] is '' (application/config/config.php:153) — there is no trusted-proxy allowlist narrowing which callers are allowed to set those headers. This is an existing platform-wide gap, already tracked as GitHub #322 and documented at AD-01 Admin Auth. The consequence specific to this endpoint: since the caller IP itself can be spoofed by an attacker who controls their own request headers, deployments should keep IS_PROTECTED on rather than relying on the IP allowlist as a sole control.
  • Registry/pscache propagation delay. Registry::setValue() (application/libraries/Registry.php) updates the in-memory registry for the current request and upserts the DB row via registry_model::set_regval(), but does not invalidate the pscache-backed registry cache other requests are reading from. A token just saved on the settings page becomes visible to the front-controller process only after the next admin page load triggers a registry cache refresh. During acceptance testing this can look like "the token I just saved doesn't work" when it is actually a propagation delay, not a bug.
  • IN-01 Feed Generation -- the separate, pre-existing ContactPigeon product XML feed (outbound catalog export, own registry flags and token, unaffected by this endpoint)
  • AD-01 Admin Auth -- documents the shared MY_Input::ip_address() header-spoofing gap (#322) this endpoint's IP allowlist inherits
  • AD-13 Settings & Configuration -- the settings/third_party_providers admin page this feature's panel lives on
  • AD-11 Marketplace Orders -- the admin order list, source of the orderStatusPresentationData() status-label vocabulary this endpoint reuses