DocsRecipes

Recipes

Scheduled reports and recurring delivery

4 min readReviewed July 2026

Recurring reports bring a reviewed analytical view to a team on a predictable cadence: a Monday operating summary, a daily reliability digest, or a monthly customer report.

GraphJSON does not currently include a native dashboard-subscription scheduler. Run the schedule in your application, job system, or orchestrator. Query GraphJSON through the Data API, render a bounded artifact or message, and send it through the delivery provider you control.

external scheduler
  → resolve active subscriptions
  → verify source freshness
  → run versioned report
  → render bounded output
  → deliver through email or chat provider
  → record outcome and retry safely

Define the report contract#

Keep code and metadata together:

report key: weekly_product_health
definition version: 4
owner: Product Operations
schedule: Monday 09:00 America/Los_Angeles
data window: previous complete local week
source freshness: complete through Sunday 23:59 local + 6h lateness
audience: internal product leads
output: summary email with secure dashboard link
retention: delivery metadata 90 days; no attachment archive

The schedule’s time zone and daylight-saving behavior must be explicit. Define whether a missed run is skipped, delivered late, or backfilled.

Keep subscriptions outside analytics#

Store recipient and schedule state in an operational database:

{
  "subscription_id": "sub_report_91",
  "report_key": "weekly_product_health",
  "report_version": 4,
  "audience_id": "team_product",
  "channel": "email",
  "timezone": "America/Los_Angeles",
  "schedule": "0 9 * * 1",
  "status": "active"
}

Do not use an event stream as the authority for who should receive mail. Recipient addresses, access changes, unsubscribe requests, and legal suppression rules belong in the delivery system.

A report email should usually contain:

  • reporting window
  • freshness status
  • a few non-sensitive headline values
  • meaningful change context
  • a secure link to the full dashboard
  • report owner and unsubscribe or preference link

Attachments and images create durable copies outside application authorization. Use them only when the data classification permits it and the delivery list is tightly controlled.

For customer-facing reports, never rely on a public GraphJSON dashboard link for tenant authorization. Query on your server and enforce the authorized account.

Execute a versioned Data API report#

Register allowlisted SQL in the worker’s code. Do not accept SQL from a subscription record or URL.

const REPORTS = {
  weekly_product_health_v4: {
    sql: `
      SELECT
        toStartOfWeek(toDateTime(timestamp), 1, 'UTC') AS week,
        uniqExact(JSONExtractString(json, 'account_id')) AS active_accounts,
        countIf(JSONExtractString(json, 'event') = 'workflow_completed')
          AS completed_workflows
      FROM product_events
      WHERE
        toDateTime(timestamp) >= toDateTime('2026-07-13 00:00:00', 'UTC')
        AND toDateTime(timestamp) < toDateTime('2026-07-20 00:00:00', 'UTC')
      GROUP BY week
      ORDER BY week
    `
  }
} as const;

This shows a fully resolved query. The Data API accepts sql_query but does not currently expose named bind variables in the request contract. Generate the two UTC instants from the scheduler, validate them as real dates, and format only those server-owned values into the reviewed template. Never interpolate recipient-controlled strings, field names, operators, or SQL fragments.

Keep the GraphJSON API key on the server. The current key is workspace-wide, so never embed it in email markup, image URLs, browser code, or third-party template variables. Follow API key security and the Data API frontend reference.

Freeze the reporting window#

Use one half-open window:

window_start <= event_time < window_end

Resolve it once at the start of the run and store the exact UTC boundaries. Do not calculate “last week” independently in the query, subject line, and template.

Wait for the source watermark. If required inputs are late:

  • delay within a bounded grace period
  • send a clearly labeled provisional report
  • or fail without delivery

Choose one policy in the contract. Never silently send a partial result as final.

Render with a narrow data model#

Transform query rows into a typed report model:

type WeeklyProductHealth = {
  windowLabel: string;
  activeAccounts: number;
  completedWorkflows: number;
  changeVsPriorPeriod: number | null;
  completeThrough: string;
  status: "final" | "provisional";
};

The renderer should not understand GraphJSON SQL or arbitrary row shapes. This keeps definition changes from silently breaking email templates.

For chart images, render through a controlled server process or an approved charting library. Do not expose the API key in a chart URL. Add accessible text and a tabular fallback.

Isolate tenant caches#

If the report is per customer, cache using:

report key
+ definition version
+ authorized account ID
+ exact window
+ permitted filters
+ data revision or completeness time

Do not cache only by report name. Test that two tenants requesting the same report cannot share a rendered artifact, storage object, or delivery record.

Make delivery idempotent#

Create a stable delivery identity:

subscription_id + report_version + window_start + window_end

Record attempts separately:

{
  "delivery_id": "sub_report_91:v4:2026-07-13:2026-07-20",
  "attempt": 2,
  "status": "delivered",
  "provider_message_id": "msg_81",
  "rendered_at": "2026-07-20T16:04:00Z",
  "delivered_at": "2026-07-20T16:05:00Z"
}

Before retrying an uncertain timeout, reconcile with the provider when possible. A repeated report is annoying; a repeated report containing sensitive data is also a trust failure.

Design failure behavior#

Classify failures:

Failure Response
Source incomplete Delay, label provisional, or fail per contract
GraphJSON timeout Retry with exponential backoff and jitter
Invalid query shape Stop, alert owner, do not retry indefinitely
Rendering failure Preserve sanitized diagnostic metadata
Recipient unauthorized Suppress delivery and review access
Provider rate limit Respect retry guidance and delivery deadline
Permanent address failure Disable or review the subscription

Alert on missed delivery deadlines, repeated failures, and unusual recipient growth. Do not include raw query results or API keys in error logs.

Record analytics without leaking content#

Operational delivery events may include:

{
  "event": "scheduled_report_delivered",
  "report_key": "weekly_product_health",
  "report_version": 4,
  "subscription_id": "sub_report_91",
  "channel": "email",
  "status": "delivered",
  "duration_ms": 1840
}

Avoid recipient addresses, message bodies, rendered links with tokens, and customer result values. The delivery provider and subscription database remain the authorities.

Version and retire reports#

When a definition changes materially:

  1. create a new report version
  2. run old and new versions over the same settled windows
  3. review changes with the owner
  4. migrate active subscriptions deliberately
  5. state the effective date in the first new delivery

Retirement requires more than deleting a job. Disable schedules, revoke stored artifacts, update links, remove unused credentials, preserve minimal delivery evidence, and notify owners.

Production checklist#

  • Schedule, time zone, window, and missed-run policy are explicit.
  • Subscription and recipient state lives in an operational authority.
  • SQL is allowlisted and versioned in server code.
  • The API key never reaches clients, templates, or third-party URLs.
  • Freshness is checked before rendering.
  • Per-tenant authorization and cache isolation are tested.
  • Delivery identities make retries safe.
  • Messages minimize sensitive data and link to authorized detail.
  • Failures alert an owner without logging report contents.
  • Definition migrations and retirement have explicit procedures.

For instrumentation of the delivery channel itself, continue with Email, push, and notification analytics.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support