Outbound webhooks
Receive signed JSON events from Dashly on your own HTTPS endpoint.
Dashly can notify your system when something happens in an agency's workspace: it sends a
signed JSON POST to every endpoint the agency registered.
Registering an endpoint
An endpoint is a public HTTPS URL. Register it in the dashboard under
Connectors → API & webhooks → Outgoing webhooks, or through the API with the
create-webhook action of the agency-settings resource:
curl -s -X POST https://api.usedashly.com/v1/agency-settings/actions/create-webhook \
-H "Authorization: Bearer $DASHLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/dashly"}'{
"data": {
"url": "https://example.com/webhooks/dashly",
"secret": "whsec_3q2d…"
}
}The signing secret is returned once, at registration. Store it right away: it cannot
be read again. To get a new one, delete the endpoint and register it again.
To remove an endpoint, call delete-webhook with the exact URL you registered. Every
endpoint registered for that URL is deleted and deliveries stop immediately:
curl -s -X POST https://api.usedashly.com/v1/agency-settings/actions/delete-webhook \
-H "Authorization: Bearer $DASHLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/dashly"}'Both are write actions on agency-settings, so the key needs write access to that resource
(see Authentication). Endpoints are addressed by their URL; there is
no API call to list them, the dashboard shows the list. From an AI assistant, the same
actions are available through the MCP run_action tool.
Allowed URLs. Only https URLs on public addresses are accepted. localhost, .local
and .internal names and private, loopback, link-local or reserved IP addresses are refused
(url_not_allowed). The host name is resolved again at each delivery, and a name that
resolves to a non-public address is not called.
What you receive
Every endpoint of the agency receives every event. There is no per-endpoint event
filter: switch on event in your handler and ignore what you do not need.
POST /webhooks/dashly HTTP/1.1
Content-Type: application/json
X-Webhook-Event: ticket.created
X-Webhook-Signature: 5f0c3b7e9a…(64 hex characters)
{"event":"ticket.created","data":{"id":"t8kq2mz4xa","title":"Update the pricing page","status":"todo","priority":"high","scope":"client","clientId":"hsbftmp3f5","projectId":null},"timestamp":"2026-10-29T09:41:07.512Z"}| Field | Description |
|---|---|
event | The event name, also in the X-Webhook-Event header. |
data | The event's payload (see Events). |
timestamp | When the event was sent, ISO 8601 in UTC. |
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event name. |
X-Webhook-Signature | Lowercase hex HMAC-SHA256 of the raw request body, keyed with the endpoint's secret. |
Verifying the signature
Compute the HMAC-SHA256 of the raw body bytes, exactly as received and before any JSON
parsing, with your endpoint's secret as the key. Compare it to X-Webhook-Signature in
constant time, and reject the request if they differ.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.DASHLY_WEBHOOK_SECRET!; // whsec_…
// Keep the raw body: the signature is computed over the exact bytes sent.
app.post("/webhooks/dashly", express.raw({ type: "application/json" }), (req, res) => {
const expected = createHmac("sha256", SECRET).update(req.body).digest("hex");
const received = String(req.header("X-Webhook-Signature") ?? "");
const valid =
received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
switch (event.event) {
case "ticket.created":
// event.data.id, event.data.clientId… (public ids: use them with the API)
break;
case "client.created":
// event.data.id, event.data.name, event.data.status
break;
}
res.status(204).end();
});The secret is the whole string returned at registration, whsec_ prefix included. The
signature covers the body, timestamp included, so you can also reject deliveries whose
timestamp is too old to protect against replays.
Delivery
- Success is any
2xxresponse. The response body is ignored. - Timeout: your endpoint has 5 seconds to answer; after that the delivery is abandoned. Answer quickly and do slow work in the background.
- Redirects are not followed. A
3xxanswer counts as a failed delivery: register the final URL. - No retries. Each event is attempted once per endpoint. A delivery that fails (timeout,
network error, non-
2xx, redirect) is not sent again, and there is no delivery log to replay from. If you cannot afford to miss an event, reconcile periodically with the API (for example, list recently created tickets withsort=-createdAt). - Not on the critical path. Events are sent just after the action that caused them completes; a slow or failing endpoint never slows down or breaks the product.
- No ordering guarantee between events or between endpoints.
Events
Two events exist today.
Both events fire only for records created from the Dashly dashboard (including a few in-app flows, listed below). A ticket or client created through the REST API or the MCP server does not emit an event today, nor does a request a client opens from the client portal or by email. Do not rely on webhooks to observe writes your own integration makes; you already know about those.
ticket.created
A ticket (work item) was created in the dashboard: from the ticket form, by opening tickets from the SEO checklist, or from a recommendation in a periodic report.
{
"event": "ticket.created",
"data": {
"id": "t8kq2mz4xa",
"title": "Update the pricing page",
"status": "todo",
"priority": "high",
"scope": "client",
"clientId": "hsbftmp3f5",
"projectId": null
},
"timestamp": "2026-10-29T09:41:07.512Z"
}| Field | Description |
|---|---|
id | The ticket's public id: fetch it with GET /v1/tickets/{id}. |
title | The ticket's title. |
status | Its workflow column when it was created (todo unless the agency's workflow starts elsewhere). |
priority | low, medium, high or urgent. |
scope | client, agency or internal. |
clientId | The public id of the client it is for, or null. |
projectId | The public id of its project, or null. |
Every id in a payload is a public id, the same the API uses in paths and relations.
client.created
An organisation was created in the dashboard's CRM (as a client or a prospect), or created while filing a project that a partner agency shares with you under a new client.
{
"event": "client.created",
"data": {
"id": "hsbftmp3f5",
"name": "Acme Studio",
"slug": "acme-studio",
"sector": "Retail",
"status": "prospect"
},
"timestamp": "2026-10-29T09:41:07.512Z"
}| Field | Description |
|---|---|
id | The organisation's public id: fetch it with GET /v1/clients/{id}. |
name | The organisation's name. |
slug | Its slug, as in the dashboard's address, or null. |
sector | Its sector, or null. |
status | prospect, active, inactive or lost. |
Until 2026-10-30, ticket.created carried an internal accountId instead of clientId,
and neither event carried the record's id. A handler written against those payloads
should read clientId and id instead.