
For your website
Add booking, a support form and bug reporting to your website: with one line of HTML, as a React component, as an iframe or via the REST API. Everything you need is on this page.
Quick start in three steps
1
Create an embed
Sign in and open Organization › Embeds. Choose what to embed: booking, support form or bug reporting. CIRO NOISE creates a public key for it.
2
Add your domain
Enter the domains the key should work on, for example https://my-company.com. While the list is empty, it works everywhere. That’s handy for trying it out. Before your site goes live, enter your domains.
3
Paste the snippet
Copy the snippet from the back office or from this page and insert your key. The example below embeds a booking form inline.
<div data-ciro-embed="ciro_pk_YOUR_KEY"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>The public key
Every embed has its own key of the form ciro_pk_…, where “pk” stands for public key. It is safe to sit openly in your page’s source. It belongs to no person, carries no permissions in CIRO NOISE and can only do what a public form can do: book an appointment, send a request or report a bug.
One key, one kind
- Booking
- Shows an appointment type with open slots and creates the booking.
- Support form
- Creates a support request. The customer receives a confirmation with a link.
- Bug reporting
- Files a report as a task on a board of your choice.
Which way for which embed
Every way leads to the same result. Pick the one that fits your site.
| Way | Booking | Support | Bug reporting |
|---|---|---|---|
| HTML loader, inline | yes | yes | — |
| HTML loader, floating button | yes | yes | — |
| HTML loader, on your button | yes | yes | — |
| iframe without JavaScript | yes | yes | — |
| React component | yes | yes | yes |
| REST API | — | yes | yes |
Booking
Visitors see the open slots of an appointment type, pick a time and answer your questions. The booking lands in CIRO NOISE: the visitor gets a confirmation with a calendar entry, the responsible person on your team a notice. You create one key per appointment type.
With the HTML loader
One script covers three forms. The loader builds the iframe, fits its height to the content and opens a dialog on request that already handles focus, the Escape key and closing.
<div data-ciro-embed="ciro_pk_YOUR_KEY"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>Inline: the form sits in your content and grows with it. Floating button: bottom right, opens a dialog; data-accent sets its color, data-label its text. On your button: your element and your design, and one attribute is enough.
Without JavaScript: the iframe
For site builders that allow no scripts. The iframe has a fixed height and may scroll, so the submit button stays within reach even without height measurement.
In React
There are two ways to do this. The CiroEmbed component builds the iframe itself and needs no loader. It fits the height and reports the booking as a callback. For a dialog on your own button, load the loader once and call CiroEmbed.open.
'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 form
Customers write to you straight from your site. Every request lands in the Requests feature, the customer gets a confirmation with a link to the conversation, your team a notification.
As a ready-made form
The same ways as for booking: loader inline, floating button, on your button, iframe or the React component. The form is the same one that CIRO NOISE shows on your organization's public form page.
<div data-ciro-embed="ciro_pk_YOUR_KEY"></div>
<script src="https://www.ciro-noise.de/ciro-embed/v1.js" async></script>With your own form
Want to build the fields and design yourself? Send the request straight to the REST API. Subject, description, name and either email or phone are required. On an error the response names the field so you can show the message there.
'use client'
import { useState, type FormEvent } from 'react'
const ENDPOINT = 'https://www.ciro-noise.de/api/v1/support'
const TOKEN = 'ciro_pk_YOUR_KEY'
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>
)
}Bug reporting
Let the users of your application report bugs and improvements without having to know a ticket system. Every report becomes a task on the board you set on the key. Page, browser and window size are sent along automatically, and a screenshot too if you like.
As a flag in React
The flag consists of a button on the screen edge, a dialog and a single call. html2canvas takes the screenshot. The library only loads on click, so it doesn’t slow your page down before that. The image is sent as a data URL and may be 3 MB at most.
'use client'
import { useState } from 'react'
const ENDPOINT = 'https://www.ciro-noise.de/api/v1/reports'
const TOKEN = 'ciro_pk_YOUR_KEY'
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>
)}
</>
)
}Events and JavaScript API
The loader reports what happens in the frame as an event on your page. You can use it, for example, to redirect to a thank-you page or for your own analytics. window.CiroEmbed lets you control the loader directly.
- ciro:booked
- An appointment was booked. detail contains startTime and endTime as ISO 8601 timestamps.
- ciro:submitted
- A support request was sent.
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
})Your own iframe integration
If you build the iframe yourself, the frame sends via postMessage: ciro-embed-ready, ciro-embed-height (with height in pixels), ciro-embed-booked, ciro-embed-submitted and ciro-embed-close. Always check event.origin against the CIRO address, because otherwise any other iframe on your page can fake these messages.
REST reference
Two versioned endpoints under /api/v1, with CORS for every browser. The key always travels in a header, never in the body.
Headers
- X-Ciro-Token
- Your public key. Alternatively: Authorization: Bearer ciro_pk_…
- Content-Type
- application/json
- X-Ciro-Origin
- Only for calls from a server: the domain you are calling for. Browsers send it as Origin by themselves.
POST /api/v1/support
Creates a support request. Key of kind support form.
| Field | Type | Required |
|---|---|---|
| subject | string (3–500) | yes |
| description | string (5–10000) | yes |
| customerName | string (2–255) | yes |
| customerEmail | string | no |
| customerPhone | string | no |
| language | 'de' | 'en' … | no |
// 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
Files a report as a task. Key of kind bug reporting. The report lands on the board whose prefix is set on the key. Without a prefix, kind bug lands on BUG and kind ux on UX.
| Field | Type | Required |
|---|---|---|
| kind | 'bug' | 'ux' | yes |
| description | string (≤ 5000) | yes |
| pageUrl | string (≤ 2000) | yes |
| userAgent | string | no |
| product | slug [a-z0-9-] | no |
| labels | string[] (≤ 10) | no |
| expectation | string | no |
| severity | string | no |
| viewport | { width, height } | no |
| consoleErrors | { message }[] (≤ 100) | no |
| networkRequests | { url, method, status }[] (≤ 100) | no |
| screenshotBase64 | data URL (≤ 3 MB) | no |
| reporter | { name, email } | no |
// 201 Created
{ "key": "BUG-12", "url": "https://…/boards/…", "taskId": "…" }
// 400 Bad Request
{ "error": "description: …" }Status codes
- 200 / 201
- Created.
- 400
- Invalid input. error describes the problem, and for the support endpoint field and code also name the field.
- 401
- Key unknown, revoked, of the wrong kind or from a domain not on the list. The answer is deliberately the same in every case.
- 429
- Too many requests. Retry-After gives the seconds until the next attempt.
Security and limits
- Allowed domains
- If the key has a list, CIRO NOISE only accepts calls from those domains. It compares the full origin, not just the beginning. An empty list allows any domain.
- Revoking
- A revoked key stops working immediately, on every page. Create a new one and swap it in.
- Rate limits
- Support: 10 requests per hour and address, 60 per key. Bug reporting: 5 per hour and address, 60 per key. Bookings: 200 per hour and organization.
- HTTPS only
- The frame can be embedded on any HTTPS page, plus localhost and 127.0.0.1 for development.
- Sandboxed frame
- The iframe runs in a sandbox without allow-top-navigation: it cannot navigate your page away and sees neither its cookies nor its data.
- Stable address
- /ciro-embed/v1.js stays as it is. If behavior changes, v2.js ships next to it, so your integration won’t break overnight.
When it doesn’t work
- The API answers 401
- First check that the key matches the kind: a booking key cannot create a request. Then check whether it is revoked and whether your domain is on the list, with https:// and without a path.
- The widget stays empty
- Place the script below the target element or load it with async. The loader detects site builders that insert sections later by itself.
- The iframe cuts off
- Without the loader the iframe doesn’t know its height. Use the loader or the React component, or raise height on the iframe.
- Nothing appears in a local test
- Locally only localhost or 127.0.0.1 work over http. Other addresses need HTTPS.
Create your first embed
Create a key, add your domain, paste the snippet: in ten minutes the widget is on your site.