Digital Agency Tools Docs

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:

HeaderValue
DA-EventThe event type, e.g. invoice.paid.
DA-DeliveryThis delivery's id. A retry reuses it.
DA-Signaturet=<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:

  1. Read the raw request body, before any JSON parsing — re-encoding changes the bytes.
  2. Split DA-Signature on commas into t and one or more v1 values.
  3. Reject it if t is more than 5 minutes from your clock (replay protection).
  4. Compute the HMAC of t + "." + body with your secret and compare it to each v1 in 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
Rotating a secret takes effect at once: every delivery after the rotation is signed with the new secret only, so update your receiver straight away. Write the verifier to accept any matching 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:

AttemptWaits
1Sent immediately
21 minute
35 minutes
430 minutes
52 hours
612 hours
724 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.

EventWhen
contact.createdA contact was added.
contact.updatedA contact's details changed.
contact.deletedA contact was deleted.
deal.status_changedA contact moved to another pipeline status.
deal.wonA deal was marked won (the contact became a client).
deal.lostA deal was marked lost.
project.createdA project was created.
project.updatedA project's details or status changed.
project.deletedA project was deleted.
task.createdA task was created.
task.updatedA task changed.
task.completedA task moved to a terminal (done) status.
task.deletedA task was deleted.
meeting.createdA meeting or booking was created.
meeting.cancelledA meeting or booking was cancelled.
meeting.completedA meeting was marked completed.
booking.createdA customer booked through the public booking page.
booking.paidA booking payment succeeded.
invoice.issuedAn invoice was issued (left draft).
invoice.paidAn invoice was paid in full.
invoice.voidedAn invoice was voided.
proposal.sentA proposal was sent to the client.
proposal.acceptedThe client accepted a proposal.
proposal.declinedThe client declined a proposal.
ticket.createdA support ticket was opened (panel, email, WhatsApp or the public form).
ticket.repliedA reply was added to a ticket.
ticket.status_changedA ticket's status changed.
form.submittedAn intake form was submitted.
subscriber.createdSomeone subscribed to the newsletter.
subscriber.unsubscribedSomeone unsubscribed from the newsletter.
deliverable.approvedA client approved a deliverable in the portal.
webhook.testSent by "Send test" — never emitted by real activity.