PorchDashboard →
Integrations

Porch + Resend

Send your customer an email the moment your agent finishes — using Porch for the live status link, Resend for the delivery. Two lines of code from done.

Why pair them

Porch and Resend split the customer’s inbox cleanly. Porch is the live view — your customer clicks a link and watches the agent work in real time. Resend is the final view — when the agent finishes, your customer gets an email with the summary and a link back to the archive of what happened.

Both ship as one-line API calls. You’ll have the full flow wired in five minutes.

Install

If you haven’t already, install both SDKs and grab API keys for each.

npm install @porchso/sdk resend

Get your Porch key with npx porch login (writes to ~/.porchrc) or from your dashboard. Resend keys live at resend.com/api-keys. Set them as environment variables:

PORCH_API_KEY=porch_...
RESEND_API_KEY=re_...

The pattern

The shape is the same regardless of language: create a track, run your work updating the track as you go, then on completion send the email with the track URL embedded so your customer can revisit the archive.

Because your code is the line that calls track.complete(), you already know the agent finished — no webhook, no polling, no extra infrastructure. The next line sends the email.

import { createTrack } from "@porchso/sdk";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function importContacts(customer: {
  id: string;
  email: string;
  name: string;
}) {
  const track = await createTrack({
    title: `Importing your Salesforce contacts`,
    steps: [
      "Connecting to Salesforce",
      "Mapping custom fields",
      "Importing contacts",
      "Verifying import",
    ],
    metadata: { customerId: customer.id }, // founder-only context
  });

  // Run the agent. Update the track at each step.
  await track.startStep(0);
  // ... your import logic ...
  await track.completeStep(0, { detail: "Authenticated as john@acme.com" });

  // ... the rest of the work ...

  // Tell Porch the agent finished.
  await track.complete({
    summary: "Imported 12,043 contacts. Skipped 32 duplicates.",
    cta: { label: "Open your contacts", url: "https://app.acme.com/contacts" },
  });

  // Tell the customer the agent finished.
  await resend.emails.send({
    from: "you@yourdomain.com",
    to: customer.email,
    subject: "Your Salesforce import is done",
    html: `
      <p>Hi ${customer.name},</p>
      <p>Imported 12,043 contacts. Skipped 32 duplicates.</p>
      <p><a href="${track.url}">View the live archive →</a></p>
    `,
  });
}

That’s the whole integration. Five lines of new code on top of your existing track lifecycle. Your customer gets two things in their inbox: a live link the moment work starts, and a completion email the moment it finishes.

Failure path

Mirror the success flow — if the agent fails, send a different email so your customer doesn’t silently sit on a stale page.

try {
  // ... the work ...
  await track.complete({ summary: "..." });
  await resend.emails.send({ /* success email */ });
} catch (err) {
  await track.fail({
    reason: err instanceof Error ? err.message : "Unknown error",
    retryable: true,
    userAction: "wait_and_retry",
  });
  await resend.emails.send({
    from: "you@yourdomain.com",
    to: customer.email,
    subject: "Your import hit a snag",
    html: `
      <p>Hi ${customer.name},</p>
      <p>Your Salesforce import didn't finish. We're retrying.</p>
      <p><a href="${track.url}">See what happened →</a></p>
    `,
  });
}

Production hardening

Use React Email for the template

Inline HTML strings work but won’t scale beyond two templates. Resend ships React Email so you can author templates as React components and pass react={<YourEmail />}instead of an HTML string. Keep the email’s "view live archive" CTA pointed at track.url.

Personalize the subject line

track.title is the same string the customer sees on the status page; reuse it in the email subject so the inbox preview matches what they were watching: subject: track.title + " is ready".

Track the email send

Resend returns an email ID; stash it on Porch’s track via metadata on the next event so you have an audit trail of which email went with which run:

const { data } = await resend.emails.send({ ... });
await track.log(`Sent completion email ${data?.id}`, {
  resendEmailId: data?.id,
});

Cross-process workflows

The pattern above assumes your agent and your email-sending code share a request boundary — same function, same process. That’s the common case for solo founders shipping B2B agents.

When the agent runs somewhere your email pipeline can’t see — an Inngest job, a Trigger.dev task, a detached worker, a queue consumer — Porch fires outbound webhooks on every track lifecycle event. Your email server receives a signed POST, verifies it, and sends the Resend email. Same end-state, different wiring.

Register your endpoint

In the dashboard, go to Settings → Webhooks and add the URL of your handler (e.g. https://api.yourapp.com/webhooks/porch). Porch generates a signing secret you save as PORCH_WEBHOOK_SECRET on your server. The secret is shown once at creation — store it immediately.

Porch then fires a signed POST at that URL on every track.created, track.started, track.completed, and track.failed event. Retries are automatic on non-2xx responses.

Stash customer info at create-time

The webhook handler needs to know which customer this track was for so it can address the email. Pass that context through metadata when you create the track — Porch round-trips it on every event.

// In your agent code (the worker, the Inngest job, etc.)
const track = await createTrack({
  title: "Importing your Salesforce contacts",
  steps: ["Connecting", "Mapping", "Importing", "Verifying"],
  metadata: {
    customerEmail: customer.email,    // ← the email handler reads this
    customerName: customer.name,
    customerId: customer.id,
  },
});
// ... do the work ...
await track.complete({ summary: "Imported 12,043 contacts." });
// Note: no resend.send() call here. The webhook handler sends
// the email.

Build your webhook handler

The handler verifies the signature, parses the event, and branches on event.type.

// app/webhooks/porch/route.ts (Next.js App Router)
import { Resend } from "resend";
import { verifyPorchSignature } from "@porchso/sdk/webhooks";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(req: Request) {
  // CRITICAL: read raw text BEFORE parsing JSON. JSON.parse +
  // re-stringify can reorder bytes and break the signature.
  const body = await req.text();

  const ok = verifyPorchSignature({
    signature: req.headers.get("porch-signature"),
    body,
    secret: process.env.PORCH_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("invalid signature", { status: 401 });

  const event = JSON.parse(body);
  const { customerEmail, customerName } = event.data.metadata as {
    customerEmail: string;
    customerName: string;
  };

  if (event.type === "track.completed") {
    await resend.emails.send({
      from: "you@yourdomain.com",
      to: customerEmail,
      subject: `${event.data.title} is ready`,
      html: `
        <p>Hi ${customerName},</p>
        <p>${event.data.summary ?? "Done."}</p>
        <p><a href="${event.data.url}">View the live archive →</a></p>
      `,
    });
  } else if (event.type === "track.failed") {
    await resend.emails.send({
      from: "you@yourdomain.com",
      to: customerEmail,
      subject: `${event.data.title} hit a snag`,
      html: `
        <p>Hi ${customerName},</p>
        <p>Your task didn't finish: ${event.data.failureReason}</p>
        <p><a href="${event.data.url}">See what happened →</a></p>
      `,
    });
  }
  // track.created and track.started don't need email — ignore.

  return new Response("ok");
}

Test it

On the webhooks page click Send test on your endpoint. A synthetic track.created event fires through the real signing + delivery path. Check the delivery log on the same page — green status + 200 response code means your handler is wired correctly.

When to use which pattern

If your track.complete() call and your resend.send() call live in the same function — same Next.js route, same Express handler, same script — stick with the inline pattern above. It’s simpler, faster (no extra hop), and doesn’t need a webhook handler. The webhook pattern earns its complexity only when the two halves can’t share a request boundary.