DEV Community

Cover image for How to Safely Trigger API Calls from an Email Link
Alan West
Alan West

Posted on

How to Safely Trigger API Calls from an Email Link

The frustrating UX problem

Last month I was building an approval flow for a side project — managers get an email, click "Approve" or "Reject", and the request gets processed without them logging in. Sounds simple, right? But here's the thing that always trips people up: clicking that button can't run JavaScript. Email clients strip scripts, sandbox everything, and treat your beautiful HTML email like a suspicious package at airport security.

So how do you actually trigger a backend action from an email? I've shipped this pattern across maybe a dozen projects now, and there's a clean way to do it that doesn't involve forcing the user into a portal to find the request again.

Why this is harder than it looks

Email is fundamentally a static document. When you receive an HTML email, your client (Gmail, Outlook, Apple Mail) renders it but doesn't execute JavaScript. There's no fetch(), no XMLHttpRequest, nothing dynamic. The only interactive element you have is a link.

That link points to a URL. So your API call has to happen as a side effect of someone visiting that URL. Simple in theory, but the moment you think about security it gets tricky:

  • How do you know the right person clicked it?
  • How do you prevent the link from being replayed forever?
  • What if email scanners (link previewers) hit the URL before the user does?
  • How do you avoid requiring a login for what should be a one-click action?

I've seen people try to solve this with a session cookie, which fails because the user isn't logged in. Then they switch to a per-email database token, which works but adds a write for every email sent and a lookup on every click. There's a better way.

The HMAC-signed URL pattern

The trick is to encode the action and identity into the URL itself, then sign it with a secret only your server knows. No database lookup, no session required.

Here's a minimal Node.js example:

const crypto = require('crypto');

const SECRET = process.env.EMAIL_ACTION_SECRET;

function signAction(payload) {
  // payload includes action, target, user, and an expiry timestamp
  const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
  const sig = crypto
    .createHmac('sha256', SECRET)
    .update(data)
    .digest('base64url');
  return `${data}.${sig}`;
}

function verifyAction(token) {
  const [data, sig] = (token || '').split('.');
  if (!data || !sig) return null;

  // Recompute the signature and compare in constant time to avoid timing leaks
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(data)
    .digest('base64url');

  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;

  const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
  if (payload.exp < Math.floor(Date.now() / 1000)) return null; // expired

  return payload;
}
Enter fullscreen mode Exit fullscreen mode

Generating the email link is then a one-liner:

const token = signAction({
  action: 'approve_request',
  requestId: 'req_abc123',
  userId: 'user_42',
  exp: Math.floor(Date.now() / 1000) + 86400, // 24h expiry
});

const url = `https://api.example.com/email-actions/${token}`;
Enter fullscreen mode Exit fullscreen mode

The URL is self-contained — it carries the proof that the action is legitimate without any server-side state. If the secret ever leaks, you rotate it and every outstanding link is dead. Nice property.

The link-prefetch trap (this one bit me hard)

Here's a real gotcha. After deploying a version of this pattern years back, our approvals started getting silently auto-approved without anyone clicking anything. What?

Turns out: Gmail, Outlook, and most corporate security gateways prefetch links to scan them for malware. The first GET request to your URL might come from a bot before the human ever opens the email. If your handler performs the action on GET, congrats — you just gave every link scanner approval rights.

The fix: never perform side effects on a GET request. Use a tiny confirmation page instead.

app.get('/email-actions/:token', (req, res) => {
  const payload = verifyAction(req.params.token);
  if (!payload) return res.status(400).send('Invalid or expired link');

  // Just render a confirm page. No mutations here.
  res.send(`
    <form method="POST">
      <p>Approve request ${payload.requestId}?</p>
      <button type="submit">Yes, approve</button>
    </form>
  `);
});

app.post('/email-actions/:token', async (req, res) => {
  const payload = verifyAction(req.params.token);
  if (!payload) return res.status(400).send('Invalid or expired link');

  // The actual mutation only happens on POST
  await executeAction(payload);
  res.send('Done! You can close this tab.');
});
Enter fullscreen mode Exit fullscreen mode

It's an extra click, but it dodges every link prefetcher I've ever encountered, and gives the user a chance to bail if they hit the wrong button on mobile.

If you absolutely need true one-click — most commonly for unsubscribe flows — look at RFC 8058, which standardizes the List-Unsubscribe-Post header. Well-behaved prefetchers honor it and skip the URL.

When you actually want interactivity: AMP for Email

If you want fully dynamic email — submitting a form without leaving the inbox, live data, etc. — Google's AMP for Email lets you embed forms that POST directly to your API. Gmail and a few other clients support it.

The catch: setup is a slog. You need CORS-with-credentials configured carefully, your sender domain has to be allowlisted by each provider, and most email clients still don't render AMP at all, so you ship a fallback HTML version anyway. I've used it for newsletter polls and it works, but for ninety percent of "click to approve" flows, signed URLs are way less hassle.

Prevention tips

A few things I now do reflexively when building email actions:

  • Always expire tokens. 24 hours is fine for most actions. Don't ship a link that works forever.
  • Use constant-time comparison. crypto.timingSafeEqual exists for a reason. Don't compare signatures with ===.
  • Make actions idempotent. If the user clicks the confirm button twice, the second click shouldn't double-approve. Track consumed token IDs, or check the resource's state before mutating.
  • Log everything. When someone says "I never clicked that," your audit log with IP, user agent, and timestamp will save you.
  • Never put secrets in the payload. The token is verifiable, but anything inside is visible to anyone holding the link. Use opaque IDs, not raw data.

That's pretty much it. The signed-URL pattern feels obvious once you've built it, but it's surprisingly easy to get wrong the first time around. Hopefully this saves you from the auto-approved-by-bot incident I had to debug at 11pm on a Friday.

Top comments (0)