Skip to content

Setup

One webhook endpoint per partner receives the events of every workspace. Each delivery says which workspace and which external_id it belongs to, so a single route in your app fans out to the right customer.

Method Path Body Returns
GET /v1/webhook { url, events, secret_prefix, created } or null when none is set
PUT /v1/webhook { url, events?: ["*"] } { url, events, secret }. The secret is shown once
DELETE /v1/webhook 204
POST /v1/webhook/test Sends a ping event to the URL right away and returns that event (same shape as an item of GET /v1/events, with delivery.status, delivery.attempts and delivery.last_status)
GET /v1/events query: workspace, type, since, page, limit List of events, for polling

Webhook endpoints need a partner key. A workspace key cannot read or change them.

Terminal window
curl -X PUT https://social.missless.tel/v1/webhook \
-H "Authorization: Bearer $MISSLESS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://app.example.com/webhooks/missless", "events": ["*"] }'

Response:

{
"url": "https://app.example.com/webhooks/missless",
"events": ["*"],
"secret": "7d0b7c8f5a1e4c2b9f6d3a8e1c4b7f0a9d2e5c8b1a4f7d0c"
}

Store secret in your secret manager as MISSLESS_WEBHOOK_SECRET. It is returned by this call only. GET /v1/webhook shows secret_prefix, the first characters, so you can confirm which secret is live without exposing it.

Requirements for url:

  • https only. Plain http is rejected with validation_error on field: "url".
  • Publicly reachable. Loopback, link-local and private network addresses are refused.
  • Answers 2xx within a few seconds. Do the work after you respond, not before.

events is a list of type names, or ["*"] for everything.

{ "url": "https://app.example.com/webhooks/missless", "events": ["post.published", "post.partial", "post.failed", "account.expired"] }

ping is always delivered, whatever the filter. The full list is in Events.

PUT /v1/webhook replaces the whole configuration, secret included. To rotate, call it again with the same url, read the new secret, and deploy it. Deliveries signed with the old secret stop as soon as the call returns, so deploy the new secret first if you cannot tolerate a gap; verify against both for a few minutes.

Terminal window
curl -X POST https://social.missless.tel/v1/webhook/test \
-H "Authorization: Bearer $MISSLESS_API_KEY"

Your URL receives a ping:

{
"id": "evt0a2c4e6g8i1k",
"type": "ping",
"created_at": "2026-08-25T09:00:00Z",
"partner": "acme",
"workspace": null,
"external_id": null,
"data": {}
}

with the same headers as a real event, so you can test signature verification end to end. See Verify signatures.

DELETE /v1/webhook stops deliveries. Events keep being recorded and stay readable through GET /v1/events, so nothing is lost while you have no endpoint. Register again when you are ready.

The receiver has three jobs: verify, acknowledge, dispatch. In that order.

receiver.mjs
import express from 'express';
import { verifyMissLess } from './verify.mjs';
const app = express();
app.post('/webhooks/missless', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyMissLess(req.body, req.headers, process.env.MISSLESS_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).end();
queue.push(event); // handle asynchronously, de-duplicate on event.id
});

verifyMissLess is on the next page. The body must be read raw, before any JSON middleware touches it, because the signature covers the exact bytes.