Appearance
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
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/contact_pigeon/orders?mobphone=…&token=… | IP allowlist + optional shared token | Order history, default site language |
GET | /{lang}/api/contact_pigeon/orders?mobphone=…&token=… | IP allowlist + optional shared token | Order 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
| Param | Required | Description |
|---|---|---|
mobphone | Yes (functionally) | The phone number to look up. Any format accepted by phone matching; an unparseable value yields {"orders": []}, not an error. |
token | Only when CONTACTPIGEON.IS_PROTECTED is enabled | Compared 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/jsonBefore 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()— inMAINTENANCE_MODE=offline, issues aredirect()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 underGLOBAL.SITE_MODEvalues 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 againstapplication/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::classis registered insiteModeAllowedControllers, alongside the pre-existingApi_services::classentry, 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/soonwith 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:
| # | Guard | Source | Failure behavior |
|---|---|---|---|
| 1 | IS_ENABLED | CONTACTPIGEON.IS_ENABLED registry key | JSON 404 |
| 2 | IP allowlist | CONTACTPIGEON_IP_ALLOWLIST env var | JSON 404 |
| 3 | Token | CONTACTPIGEON.TOKEN, hash_equals(), only when IS_PROTECTED | JSON 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, andshipping_mobileare never emitted.shop_order.shipping_mobileandshop_customer.sendto_mobilephoneare 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:
- Drop every character that is not a digit, except a leading
+. - A leading
00becomes+(the international dialing prefix is equivalent to+). - 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.
- Minimum-length floor: if the canonical form has fewer than 6 digits, the lookup returns the same
200 {"orders": []}response as a blank/unparseablemobphone— not a guard rejection. This closes a defect where a degenerate needle such as?mobphone=00normalized 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()inecommercen/helpers/phone_helper.php. Framework-free by design (noget_instance(), noconfig_item()) so it can be unit-tested without booting CodeIgniter — seetests/Unit/Helpers/PhoneMatchingNormalizationTest.php. - SQL side:
Adv_order_model::normalizePhoneSqlExpression()— aCASEexpression 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
| Status | Included? | Why |
|---|---|---|
PENDING | No | Not yet an accepted order |
CANCELED | No | Not a completed purchase |
PENDING_ACCEPTED | Yes | Accepted COD order — despite the "PENDING" name, this is not pending |
PENDING_ACCEPTED_VOUCHER | Yes | Accepted voucher-payment order, same reasoning |
| every other status | Yes | — |
Results are capped at MAX_ORDERS = 50, ordered entry_datetime DESC, id DESC (newest first).
Payload Field Mapping
| Field | Source | Notes |
|---|---|---|
order_id | shop_order.order_serial | |
order_date | shop_order.entry_datetime | |
order_value | shop_order.total_vat | VAT-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). |
status | shop_order.status | Raw status code, always present |
status_label | derived, see Status Labels | Localized short label; falls back to the raw status on any miss |
tracking_number | shop_order.gtcode | null until the order is handed to the courier |
pricing_name, pricing_surname, pricing_address, pricing_city, pricing_postal | shop_order.pricing_* | Billing party only — see GDPR / PII Posture |
items[].sku | resolved barcode, see SKU Resolution | null when no barcode row exists |
items[].name | shop_product_mui.name for the requested language | |
items[].quantity | shop_order_basket.qty | |
items[].price | shop_order_basket.price | VAT-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
| Component | Path | Purpose |
|---|---|---|
AdvApiContactpigeonOrders | ecommercen/api/controllers/AdvApiContactpigeonOrders.php | The real implementation. Extends Base_c, uses ApiEndpointTrait. |
Api_contactpigeon_orders | application/modules/api/controllers/Api_contactpigeon_orders.php | One-line routing stub: extends AdvApiContactpigeonOrders {}. The route target — required for the route to resolve at all. |
phone_helper.php | ecommercen/helpers/phone_helper.php | normalizePhoneForMatching() — framework-free phone canonicalization, shared contract with the SQL-side twin. |
Adv_order_model::getOrdersForContactPigeonByMobile() | ecommercen/eshop/models/Adv_order_model.php | The order lookup query (LEFT JOIN, status filter, phone normalization). |
Adv_order_model::getContactPigeonBasketItems() | ecommercen/eshop/models/Adv_order_model.php | Basket line lookup + barcode resolution, batched by order id. |
Adv_order_model::normalizePhoneSqlExpression() | ecommercen/eshop/models/Adv_order_model.php | SQL-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:
| Table | Columns read | Role |
|---|---|---|
shop_order | id, order_serial, entry_datetime, total_vat, status, gtcode, store_id, pricing_mobile, pricing_name, pricing_surname, pricing_address, pricing_city, pricing_postal | Order header, billing party |
shop_customer | id, mobilephone | LEFT-joined for registered-customer phone matching |
shop_order_basket | order_id, price, qty, product_code_id | Basket lines |
product_codes | id, product_id | Bridges basket row to product |
shop_product_barcodes | product_id, barcode, id | Barcode resolution (lowest id wins) |
shop_product_mui | product_id, lang, name | Localized item name |
Configuration
Registry (CONTACTPIGEON group)
| Key | Type | Description |
|---|---|---|
IS_ENABLED | bool (0/1) | Guard 1 — master switch. Endpoint returns 404 for everything when falsy. |
IS_PROTECTED | bool (0/1) | Whether guard 3 (token check) is enforced. |
TOKEN | string | Shared 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
| Key | Description |
|---|---|
CONTACTPIGEON_IP_ALLOWLIST | Comma-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— thesiteModeAllowedControllersentry (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, thecontactpigeon_*$_POSThandling inthird_party_providers(), and the correspondingform_validationrules.
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
- Guard order is fixed and all three run before any DB query.
IS_ENABLED→ IP allowlist → token. A rejection at any step short-circuits. - 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.
- The IP allowlist fails closed. Empty/unset
CONTACTPIGEON_IP_ALLOWLISTdenies every caller unconditionally — a fresh deployment cannot accidentally serve PII before the allowlist is configured. IS_PROTECTEDoff 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.- 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). - Billing party only, always. No shipping-side field is ever read or emitted.
- Guest orders are included via the
LEFT JOIN—customer_id IS NULLdoes not exclude a matching order. PENDINGandCANCELEDorders are excluded;PENDING_ACCEPTEDandPENDING_ACCEPTED_VOUCHERare included despite the "PENDING" name — they are accepted orders.- Results are capped at 50, newest first.
- An unknown/custom order status never leaks a raw language key to the partner; it falls back to the raw status code.
- Maintenance mode pre-empts everything above and is NOT exempted.
Adv_base_controller's constructor-timemaintenanceModeGuard()redirects to/maintenancebefore this controller's own guards run, so a maintenance-window response is an HTML 302, not a JSON body. - 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-defaultGLOBAL.SITE_MODEvalues. Unlike maintenance mode, this endpoint is registered insiteModeAllowedControllers(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) trustsHTTP_CF_CONNECTING_IPand the firstX-Forwarded-Forentry 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 keepIS_PROTECTEDon 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 viaregistry_model::set_regval(), but does not invalidate thepscache-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.
Related Flows
- 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_providersadmin 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