Skip to content
WhatIsUp.dev

Webhook endpoints

Tell WhatIsUp.dev where to deliver event notifications. One customer can have multiple endpoints (different URLs for different event subsets). One channel can be the source of events for multiple endpoints, and one endpoint can subscribe to multiple channels — they're a many-to-many.

Conceptual model

Endpoints describe a subscription: where to send, which events to send, and the signing secret used for the HMAC. The actual delivery records are in Webhook deliveries.

api_key → customer ──┬── channel ◄───┐
                     │                 │
                     └── webhook_endpoint
                              │
                              └── webhook_delivery (per event)

List endpoints

GET/v1/webhook-endpointsBearer · API key

Returns every endpoint owned by the authenticated customer.

Create an endpoint

POST/v1/webhook-endpointsBearer · API key
Schema · Request body
FieldTypeRequiredNotes
urlstring · URLrequired
eventsarray<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `message.edited` \| `message.deleted` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `channel.limits` \| `channel.health` \| `qr.updated` \| `group.created` \| `group.updated` \| `group.participant_added` \| `group.participant_removed` \| `group.admin_promoted` \| `group.admin_demoted` \| `chat.updated` \| `chat.archived` \| `chat.pinned` \| `presence.updated` \| `order.placed` \| `order.cancelled` \| `cart.updated` \| `story.viewed` \| `call.offered` \| `call.terminated` \| `contact.resolved`>required
channel_idstring · uuidoptional
signing_secretstringoptional
enabledbooleanoptional
chat_type_filter`all` \| `direct_only` \| `groups_only`optional
curl -sX POST "$WHATISUP_API/v1/webhook-endpoints" \
  -H "Authorization: Bearer $WHATISUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://api.acme.dev/whatisup/webhook","events":["message.received","message.sent"],"channel_id":"8d653c66-e4ff-43ee-97da-3de5ad5680d4"}'

channel_id is optional and singular (a UUID, not an array). Omit it for an account-wide endpoint that receives every channel's events; set it to scope the endpoint to one channel.

Every endpoint has its own signing secret. Pass signing_secret to choose it, or omit it and the gateway generates a unique whsec_… secret for that endpoint. The 201 response echoes the secret either way — it's the only response that carries it. After that you can read it again at any time via GET /v1/webhook-endpoints/{id}/signing-secret, which returns { signing_secret, is_default }; is_default is always false and is kept only for compatibility. Normal reads (GET, PATCH) expose only the boolean has_custom_signing_secret.

Schema · Response · WebhookEndpoint
FieldTypeRequiredNotes
idstring · uuidrequired
customer_idstring · uuidrequired
channel_idstring · uuidrequired · nullable
urlstring · URLrequired
eventsarray<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `message.edited` \| `message.deleted` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `channel.limits` \| `channel.health` \| `qr.updated` \| `group.created` \| `group.updated` \| `group.participant_added` \| `group.participant_removed` \| `group.admin_promoted` \| `group.admin_demoted` \| `chat.updated` \| `chat.archived` \| `chat.pinned` \| `presence.updated` \| `order.placed` \| `order.cancelled` \| `cart.updated` \| `story.viewed` \| `call.offered` \| `call.terminated` \| `contact.resolved`>required
enabledbooleanrequired
has_custom_signing_secretbooleanrequired
chat_type_filter`all` \| `direct_only` \| `groups_only`required
created_atstring · ISO 8601required
updated_atstring · ISO 8601required

The url is validated for SSRF at create time and at delivery time (DNS-rebind defense). Loopback / link-local / RFC1918 / cloud-metadata IPs are rejected with a 400. In production (NODE_ENV=production), only https:// is accepted.

Update an endpoint

PATCH/v1/webhook-endpoints/:idBearer · API key
Schema · Request body
FieldTypeRequiredNotes
urlstring · URLoptional
eventsarray<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `message.edited` \| `message.deleted` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `channel.limits` \| `channel.health` \| `qr.updated` \| `group.created` \| `group.updated` \| `group.participant_added` \| `group.participant_removed` \| `group.admin_promoted` \| `group.admin_demoted` \| `chat.updated` \| `chat.archived` \| `chat.pinned` \| `presence.updated` \| `order.placed` \| `order.cancelled` \| `cart.updated` \| `story.viewed` \| `call.offered` \| `call.terminated` \| `contact.resolved`>optional
signing_secretstring \| unknownoptional
enabledbooleanoptional
chat_type_filter`all` \| `direct_only` \| `groups_only`optional

There is no rotate_secret flag. To rotate, PATCH the endpoint with a new signing_secret string (16–255 chars, your own value):

{ "signing_secret": "whsec_a-new-32-plus-char-secret-here" }

Pass "signing_secret": null instead to have the gateway generate a fresh secret for the endpoint (read it with the signing-secret route above). Either change takes effect on the next delivery — the old secret stops working immediately, so roll your receiver first.

Test an endpoint

Two routes fire a signed synthetic delivery. They return the same superset, so one parser reads both:

  • POST /v1/webhook-endpoints/test — probe any URL, stored or not. Body: { "url": "..." }.
  • POST /v1/webhook-endpoints/:id/test — probe a stored endpoint with its real secret.
{
  "ok": true,
  "status": "success",
  "outcome": "success",
  "http_status": 200,
  "latency_ms": 372,
  "elapsed_ms": 372,
  "signed_with": "endpoint",
  "endpoint_id": "0f2c5ad1-8b47-4e93-a5d2-6c81e04fb7a9"
}

status means different things on the two routes, kept that way for backward compatibility: on /test it is the upstream HTTP status code, on /:id/test it is the success/failure discriminator. Read ok — a boolean meaning the same thing on both. outcome is the discriminator under a name that doesn't clash.

signed_with: "gateway_default" means we hold no stored secret for that URL, so the probe was signed with a one-off random secret: a 401 back is expected and does not mean "your receiver is broken". Save the endpoint and test it with /:id/test to sign with its real secret.

Delete an endpoint

DELETE/v1/webhook-endpoints/:idBearer · API key

Returns 204 No Content. In-flight deliveries that have already been queued will fail against the now-deleted endpoint and exhaust their retry budget normally.