I run Rivale.io, a competitive-intelligence tool for WordPress plugin founders and developers. Rivale keeps users informed by email (digests, alerts, weekly summaries), and all of that email goes through Loops.
This story starts there: I republished an email template in Loops and quietly broke my own health check. Every republish gives the email a new internal ID, and my code was still pointing at the old one. The fix needed the Loops API key, and the Loops API key lives only in the deployed function’s environment, which is the correct place for it and not somewhere I can read from outside.
The fix was to give the function that already holds it a permanent read-only lookup mode, a query-param branch that reads every template’s live ID straight from Loops and sends nothing.
This guide documents the whole path. I noticed the broken health check, brought it into a working session where Claude wrote and ran the code, made every publish myself from the deploy platform, and verified each one against the live system.
The technical detail below covers:
- the lookup branch itself
- how to call it without a privileged key
- a deploy step that can put stale code live (and how to catch that)
- the verification that closed it out: the full contract suite green, every Loops canary and every Guardian check passing against the live deployed function.
Note: Endpoint paths, credential names, and deploy-platform specifics are intentionally generic here. Substitute your own. The pattern is what transfers.
Architecture at a glance
The pieces involved: Rivale’s backend runs on serverless functions, one of which owns the email health checks. A scheduler calls it on a cadence, it talks to Loops, and a deploy platform publishes it from git.

