Skip to content

Webhooks ​

Gilt ab Work7 0.10.0 · Stand 01.11.2026

Webhooks benachrichtigen dein System, sobald in Work7 etwas passiert: ein Regiebericht wurde abgeschlossen, eine Rechnung bezahlt, eine Anfrage angelegt. Du registrierst eine https-URL und die Events, die dich interessieren. Work7 sendet dann einen signierten POST.

Einrichten ​

  1. Einstellungen › Integrationen › API & Webhooks, Abschnitt Ausgehende Webhooks.
  2. Name, Ziel-URL und Events wählen. Wildcards wie site_report.* sind erlaubt.
  3. Webhook anlegen. Das Secret (whsec_…) wird einmalig angezeigt und wird zum Prüfen der Signatur gebraucht.

Webhooks lassen sich pausieren und wieder aktivieren. Ereignisse während einer Pause werden nicht nachgeliefert.

Regeln für die Ziel-URL ​

  • Nur https://, Port 443 (oder kein Port), keine Zugangsdaten in der URL.
  • Das Ziel muss öffentlich erreichbar sein. Work7 löst den Hostnamen beim Anlegen und vor jeder Zustellung auf und lehnt Adressen aus privaten oder internen Bereichen ab (Loopback, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, Link-local wie 169.254.0.0/16, Cloud-Metadaten, IPv6-ULA/Link-local) sowie Hostnamen wie localhost, *.local, *.internal, *.svc, *.cluster.local.
  • Beim Anlegen antwortet die API in diesem Fall mit 400 und code: "blocked_target". Zeigt ein bereits eingetragener Hostname später auf eine solche Adresse, bekommt die Zustellung den Status blocked_target und wird nicht wiederholt.
  • Weiterleitungen (3xx) folgt Work7 nicht; sie gelten als Fehlschlag. Antworte direkt mit 2xx.
  • Ein Reverse-Proxy vor einem privaten Backend ist in Ordnung, solange der öffentliche Hostname auf eine öffentliche Adresse zeigt.

Events ​

Ein Event-Name besteht aus <bereich>.<aktion>, in snake_case und im Singular (site_report.completed). Ein Bereich darf mehrsegmentig sein (catalog.material.created); die Aktion ist immer das letzte Segment. Ein Wildcard <bereich>.* trifft alle Aktionen des Bereichs, bei mehrsegmentigen Bereichen auch die Unterbereiche (catalog.* trifft catalog.material.created).

Es werden nur Events von Bereichen gesendet, die für den Betrieb freigeschaltet sind. Die Verwaltung bietet beim Anlegen genau die Muster an, zu denen Work7 heute Ereignisse erzeugt — Bereiche wie Termine (appointment.*) oder Buchhaltung (accounting.*) sind im Vokabular reserviert, senden aber noch nichts und erscheinen deshalb nicht in der Auswahl.

