Porch + Slack
Send a live track URL into a Slack channel the moment work kicks off. Your customer or team watches the agent work in real time, right from Slack.
Why pair them
Porch URLs are clickable, live, and update in real time. Slack channels are where teams already watch things happen. Pasting a track URL into a channel turns that thread into an ambient live view of the agent’s work — no new tool, no new login.
The most valuable moment to post is when work starts, not when it finishes. The URL gives the watcher something to look at during the wait, which is the whole point. Completion pings are a useful add-on; the kick-off message is the unlock.
Set up Slack
Slack’s incoming-webhook URL is the only Slack primitive you need — no OAuth, no app review.
- Go to api.slack.com/apps and click Create New App → From scratch.
- Name it (e.g. “Porch”), pick your workspace, create.
- In the left sidebar, click Incoming Webhooks and toggle them on.
- At the bottom, click Add New Webhook to Workspace, pick the channel you want messages to land in, click Allow.
- Copy the webhook URL (looks like
https://hooks.slack.com/services/T../B../…) and save it asSLACK_WEBHOOK_URLin your environment.
Treat the URL as a secret — anyone with it can post to that channel. Rotate by deleting the webhook and creating a new one from the same screen.
The kick-off pattern (primary)
Create the track, post the URL into Slack, then do the work. That’s the whole pattern. The watcher in Slack clicks the link and follows the agent live.
import { createTrack } from "@porchso/sdk";
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!;
export async function importContacts(customer: { name: string }) {
const track = await createTrack({
title: `Importing ${customer.name}'s Salesforce contacts`,
steps: [
"Connecting to Salesforce",
"Mapping fields",
"Importing contacts",
"Verifying",
],
});
// Post the live URL to Slack the moment the track is created.
// Slack auto-renders the URL as a clickable link in the channel.
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:wrench: Importing ${customer.name}'s contacts — ` +
`watch live: ${track.url}`,
}),
});
// Now do the work; the channel watches it happen.
await track.startStep(0);
// ... run the import ...
await track.complete({
summary: "Imported 12,043 contacts.",
});
}One fetch call, one URL, done. The Slack message acts as the entry point; the live status page is the experience.
Completion + failure pings (optional)
For long jobs, the watcher will have closed the tab by the time work finishes. A second Slack message at track.complete() — or track.fail()— pulls them back.
try {
// ... the work ...
await track.complete({
summary: "Imported 12,043 contacts.",
});
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:white_check_mark: Done. Imported 12,043 contacts. ` +
`See the archive: ${track.url}`,
}),
});
} catch (err) {
await track.fail({
reason: err instanceof Error ? err.message : "Unknown error",
retryable: true,
userAction: "wait_and_retry",
});
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:warning: Import hit a snag. Retrying — ` +
`details: ${track.url}`,
}),
});
}Same channel as the kick-off message, or a separate one (e.g. #porch-alerts for failures only). Either works.
Cross-process workflows
The patterns above assume the code that creates the track also posts to Slack — same function, same process. If your agent runs somewhere your Slack-posting code doesn’t see (an Inngest job, a Trigger.dev task, a detached worker), use Porch’s outbound webhooks: Porch fires a signed event when a track lifecycle transition happens, your webhook handler posts to Slack.
Register a webhook endpoint at Settings → Webhooks in the dashboard, pointing at a handler you own. Then:
// app/webhooks/porch/route.ts (Next.js App Router)
import { verifyPorchSignature } from "@porchso/sdk/webhooks";
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!;
export async function POST(req: Request) {
// CRITICAL: read raw text BEFORE parsing JSON; the signature is
// over the byte-exact body.
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);
if (event.type === "track.created") {
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:wrench: ${event.data.title} starting — ` +
`watch live: ${event.data.url}`,
}),
});
} else if (event.type === "track.completed") {
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:white_check_mark: ${event.data.title} done. ` +
`See: ${event.data.url}`,
}),
});
} else if (event.type === "track.failed") {
await fetch(SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text:
`:warning: ${event.data.title} failed: ` +
`${event.data.failureReason} — ${event.data.url}`,
}),
});
}
// track.started doesn't need its own ping if you already pinged
// on track.created.
return new Response("ok");
}Full webhook reference (event shapes, retries, signature verification): Webhooks docs.
Channel choice
Two common shapes — pick based on who you want watching:
- Customer-facing Slack Connect channel. You and your customer share a Slack channel; the agent posts the live URL there. Customer clicks, watches the agent work, sees it land. The channel becomes the ambient status feed.
- Internal alerting channel. Your team has a
#porch-alertschannel. Every track lifecycle event posts there for ops awareness — good for catching failures, monitoring volume, debugging edge cases.
Different webhook URLs for different channels means you can run both at once — one URL pointed at the customer channel, one pointed at the internal channel, different filter logic in your handler.
Production hardening
Use Block Kit for richer messages
Plain text works and auto-renders URLs as links. For more structure (button to open the track, inline metadata, status icons), use Block Kit: pass blocks: [...] alongside the text field. The text stays as a fallback for notifications.
Add Slack thread IDs to track metadata
Slack’s post response includes ts(timestamp) and channel id. Stash them on the track via metadata so you can reply in the same thread later (e.g. track.log()→ threaded reply on kick-off message, completion edits the original).
Rate limits
Slack incoming webhooks tolerate ~1 message/sec per webhook. If your agent runs many tracks in parallel, post from a queue rather than per-track, or use multiple webhooks (one per workspace/customer).