VIEWPORT
Reference

Webhooks

Event schema, signing model, retry behavior. Reference for what every payload contains.

The setup flow is in Teams: Webhook delivery. This page is the reference for what's actually delivered.

Headers

Every delivery includes:

HeaderPurpose
X-Viewport-EventEvent family, e.g., inbox_item.created.
X-Viewport-DeliveryULID, unique per attempt. Use for idempotency.
X-Viewport-Signaturet=<unix_ts>,v1=<hex_hmac_sha256>
X-Viewport-WorkspaceSource workspace ULID.
User-AgentViewport-Webhooks/1.0

Signature scheme: HMAC-SHA256(secret, "{t}.{raw_body}"). Reject if t is older than 300s. See Teams: Webhook delivery for code.

Common payload envelope

{
  "type": "inbox_item.created",
  "id": "evt_01J7…",
  "occurred_at": "2026-05-11T14:42:00Z",
  "workspace": {
    "id": "01J7M8N9P0Q1R2S3T4U5V6W7X8",
    "slug": "acme",
    "name": "Acme Engineering"
  },
  "actor": {
    "type": "user" | "system" | "agent",
    "id": "usr_…" | null,
    "email": "alice@acme.dev" | null,
    "kind": "agent_request" | null     // for system/agent actors
  },
  "subject": { /* event-specific */ }
}

Event reference

inbox_item.*

inbox_item.created:

"subject": {
  "type": "inbox_item",
  "id": "ibx_01J7…",
  "kind": "plan_review" | "approval_gate" | "context_candidate",
  "session_id": "ses_01J7…",
  "preview": "Refactor auth middleware to bearer tokens.",
  "expires_at": "2026-05-11T15:42:00Z",
  "decision_url": "https://app.getviewport.com/inbox/ibx_01J7…",
  "audience": ["team:platform"]
}

inbox_item.resolved:

"subject": {
  "type": "inbox_item",
  "id": "ibx_01J7…",
  "decision": "approve" | "deny",
  "decided_at": "…",
  "decider": { "type": "user", "id": "usr_…", "email": "alice@acme.dev" },
  "note": "string or null",
  "source": "web" | "mobile" | "slack" | "webhook" | "auto"
}

inbox_item.dismissed, inbox_item.timed_out: same shape minus decider and decision.

plan.*

plan.submitted, plan.approved, plan.denied, plan.revised:

"subject": {
  "type": "plan",
  "id": "pln_01J7…",
  "title": "Migrate auth middleware",
  "session_id": "ses_…",
  "files_changed": 9,
  "lines_changed_lte": 240,
  "url": "https://app.getviewport.com/plans/pln_01J7…"
}

approval_gate.*

approval_gate.requested, approval_gate.approved, approval_gate.denied:

"subject": {
  "type": "approval_gate",
  "id": "agt_01J7…",
  "inbox_item_id": "ibx_…",
  "workflow_id": "wfd_…",
  "node": "publish-release",
  "question": "Tag and push v3.4.0?",
  "tags": ["release", "destructive"]
}

context.*

context.candidate_proposed, context.candidate_approved, context.candidate_discarded:

"subject": {
  "type": "context_candidate",
  "id": "ccx_01J7…",
  "vault_id": "vlt_…",
  "title": "Recovery for failed migrations",
  "preview_first_line": "When db:migrate fails twice...",
  "proposed_by_session_id": "ses_…",
  "rationale_preview": "…"
}

Vault content itself is never in the webhook. Only the candidate metadata. Decryption is client-side.

workflow_run.*

workflow_run.started, workflow_run.step_started, workflow_run.step_completed, workflow_run.failed, workflow_run.completed:

"subject": {
  "type": "workflow_run",
  "id": "wfr_01J7…",
  "workflow_id": "wfd_…",
  "trigger": "manual" | "schedule" | "webhook" | "push",
  "status": "running" | "completed" | "failed" | "cancelled",
  "node_id": "step-7" | null,
  "duration_ms": 12450 | null,
  "url": "https://app.getviewport.com/workflows/runs/wfr_…"
}

audit.*

Full audit event subset is delivered when subscribed. See Audit log.

member.*

member.invited, member.accepted, member.role_changed, member.removed:

"subject": {
  "type": "membership",
  "user_id": "usr_…",
  "email": "alice@acme.dev",
  "role": "admin",
  "previous_role": "member" | null,
  "team_grants": ["team:platform"]
}

machine.*

machine.paired, machine.revoked:

"subject": {
  "type": "machine",
  "id": "mch_…",
  "install_id": "ins_…",
  "name": "mehr-mbp",
  "os": "darwin",
  "version": "0.2.1"
}

Retry policy

AttemptDelay from initial
Initial0
Retry 1+5s
Retry 2+30s
Retry 3+5m
Retry 4+30m
Retry 5+2h

After 5 failed retries, the delivery is parked. Resend manually from the dashboard.

Best practices

  • Be idempotent. Same X-Viewport-Delivery = same event. Replays happen.
  • Acknowledge fast. Return 2xx in under 5s. If you need to do heavy work, queue it and return 2xx immediately.
  • Validate signatures. Don't skip this. The endpoint URL is not secret.
  • Log every delivery. When something's wrong, the delivery id is the only debug primitive.

Where to go next

On this page