BereichEvents (Auswahl)
Projekte, Kontakte, Dokumente, Teamproject.created, project.updated (auch über POST /projects bzw. PATCH /projects/{id}, seit API 1.21; ein Statuswechsel auf denselben Status sendet nichts), project.sent (Fotos an den Kunden gesendet, seit 0.9.0), contact.created, contact.updated, contact.archived, contact.deleted (Kontakt gelöscht, auch über DELETE /contacts/{id}), document.created, document.approved/document.rejected (Foto eines Mitarbeiters freigegeben bzw. abgelehnt, seit 0.10.0), team.invite_created, team.invite_accepted
Anfrageninquiry.created, inquiry.updated, inquiry.status_changed, inquiry.confirmed, inquiry.assigned
Regieberichtesite_report.created, site_report.updated, site_report.completed, site_report.sent, site_report.signature_requested, site_report.approved, site_report.rejected (Büro-Freigabe, seit 0.10.0)
Angebote, Textbausteineoffer.created, offer.updated, offer.finalized (fertiggestellt: gesperrt, PDF archiviert, aktuelle Fassung), offer.version_created (neue Version geöffnet, wieder bearbeitbar), offer.version_discarded (neue Version verworfen), offer.sent, offer.accepted, offer.rejected (als abgelehnt markiert, seit 0.10.0), offer.reopened (Ablehnung zurückgenommen, seit 0.10.0), offer.item_added, offer.items_added (mehrere Positionen und Titel auf einmal, z. B. Leistungspaket übernommen, seit 0.4.0), text_block.created
Rechnungeninvoice.created, invoice.finalized (fertiggestellt: Nummer vergeben, gesperrt, gebucht; seit API 1.6, seit API 1.8 auch für Stornorechnungen), invoice.cancelled (Original storniert, seit API 1.8), invoice.corrected (Original per Rechnungskorrektur gemindert, seit API 1.10), invoice.refund_recorded (Guthaben eines Storno-/Korrekturbelegs erstattet, seit API 1.11), invoice.credit_applied (Guthaben mit einer Rechnung verrechnet, seit API 1.11), invoice.sent, invoice.paid, invoice.payment_recorded (seit 0.4.0 nur, wenn auf die Rechnung etwas angerechnet wurde; eine reine Mahnkosten-Zahlung sendet kein Ereignis), invoice.created_from_offer, invoice.exported (E-Rechnung), invoice.overdue (Rechnung ist überfällig, aus dem Nachtlauf), invoice.dunning_sent (Mahnung als per Post versendet markiert), invoice.updated (u. a. Zahlungsaufschub)
Aufmaßmeasurement.created, measurement.updated, measurement.status_changed, measurement.offer_created
Abnahmeacceptance.created, acceptance.completed, acceptance.sent, acceptance_defect.created, acceptance_media.created
Bautagebuchsite_diary.upserted, site_diary.deleted, site_diary.approved, site_diary.rejected (Büro-Freigabe, seit 0.10.0)
Katalogcatalog.material.created, catalog.material.updated, catalog.service.created, supplier_catalog.created
Leistungspaketeservice_bundle.created, service_bundle.updated, service_bundle.deleted (Positionsgruppen mit Leitmenge im Modul Angebote, seit 0.4.0)
Zeiterfassungtime_entry.project_hours_updated, time_entry.approved, time_entry.rejected (Büro-Freigabe, seit 0.10.0)
Terminlinksscheduling_link.booked
Urlaubleave_request.decided
Posteinganginbox_message.classification, inbox_message.action_completed (nur Firmenpostfächer)
WhatsAppwhatsapp_session.created, whatsapp_session.closed

Seit API 1.6 trägt invoice.created keine Rechnungsnummer mehr (invoice_number: null) — ein neuer Entwurf bekommt sie erst beim Fertigstellen. Diese kommt mit dem neuen Ereignis invoice.finalized, das beim Fertigstellen (auch automatisch beim Versand eines Entwurfs) einmalig gesendet wird.

Büro-Freigabe (seit 0.10.0, API-Version 1.20): Projektstunden, Rapporte, Bautagebuch-Einträge und Fotos von Mitarbeitern können im Betrieb auf eine Freigabe durch das Büro warten. Gibt das Büro sie im Freigabe-Fenster frei, kommt je Eintrag <bereich>.approved; lehnt es sie ab, <bereich>.rejected (Bereiche time_entry, site_report, site_diary, document). Schließt das Büro einen offenen Rapport ab, versendet ihn oder fordert eine Unterschrift an, gilt das ebenfalls als Freigabe: dann kommt site_report.approved zusätzlich zu site_report.completed bzw. site_report.sent, für offene Fotos des Rapports je ein document.approved. Wurde der Rapport zwischen Prüfung und Versand bzw. Unterschrift geändert, gibt das Büro ihn damit nicht frei, und es kommt kein site_report.approved. Ein bereits freigegebener Eintrag löst kein zweites approved aus. Die Nutzlast trägt entityId, project_id (nicht bei document.approved), bei time_entry.* zusätzlich entry_id und user_id (wie time_entry.project_hours_updated) und approval_status (approved/rejected), nie den Ablehnungsgrund (Freitext). Für Zeiten und Rapporte liest du den Grund bei Bedarf über GET /time-entries?approval_status=rejected bzw. GET /site-reports/{id} (rejected_reason); für site_diary.* und document.* gibt es keine Leseroute in v1, der Grund steht nur in Work7. Ändert ein Mitarbeiter einen freigegebenen Eintrag, wartet er wieder auf Freigabe, ohne eigenes Ereignis; site_report.updated trägt dann approval_status = pending.

