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).
- New target: enter a name, an address (only
https://, port 443, no internal addresses) and tick the events. - The secret (
whsec_…) is shown exactly once. Enter it in the receiving system. After that you can only generate a new one. - Test event sends a
webhook.testright 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": { }
}
idbelongs to the event. If several targets get the same event, they all carry the sameid, but each has its ownwebhook-id.versiononly changes when the fields of an event change in an incompatible way. New fields are added without a new version.timestampis 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-idand the same text; onlywebhook-timestampand 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.