PorchDashboard →
Reference

Approvals

Pause your agent mid-run. Ask the customer a question — choice, free text, or yes/no. Resume with the answer when they respond. One SDK call, hosted page, magic-link auth, audit log included.

When to use approvals

Approvals fit the case where your agent needs a decision from someone outsideyour own app — a customer signing off on an action, a vendor confirming a price, a counterparty reviewing a contract clause, a candidate confirming an interview slot.

If your reviewer logs into your product, build the approval into your existing dashboard. We’re for the case where they don’t — the agency’s client’s CFO, the SaaS customer’s vendor, the recruiter’s candidate. They have no login, no account, no reason to make one. Magic-link auth on a hosted page is the right shape.

Quickstart

Create a track with a bound customerEmail, then call track.confirm() at the moment you need approval. The call blocks until the customer answers, the timeout elapses, or you cancel.

import { createClient } from "@porchso/sdk";

const porch = createClient({ apiKey: process.env.PORCH_API_KEY! });

const track = await porch.createTrack({
  title: "Vendor invoice batch",
  steps: ["Fetch", "Parse", "Approve", "Schedule"],
  customerEmail: "cfo@acme.com",  // ← required for ask-enabled tracks
});

await track.startStep(0);
await track.action("Pulled 47 invoices from Bill.com");
await track.completeStep(0);

await track.startStep(1);
await track.action("Parsed line items + matched to POs");
await track.completeStep(1);

// Agent pauses here. The CFO opens the track URL (emailed via
// your existing pipeline), verifies via magic link, and clicks
// Approve or Hold. This call returns when they answer.
const answer = await track.confirm({
  prompt: "Acme Office Supplies invoice is 8% above contract. Approve?",
  acceptLabel: "Approve the new rate",
  rejectLabel: "Hold for negotiation",
  timeoutSeconds: 86400,  // 24h
});

if (answer.status === "answered" && answer.value === true) {
  await track.startStep(2);
  await track.action("Approval recorded — scheduling payments");
  await track.completeStep(2);

  await track.startStep(3);
  await track.action("All payments queued for Friday");
  await track.completeStep(3);

  await track.complete({ summary: "47 payments scheduled." });
} else {
  await track.fail({
    reason: answer.status === "answered"
      ? "Customer held the invoice"
      : `Approval ${answer.status}`,
  });
}

SDK methods

Four methods on the track object cover the ask lifecycle. Three are convenience wrappers around the general-purpose track.ask(); pick whichever reads cleanest at the call site.

track.confirm() — yes/no approval

const answer = await track.confirm({
  prompt: "Send this email to the customer?",
  acceptLabel: "Send",    // optional, default "Yes"
  rejectLabel: "Hold",    // optional, default "No"
  timeoutSeconds: 1800,   // optional, default 1800 (30 min)
});
// answer.status: "answered" | "timeout" | "cancelled"
// answer.value:  boolean (only meaningful if answered)

track.askText() — free-text input

const answer = await track.askText({
  prompt: "What email should we send the report to?",
  validate: "email",   // optional: "short" | "long" | "email" | "url"
  timeoutSeconds: 1800,
});
// answer.value is a string when answered

track.ask() — multiple choice (or anything custom)

const answer = await track.ask({
  kind: "choice",
  prompt: "Which template should the contract use?",
  config: {
    options: [
      { value: "standard", label: "Standard MSA" },
      { value: "enterprise", label: "Enterprise MSA" },
      { value: "trial", label: "30-day trial agreement" },
    ],
  },
  timeoutSeconds: 1800,
});
// answer.value is the string `value` of the selected option

track.cancelInputRequest() — agent-side cancel

Call when the agent decides it no longer needs the answer (e.g., it found the information elsewhere). The customer sees the card switch to a “no longer needed” state; the SDK’s pending ask call returns with status: "cancelled".

Ask kinds

Three kinds today — choice, text, confirm. Pick by the shape of the answer:

  • choice— one-of-N selection. Use for templates, classifications, branching decisions. Config takes options: {value, label}[] (2–8 options). The customer sees buttons; the answer is the selected value.
  • text— free-form string. Use for missing data, custom prompts, anything not enumerable. Optional validate hint (short / long / email / url) sets the input control + client-side check.
  • confirm— boolean yes/no. Use for approvals, gates, “is this OK” moments. Customer sees two buttons; answer is true or false.

Display metadata

Every ask kind can carry optional display fields that light up the customer’s card with context. None are required; the card renders cleanly with just prompt. All round-trip on the input_request.created webhook payload too.

  • description— one-sentence subtitle under the prompt (≤500 chars). Use to add the “why” behind the question.
  • summary— key/value table rendered in the card body (max 20 entries; each key+value ≤200 chars). Use for structured context like “Previous rate: $2,400 / New rate: $2,592 / Reason: Q3 passthrough”.
  • details— longer text in an expandable “Show details” disclosure (≤8000 chars). Use for full context the customer might want without burying the prompt.
  • risk"low" / "medium" / "high". Currently accepted but not yet visually surfaced; reserved for an upcoming risk-badge UI in v1.2.