Seit API 1.7 gibt es offer.finalized: einmalig beim Fertigstellen eines Angebots, auch automatisch beim Versand bzw. „als versendet markieren“ eines Entwurfs (dann folgt offer.sent). finalized_at ist ab dann gesetzt; status bleibt der Annahmestatus (draft = noch nicht versendet).

Seit API 1.9 lässt sich ein fertiges, nicht angenommenes Angebot als neue Version unter derselben Nummer überarbeiten: offer.version_created (mit version_no der neuen Version und from_version_no = Ausgangsfassung; finalized_at ist wieder null, status draft), danach wieder offer.finalized für die neue Fassung. offer.version_discarded (mit discarded_version_no) meldet, dass die offene Version verworfen wurde und die letzte Fassung wieder gilt. offer.accepted trägt accepted_version_no.

Seit API 1.8 wird eine fertige Rechnung nie mehr geändert, sondern nur noch per Stornorechnung aufgehoben oder per „Korrigieren“ (Storno + neuer Entwurf) ersetzt. invoice.cancelled geht für das Original raus, sobald der Storno fertiggestellt ist (data.data.cancellation_id, cancellation_number); der Storno-Beleg selbst löst zusätzlich invoice.finalized aus (document_kind: "cancellation"). Storno und Rechnungskorrektur tragen negative Beträge; ein Altfall (stornierte Bestandsrechnung ohne eigenen Stornobeleg) sendet weder invoice.cancelled noch ein anderes Ereignis, bis er über Storno nachholen oder Nie ausgestellt geklärt ist.

Seit API 1.10 lässt sich eine fertige Rechnung auch um einen Teilbetrag mindern (Rechnungskorrektur, document_kind: "correction", Nummer z. B. RK-2026-0001). Der Entwurf löst invoice.created aus (ohne Nummer), das Fertigstellen invoice.finalized für die Korrektur und invoice.corrected für das Original (data.data.correction_id, correction_number; status, credited_amount und remaining des Originals nach der Verrechnung — ist danach nichts mehr offen, steht es auf paid). Der Grund der Korrektur bleibt wie beim Storno draußen.

Seit API 1.11 lässt sich das Guthaben eines Storno- oder Korrekturbelegs (negatives remaining) erstatten oder verrechnen. invoice.refund_recorded kommt für den Gegenbeleg (amount, payment_date, payment_method bank/cash, from_invoice_id; already_paid ist danach um den Betrag niedriger, also negativ), auch wenn die Erstattung aus dem Bankabgleich stammt. invoice.credit_applied kommt für die Rechnung, die das Guthaben bezahlt (amount, from_invoice_id, to_invoice_id; credited_amount, remaining und status der Rechnung nach der Verrechnung, paid, partial_paid bzw. overdue). Zusätzlich kommt invoice.updated für den Gegenbeleg, dessen Guthaben sich geändert hat (credited_amount, remaining). Die Referenz einer Erstattung bleibt draußen.

invoice.overdue sendet der nächtliche Überfällig-Lauf je Rechnung, die er umstuft. Der erste Lauf nach dem Einschalten stuft auch den Altbestand um: Für jede längst fällige, nicht als bezahlt erfasste Rechnung kommt dann ein Ereignis, bei einem Abo auf invoice.* also unter Umständen viele auf einmal.

