Webhooks
Instead of polling, register an HTTPS endpoint and we POST a signed event to it when something happens: a deal is won, an invoice is paid, a ticket is opened.
Registering an endpoint
Add endpoints in the panel at Settings → Developers → Webhooks, or
with an admin key through POST /v1/webhook-endpoints.
Subscribe to specific event types, or * for all of them.
The endpoint's signing secret is shown once, when it is created or
rotated (POST /v1/webhook-endpoints/{id}/rotate-secret).
URLs must be https:// on port 443, with a hostname that resolves only
to public addresses. Private, loopback and link-local addresses are refused, and
redirects are not followed.
Request
curl -X POST https://api.digitalagency.tools/v1/webhook-endpoints \
-H "Authorization: Bearer $DA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/da",
"events": ["invoice.paid", "deal.won"],
"description": "Accounting sync"
}'
What we send
A POST with a JSON body and three headers:
| Header | Value |
|---|---|
DA-Event | The event type, e.g. invoice.paid. |
DA-Delivery | This delivery's id. A retry reuses it. |
DA-Signature | t=<unix ts>,v1=<hex HMAC-SHA256> |
data is the resource in the same shape the API returns it. The event
id is the same across every endpoint that receives it — use it to
de-duplicate.
Answer any 2xx within 10 seconds. Do slow work after you respond.
Headers
POST /hooks/da HTTP/1.1
Content-Type: application/json
DA-Event: contact.created
DA-Delivery: 9314
DA-Signature: t=1793872800,v1=5f2c0e…a81b
Body
{
"id": "evt_4b1d9c0e7a2f6b3c8d5e1a90",
"type": "contact.created",
"created": 1793872800,
"business_id": 12,
"data": {
"id": 482,
"name": "Niamh Kelly",
"email": "niamh@example.com",
"type": "prospect",
"created_at": "2026-11-05T10:00:00+00:00",
…
}
}
Verifying the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>"),
hex encoded. To verify:
- Read the raw request body, before any JSON parsing — re-encoding changes the bytes.
- Split
DA-Signatureon commas intotand one or morev1values. - Reject it if
tis more than 5 minutes from your clock (replay protection). - Compute the HMAC of
t + "." + bodywith your secret and compare it to eachv1in constant time. Accept if any matches.
PHP
<?php
function da_verify(string $secret, string $header, string $body, int $tolerance = 300): bool {
$t = 0; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = (int)$v;
if ($k === 'v1') $sigs[] = $v;
}
if ($t <= 0 || abs(time() - $t) > $tolerance) return false;
$want = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($sigs as $sig) {
if (hash_equals($want, $sig)) return true;
}
return false;
}
$body = file_get_contents('php://input');
if (!da_verify(getenv('DA_WEBHOOK_SECRET'), $_SERVER['HTTP_DA_SIGNATURE'] ?? '', $body)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
function daVerify(secret, header, rawBody, tolerance = 300) {
let t = 0; const sigs = [];
for (const part of (header || '').split(',')) {
const [k, v = ''] = part.trim().split('=', 2);
if (k === 't') t = parseInt(v, 10);
if (k === 'v1') sigs.push(v);
}
if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const want = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return sigs.some(s => {
const got = Buffer.from(s);
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
// express.raw keeps the exact bytes that were signed.
app.post('/hooks/da', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!daVerify(process.env.DA_WEBHOOK_SECRET, req.get('DA-Signature'), raw)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
def da_verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
t, sigs = 0, []
for part in (header or "").split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = int(v or 0)
elif k == "v1":
sigs.append(v)
if not t or abs(time.time() - t) > tolerance:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, s) for s in sigs)
@app.post("/hooks/da")
def hook():
if not da_verify(os.environ["DA_WEBHOOK_SECRET"], request.headers.get("DA-Signature"), request.get_data()):
abort(400)
event = request.get_json()
return "", 200
v1 value, as above, so it keeps working
if more than one signature is ever sent.Retries
Anything other than a 2xx within 10 seconds — an error status, a timeout, a refused connection — is a failure. We retry on this schedule, measured from the previous attempt:
| Attempt | Waits |
|---|---|
| 1 | Sent immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
After the last retry the delivery is marked failed. You can resend any
delivery from the panel or with
POST /v1/webhook-endpoints/{id}/deliveries/{delivery_id}/retry.
Automatic disabling
An endpoint that fails 6 deliveries in a row is switched
off, and the business owner gets a “needs attention” notification with the last error. Fix
the receiver, then re-enable the endpoint in the panel or with
PATCH /v1/webhook-endpoints/{id} ({"is_active": true}). One
success resets the count.
Testing
POST /v1/webhook-endpoints/{id}/test (or Send test in the
panel) sends a webhook.test event to that endpoint only.
Ordering and duplicates
Deliveries are not guaranteed to arrive in order, and a delivery can occasionally arrive
twice. Use the event id to de-duplicate and the resource's
updated_at to decide which version is newest.
Event types
The full catalogue, also available from GET /v1/events and in the
reference's Webhooks section.
| Event | When |
|---|---|
contact.created | A contact was added. |
contact.updated | A contact's details changed. |
contact.deleted | A contact was deleted. |
deal.status_changed | A contact moved to another pipeline status. |
deal.won | A deal was marked won (the contact became a client). |
deal.lost | A deal was marked lost. |
project.created | A project was created. |
project.updated | A project's details or status changed. |
project.deleted | A project was deleted. |
task.created | A task was created. |
task.updated | A task changed. |
task.completed | A task moved to a terminal (done) status. |
task.deleted | A task was deleted. |
meeting.created | A meeting or booking was created. |
meeting.cancelled | A meeting or booking was cancelled. |
meeting.completed | A meeting was marked completed. |
booking.created | A customer booked through the public booking page. |
booking.paid | A booking payment succeeded. |
invoice.issued | An invoice was issued (left draft). |
invoice.paid | An invoice was paid in full. |
invoice.voided | An invoice was voided. |
proposal.sent | A proposal was sent to the client. |
proposal.accepted | The client accepted a proposal. |
proposal.declined | The client declined a proposal. |
ticket.created | A support ticket was opened (panel, email, WhatsApp or the public form). |
ticket.replied | A reply was added to a ticket. |
ticket.status_changed | A ticket's status changed. |
form.submitted | An intake form was submitted. |
subscriber.created | Someone subscribed to the newsletter. |
subscriber.unsubscribed | Someone unsubscribed from the newsletter. |
deliverable.approved | A client approved a deliverable in the portal. |
webhook.test | Sent by "Send test" — never emitted by real activity. |