Scheduled Exports

Scheduled exports turn WALDO into a recurring report engine. Pick an analysis (or a market), pick a format and the sections you want, pick a schedule, pick where it should be delivered. WALDO does the rest — every Monday morning, every quarter-end, every first-of-the-month.

TL;DR for CRE professionals: This is the Monday-morning-PDF feature. Set up one schedule and a fresh PDF / Excel / PowerPoint of your Cuyahoga County analysis (or whatever you choose) lands in your inbox — or in your team's Slack channel via webhook — on whatever cadence makes sense for the deal. No more manual exports. No more "did anyone send the latest numbers?"


What you get from this

  • Recurring reports without the busywork. A weekly PDF lands in your inbox the moment the workweek starts. You read; you don't generate.
  • Multiple destinations per schedule. Send the same export to your team email AND to a webhook URL that drops it into Slack / Make / your CRM.
  • Big files handled gracefully. Email attachments are great until they hit 25MB. WALDO automatically switches to a signed download link when the file's too big to attach.
  • Per-run history. Every scheduled run creates a row you can review — see whether the email landed, whether the webhook receiver acknowledged, what error occurred if anything failed.
  • Pause and resume. Going on vacation? Pause the schedule. Coming back? Resume it. No need to delete-and-recreate.

Real-world workflows

ScheduleUse case
Daily 7am, latest analysis on the broker's hottest market, PDF, email to the teamOps team starts the day with the freshest numbers without anyone having to remember to export.
Weekly Monday 6am, specific analysis locked at a deal's stage, PowerPoint, webhook to n8n → SlackLive deal channel gets a fresh slide deck every week reflecting the latest WALDO data.
Monthly 1st-of-month, latest analysis on a target acquisition market, Excel, email to the principal + acquisitions teamLeadership review cycle gets a consistent recurring artifact without anyone exporting manually.
Daily, latest analysis on a market, PDF, webhook URL into Make → Google DriveThe team's shared Drive folder always has yesterday's snapshot, automatically.

