Skip to content
A team working together at a screen

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.

GDPR-compliantServers and AI in the EU

Quick start in three steps

  1. 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. 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. 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.

WayBookingSupportBug reporting
HTML loader, inlineyesyes—
HTML loader, floating buttonyesyes—
HTML loader, on your buttonyesyes—
iframe without JavaScriptyesyes—
React componentyesyesyes
REST API—yesyes

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.

FieldTypeRequired
subjectstring (3–500)yes
descriptionstring (5–10000)yes
customerNamestring (2–255)yes
customerEmailstringno
customerPhonestringno
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.

FieldTypeRequired
kind'bug' | 'ux'yes
descriptionstring (≤ 5000)yes
pageUrlstring (≤ 2000)yes
userAgentstringno
productslug [a-z0-9-]no
labelsstring[] (≤ 10)no
expectationstringno
severitystringno
viewport{ width, height }no
consoleErrors{ message }[] (≤ 100)no
networkRequests{ url, method, status }[] (≤ 100)no
screenshotBase64data 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.