invoice.dunning_sent sendet Work7, sobald ein Betrieb eine vorbereitete Mahnung als per Post versendet markiert (Mahnwesen, seit Version 0.4.0). Work7 versendet nie selbst eine Mahnung; das Ereignis meldet ausschließlich die Bestätigung durch den Betrieb. amount ist der Zahlbetrag laut Mahnung: offener Rechnungsbetrag plus Verzugszinsen und Mahnspesen, seit Version 0.4.0 abzüglich bereits bezahlter oder erlassener Mahnkosten.

Neue Aktionen können hinzukommen, dein Empfänger sollte unbekannte Events ignorieren, nicht ablehnen. Einige Aktionen tragen historisch deutsche Namen (inquiry.gewonnen, inquiry.besichtigung_geplant); sie bleiben stabil.

Alte Event-Namen ​

Bis September 2026 nannte die Verwaltung die Events nach den deutschen Bereichen (anfrage.*, kontakt.*, rapport.*, aufmass.*, bautagebuch.*, leave.*, inbox.*, lager.*), und die Zustellung nutzte intern die Tabellennamen (anfragen.created, customers.updated, rapports.completed). Beides wird dauerhaft verstanden: ein bestehendes Abo mit anfrage.* erhält inquiry.created, und POST /api/company/webhooks mit events: ["rapport.*"] speichert site_report.*. In der Verwaltung und in der Delivery-Liste erscheinen nur noch die neuen Namen.

Die sent-Events tragen im Feld sender die Absenderadresse des Versands. Ging das Dokument aus dem persönlichen Postfach einer Mitarbeiterin oder eines Mitarbeiters raus, ist das Feld leer (null) — persönliche Postfachadressen verlassen den Betrieb nicht.

Zustellung ​

http
POST https://deine-domain.example/work7-webhook
Content-Type: application/json
User-Agent: Work7-Webhooks/1.0
X-Work7-Event: site_report.completed
X-Work7-Delivery: 8123
X-Work7-Timestamp: 1757671200
X-Work7-Signature: sha256=5f1c…e9
json
{
  "id": "0d9a8f2e-3b6c-4e1a-9f21-6d1f0c4b7a10",
  "type": "site_report.completed",
  "companyId": "muster-malerei",
  "createdAt": "2026-09-12T10:00:00.000Z",
  "data": {
    "entity": "site_report",
    "action": "completed",
    "entityId": 4711,
    "userId": "18",
    "data": {
      "id": 4711,
      "rapport_number": "RA-2026-0087",
      "completed": true,
      "billing_status": "open",
      "project_id": 88,
      "kontakt_id": 12,
      "created_at": "2026-09-10T07:30:00.000Z",
      "updated_at": "2026-09-12T10:00:00.000Z"
    }
  }
}
  • id ist je Ereignis eindeutig. Mehrere Webhooks erhalten dasselbe id. Nutze es, um Doppelverarbeitung zu vermeiden.
  • data.entity und type tragen den öffentlichen Bereich (site_report, inquiry, contact), nie interne Tabellennamen.
  • Antworte innerhalb von 10 Sekunden mit einem 2xx-Status. Alles andere gilt als Fehlschlag.

Felder in data.data ​

Die Nutzlast enthält nur freigegebene Felder der betroffenen Entität, nie vollständige Datensätze: Kennung, Nummer, Status, Titel bzw. Name, Fremdschlüssel (project_id, kontakt_id, offer_id …), Zeitstempel und Beträge. Freitexte (Beschreibungen, Notizen, Mailtexte), Mail-Adressen und Telefonnummern von Kontakten, Unterschriften und Dateien sind nie enthalten. Kurz: keine Mailadressen, keine Telefonnummern, keine Blobs, keine Secrets; Namen und Titel sind bewusst enthalten. Details holst du bei Bedarf über die API, zum Beispiel GET /site-reports/4711 (Alias: GET /rapports/4711).

