Overview For the technically curious

Webhooks: connecting Zapier, Make and n8n

Send events from the CRM to other apps, check the signature and create or update contacts from outside.

With webhooks, the CRM reports events (new contact, quote accepted, invoice paid, job completed) to Zapier, Make, n8n or your own system. The other way round, these apps can create inquiries and update contacts. Every message is signed according to the Standard Webhooks standard, so the receiving system can check that it really comes from us.

Your business signs the data processing agreement (DPA, German "AVV") with the service you connect itself. Access codes, keys, working hours and wages are never sent.

Setting up

In the CRM under Workflows > Webhooks & Lead Capture, card Send events to other apps. Only people with the Integrations and webhooks permission see the card (by default the Owner rank).

  1. New target: enter a name, an address (only https://, port 443, no internal addresses) and tick the events.
  2. The secret (whsec_…) is shown exactly once. Enter it in the receiving system. After that you can only generate a new one.
  3. Test event sends a webhook.test right away.
Service What you create there
Zapier Trigger "Webhooks by Zapier", event "Catch Hook". Enter the address it shows as the target.
Make Module "Webhooks", "Custom webhook". Copy the address.
n8n Node "Webhook", method POST, enter the Production URL. Check it with a Code node (example below).

What we send

A POST request with JSON. Headers:

Header Content
content-type application/json
user-agent Agency-Flow-Webhooks/1
webhook-id msg_…, the same for every retry of the same message
webhook-timestamp Unix time in seconds, time of this attempt
webhook-signature v1,<base64>

Envelope (the same for all events):

{
  "id": "evt_3f1c2a9b8e7d4c6a9f0b1c2d3e4f5a6b",
  "type": "contact.created",
  "version": 1,
  "timestamp": "2026-10-04T18:22:31.512Z",
  "account_id": "6f1a7c2e-0b8d-4d4e-9a31-5c2b8e9f0a11",
  "data": { }
}
  • id belongs to the event. If several targets get the same event, they all carry the same id, but each has its own webhook-id.
  • version only changes when the fields of an event change in an incompatible way. New fields are added without a new version.
  • timestamp is the time of the event, not of sending.
  • The order of the keys in the JSON is not fixed.

Events and fields

data contains only the fields listed here (allow list in the database, public.webhook_erlaubte_felder). Anything missing is null.

Contacts and inquiries

contact.created: a contact was created (by hand, import, form, call). contact.updated: name, position, email, phone, company, tags, status or newsletter opt-out changed. Other changes (for example the lead score) trigger nothing. Deleted and anonymized contacts trigger nothing.

Field Content
id Contact number
first_name, last_name, title Name, position
email, emails first address, all addresses
phone, phones first number, all numbers
company { "id", "name" } or null
tags Tag names
status Status in the CRM
email_opt_out true if no marketing emails are wanted
changed only for contact.updated: changed fields, e.g. ["first_name","phones"]
{
  "type": "contact.updated",
  "version": 1,
  "data": {
    "id": 4711,
    "first_name": "Anna",
    "last_name": "Meier",
    "title": "Hausverwaltung",
    "email": "anna@weber.de",
    "emails": ["anna@weber.de"],
    "phone": "+49 170 1234567",
    "phones": ["+49 170 1234567"],
    "company": { "id": 12, "name": "Praxis Dr. Weber" },
    "tags": ["Stammkunde"],
    "status": "warm",
    "email_opt_out": false,
    "changed": ["first_name"]
  }
}

inquiry.received: an inquiry came in through lead capture (form, Zapier, lead-radar, call appointment). Appointments from booking pages arrive as booking.created.

Field Content
id Inquiry number
contact_id Contact it belongs to
source Source of the endpoint, e.g. website
first_name, last_name, email, phone as entered
message Message, at most 5000 characters
utm_source, utm_medium, utm_campaign, landing_url Origin
created_at Point in time

Not included: IP address, browser, visitor and session ID.

Pipeline

deal.stage_changed: a card entered a pipeline or changed its stage. Reordering within a stage triggers nothing.

Field Content
id Card number
contact { "id", "first_name", "last_name" }
pipeline { "id", "name" }
stage, stage_label new stage (key, name)
previous_stage, previous_stage_label old stage, null for a new card
title, value Title and value of the card
won, lost, lost_reason won, lost, reason

Quotes and invoices

quote.sent, quote.accepted, quote.rejected, invoice.created, invoice.paid. Documents imported during the sync with Lexware or Qonto trigger nothing when they are created; if such a document is paid later, invoice.paid is sent.

Field Content
id, number internal number, document number
contact_id, company_id, deal_id References
customer { "name", "email", "address", "zip", "city", "country", "vat_id" }
currency, net, vat_rate, vat, gross Amounts
issue_date Document date
valid_until quotes only
due_date invoices only
status Status in the CRM
sent_at, accepted_at, rejected_at depending on the quote event
paid_at, amount_paid only invoice.paid

Not included: internal notes, note texts, PDF links.

{
  "type": "invoice.paid",
  "version": 1,
  "data": {
    "id": "8a0c…",
    "number": "RE-2026-0142",
    "contact_id": 4711,
    "company_id": 12,
    "deal_id": null,
    "customer": { "name": "Praxis Dr. Weber", "email": "buchhaltung@weber.de",
                  "address": "Rosenweg 12", "zip": "50667", "city": "Köln",
                  "country": "DE", "vat_id": null },
    "currency": "EUR", "net": 400, "vat_rate": 19, "vat": 76, "gross": 476,
    "issue_date": "2026-09-30", "due_date": "2026-10-14",
    "status": "paid", "paid_at": "2026-10-04T08:12:00Z", "amount_paid": 476
  }
}

Appointments

booking.created, booking.cancelled.

Field Content
id, start_at, end_at, status Appointment
booking_page { "id", "title" }
contact_id Contact, if assigned
guest { "name", "email", "phone", "address" }
notes, answers Message and answers from the form (only booking.created)
created_at only booking.created
cancelled_at, cancel_reason only booking.cancelled

Not included: the guest's management link, calendar IDs.

Jobs and complaints

job.completed: a job was set to completed.

Field Content
id, title, start_at, end_at Job
object { "id", "name", "street", "zip", "city" }
contact_id Client of the property

Not included: employees, working hours, notes, access codes, key information, internal property note.

complaint.created: a complaint or a problem at the property was reported.

Field Content
id, title, description, priority, status, created_at Report
object { "id", "name" }

Not included: who reported it, photos.

Test

webhook.test with data = { "message", "subscription": { "id", "name" } }. Only sent via the Test event button.

Checking the signature

The signed content is webhook-id + "." + webhook-timestamp + "." + body with HMAC-SHA256. The key is the secret without whsec_, base64-decoded. The header can carry several signatures (v1,… v1,…); one match is enough.

Important: check the text exactly as it arrived. If you parse the JSON first and write it out again, you get a different string, and the signature won't match.

Reject the timestamp if it differs from your own clock by more than 5 minutes. Remember the webhook-id and don't process a second message with the same webhook-id again; answer it with 200 instead.

Node.js (without a library)

import crypto from "node:crypto";
import express from "express";

const GEHEIMNIS = process.env.AGENCY_FLOW_WEBHOOK_SECRET; // whsec_…
const app = express();

// express.raw: keep the text unchanged
app.post("/agency-flow", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const ts = req.header("webhook-timestamp");
  const sigKopf = req.header("webhook-signature") ?? "";
  const body = req.body.toString("utf8");

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const schluessel = Buffer.from(GEHEIMNIS.replace(/^whsec_/, ""), "base64");
  const erwartet = crypto.createHmac("sha256", schluessel)
    .update(`${id}.${ts}.${body}`).digest("base64");
  const ok = sigKopf.split(" ").some((teil) => {
    const [version, sig] = teil.split(",");
    return version === "v1" && sig && sig.length === erwartet.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(erwartet));
  });
  if (!ok) return res.sendStatus(401);

  const ereignis = JSON.parse(body);
  // … process it, remember the webhook-id …
  res.sendStatus(204);
});

