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` \| `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_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.
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.
| 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` \| `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 | |
| 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` \| `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_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 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
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.