The most commonly misconfigured component here is the stored publishedEmailMessageId in the function’s template table. It goes stale on every Loops republish, and the only symptom is a warn-only Guardian 404 buried in the contract-suite output.
Step 1: Recognize the symptom
Running the smoke suite locally:
cd <repo>/code && npm run smoke 2>&1 | tail -60
gives 10 passed | 1 failed, where the failure is:
[7] Notification Contracts
✗ contract suite: all Loops templates reachable
→ privileged service key required for the deployed contract suite
This local failure is by design, not the bug. The local smoke script needs a privileged service key to call the deployed contract suite, and that key intentionally doesn’t exist on the local machine. The actual bug only shows in the deployed suite’s response (Step 5 shows how to call it): a warn-only Guardian entry WARN <template> | HTTP 404: {"message":"Email message not found"}, because that template’s publishedEmailMessageId was still a placeholder after it was republished in the Loops UI.
Step 2: Confirm the Loops key is not reachable from outside the function
Before adding server-side code, the session verified there was no cheaper path: the key is only readable from inside the function’s environment, by design. The two ways forward were: paste the key by hand, or add a server-side read to a function that already holds it. The decision was the server-side way, made permanent rather than throwaway because the same lookup doubles as a standing drift check.
Step 3: Add the lookup branch
No new function was created. An existing function was already the right host: behind the same auth gate, already reading the Loops key from its environment, already calling Loops through a timeout-wrapped fetch helper.
Add the list-endpoint constant:
const LOOPS_LIST_URL = 'https://app.loops.so/api/v1/transactional-emails?perPage=50';
Then an early-return branch immediately after the auth guard and before the canary suite (abridged below, error-response bodies elided):
// ── Template-ID lookup mode ───────────────────────────────────────────────────
// Diagnostic read: returns each template's live publishedEmailMessageId and
// dataVariables straight from Loops, so a republished template's new ID can be
// read without the API key ever leaving the function env. Returns BEFORE the
// canary suite so it never triggers a real send.
if (new URL(req.url).searchParams.get('mode') === 'lookup') {
const lookupKey = Deno.env.get('<LOOPS_KEY_ENV>');
if (!lookupKey) { /* 500: 'Loops API key not set in function env' */ }
const res = await fetchWithTimeout(LOOPS_LIST_URL, {
headers: { Authorization: `Bearer ${lookupKey}` },
}, GUARDIAN_TIMEOUT_MS);
// non-ok → 502 with Loops' status + body
const payload = await res.json();
const live = payload.data ?? [];
const liveById = new Map(live.map((t) => [t.id, t]));
// Report against the templates this file actually sends, so a template
// missing from Loops surfaces as absent rather than silently vanishing.
const templates = LOOPS_TEMPLATES.map((t) => {
const hit = liveById.get(t.id);
if (!hit) return { name: t.name, id: t.id, found: false };
const sent = Object.keys(t.variables);
const liveVars = hit.dataVariables ?? [];
return {
name: t.name, id: t.id, found: true, liveName: hit.name,
publishedEmailMessageId: hit.publishedEmailMessageId,
configuredEmailMessageId: t.publishedEmailMessageId,
idMatches: hit.publishedEmailMessageId === t.publishedEmailMessageId,
dataVariables: liveVars,
drift: {
missingInTemplate: liveVars.filter((v: string) => !sent.includes(v)),
unsupportedBySent: sent.filter((v) => !liveVars.includes(v)),
},
};
});
return new Response(JSON.stringify({
mode: 'lookup',
totalInLoops: payload.pagination?.totalResults ?? live.length,
returned: live.length,
nextCursor: payload.pagination?.nextCursor ?? null,
templates,
}, null, 2), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}
Warning: Placement matters. The rest of this endpoint runs canary checks that perform real sends. The lookup branch must return before that suite fires, otherwise a diagnostic call would send real emails.
The branch iterates every template, including the ones that pass. Reporting idMatches (live vs. hardcoded ID) and per-template variable drift turns a one-time ID fetch into a standing drift check. The response’s mode: 'lookup' field also doubles as a deploy probe: if it’s missing, the old build is still live (used in Step 5).
Design notes from the session: GET /v1/transactional-emails returns publishedEmailMessageId and dataVariables per template. perPage accepts 10 to 50, and one page covered the full template set (nextCursor: null). The edit pushed the file past our 400-line review threshold, which required explicit sign-off before editing.
Step 4: Gate-check, commit, publish
deno check <path/to/function>/index.ts
npm run smoke # unchanged: 10/1, same pre-existing service-key gate, no regression
npm run lint # 0 errors, 26 pre-existing warnings, none in this file
Bump the redeploy marker at the top of the file. In this stack the deploy platform only picks up the change when a marker comment at the top of the file moves:
// Redeployed 2026-07-30a (adds diagnostic lookup read)
Commit, push to staging, then publish the function from the deploy platform (a manual UI action).
Step 5: Call the lookup without a privileged key
The function is behind an auth gate that accepts either a privileged service key or a shared secret used by the scheduler. The privileged key deliberately doesn’t exist on the local machine, so the call uses the shared secret, taken from wherever your stack already keeps it (a password manager, your deployment config, the secret store your scheduler reads at run time).
Tip: If the only copy is inside your scheduler’s configuration, mint a fresh one for diagnostic calls instead of extracting the live value. Pulling a production secret onto a laptop puts it in shell history and terminal scrollback, which is a worse trade than issuing a second credential.
With a token in hand:
curl -s -H "Authorization: Bearer <SHARED_SECRET>" \
"https://<your-function-host>/<your-function>?mode=lookup" \
--max-time 90
Check mode in the response first. mode: 'lookup' means the new build is live. Missing means the old build is still deployed. In this session the lookup came back with every template on one page and the answer:
MISMATCH <template-name>
live : <new-id>
configured: <placeholder-not-yet-set>
If publishedEmailMessageId comes back null instead, the edited template was never republished on Loops’ side. That’s a Loops-UI action, not a code fix.
Step 6: Swap the ID in, and verify what actually deployed
Replace the placeholder in the template table:
// publishedEmailMessageId below was read from Loops via the lookup mode
// on 2026-07-30 after the founder republished; re-read it the same way if
// the template is ever republished again (it changes on every republish).
name: '<template-name>',
id: '<transactional-id>', publishedEmailMessageId: '<published-email-message-id>',
Commit, bump the marker to 2026-07-30b, push. In this session the push was rejected:
! [rejected] staging -> staging (non-fast-forward)
error: failed to push some refs to 'https://github.com/<owner>/<repo>.git'
Warning: Some deploy platforms build from their own snapshot of the repo rather than your latest push, and commit the result themselves. When that happens, a change you pushed can be absent from what actually deployed even though your commit is still in history. Assume nothing about what’s live until you’ve read it back.
Recovery:
git rebase origin/staging # → CONFLICT on the redeploy-marker line
# Resolve by keeping the real ID and bumping the marker PAST the platform's:
# // Redeployed 2026-07-30c (template publishedEmailMessageId set)
grep -n "<<<<<<<\|>>>>>>>\|=======" <path/to/function>/index.ts # must be empty
grep -n "<your-id>\|<placeholder>" <path/to/function>/index.ts
deno check <path/to/function>/index.ts
git add <path/to/function>/index.ts && GIT_EDITOR=true git rebase --continue
git push origin staging
Then publish again. When the platform reports “no functional changes,” treat that as a signal to verify, not reassurance. It may have rewritten the file from its snapshot again and re-reverted the ID. Check what the remote actually holds:
git fetch -q origin && git log --oneline HEAD..origin/staging | head
git show origin/staging:<path/to/function>/index.ts | \
grep -n "<your-id>\|<placeholder>\|^// Redeployed"
This time the real ID survived and only the marker had been rewritten.
Step 7: Verify end to end
Run the lookup once more to confirm the deployed build carries the real ID (configured now equals live), then run the full contract suite for the actual proof:
curl -s -H "Authorization: Bearer <SHARED_SECRET>" \
"https://<your-function-host>/<your-function>" \
--max-time 120
Session result:
HTTP 200
allPassed: True
loops failures : NONE (all pass)
guardian failures: NONE (all pass)
<template-name> guardian -> PASS | ok
First fully clean cycle for the suite. The lookup’s drift report is also the prerequisite for ever tightening a drift check from warn-only to enforcing: clear known drift first, then flip.
Understanding Loops template IDs
The session hinged on a distinction the placeholder comment got right but the docs make easy to miss:
id (transactionalId) | publishedEmailMessageId | |
|---|---|---|
| What it identifies | The transactional template | The specific published version of its email message |
| Stability | Survives edit-in-place (the template kept its <transactional-id> through the rewrite) | Changes on every republish (became a new <published-email-message-id>) |
| Used by | POST /v1/transactional sends | GET /v1/email-messages/{id}/guardian drift checks |
| Failure mode when stale | n/a | HTTP 404 {"message":"Email message not found"} |
| How to refresh | n/a | GET /v1/transactional-emails (returns both IDs + dataVariables) |
Verification checklist
| Component | Test action | Expected result |
|---|---|---|
| Local build | deno check on the edited function | clean |
| Local suite | npm run smoke | 10/1, only the pre-existing service-key gate fails |
| Lint | npm run lint | 0 errors (26 pre-existing warnings, none in this file) |
| New build live | lookup response has mode: 'lookup' | present (missing = old build) |
| ID freshness | lookup: idMatches for every template | true (mismatch shows live vs configured) |
| Remote integrity after publish | git show origin/staging:<file> | grep <ID> | real ID present, no placeholder |
| Full contract suite | curl the function with no query param | allPassed: true, all Loops and Guardian checks pass |
Troubleshooting reference
| Symptom | Root cause | Fix |
|---|---|---|
HTTP 404: {"message":"Email message not found"} from Guardian | Stored publishedEmailMessageId is stale. Loops mints a new one on every republish | Read the live ID via the lookup mode, update the code, redeploy |
privileged service key required in npm run smoke | The privileged key doesn’t exist locally, by design | Call the deployed function with the shared secret instead (Step 5) |
! [rejected] staging -> staging (non-fast-forward) after a publish | The platform committed from its own snapshot | git rebase origin/staging, resolve the marker conflict, bump the marker past the platform’s |
| Your pushed change missing from deployed code despite clean push | The platform’s snapshot commit reverted it | Verify with git show origin/staging:<file>, then re-push on top and publish again |
Lookup returns publishedEmailMessageId: null | Template edited but never republished in the Loops UI | Republish in Loops, then re-run the lookup |
Lookup response has no mode field | Old build still live, new code not yet deployed | Bump the // Redeployed marker and publish again |
What this changes going forward
The republish loop is now a four-step operation instead of an investigation:
- call the lookup
- swap the ID
- publish
- verify.
The part worth remembering is the last one. Two of the three publishes in this session put the wrong code live while reporting success, so never trust the publish message. Check what actually deployed with git show origin/staging:<file>.
Raw draft by Claude from session transcripts. Edited, fact-checked, and finalized by Matteo Duò.