With the standard's library: npm install standardwebhooks, then new Webhook(GEHEIMNIS).verify(body, req.headers).

Python (without a library)

import base64, hashlib, hmac, time
from flask import Flask, request, abort

GEHEIMNIS = "whsec_..."
app = Flask(__name__)

@app.post("/agency-flow")
def empfangen():
    msg_id = request.headers["webhook-id"]
    ts = request.headers["webhook-timestamp"]
    sig_kopf = request.headers.get("webhook-signature", "")
    body = request.get_data(as_text=True)  # unchanged

    if abs(time.time() - int(ts)) > 300:
        abort(400)
    schluessel = base64.b64decode(GEHEIMNIS.removeprefix("whsec_"))
    erwartet = base64.b64encode(
        hmac.new(schluessel, f"{msg_id}.{ts}.{body}".encode(), hashlib.sha256).digest()
    ).decode()
    if not any(
        teil.startswith("v1,") and hmac.compare_digest(teil[3:], erwartet)
        for teil in sig_kopf.split(" ")
    ):
        abort(401)
    # … process it, remember msg_id …
    return "", 204

With the library: pip install standardwebhooks, then Webhook(GEHEIMNIS).verify(body, dict(request.headers)).

n8n (Code node)

In the Webhook node, turn on "Raw Body" under Options, then in the Code node:

const crypto = require("crypto");
const h = $json.headers;
const body = Buffer.from($binary.data.data, "base64").toString("utf8");
const key = Buffer.from($env.AF_SECRET.replace(/^whsec_/, ""), "base64");
const soll = crypto.createHmac("sha256", key)
  .update(`${h["webhook-id"]}.${h["webhook-timestamp"]}.${body}`).digest("base64");
if (!h["webhook-signature"].split(" ").includes(`v1,${soll}`)) throw new Error("Signatur falsch");
return [{ json: JSON.parse(body) }];

Delivery, retries, pause

  • Events go out within one minute (queue, cron every minute, only when something is waiting).
  • Success is any response from 200 to 299. Everything else is a failure: 3xx (redirects are not followed), 4xx, 5xx, a timeout of 8 seconds, no connection.
  • Retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the 6th attempt the message counts as failed. Every retry carries the same webhook-id and the same text; only webhook-timestamp and the signature are new.
  • retry-after (seconds or a date) on 429 or 503 extends the waiting time, up to 12 hours at most.
  • 410 Gone means: the target doesn't want anything any more. The target is paused immediately.
  • After 10 failed attempts in a row the target pauses automatically. Owners and admins get a bell notification. Turning it back on resets the counter to 0; waiting messages then go out.
  • Send again in the log sends a message once more, with the same webhook-id.
  • Answer quickly (in under 8 seconds) and handle long tasks in the background.

The log in the CRM shows, for each message, the status, response code, duration, the first 1000 characters of the response and the content sent. Entries are kept for 30 days.

Sending webhooks to Agency-Flow

Endpoints are under Webhooks & Lead Capture, on the lead capture card. Address: POST https://kqypndusxumtaggmidib.supabase.co/functions/v1/lead-webhook?token=<token> (or the x-lead-token header). At most 100 KB, 60 calls per minute per sender.

Signature: new endpoints check the signature by default using the same standard (a separate secret per endpoint, 5 minutes tolerance). A webhook-id the endpoint has already processed is not processed again; the response is 200 with "duplicate": true. Turn the signature off for a form directly on a website, because a browser can't protect a secret. Existing endpoints stay as they were.

Rejected calls are listed under Calls with the reason (signature missing, does not match, timestamp too old) and without the content.

Creating a contact or inquiry (without action)

Field Required Content
email or phone one of them Key; if the contact already exists (email ignoring upper and lower case, number as +49…), no new one is created
first_name, last_name or name no Name
company no Company name, looked up or created
message no Message of the inquiry
utm_source, utm_medium, utm_campaign, landing_url no Origin

Further fields can be assigned in the CRM via field mapping. Response: 200 { "ok": true, "intake_id", "contact_id" }.

Updating a contact ("action": "contact.update")

Only for endpoints with a signature. Never creates a contact.

{
  "action": "contact.update",
  "email": "anna@weber.de",
  "last_name": "Schulz",
  "add_phone": "+49 221 998877",
  "note": "Neue Durchwahl laut Telefonat"
}
Field Content
email Key; looked up first
phone Key, if no email matches
first_name, last_name, title replace the value
add_email, add_phone are added if not present yet
note is added as a note on the contact

All other fields are ignored. Responses: 200 { "ok": true, "contact_id", "changed": [...] }, 404 { "error": "kontakt_nicht_gefunden" }, 403 if the endpoint doesn't require a signature.

Limits and data protection

Limit Value
Targets per account 10
Messages per target and hour 1000, anything above is discarded (visible in the log)
Size of a message 64 KB
Test events per target and hour 20
Timeout 8 seconds
Log retention 30 days
  • Permission: setting up, viewing and testing only with Integrations and webhooks (Owner rank; admins, organization and agency according to the usual rule).
  • Targets: only https://, port 443, no credentials in the address. Before every send the name is resolved; if it points to a private, local, link-local or otherwise non-public address (IPv4 and IPv6), nothing is sent. Remaining risk: if DNS changes exactly between the check and the connection (DNS rebinding), a gap of milliseconds remains. Delivery runs in an Edge Function outside the database network.
  • Secrets are stored encrypted in the Supabase Vault, never in plain text in tables or logs. A secret is only shown when it is created or renewed.
  • Never sent: passwords, tokens, API keys, access codes and key information of properties, working hours, wages, personnel files, absences and sick notes.
  • DPA (AVV): with a target, customer data goes to the service the business chooses, to the USA in the case of Zapier and Make. The business signs the data processing agreement with the service itself. n8n can be self-hosted in the EU.