BereichFelder
inquiryid, anfrage_number, status, source, title, kontakt_id, project_id, offer_id, appointment_id, assigned_user_id, follow_up_at, created_at, updated_at
contactid, customer_number, name, kind, type, status, is_active, created_at, updated_at
projectid, project_number, name, status, phase, type, kontakt_id, project_manager_id, start_date, end_date, deadline, is_active, created_at, updated_at, status_id, status_label, status_system_key (Projektstatus des Betriebs, seit API-Version 1.14; status wie in GET /projects: active/completed/archived, für übernommene Altwerte paused/cancelled)
site_reportid, rapport_number, completed, billing_status, kontakt_id, project_id, signed_at, created_at, updated_at, approval_status (seit 0.10.0); je nach Aktion rapport_id, hours, sender
offerid, offer_number, status, kontakt_id, project_id, anfrage_id, subtotal, total, valid_until, signed_at, created_at, updated_at, finalized_at, version_no (Bearbeitungsstatus und Fassung, seit API 1.7), accepted_version_no (seit API 1.9); je nach Aktion from_version_no/discarded_version_no (seit API 1.9); je nach Aktion sender, promoted_to_customer, Unterobjekt item/section mit id, position, quantity, unit, unit_price, total_price, material_id, service_id, section_id, code, title, sort_order; bei offer.items_added zusätzlich item_count, section_count und bundle_id (Leistungspaket, sonst null)
invoiceid, invoice_number, type, status, kontakt_id, project_id, offer_id, subtotal, total, already_paid, remaining, currency, issue_date, due_date, service_date, paid_at, sent_at, signed_at, created_at, updated_at, finalized_at, payment_extended_until (Zahlungsaufschub, seit API 1.6), document_kind, corrects_invoice_id, replaces_invoice_id, credited_amount (Belegart und Verrechnung, seit API 1.8; correction_reason bleibt draußen); je nach Aktion amount (bei payment_recorded der Anteil auf die Rechnung, ohne mitbezahlte Mahnkosten), payment_method, payment_date, method, sender, rapport_ids, dunning_level, payment_deadline (bei dunning_sent; dort ist amount der Zahlbetrag laut Mahnung nach Anrechnung bezahlter oder erlassener Mahnkosten), cancellation_id, cancellation_number (bei invoice.cancelled), correction_id, correction_number (bei invoice.corrected, seit API 1.10), from_invoice_id, to_invoice_id (bei invoice.refund_recorded und invoice.credit_applied, seit API 1.11), Unterobjekt item/labor_rate; in item zusätzlich discount_percent und discount_amount (Positionsrabatt in Prozent oder als Betrag, sonst null) und line_net (Netto der Position nach Positionsrabatt, seit API 1.21); discount_amount steht damit auch am Rechnungskopf (Gesamtrabatt netto)
measurementid, aufmass_number, kind, status, source, anfrage_id, project_id, appointment_id, assigned_user_id, created_at, updated_at, offer_id
acceptanceid, acceptance_number, kontakt_id, project_id, acceptance_date, completed, completed_at, signed_at, created_at, updated_at, sender
acceptance_defect / acceptance_mediaid, protocol_id, severity, status, mime_type, sort_order, created_at
site_diaryid, project_id, work_date, approval_status (seit 0.10.0)
text_blockid, category, title, sort_order, is_active, created_at, updated_at
catalog.material / catalog.service / supplier_catalogid, fields (geänderte Spalten), supplierKontaktId
service_bundleid, name, is_active, lead_label (Bezeichnung der Leitmenge), lead_quantity (Standard-Leitmenge als Zeichenkette), group_count, item_count, created_at, updated_at; je nach Herkunft source_bundle_id, source_offer_id. Keine Preise, keine Beschreibung
documentid, project_id, kontakt_id, anfrage_id, aufmass_id, type, status, document_number, mime_type, file_size, created_at, updated_at, document_id, created_count, skipped_count, approval_status (seit 0.10.0)
leave_requestdecision, appointmentId, leaveRequestId
scheduling_linkanfrage_id, appointment_id
inbox_messageconnection_id, action_key
whatsapp_sessionid, session_type, status, started_at, ended_at
teamrole, membershipId, userId
time_entryproject_id, entry_id, user_id, approval_status (seit 0.10.0)

