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.publishedThe 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
2xxwithin 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-idis 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
createdif it matters.
Test it#
npx shipnoted webhooks test we_abc # sends a signed "ping" now and prints what your server answeredTo 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 })