Dashly Docs

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"}
FieldDescription
eventThe event name, also in the X-Webhook-Event header.
dataThe event's payload (see Events).
timestampWhen the event was sent, ISO 8601 in UTC.
HeaderDescription
Content-Typeapplication/json
X-Webhook-EventThe event name.
X-Webhook-SignatureLowercase 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 2xx response. 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 3xx answer 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 with sort=-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"
}
FieldDescription
idThe ticket's public id: fetch it with GET /v1/tickets/{id}.
titleThe ticket's title.
statusIts workflow column when it was created (todo unless the agency's workflow starts elsewhere).
prioritylow, medium, high or urgent.
scopeclient, agency or internal.
clientIdThe public id of the client it is for, or null.
projectIdThe 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"
}
FieldDescription
idThe organisation's public id: fetch it with GET /v1/clients/{id}.
nameThe organisation's name.
slugIts slug, as in the dashboard's address, or null.
sectorIts sector, or null.
statusprospect, 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.

On this page