Die Listen sind additiv: Felder kommen hinzu, fallen aber nicht weg. Fehlt dir ein Feld, melde dich beim Support.

Angebotsnummern in offer.*-Events: offer_number ist die vollständige Nummer aus dem Nummernkreis des Betriebs, mit Präfix (Standard AN-2026-0001). Frühere, rein numerische Nummern tragen seit der Umstellung das Präfix AN- (aus 4711 wird AN-4711). Vergleiche Nummern als Text und verlasse dich nicht auf eine reine Ziffernfolge; für Verknüpfungen nimm id.

Die Zusatzfelder eines Projekts (attributes, seit API-Version 1.5) sind frei benannte Texte und deshalb nicht in project.* enthalten. Du holst sie bei Bedarf über GET /projects/{id}.

Signatur prüfen ​

Jede Zustellung ist mit HMAC-SHA256 über "<timestamp>.<body>" signiert, Schlüssel ist das Webhook-Secret. Prüfe immer die Signatur und das Alter des Zeitstempels (empfohlen: maximal fünf Minuten), bevor du das Ereignis verarbeitest.

js
import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.WORK7_WEBHOOK_SECRET

// Rohen Body behalten: die Signatur gilt für die exakten Bytes.
app.post('/work7-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('X-Work7-Timestamp') ?? ''
  const signature = req.header('X-Work7-Signature') ?? ''
  const body = req.body.toString('utf8')

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return res.status(400).send('stale')

  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).send('bad signature')

  const event = JSON.parse(body)
  // Idempotenz: event.id merken, bereits gesehene Events überspringen.
  if (event.type === 'site_report.completed') {
    // ... eigene Verarbeitung, z. B. Regiebericht per API laden
  }
  res.status(204).end()
})
php
<?php
$secret = getenv('WORK7_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WORK7_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WORK7_SIGNATURE'] ?? '';

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit('stale');
}

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('bad signature');
}

$event = json_decode($body, true);
// Idempotenz: $event['id'] speichern, Duplikate überspringen.
if ($event['type'] === 'invoice.paid') {
    // ... eigene Verarbeitung
}
http_response_code(204);

Wiederholungen ​

Antwortet dein Endpunkt nicht mit 2xx oder nicht innerhalb von 10 Sekunden, versucht Work7 es erneut, insgesamt bis zu fünf Mal mit wachsendem Abstand:

VersuchWartezeit danach
11 Minute
25 Minuten
315 Minuten
460 Minuten
5keine, Status dead

Den Zustellstatus je Ereignis siehst du in der Verwaltung unter Letzte Zustellungen (Status, HTTP-Code, Fehlertext, Versuche). Status: pending (wartet), failed (Fehlschlag, Wiederholung geplant), delivered, dead (aufgegeben) und blocked_target (Ziel-URL zeigte auf eine interne Adresse, keine Wiederholung; siehe Regeln für die Ziel-URL). Ereignisse mit Status dead oder blocked_target werden nicht automatisch erneut gesendet.

Testen ​

  • Lokal lässt sich der Endpunkt mit einem Tunnel wie ngrok oder cloudflared erreichbar machen. Die URL muss https und öffentlich auflösbar sein.
  • Ein erstes Ereignis erzeugst du am einfachsten in der App, etwa durch Abschließen eines Test-Regieberichts.
  • Für Unit-Tests der Signaturprüfung reicht es, "<timestamp>.<body>" mit dem Secret zu signieren, wie oben gezeigt.

Work7 · Software für Handwerksbetriebe · Doku-Stand Version 0.9.0 (Build f72f8bdd)