
Für deine Website
Bau Terminbuchung, Support-Formular und Fehlermelder in deine Website ein: mit einer Zeile HTML, als React-Komponente, als iframe oder über die REST-API. Auf dieser Seite findest du alles, was du dafür brauchst.
Schnellstart in drei Schritten
1
Einbettung anlegen
Melde dich an und öffne Organisation › Einbettungen. Wähle, was du einbetten willst: Terminbuchung, Support-Formular oder Fehlermelder. CIRO NOISE erzeugt dafür einen öffentlichen Schlüssel.
2
Domain eintragen
Trag die Domains ein, auf denen der Schlüssel funktionieren soll, zum Beispiel https://meine-firma.de. Solange die Liste leer ist, funktioniert er überall. Das ist praktisch zum Ausprobieren. Bevor deine Seite live geht, trägst du deine Domains ein.
3
Schnipsel einfügen
Kopier den Schnipsel aus CIRO NOISE oder von dieser Seite und setz deinen Schlüssel ein. Das Beispiel unten bettet eine Terminbuchung fest in die Seite ein.
<div data-ciro-embed="ciro_pk_DEIN_SCHLUESSEL"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>Der öffentliche Schlüssel
Jede Einbettung hat einen eigenen Schlüssel der Form ciro_pk_…, wobei „pk“ für public key steht. Er darf offen im Quelltext deiner Seite stehen. Er gehört keiner Person, trägt keine Rechte in CIRO NOISE und kann nur, was ein öffentliches Formular auch kann: einen Termin buchen, eine Anfrage stellen oder einen Fehler melden.
Ein Schlüssel gilt für genau eine Art
- Terminbuchung
- Zeigt eine Terminart mit freien Zeiten und legt die Buchung an.
- Support-Formular
- Legt eine Support-Anfrage an. Die Kundin bekommt eine Eingangsbestätigung mit Link.
- Fehlermelder
- Legt eine Meldung als Aufgabe auf einem Board deiner Wahl an.
Welcher Weg für welche Einbettung
Alle Wege landen beim selben Ergebnis. Nimm den, der zu deiner Seite passt.
| Weg | Termin | Support | Fehlermelder |
|---|---|---|---|
| HTML-Lader, fest auf der Seite | ja | ja | — |
| HTML-Lader, schwebender Knopf | ja | ja | — |
| HTML-Lader, an deinem Knopf | ja | ja | — |
| iframe ohne JavaScript | ja | ja | — |
| React-Komponente | ja | ja | ja |
| REST-API | — | ja | ja |
Terminbuchung
Besucher sehen die freien Zeiten einer Terminart, wählen einen Termin und beantworten deine Fragen. Die Buchung landet in CIRO NOISE: Der Besucher bekommt eine Bestätigung mit Kalendereintrag, die zuständige Person in deinem Team einen Hinweis. Den Schlüssel legst du je Terminart an.
Mit dem HTML-Lader
Ein Skript deckt drei Formen ab. Der Lader baut das iframe, passt die Höhe an den Inhalt an und öffnet auf Wunsch einen Dialog, der Fokus, Escape-Taste und Schließen schon mitbringt.
<div data-ciro-embed="ciro_pk_DEIN_SCHLUESSEL"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>Fest auf der Seite: das Formular steht im Inhalt und wächst mit. Schwebender Knopf: unten rechts, öffnet einen Dialog; data-accent setzt seine Farbe, data-label die Beschriftung. An deinem Knopf: dein Element und dein Design, und ein Attribut genügt.
Ohne JavaScript: das iframe
Für Baukästen, die kein Skript erlauben. Das iframe hat eine feste Höhe und darf scrollen, damit der Absenden-Knopf auch ohne Höhenmessung erreichbar bleibt.
In React
Dafür gibt es zwei Wege. Die Komponente CiroEmbed baut das iframe selbst und braucht den Lader nicht. Sie passt die Höhe an und meldet die Buchung als Callback. Für den Dialog an deinem eigenen Knopf lädst du den Lader einmal und rufst CiroEmbed.open auf.
'use client'
import { useEffect, useRef, useState } from 'react'
const CIRO_ORIGIN = 'https://www.ciro-noise.de'
const MIN_HEIGHT = 420
type BookedDetail = { startTime?: string; endTime?: string }
type CiroEmbedProps = {
widgetKey: string
title?: string
onBooked?: (detail: BookedDetail) => void
onSubmitted?: () => void
}
export function CiroEmbed({ widgetKey, title = 'CIRO', onBooked, onSubmitted }: CiroEmbedProps) {
const frame = useRef<HTMLIFrameElement>(null)
const [src, setSrc] = useState<string>()
const handlers = useRef({ onBooked, onSubmitted })
handlers.current = { onBooked, onSubmitted }
// Build the URL in the browser: the frame needs the host origin and time zone.
useEffect(() => {
const query = new URLSearchParams({
mode: 'inline',
parent: window.location.origin,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
setSrc(`${CIRO_ORIGIN}/embed/${encodeURIComponent(widgetKey)}?${query}`)
}, [widgetKey])
useEffect(() => {
function onMessage(event: MessageEvent) {
// Only trust messages from CIRO and from this very frame.
const el = frame.current
if (!el || event.origin !== CIRO_ORIGIN || event.source !== el.contentWindow) return
const data = event.data
if (!data || typeof data.type !== 'string') return
if (data.type === 'ciro-embed-height' && typeof data.height === 'number') {
el.style.height = `${Math.max(MIN_HEIGHT, Math.ceil(data.height))}px`
}
if (data.type === 'ciro-embed-booked') handlers.current.onBooked?.(data.detail ?? {})
if (data.type === 'ciro-embed-submitted') handlers.current.onSubmitted?.()
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
if (!src) return <div style={{ minHeight: MIN_HEIGHT }} />
return (
<iframe
ref={frame}
src={src}
title={title}
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
style={{ width: '100%', minHeight: MIN_HEIGHT, border: 0, display: 'block' }}
/>
)
}Support-Formular
Kundinnen schreiben dir direkt von deiner Seite aus. Jede Anfrage landet in der Funktion Anfragen, die Kundin bekommt eine Eingangsbestätigung mit Link zum Verlauf, dein Team eine Benachrichtigung.
Als fertiges Formular
Dieselben Wege wie bei der Terminbuchung: Lader fest auf der Seite, schwebender Knopf, an deinem Knopf, iframe oder die React-Komponente. Das Formular ist dasselbe, das CIRO NOISE auf der öffentlichen Formularseite deiner Organisation zeigt.
<div data-ciro-embed="ciro_pk_DEIN_SCHLUESSEL"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>Mit deinem eigenen Formular
Du willst Felder und Design selbst bauen? Schick die Anfrage direkt an die REST-API. Pflicht sind Betreff, Beschreibung, Name und entweder E-Mail oder Telefon. Bei einem Fehler nennt die Antwort das Feld, damit du die Meldung dort anzeigen kannst.
'use client'
import { useState, type FormEvent } from 'react'
const ENDPOINT = 'https://www.ciro-noise.de/api/v1/support'
const TOKEN = 'ciro_pk_DEIN_SCHLUESSEL'
export function SupportForm() {
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle')
const [error, setError] = useState<{ field?: string; message: string } | null>(null)
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = new FormData(event.currentTarget)
setState('sending')
setError(null)
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Ciro-Token': TOKEN },
body: JSON.stringify({
subject: form.get('subject'),
description: form.get('description'),
customerName: form.get('name'),
customerEmail: form.get('email'),
language: navigator.language.slice(0, 2),
}),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
setState('idle')
setError({ field: data.field, message: data.error ?? 'Error' })
return
}
setState('sent')
}
if (state === 'sent') return <p>Thanks — we will get back to you.</p>
return (
<form onSubmit={onSubmit} noValidate>
<input name="name" placeholder="Name" aria-invalid={error?.field === 'customerName'} />
<input name="email" type="email" placeholder="Email" aria-invalid={error?.field === 'customerEmail'} />
<input name="subject" placeholder="Subject" aria-invalid={error?.field === 'subject'} />
<textarea name="description" placeholder="How can we help?" aria-invalid={error?.field === 'description'} />
{error && <p role="alert">{error.message}</p>}
<button type="submit" disabled={state === 'sending'}>Send</button>
</form>
)
}Fehlermelder
Lass die Nutzer deiner Anwendung Fehler und Verbesserungen melden, ohne dass sie ein Ticket-System kennen müssen. Jede Meldung wird eine Aufgabe auf dem Board, das du beim Schlüssel einträgst. Seite, Browser und Fenstergröße gehen automatisch mit, auf Wunsch auch ein Bildschirmfoto.
Als Fahne in React
Die Fahne besteht aus einem Knopf am Bildschirmrand, einem Dialog und einem einzigen Aufruf. Das Bildschirmfoto macht html2canvas. Die Bibliothek wird erst beim Klick geladen und belastet deine Seite vorher nicht. Das Bild geht als Data-URL mit und darf höchstens 3 MB groß sein.
'use client'
import { useState } from 'react'
const ENDPOINT = 'https://www.ciro-noise.de/api/v1/reports'
const TOKEN = 'ciro_pk_DEIN_SCHLUESSEL'
const MAX_SCREENSHOT = 3 * 1024 * 1024 // base64 limit of the endpoint
async function captureScreenshot(): Promise<string | undefined> {
try {
const { default: html2canvas } = await import('html2canvas-pro')
const canvas = await html2canvas(document.body, {
x: window.scrollX,
y: window.scrollY,
width: window.innerWidth,
height: window.innerHeight,
})
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
return dataUrl.length <= MAX_SCREENSHOT ? dataUrl : undefined
} catch {
return undefined // report without a picture rather than not at all
}
}
export function BugFlag({ product = 'webshop' }: { product?: string }) {
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
const [screenshot, setScreenshot] = useState<string>()
const [result, setResult] = useState<string | null>(null)
async function start() {
setScreenshot(await captureScreenshot()) // before the dialog covers the page
setOpen(true)
}
async function send() {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Ciro-Token': TOKEN },
body: JSON.stringify({
kind: 'bug',
description: text,
pageUrl: location.href,
userAgent: navigator.userAgent,
viewport: { width: innerWidth, height: innerHeight },
product,
screenshotBase64: screenshot,
}),
})
const data = await res.json().catch(() => ({}))
setResult(res.ok ? `Thanks — filed as ${data.key}` : (data.error ?? 'Error'))
if (res.ok) setText('')
}
return (
<>
<button
type="button"
onClick={start}
style={{ position: 'fixed', right: 0, top: '40%', writingMode: 'vertical-rl' }}>
Report a bug
</button>
{open && (
<div role="dialog" aria-modal="true" aria-label="Report a bug">
<textarea aria-label="What went wrong?" value={text} onChange={(e) => setText(e.target.value)} />
{result && <p role="status">{result}</p>}
<button type="button" onClick={send}>Send</button>
<button type="button" onClick={() => { setOpen(false); setResult(null) }}>Close</button>
</div>
)}
</>
)
}Ereignisse und JavaScript-API
Was im Rahmen passiert, meldet der Lader als Ereignis an deine Seite. Damit kannst du zum Beispiel auf eine Danke-Seite weiterleiten oder selbst messen. Über window.CiroEmbed steuerst du den Lader direkt.
- ciro:booked
- Ein Termin wurde gebucht. detail enthält startTime und endTime als ISO-8601-Zeitpunkt.
- ciro:submitted
- Eine Support-Anfrage wurde abgeschickt.
window.addEventListener('ciro:booked', (event) => {
// event.detail = { startTime, endTime } (ISO 8601)
console.log('Booked', event.detail)
})
window.addEventListener('ciro:submitted', () => {
// A support request was sent
})Eigene iframe-Einbindung
Baust du das iframe selbst, schickt der Rahmen per postMessage: ciro-embed-ready, ciro-embed-height (mit height in Pixeln), ciro-embed-booked, ciro-embed-submitted und ciro-embed-close. Prüf immer event.origin gegen die CIRO-Adresse, denn sonst kann jedes fremde iframe auf deiner Seite diese Nachrichten fälschen.
REST-Referenz
Zwei versionierte Endpunkte unter /api/v1, mit CORS für jeden Browser. Den Schlüssel schickst du immer im Header mit, nie im Body.
Header
- X-Ciro-Token
- Dein öffentlicher Schlüssel. Alternativ: Authorization: Bearer ciro_pk_…
- Content-Type
- application/json
- X-Ciro-Origin
- Nur für Aufrufe von einem Server: die Domain, für die du anfragst. Der Browser schickt sie als Origin von selbst.
POST /api/v1/support
Legt eine Support-Anfrage an. Schlüssel der Art Support-Formular.
| Feld | Typ | Pflicht |
|---|---|---|
| subject | string (3–500) | ja |
| description | string (5–10000) | ja |
| customerName | string (2–255) | ja |
| customerEmail | string | nein |
| customerPhone | string | nein |
| language | 'de' | 'en' … | nein |
// 200 OK
{
"ticket": {
"id": "…",
"number": 1042,
"pin": "…",
"url": "https://…/ticket/…",
"accessToken": "…"
},
"org": { "name": "Your company" }
}
// 400 Bad Request
{ "error": "…", "field": "customerEmail", "code": "contactRequired" }POST /api/v1/reports
Legt eine Meldung als Aufgabe an. Schlüssel der Art Fehlermelder. Die Meldung landet auf dem Board, dessen Kürzel am Schlüssel steht. Ohne Kürzel landet kind bug auf BUG und kind ux auf UX.
| Feld | Typ | Pflicht |
|---|---|---|
| kind | 'bug' | 'ux' | ja |
| description | string (≤ 5000) | ja |
| pageUrl | string (≤ 2000) | ja |
| userAgent | string | nein |
| product | slug [a-z0-9-] | nein |
| labels | string[] (≤ 10) | nein |
| expectation | string | nein |
| severity | string | nein |
| viewport | { width, height } | nein |
| consoleErrors | { message }[] (≤ 100) | nein |
| networkRequests | { url, method, status }[] (≤ 100) | nein |
| screenshotBase64 | data URL (≤ 3 MB) | nein |
| reporter | { name, email } | nein |
// 201 Created
{ "key": "BUG-12", "url": "https://…/boards/…", "taskId": "…" }
// 400 Bad Request
{ "error": "description: …" }Statuscodes
- 200 / 201
- Angelegt.
- 400
- Eingabe fehlerhaft. error beschreibt den Fehler, beim Support-Endpunkt nennen field und code zusätzlich das Feld.
- 401
- Schlüssel unbekannt, widerrufen, von der falschen Art oder von einer Domain, die nicht in der Liste steht. Die Antwort ist absichtlich in allen Fällen dieselbe.
- 429
- Zu viele Anfragen. Retry-After nennt die Sekunden bis zum nächsten Versuch.
Sicherheit und Grenzen
- Erlaubte Domains
- Steht eine Liste am Schlüssel, nimmt CIRO NOISE nur Aufrufe von diesen Domains an. Verglichen wird der vollständige Origin, nicht nur sein Anfang. Eine leere Liste erlaubt jede Domain.
- Widerrufen
- Ein widerrufener Schlüssel hört sofort auf zu arbeiten, auf jeder Seite. Leg danach einen neuen an und tausch ihn aus.
- Ratengrenzen
- Support: 10 Anfragen je Stunde und Adresse, 60 je Schlüssel. Fehlermelder: 5 je Stunde und Adresse, 60 je Schlüssel. Buchungen: 200 je Stunde und Organisation.
- Nur über HTTPS
- Der Rahmen lässt sich auf jeder HTTPS-Seite einbetten, zum Entwickeln zusätzlich auf localhost und 127.0.0.1.
- Abgeschotteter Rahmen
- Das iframe läuft in einer Sandbox ohne allow-top-navigation: Es kann deine Seite nicht wegnavigieren und sieht weder ihre Cookies noch ihre Daten.
- Stabile Adresse
- /ciro-embed/v1.js bleibt, wie sie ist. Ändert sich das Verhalten, kommt v2.js daneben, damit deine Einbindung nicht über Nacht bricht.
Wenn es nicht klappt
- Die API antwortet mit 401
- Prüf zuerst, ob der Schlüssel zur Art passt: Ein Terminbuchungs-Schlüssel kann keine Anfrage anlegen. Prüf dann, ob er widerrufen ist und ob deine Domain mit https:// und ohne Pfad in der Liste steht.
- Das Widget bleibt leer
- Setz das Skript unter das Ziel-Element oder lade es mit async. Baukästen, die Abschnitte nachträglich einfügen, erkennt der Lader von selbst.
- Das iframe schneidet ab
- Ohne Lader kennt das iframe seine Höhe nicht. Nimm den Lader oder die React-Komponente, oder erhöh height im iframe.
- Im lokalen Test erscheint nichts
- Lokal geht nur localhost oder 127.0.0.1 über http. Andere Adressen brauchen HTTPS.
Leg deine erste Einbettung an
Schlüssel erzeugen, Domain eintragen, Schnipsel einfügen: In zehn Minuten steht das Widget auf deiner Seite.