Guides
Webhooks
Get comments, messages and publish results pushed to your server as signed events, and handle retries safely.
Subscribe
Register an https URL and the event types you want. The response contains the signing secret, once. Store it now.
curl -X POST https://connect.social.wasparks.com/v1/webhook-subscriptions \
-H "Authorization: Bearer $WASPARKS_KEY" -H "X-Tenant-Id: acme-001" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/wasparks",
"event_types": ["post.published", "post.failed", "comment.created", "message.received"]
}'
const res = await fetch("https://connect.social.wasparks.com/v1/webhook-subscriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WASPARKS_KEY}`,
"X-Tenant-Id": "acme-001",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://app.example.com/hooks/wasparks",
event_types: ["post.published", "post.failed", "comment.created", "message.received"],
}),
});
const subscription = await res.json(); // keep subscription.secret
import os, requests
r = requests.post(
"https://connect.social.wasparks.com/v1/webhook-subscriptions",
headers={"Authorization": f"Bearer {os.environ['WASPARKS_KEY']}", "X-Tenant-Id": "acme-001"},
json={
"url": "https://app.example.com/hooks/wasparks",
"event_types": ["post.published", "post.failed", "comment.created", "message.received"],
},
)
subscription = r.json() # keep subscription["secret"]- Unknown event types, types your key may not subscribe to, and URLs that are not
httpsreturn400 validation_failed. GET /v1/webhook-subscriptionslists your subscriptions.PATCHchanges the event types, or pauses (status: PAUSED) and resumes (status: ACTIVE) deliveries.DELETEremoves one.POST /v1/webhook-subscriptions/{subscriptionId}/testsends a signedwebhook.testevent to your URL right away and tells you how it went. Use it to check your endpoint and your signature code.
The event types page lists every type and its payload.
What a delivery looks like
We POST the event to your URL as JSON. Any 2xx answer counts as delivered. We wait up to 10 seconds for it.
Headers:
| Header | Value |
|---|---|
X-WSS-Event |
The event type, the same as the body's type. |
X-WSS-Delivery |
This delivery's id. Retries and redeliveries keep it. |
X-WSS-Signature |
t=<unix seconds>,v1=<hex signature>. See below. |
Body, the same envelope for every event:
{
"id": "evt_0192a7c4-5d00-7b88-8f99-a0b1c2d3e001",
"type": "comment.created",
"created_at": "2026-11-02T09:00:00Z",
"org_id": "0192a7c1-5d2e-7a10-9c4b-2f6e8d1a3b01",
"workspace_id": "0192a7c1-5d2e-7a10-9c4b-2f6e8d1a3b02",
"data": { "comment": { "id": "0192a7c3-2a00-7e55-9c66-7d8e9fa0b001", "text": "Is the pumpkin latte available with oat milk?", "…": "…" } }
}idis the event's id. It is the same on every retry: use it to deduplicate.org_idis the org the event is about. It isnullon partner-level events.workspace_idis the workspace of the account, post, comment or conversation. It isnullon org-level and partner-level events.datadepends ontype.
Verify the signature
Check every delivery before you trust it. The signature is an HMAC-SHA256, in hex, of the string <t>.<raw body>, keyed with the subscription's secret (the secret string as you received it, used as is):
- Split
X-WSS-Signatureintotandv1. - Reject the delivery if
tis more than 5 minutes from now. - Compute
HMAC_SHA256(secret, t + "." + raw_body)and compare it withv1in constant time.
Use the raw bytes of the request body. If your framework parses the JSON first and you serialise it again, the bytes change and the signature will not match.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.WASPARKS_WEBHOOK_SECRET;
export function verifySignature(rawBody, header, secret = SECRET, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
// express.raw keeps the body as bytes; do not use express.json() on this route.
app.post("/hooks/wasparks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.get("X-WSS-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body);
// dedupe on event.id, then handle event.type
res.sendStatus(200);
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["WASPARKS_WEBHOOK_SECRET"]
def verify_signature(raw_body: bytes, header: str, secret: str = SECRET, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/hooks/wasparks")
def hook():
if not verify_signature(request.get_data(), request.headers.get("X-WSS-Signature")):
abort(400)
event = request.get_json()
# dedupe on event["id"], then handle event["type"]
return "", 200Retries and redelivery
Deliveries are at least once and not ordered. Handle both:
- Deduplicate on the envelope
id. - Do not rely on order. Use the timestamps and the current state of the resource, for example by fetching the post, when order matters.
- Ignore fields and event types you do not know. We add them without notice.
If your endpoint does not answer with a 2xx, we retry with growing gaps: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After the last attempt the delivery is DEAD.
If 20 deliveries in a row end DEAD, the subscription is set to DISABLED and a webhook.disabled event goes to your other subscriptions. Fix your endpoint and turn it back on with PATCH and "status": "ACTIVE".
To see what happened to a delivery, call GET /v1/webhook-subscriptions/{subscriptionId}/deliveries. Each entry has a status (PENDING, SENDING, DELIVERED, FAILED or DEAD), the number of attempts and the last HTTP status. To send one again, call POST /v1/webhook-subscriptions/deliveries/{deliveryId}/redeliver. It carries the same event id, so your deduplication still works.
If your endpoint was down for a long time, also run POST /v1/accounts/{accountId}/comments/sync to catch up on comments.
One webhook for all your clients
If you are a partner in operator mode, you do not need one subscription per client. Create a single partner subscription:
POST /v1/partner/webhook-subscriptions
{
"url": "https://app.example.com/hooks/wasparks",
"event_types": ["comment.created", "message.received", "post.published", "org.created", "usage.daily_ready"]
}It receives content events for all your orgs, together with the partner events (org.*, usage.daily_ready). Every delivery has org_id, and workspace_id for content events. Look up the client with the org's external_ref and route the event to it. Use one handler and one secret.
An org can still have its own subscription at /v1/webhook-subscriptions if one client needs a separate endpoint.