Example: a contract-redline approval

await track.ask({
  kind: "choice",
  prompt: "Counterparty pushed back on the liability cap. Accept or counter?",
  config: {
    description: "Last 3 deals settled at $750k avg. Renewal value: $480k ARR.",
    summary: {
      "Our redline":  "$500k cap, mutual",
      "Their counter": "$1M cap, mutual",
      "Last 3 deals":  "$750k avg",
    },
    details: "Original draft of Section 8.2 follows below...\n\n[full text]",
    options: [
      { value: "accept",  label: "Accept their $1M counter" },
      { value: "counter", label: "Counter at $750k" },
    ],
  },
});

Auth model

Three composable tiers. Every ask-enabled track uses tier 2 by default; tier 1 is optional add-on; tier 3 has no UI (just the unguessable slug).

  • Tier 1 — Password (optional). Set password on createTrack() to require an unlock page before the customer can view the track at all. Useful when the link will travel through Slack / forwarded email and you want a second factor. Dashboard toggle available to set/change/clear without re-deploying.
  • Tier 2 — Magic-link OTP (default for ask-enabled tracks). When you set customerEmail on createTrack(), the customer must verify that email via a one-time signed link before they can submit an answer. Watching the page stays open; only the answer requires verification. Cookie binds the browser to that email for 7 days.
  • Tier 3 — Link-only (default for non-ask tracks). The unguessable slug is the credential. Anyone with the URL can view; nobody can answer (because no ask exists).

Tiers compose: a password-protected track with customerEmail set requires unlock first, then email verification before answer. We tested both gates firing in order; works as expected.

Patterns

Approval gate (most common)

const answer = await track.confirm({
  prompt: "Send this invoice for payment?",
});

if (answer.status !== "answered" || answer.value === false) {
  await track.fail({ reason: "Not approved", retryable: false });
  return;
}
// ... proceed with sending

Branching choice

const answer = await track.ask({
  kind: "choice",
  prompt: "How should we handle this duplicate contact?",
  config: {
    options: [
      { value: "merge",  label: "Merge — keep newest fields" },
      { value: "keep",   label: "Keep both" },
      { value: "delete", label: "Delete the duplicate" },
    ],
  },
});

if (answer.status !== "answered") {
  await track.fail({ reason: `No answer (${answer.status})` });
  return;
}

switch (answer.value) {
  case "merge":  await mergeContact(); break;
  case "keep":   /* no-op */ break;
  case "delete": await deleteDuplicate(); break;
}

Missing data collection

// Customer didn't include their billing email when signing up;
// the agent collects it before generating the first invoice.
const answer = await track.askText({
  prompt: "What email should we send invoices to?",
  validate: "email",
});

if (answer.status === "answered") {
  await saveBillingEmail(answer.value);
}

Caveats + gotchas

  • customerEmail is required for asks. Tracks without it cannot call any ask method — the SDK returns requires_customer_email. Set it at createTrack() time.
  • One open ask per track at a time. Trying to open a second ask while one is still pending returns already_awaiting. Cancel the first with track.cancelInputRequest() if you need to switch contexts.
  • Timeouts don’t auto-reap yet. Today, when the deadline passes, your SDK call returns with status: "timeout" and you handle it in your agent code. The DB-side sweeper that flips state automatically (and fires the input_request.timeoutwebhook) ships in v1.2. If your agent isn’t running when the timeout would fire, the row stays in open until you re-run it.
  • The customer never signs up for Porch. The magic-link cookie binds them to one track for 7 days. No account, no profile, no dashboard. We are not creating a second product for your customer to learn.
  • Plan caps apply. Free: 25 asks/mo. Pro ($99): 1,000 asks/mo with a 24h / 100-ask grace window. Past grace, ask creation returns monthly_ask_limit_reached. Email us for Enterprise above that.

Webhook events

Every ask lifecycle event fires an outbound webhook (when you have an endpoint registered). See the Webhooks reference for signing, retry, and payload shape details.

  • input_request.created— agent paused; ask is open. Use to alert the customer (Slack DM, SMS, email) outside the in-app status page.
  • input_request.answered— customer responded. Payload carries answerValue and duration_ms. Use to clear “waiting on customer” alerts or log to your audit trail.
  • input_request.cancelled— agent called cancelInputRequest(). Mirror to your queue so stale “we need your input” notifications stop firing.

See it in action

The landing pageruns scripted demos of asks across four real-world workflows: vendor invoice approval (finance), contract redline review (legal), candidate interview confirmation (recruiting), and cold outreach batch approval (sales). Each demo is the actual status page + ask card with deterministic timing — click any tab to watch the loop end-to-end.