loading...

17/08/2026

Reading Loops template IDs server-side: a diagnostic lookup mode for a serverless function

Reading Loops template IDs server-side: a diagnostic lookup mode for a serverless function

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.

Architecture: local curl and a scheduler call the diagnostic function behind an auth gate; the function calls the Loops API; 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 identifiesThe transactional templateThe specific published version of its email message
StabilitySurvives edit-in-place (the template kept its <transactional-id> through the rewrite)Changes on every republish (became a new <published-email-message-id>)
Used byPOST /v1/transactional sendsGET /v1/email-messages/{id}/guardian drift checks
Failure mode when stalen/aHTTP 404 {"message":"Email message not found"}
How to refreshn/aGET /v1/transactional-emails (returns both IDs + dataVariables)

Verification checklist

ComponentTest actionExpected result
Local builddeno check on the edited functionclean
Local suitenpm run smoke10/1, only the pre-existing service-key gate fails
Lintnpm run lint0 errors (26 pre-existing warnings, none in this file)
New build livelookup response has mode: 'lookup'present (missing = old build)
ID freshnesslookup: idMatches for every templatetrue (mismatch shows live vs configured)
Remote integrity after publishgit show origin/staging:<file> | grep <ID>real ID present, no placeholder
Full contract suitecurl the function with no query paramallPassed: true, all Loops and Guardian checks pass

Troubleshooting reference

SymptomRoot causeFix
HTTP 404: {"message":"Email message not found"} from GuardianStored publishedEmailMessageId is stale. Loops mints a new one on every republishRead the live ID via the lookup mode, update the code, redeploy
privileged service key required in npm run smokeThe privileged key doesn’t exist locally, by designCall the deployed function with the shared secret instead (Step 5)
! [rejected] staging -> staging (non-fast-forward) after a publishThe platform committed from its own snapshotgit rebase origin/staging, resolve the marker conflict, bump the marker past the platform’s
Your pushed change missing from deployed code despite clean pushThe platform’s snapshot commit reverted itVerify with git show origin/staging:<file>, then re-push on top and publish again
Lookup returns publishedEmailMessageId: nullTemplate edited but never republished in the Loops UIRepublish in Loops, then re-run the lookup
Lookup response has no mode fieldOld build still live, new code not yet deployedBump the // Redeployed marker and publish again

What this changes going forward

The republish loop is now a four-step operation instead of an investigation:

  1. call the lookup
  2. swap the ID
  3. publish
  4. 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ò.

Share this post on LinkedIn

Posted in Building in public, How To