Skip to main content
opini

Webhooks

Outbound only. Opini POSTs a JSON body to a URL you own whenever something you care about happens — a umbrella ships, a comment goes pending, a release publishes, a follow-up gets answered. Paste a Discord webhook URL and messages arrive in your channel with zero glue code.

What they are

Three recurring asks that webhooks answer directly:

  • "Tell my Discord channel when a new comment is pending approval."
  • "Kick my deploy pipeline when we publish a release."
  • "Notify the CS channel when a follow-up is answered."

Each is the same primitive: Opini, call this URL when X happens, with a JSON body I can parse. Webhooks are per-project, capped at 10 per project.

Creating one

Open Settings → Webhooks in the dashboard. Fill in:

  • URL — https:// or http:// (though please, https://). No userinfo in the URL.
  • Label — something human, e.g. ops-discord or deploy-pipeline. Shows up in the webhooks list and in the deliveries drawer.
  • Events — tick the kinds you want. Grouped by entity (comments, umbrellas, releases, follow-ups, feedback).
  • Discord-compatible — flip on if the URL is a Discord channel webhook. See below.
  • Signing secret (optional) — generate one to sign requests with HMAC-SHA256. Shown once on create; rotate later if lost.

A Send test event button on every row fires a synthetic event at the URL so you can confirm the endpoint is reachable before relying on it.

Events

Initial allowlist — unknown kinds get a 422 at create time:

KindFires when
comment.pendingAn inbound comment is awaiting moderation.
comment.approvedAn admin approves a comment.
comment.rejectedA comment is rejected.
umbrella.createdA new umbrella is created (draft or published).
umbrella.movedAn umbrella moves between status columns.
umbrella.shippedThe destination column is a 'shipped' or 'done' column. Fires in addition to umbrella.moved.
umbrella.publishedAn umbrella becomes publicly visible for the first time (surfaces in the public roadmap API).
umbrella.unpublishedAn umbrella is taken off the public roadmap.
release.publishedA release publishes.
release.unpublishedA release is taken down.
follow_up.askedAn admin sends a follow-up question.
follow_up.answeredThe submitter replies to a follow-up.
feedback.receivedA new feedback row lands in Triage (widget or API).

Webhooks only see events that happen after the webhook is created — no backfill. This is intentional: replaying months of state changes to a freshly subscribed URL is almost never what anyone actually wants.

Payload shape

Raw mode is the default. Every request carries the same top-level envelope; the data object is event-kind specific.

json
{
  "event_id":    "01JSAE...",
  "event_kind":  "comment.pending",
  "occurred_at": "2026-04-24T07:03:11Z",
  "project": { "id": "prj_...", "slug": "acme" },
  "entity": {
    "kind": "comment",
    "id":   "cmt_...",
    "url":  "https://opini.dev/app/orgs/<org>/projects/<project>/moderation"
  },
  "data": {
    "body":           "This is the comment body...",
    "umbrella_id":    "umb_...",
    "umbrella_title": "Dark mode",
    "author_name":    "anon-dolphin"
  }
}

Discord-compatible mode

Flip the Discord-compatible toggle and the payload flattens to {content, embeds} — the shape Discord's webhook API expects. No middleware needed.

json
{
  "content": "New comment pending approval on **Dark mode**",
  "embeds": [{
    "title": "Dark mode",
    "description": "This is the comment body, truncated to 300 chars...",
    "url": "https://opini.dev/app/orgs/<org>/projects/<project>/moderation",
    "color": 16730759
  }]
}

Colors are per-kind: pending comments are amber, approvals green, shipped umbrellas violet, everything else brand blue. Descriptions cap at 300 chars (Discord allows 4096, but we stay conservative so the embed stays scannable).

Signing & headers

If you configure a signing secret, every request carries:

  • X-Opini-Signature: sha256=<hex> — HMAC-SHA256 of the raw body, keyed by the secret.
  • X-Opini-Event: <event_kind> — the event kind, for quick routing.
  • X-Opini-Delivery: <delivery_id> — a unique id per delivery attempt. Use this to dedupe retries on the receiver.

Discord doesn't verify these headers, so they're harmless on a Discord-compatible webhook. Rotating the secret produces a new value; the old one stops verifying immediately.

Deliveries drawer

Click a webhook row and a drawer slides in with the last 100 delivery attempts. Each row shows the event kind, a status chip (2xx green, 4xx amber, 5xx or network error red), the round-trip duration, and a relative timestamp. Expanding a row shows the request/response summary.

Attempts older than the 100-most-recent get pruned automatically — this isn't a full audit log, it's a "what happened in the last few hours" surface.

Retries & caps

  • 3 attempts, exponential backoff. 1s → 4s → 16s. Past three, Opini records a failed delivery and moves on.
  • 5-second timeout per request. A slow endpoint counts as a failure.
  • 10 webhooks per project. If you need more, consolidate — most teams end up with chat, pipeline, and one dev-debug endpoint.
  • Disabled webhooks are skipped. Flip a webhook off instead of deleting it while debugging — the deliveries history stays put.

Webhooks fire on state changes in comments, umbrellas, releases, and follow-ups.