Shipnoted

Docs

Webhooks

Your server hears when something happens, a few seconds later, signed so you know it is us.

Add an endpoint#

On Pro and Team, under Install & connect → Webhooks, or from code:

npx shipnoted webhooks create https://example.com/hooks/shipnoted --events feedback.created,release.published

The address must be a public https URL. The signing secret (whsec_…) is shown once, when you create the endpoint. Up to five endpoints per project.

Events#

Every delivery has the same envelope: an id (the same on every retry, so you can skip repeats), the type, a created time in Unix seconds, and the data.

Envelope

{
  "id": "evt_3kq",
  "type": "feedback.status_changed",
  "created": 1793500000,
  "data": {
    "id": "f_8x2",
    "number": 42,
    "title": "Dark mode",
    "from": "planned",
    "to": "shipped",
    "votes": 184
  }
}

feedback.created

A request is posted.

{
  "id": "f_8x2",
  "number": 42,
  "title": "Dark mode",
  "body": "Easier on my eyes at night",
  "category": "idea",
  "author": {
    "name": "Ada",
    "email": "ada@acme.com"
  }
}

feedback.status_changed

A request changes status.

{
  "id": "f_8x2",
  "number": 42,
  "title": "Dark mode",
  "from": "planned",
  "to": "shipped",
  "votes": 184
}

comment.created

A public comment is added. Public comments only; internal notes never leave your team.

{
  "id": "c_91",
  "request": {
    "id": "f_8x2",
    "number": 42,
    "title": "Dark mode"
  },
  "author": "Team",
  "team": true,
  "body": "On it, shipping this month."
}

release.published

A release is published. A release aimed at a group has its segment in audience, and is never sent to Slack or Discord.

{
  "id": "r_77",
  "title": "Dark mode",
  "version": "v1.4.0",
  "url": "https://acme.shipnoted.com/p/acme/dark-mode",
  "body": "- Dark mode",
  "audience": "",
  "requests": [
    {
      "id": "f_8x2",
      "title": "Dark mode"
    }
  ]
}

feedback.voted

Someone votes for a request.

{
  "id": "f_8x2",
  "number": 42,
  "title": "Dark mode",
  "votes": 185,
  "voter": {
    "email": "bo@acme.com",
    "plan": "Pro",
    "company": "Acme"
  }
}

subscriber.confirmed

Someone confirms their subscription.

{
  "email": "ada@acme.com",
  "categories": [
    "fixed"
  ]
}

Verify the signature#

Each request carries three headers: webhook-id, webhook-timestamp and webhook-signature. This is the Standard Webhooks scheme, the same one we check on the payments webhooks we receive, so any library for it works. The signature is v1, and the base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}, keyed with the secret after whsec_ (base64-decoded).

import { verifyWebhook } from "shipnoted"

// Express: app.post("/hooks/shipnoted", express.raw({ type: "application/json" }), (req, res) => { … })
export function handle(req, res) {
  let event
  try {
    event = verifyWebhook({ secret: process.env.SHIPNOTED_WEBHOOK_SECRET, headers: req.headers, body: req.body })
  } catch {
    return res.status(400).end()
  }
  if (event.type === "feedback.created") console.log("New request:", event.data.title)
  res.status(200).end()
}

Reject anything with a timestamp more than five minutes off: that stops a captured request being replayed later.

Delivery and retries#

  • Answer with any 2xx within 8 seconds. Redirects are not followed.
  • Events are written down first and sent from there, so your server being down costs a retry, never an event.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours, then dropped. The webhook-id is the same each time: use it to ignore repeats.
  • An endpoint that fails ten deliveries in a row is switched off. Fix it, then resume it from the dashboard or with shipnoted webhooks resume <id>; that also clears the count.
  • Order is not guaranteed across events. Use created if it matters.

Test it#

npx shipnoted webhooks test we_abc   # sends a signed "ping" now and prints what your server answered

To test your handler without us, sign your own payload with the same scheme:

import { signWebhook } from "shipnoted"

const body = JSON.stringify({ id: "evt_1", type: "feedback.created", created: 1, data: { title: "Hi" } })
const headers = signWebhook({ secret: "whsec_…", id: "wd_1", body })
await fetch("http://localhost:3000/api/shipnoted", { method: "POST", headers, body })