Goal
You need data from an external service — a CRM, a weather feed, an inventory API, a loyalty-points balance — to appear in a MAM app. The naive approach is to call the third party inside your mam_get_phone_data_before_send subscriber and drop the result into the JSON. That works once, on your machine, with a warm network. In production it does something worse than fail: it makes every app launch wait on a service you do not control.
This recipe shows the gateway pattern for doing it safely. The rule of thumb is four words: cache, prioritize, degrade, never block. You will build a small gateway around the third party so that a slow, flaky, or down upstream never stalls the phone-data response.
Read Recipe: Inject custom data into the mobile JSON first if you have not injected into the payload before. This article assumes you know how to register a subscriber and shape a key; it focuses on what changes once a network call is involved.
Why the phone-data path is the wrong place for a blocking call
The mobile JSON payload is built once per app launch (and on refresh) by the phone-data pipeline in mam-main. Roughly 70 subscribers hang off the mam_get_phone_data_before_send filter, and they run in series. The total launch latency the user feels is the sum of every subscriber’s work.
A single uncached 500 ms HTTP call therefore does not cost 500 ms once. It costs 500 ms on every launch, for every user, added on top of all the other subscribers. If the upstream stalls, your subscriber holds the entire payload hostage until WordPress’s default 5-second HTTP timeout fires — and the app appears frozen at launch for that whole window, on every request. That is the failure mode this pattern exists to prevent.
So the third-party call must never happen inline on the hot path. It happens out of band, and the subscriber only ever reads a local cache.
Prerequisites
- A sibling plugin with its own slug (used to namespace your top-level JSON key and your option keys).
- Credentials/endpoint for the third-party service, stored as you would any secret — never hardcoded in a subscriber.
- A clear answer to: how stale is acceptable? A loyalty balance might tolerate 15 minutes; a “store is open now” flag might tolerate 1 minute. This number drives your cache TTL.
- Coordination with the mobile client team — the app must know how to render (and how to gracefully handle the absence of) your key. See Recipe: Inject custom data into the mobile JSON, step 7.
Steps
1. Separate the fetch from the read
Split the work into two halves that never touch each other on the hot path:
- The fetch — talks to the third party, runs out of band (WP-Cron, a webhook from the upstream, or a manual admin “sync now” action), and writes the result to local storage (a transient or an option).
- The read — runs inside the
mam_get_phone_data_before_sendsubscriber, reads only the local copy, and never makes a network call.
The subscriber is the contract you are protecting. If the read half ever calls out, the pattern is broken.
// THE READ — runs on the hot path. No network, ever.
add_filter( 'mam_get_phone_data_before_send', function ( array $data_array ): array {
$cached = get_transient( 'my_plugin_partner_data' );
$data_array['my_plugin_partner'] = is_array( $cached ) ? $cached : $this->fallback();
return $data_array;
}, 10 );
2. Cache aggressively, with a TTL you chose on purpose
Store the fetched result in a WordPress transient (or an option plus a “changed at” marker, if you want to participate in the cursor cache — see step 6). Set the TTL to the staleness budget you defined in the prerequisites.
The transient is your circuit breaker. Even when the fetch half runs, the read half always returns instantly from local storage. The upstream’s latency and uptime stop mattering on the hot path entirely.
If the fetch is expensive enough that you do not want it to ever run synchronously, prime the transient from a scheduled job rather than lazily on first read.
3. Run the fetch out of band
Move the actual HTTP call to a place that is allowed to be slow:
- Scheduled — a WP-Cron event on an interval matched to your staleness budget. This is the default choice.
- Event-driven — a webhook from the upstream that pushes changes to you, so you only fetch when something actually changed.
- On demand — an admin “Sync now” button for the operator to force a refresh.
Wrap the call with a short, explicit timeout so even the out-of-band job never rides on WordPress’s default (wp_remote_get defaults to 5 seconds, and a hung upstream will use all of it):
$response = wp_remote_get( $endpoint, array(
'timeout' => 4, // explicit, short — never ride on the 5s default
'headers' => array( 'Authorization' => 'Bearer ' . $token ),
) );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return; // leave the existing cache in place — see step 4
}
set_transient( 'my_plugin_partner_data', $this->normalize( $response ), 15 * MINUTE_IN_SECONDS );
4. Degrade gracefully — the upstream being down is a normal state
Decide what the app should show when there is no fresh data, and make sure your read half always produces a valid shape. There are three honest options:
- Serve stale — keep returning the last good cached value. Best when stale data is still useful (a points balance from 20 minutes ago).
- Serve a safe default — return an explicit “unavailable” shape the app knows how to render (an empty list, an
"enabled": "no"flag). Best when stale data could mislead. - Omit the section — return the key as
null(the cursor “no change” signal) or simply do not set it, and let the app treat absence as “nothing to show.”
The non-negotiable: a failed upstream must not throw, must not return a broken shape, and must not block. On a failed fetch (step 3) you simply leave the existing cache untouched; the read half keeps serving whatever is there.
private function fallback(): array {
return array(
'enabled' => 'no', // string flag, per the frozen JSON conventions
'items' => array(),
);
}
5. Choose the right priority
Register the read half at the priority that matches what it does. From the priority conventions:
| If your subscriber… | Use priority |
|---|---|
| Only injects its own (cached) data | 10 — the default cohort, where most subscribers live |
| Reads data another plugin injected first | 100 |
| Validates form state after submission | 199 |
| Is a use-case total override | 9999 |
Almost every third-party gateway is “only injects its own data,” so priority 10 is correct. Do not register at 1000 (reserved for home_cats) and do not register at 0/-1 to “run first” (priority 1 is owned by base settings). See Priority conventions for phone-data subscribers for the full bands and the ordering contracts.
6. (Optional) Participate in the cursor cache
If your section is heavy, integrate with the JSON cursor so the app can skip re-downloading the whole payload when nothing changed. The cursor is a single opaque marker (JSON_Cursor_Manager); when your fetch half writes a new value, bump it so clients know to re-pull:
// In the fetch half, after a successful refresh that changed something:
JSON_Cursor_Manager::reset_cursor();
Note the cursor is a global, opaque token (generated via uniqid()), not a per-user timestamp you compare by hand — treat it as “something changed, invalidate caches,” and only bump it when data actually changed. Bumping it on every cron tick defeats the cache for every user. See Cursor cache mechanism.
7. Respect cloning admins
When an admin is previewing the app as another role, the request is in cloning mode and caching is bypassed so they see live state. Check mam_is_cloning() if your section’s behavior should differ during a preview — for example, to always show the configured default rather than a per-user cached value. Most gateways do not need special handling here, but be aware the read may run with caching bypassed.
if ( mam_is_cloning() ) {
// previewing as a role — return the canonical/default shape
}
8. Verify before and after
Snapshot the payload with and without your plugin active and diff, exactly as in Recipe: Inject custom data into the mobile JSON, step 6:
curl 'https://example.com/wp-admin/admin-ajax.php?action=local_app_get_phone_data' > before.json
# activate the plugin, then:
curl 'https://example.com/wp-admin/admin-ajax.php?action=local_app_get_phone_data' > after.json
diff before.json after.json
Then test the failure paths explicitly — they are the whole point of this pattern:
- Cold cache (clear the transient): the read half returns your safe default, instantly.
- Upstream down (point the endpoint at an unroutable host): the fetch fails fast, the cache is untouched, launch latency does not move.
- Upstream slow (an endpoint that sleeps): confirm the explicit timeout fires and the hot path never waits on it.
JSON conventions to keep
The same frozen conventions apply to anything you inject from a gateway:
- Prefix your top-level key with your plugin slug (
my_plugin_partner) to avoid collisions. - Strings for ids, even numeric ones (
"123", not123). "yes"/"no"for flags, nottrue/false.- Return
$data_array, nevernull, from the filter itself.
Anti-patterns to avoid
- Calling the third party inside the subscriber. This is the one rule the whole pattern exists to enforce.
- Relying on the default HTTP timeout (5 seconds in WordPress). Always set a short, explicit timeout on the out-of-band fetch.
- Caching the failure — overwriting a good cached value with an error response. On failure, leave the last good value in place.
- Bumping the cursor on every cron tick. Only invalidate caches when data actually changed.
- Letting an exception from the upstream bubble out of the read half. A down partner is a normal, expected state, not an error.
Related
- Recipe: Inject custom data into the mobile JSON
- Priority conventions for phone-data subscribers
- Cursor cache mechanism
- Phone data pipeline overview
- Hook: mam_get_phone_data_before_send
- Frozen public contracts reference
What’s next
Once your gateway is stable, document its staleness budget and its failure behavior alongside the plugin so future maintainers know what “the upstream is down” is supposed to look like. If multiple plugins integrate with the same upstream, factor the fetch half into a single shared sync job and let each subscriber read its own slice of the cached result.
