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
Returns every endpoint owned by the authenticated customer.
Create an endpoint
| Field | Type | Required | Notes |
|---|---|---|---|
| url | string · URL | required | |
| events | array<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `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_id | string · uuid | optional | |
| signing_secret | string | optional | |
| enabled | boolean | optional | |
| 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.
The 201 response echoes signing_secret — but only the value you supplied
in the request. If you omit signing_secret on create, the field comes back
null and the endpoint is signed with the gateway's default secret instead.
Either way you can read the secret that actually signs your deliveries at any
time via GET /v1/webhook-endpoints/{id}/signing-secret, which returns
{ signing_secret, is_default }. Normal reads (GET, PATCH) expose only the
boolean has_custom_signing_secret.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string · uuid | required | |
| customer_id | string · uuid | required | |
| channel_id | string · uuid | required · nullable | |
| url | string · URL | required | |
| events | array<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `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 | |
| enabled | boolean | required | |
| has_custom_signing_secret | boolean | required | |
| chat_type_filter | `all` \| `direct_only` \| `groups_only` | required | |
| created_at | string · ISO 8601 | required | |
| updated_at | string · ISO 8601 | required |
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
| Field | Type | Required | Notes |
|---|---|---|---|
| url | string · URL | optional | |
| events | array<`message.received` \| `message.sent` \| `message.reaction` \| `message.status` \| `channel.connected` \| `channel.disconnected` \| `channel.status` \| `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_secret | string \| unknown | optional | |
| enabled | boolean | optional | |
| 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 drop your custom secret and fall back to
the gateway default. 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
a 401 back means "your receiver expects a different secret", not "your receiver
is broken".
Delete an endpoint
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.