Step-by-step: creating your first scheduled export

  1. Sign in as a Professional or Enterprise user. (Scheduled exports aren't included on Basic — upgrade in Settings → Billing if needed.)
  2. Go to Settings → Integrations in the left sidebar.
  3. Click "New schedule."
  4. Name it something memorable (Weekly Cuyahoga PDF, Monthly Hamilton MSA Excel).
  5. Pick a source:
    • Specific analysis — re-export this exact analysis on the schedule. Useful when you want a deal-locked snapshot that doesn't change.
    • Latest analysis on a market — each tick exports your most recently completed analysis on this market. Useful when you want the freshest numbers automatically picked up.
  6. Pick a format (PDF, Excel, or PowerPoint) and the sections you want included.
  7. Pick the schedule — frequency (daily / weekly / monthly), day-of-week or day-of-month if applicable, the time, and your timezone.
  8. Pick destinations — at least one of:
    • Email recipients (comma-separated). Put yourself, your team, your client.
    • Webhook URL — any HTTPS endpoint you want to receive a JSON envelope with a download link. Great for piping into Slack, Make / Zapier / n8n, your CRM, or a custom dashboard.
  9. Click "Create schedule." If you specified a webhook URL, the signing secret will be revealed once — save it now so your receiver can verify deliveries.
  10. Click "Run now" to test. Within ~30 seconds you'll see the run row update to Succeeded (green) or Failed (red) with a clear error if something went wrong. The email lands; the webhook fires.

Email behavior

WALDO sends one email per scheduled run, per email destination.

  • Subject:
    text
    <Your org name> export — <schedule name> (<format>)
  • Body: A short summary block (market, format, when it was generated) plus either:
    • Attachment — when the file is < 20MB. You get the PDF/Excel/PowerPoint right there in the email.
    • Signed download link — when the file is ≥ 20MB. You get a button in the email; click it to download. The link is valid for 7 days.
  • The summary block uses your org's white-label branding when set.

Multiple recipients: just enter comma-separated emails. They each get the message. CCs are supported.

If your SMTP provider rejects a recipient, the run is marked partial — other destinations still succeed independently.


Webhook behavior

When you add a webhook URL as a destination, WALDO POSTs a signed JSON envelope to it after every run. The envelope is shaped like the regular outbound webhooks (same

text
Waldo-Signature
header, same verification snippet) — just a different
text
event
name.

Use this when you want the export to flow into your own automation: post into Slack, attach to a AcquisitionPRO® or your CRM contact note, append to a Google Sheet via Make/Zapier, kick off a downstream job.

The webhook payload includes a

text
download_url
— your receiver can fetch the binary themselves. That URL works the same way as the email-mode signed link (valid 7 days, regenerates on demand).


Pause, resume, delete

  • Pause — the schedule is kept on file but no further runs occur. Resume it any time and the next run picks up at the next scheduled occurrence.
  • Delete — removes the schedule and all run history. Permanent.
  • Run now — out-of-band manual run; useful for testing destinations after you've changed something. Doesn't shift
    text
    next_run_at
    .

Tier requirements + cost

Scheduled exports are included on Professional and Enterprise plans. Each run consumes the same cost-units as a manual

text
GET /v1/analyses/:id/export
call (currently 10 units per run). Your monthly budget shows in Settings → Billing; if you're approaching the cap, the dashboard surfaces a banner.

Run history retains 30 days; older runs are auto-deleted. The signed download link from any past run is valid only for 7 days from the run.


For your integrator (or AI agent)

Everything below is the technical reference. Paste a link to this page into Claude / ChatGPT / Cursor and ask the AI to wire up the receiver — it has all the information it needs.

Architecture

  • A user-defined schedule lives in
    text
    scheduled_exports
    . Stores cron + IANA timezone, source pointer (specific analysis ID or market_code+market_type), format, sections, destinations array, signing secret,
    text
    next_run_at
    .
  • Vercel Cron
    text
    /api/internal/jobs/scheduled-exports
    runs every 15 minutes. Selects active rows where
    text
    next_run_at <= NOW()
    , advances
    text
    next_run_at
    (using
    text
    cron-parser
    with timezone awareness), inserts a
    text
    scheduled_export_runs
    row in
    text
    pending
    , enqueues a QStash message to the worker.
  • Worker
    text
    /api/internal/jobs/run-scheduled-export
    (QStash-signed) calls
    text
    dispatchScheduledExport(runId)
    which generates the export via
    text
    lib/export/generate.ts
    , fans out to destinations, and updates the run row.
  • Per-run history persists 30 days; cleanup cron prunes older.

Schedule shapes (created via picker)

The settings UI sends one of:

json
{ "kind": "daily",   "hour": 7, "minute": 0 }
{ "kind": "weekly",  "dayOfWeek": 1, "hour": 7, "minute": 0 }
{ "kind": "monthly", "dayOfMonth": 1, "hour": 7, "minute": 0 }

text
dayOfWeek
: 0 = Sunday … 6 = Saturday.
text
dayOfMonth
: 1..28 (capped to dodge "Feb 30 doesn't exist").
text
hour
: 0..23.
text
minute
: 0..59.

The server converts to a 5-field POSIX cron:

text
<minute> <hour> <dom> * <dow>
and stores the IANA timezone separately.
text
cron-parser
advances
text
next_run_at
correctly across DST.

Destination shapes

json
{ "type": "email",   "config": { "to": ["a@b.com"], "cc": ["c@d.com"] } }
{ "type": "webhook", "config": { "url": "https://hooks.example.com/x" } }

Webhook URLs are validated at create AND at dispatch time using the same SSRF guard as outbound webhooks (private IPs / cloud metadata / loopback / link-local rejected).

Webhook envelope

Each scheduled-export delivery to a webhook destination has:

text
POST /your-handler
Content-Type:    application/json
User-Agent:      Waldo-ScheduledExports/1.0
Waldo-Event:     scheduled_export.ready
Waldo-Signature: t=<unix>,v1=<hex>

{
  "id": "<run_id>",
  "event": "scheduled_export.ready",
  "created_at": "<iso>",
  "data": {
    "scheduled_export_id": "<schedule_id>",
    "scheduled_export_name": "Weekly Cuyahoga PDF",
    "format": "pdf",
    "filename": "WALDO_CuyahogaCounty_2026-04-30.pdf",
    "byte_size": 1234567,
    "download_url": "https://app.waldocre.io/api/exports/scheduled/<run_id>/download?token=..."
  }
}

Signature scheme is identical to outbound webhooks — see the Webhooks docs for verification snippets.

Email envelope

  • Subject:
    text
    <companyName> export — <scheduleName> (<FORMAT>)
  • Body: HTML + plain text alternative
  • Attachment OR link: based on
    text
    buffer.length < 20 * 1024 * 1024
  • Branding
    text
    companyName
    resolved via
    text
    resolveExportBranding(userId)
    — same path the export pipeline uses for white-label

Signed download link

text
GET /api/exports/scheduled/{run_id}/download?token=<token>

  • No session required — the token IS the auth.
  • Token format:
    text
    <run_id>.<expiry-unix-seconds>.<hex-hmac>
    where MAC = HMAC-SHA256 over
    text
    ${run_id}.${expiry}
    keyed on
    text
    WALDO_DOWNLOAD_TOKEN_SECRET
    (env).
  • Default TTL: 7 days from the run.
  • Expired: 410 Gone. Bad signature: 403. Missing run: 404.
  • The download regenerates the export on demand via
    text
    generateExport()
    rather than serving a stored buffer (zero storage cost; data may be slightly fresher than what was emailed — accepted UX trade for v1).

Internal management API (Supabase JWT auth)

Same auth pattern as

text
/api/settings/api-keys
and
text
/api/settings/webhooks
— Supabase session JWT +
text
validatePremiumAccess()
.

MethodPathDescription
GET
text
/api/settings/scheduled-exports
List caller's schedules
POST
text
/api/settings/scheduled-exports
Create + reveal signing secret once if any webhook destination
GET
text
/api/settings/scheduled-exports/:id
Fetch one
PATCH
text
/api/settings/scheduled-exports/:id
Update name, sections, schedule, destinations, active
DELETE
text
/api/settings/scheduled-exports/:id
Delete (cascades runs)
POST
text
/api/settings/scheduled-exports/:id/run-now
Out-of-band manual run; returns
text
{ run_id }
GET
text
/api/settings/scheduled-exports/:id/runs?limit=50
Recent run history

Run statuses

text
pending
text
running
→ terminal. Terminal states:

  • text
    succeeded
    — all destinations delivered cleanly.
  • text
    partial
    — at least one destination succeeded, at least one failed. Each destination's outcome is recorded in
    text
    delivery_results
    (jsonb array).
  • text
    failed
    — all destinations failed, OR the export couldn't be generated at all.

The worker returns 500 to QStash only on total failures (DB unreachable, etc.), so QStash retries. Per-destination failures don't trigger a retry — that's the user's call (Run now, fix the receiver, etc.).

Retention + cleanup

  • text
    scheduled_export_runs
    rows older than 30 days are deleted by the daily
    text
    cleanup-export-runs
    cron at 03:30 UTC.
  • Download tokens are invalid 7 days after the run; even if you keep an old run row, the link won't work past that window.

Error shapes

The internal management API returns simple JSON errors:

json
{ "error": "schedule_tz must be a valid IANA timezone" }

The signed download endpoint returns:

json
{ "error": "Token expired" }   // 410
{ "error": "Token bad-signature" }  // 403
{ "error": "Run not found" }   // 404