> ## Documentation Index
> Fetch the complete documentation index at: https://help.turnito.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Calendario embebido

> Insertá el calendario de Turnito en otro sitio y dejá los datos del cliente ya cargados.

export const EmbedPlayground = () => {
  const [uuid, setUuid] = useState('');
  const [fields, setFields] = useState({
    name: {
      on: true,
      value: 'Juan Pérez'
    },
    email: {
      on: true,
      value: 'juan@acme.com'
    },
    phone: {
      on: true,
      value: '+5491122334455'
    }
  });
  const [meta, setMeta] = useState([{
    k: 'internal_id',
    v: 'ORD-1234'
  }, {
    k: 'source',
    v: 'mi-sitio'
  }]);
  const [signing, setSigning] = useState(false);
  const [secret, setSecret] = useState('');
  const [expMode, setExpMode] = useState('none');
  const [sig, setSig] = useState('');
  const [preview, setPreview] = useState('');
  const [copied, setCopied] = useState(false);
  const LABEL = {
    name: 'Nombre',
    email: 'Email',
    phone: 'Teléfono'
  };
  const MAX_KEYS = 20;
  const MAX_VAL = 500;
  const expValue = () => {
    const now = Math.floor(Date.now() / 1000);
    if (expMode === '1h') return String(now + 3600);
    if (expMode === '15m') return String(now + 900);
    return null;
  };
  const signable = () => {
    const p = {};
    ['name', 'email', 'phone'].forEach(f => {
      if (fields[f].on && fields[f].value.trim() !== '') {
        p['prefill_' + f] = fields[f].value.trim();
      }
    });
    meta.forEach(row => {
      const key = row.k.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40);
      if (key && row.v.length <= MAX_VAL) p['metadata_' + key] = row.v;
    });
    const e = expValue();
    if (e) p.exp = e;
    return p;
  };
  const params = signable();
  const keys = Object.keys(params).sort();
  const enc = s => encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
  const canonical = keys.map(k => enc(k) + '=' + enc(params[k])).join('&');
  useEffect(() => {
    let alive = true;
    const run = async () => {
      if (!signing || !secret || !canonical) {
        setSig('');
        return;
      }
      try {
        const enc = new TextEncoder();
        const key = await crypto.subtle.importKey('raw', enc.encode(secret), {
          name: 'HMAC',
          hash: 'SHA-256'
        }, false, ['sign']);
        const buf = await crypto.subtle.sign('HMAC', key, enc.encode(canonical));
        const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
        if (alive) setSig(hex);
      } catch (err) {
        if (alive) setSig('');
      }
    };
    run();
    return () => {
      alive = false;
    };
  }, [signing, secret, canonical]);
  const buildUrl = () => {
    if (!uuid.trim()) return '';
    const qs = new URLSearchParams({
      embed: '1'
    });
    keys.forEach(k => qs.set(k, params[k]));
    if (sig) qs.set('sig', sig);
    return `https://turnito.app/c/${encodeURIComponent(uuid.trim())}?${qs.toString()}`;
  };
  const url = buildUrl();
  const setField = (f, patch) => setFields(prev => ({
    ...prev,
    [f]: {
      ...prev[f],
      ...patch
    }
  }));
  const setRow = (i, patch) => setMeta(prev => prev.map((r, idx) => idx === i ? {
    ...r,
    ...patch
  } : r));
  const copy = async () => {
    if (!url) return;
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch (err) {}
  };
  const box = {
    border: '1px solid var(--pg-edge)',
    borderRadius: 12,
    background: 'var(--pg-panel)',
    padding: 16
  };
  const input = {
    width: '100%',
    boxSizing: 'border-box',
    padding: '7px 10px',
    borderRadius: 8,
    border: '1px solid var(--pg-edge)',
    background: 'var(--pg-input)',
    color: 'var(--pg-ink)',
    fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
    fontSize: 13
  };
  const legend = {
    fontSize: 12,
    fontWeight: 600,
    letterSpacing: '.04em',
    textTransform: 'uppercase',
    color: 'var(--pg-muted)',
    marginBottom: 10
  };
  const btn = primary => ({
    padding: '7px 13px',
    borderRadius: 8,
    fontSize: 13,
    fontWeight: 600,
    cursor: 'pointer',
    border: '1px solid ' + (primary ? 'var(--pg-accent)' : 'var(--pg-edge)'),
    background: primary ? 'var(--pg-accent)' : 'transparent',
    color: primary ? '#fff' : 'var(--pg-ink)'
  });
  return <div style={{
    margin: '1.5rem 0'
  }}>
      <style>{`
        :root{
          --pg-panel:#fff; --pg-input:#fbfbfd; --pg-edge:#e3e6ee;
          --pg-ink:#1b2030; --pg-muted:#6b7385; --pg-accent:#155dfc;
          --pg-canon:#f6f8fc;
        }
        .dark{
          --pg-panel:#161b26; --pg-input:#10141d; --pg-edge:#2b3446;
          --pg-ink:#e7ecf6; --pg-muted:#8a94a8;
          --pg-accent:#4d8dff; --pg-canon:#10141d;
        }
        .pg-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
        @media (max-width:760px){.pg-grid{grid-template-columns:1fr}}
        .pg-row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
      `}</style>

      <div className="pg-grid">
        <div style={box}>
          <div style={legend}>Agenda</div>
          <input style={input} value={uuid} onChange={e => setUuid(e.target.value)} placeholder="uuid de tu agenda" aria-label="UUID de la agenda" />
          <div style={{
    fontSize: 12,
    color: 'var(--pg-muted)',
    marginTop: 8
  }}>
            Lo encontrás en el enlace de tu página de reserva, después de <code>/c/</code>.
          </div>

          <div style={{
    ...legend,
    marginTop: 20
  }}>Datos ya cargados</div>
          {['name', 'email', 'phone'].map(f => <div className="pg-row" key={f}>
              <input type="checkbox" checked={fields[f].on} onChange={e => setField(f, {
    on: e.target.checked
  })} aria-label={'Incluir ' + LABEL[f]} />
              <span style={{
    fontSize: 13,
    width: 68,
    color: 'var(--pg-muted)'
  }}>{LABEL[f]}</span>
              <input style={{
    ...input,
    flex: 1
  }} value={fields[f].value} onChange={e => setField(f, {
    value: e.target.value
  })} disabled={!fields[f].on} aria-label={LABEL[f]} />
            </div>)}
        </div>

        <div style={box}>
          <div style={legend}>Tu información en la reserva</div>
          {meta.map((row, i) => <div className="pg-row" key={i}>
              <input style={{
    ...input,
    flex: 1
  }} value={row.k} onChange={e => setRow(i, {
    k: e.target.value
  })} placeholder="clave" aria-label={'Clave ' + (i + 1)} />
              <input style={{
    ...input,
    flex: 1.4
  }} value={row.v} onChange={e => setRow(i, {
    v: e.target.value
  })} placeholder="valor" aria-label={'Valor ' + (i + 1)} />
              <button style={btn(false)} onClick={() => setMeta(meta.filter((_, idx) => idx !== i))} aria-label="Quitar">
                ✕
              </button>
            </div>)}
          {meta.length < MAX_KEYS && <button style={btn(false)} onClick={() => setMeta([...meta, {
    k: '',
    v: ''
  }])}>
              + Agregar dato
            </button>}
          <div style={{
    fontSize: 12,
    color: 'var(--pg-muted)',
    marginTop: 10
  }}>
            Hasta {MAX_KEYS} datos. Las claves se pasan a minúsculas y admiten letras,
            números, guiones y guiones bajos.
          </div>
        </div>
      </div>

      <div style={{
    ...box,
    marginTop: 14
  }}>
        <div className="pg-row" style={{
    marginBottom: 12
  }}>
          <input type="checkbox" id="pg-sign" checked={signing} onChange={e => setSigning(e.target.checked)} />
          <label htmlFor="pg-sign" style={{
    fontSize: 14,
    fontWeight: 600,
    cursor: 'pointer'
  }}>
            Firmar el enlace
          </label>
        </div>

        {signing && <div>
            <div style={{
    border: '1px solid #d97706',
    background: 'rgba(217,119,6,.08)',
    borderRadius: 8,
    padding: '10px 12px',
    fontSize: 13,
    marginBottom: 12
  }}>
              <strong>Regenerá el secreto cuando termines de probar.</strong> Acá se usa
              solo dentro de tu navegador y no se envía a ningún lado, pero al pegarlo en
              una página quedó fuera de tu servidor. Regenerarlo desde{' '}
              <strong>Configuración de la Agenda → Desarrolladores</strong> lo vuelve a dejar seguro.
            </div>
            <div className="pg-row">
              <input style={{
    ...input,
    flex: 1
  }} value={secret} onChange={e => setSecret(e.target.value)} placeholder="pegá tu secreto de firma" aria-label="Secreto de firma" />
              <select style={{
    ...input,
    width: 160
  }} value={expMode} onChange={e => setExpMode(e.target.value)} aria-label="Vencimiento">
                <option value="none">sin vencimiento</option>
                <option value="15m">vence en 15 min</option>
                <option value="1h">vence en 1 hora</option>
              </select>
            </div>

            <div style={{
    ...legend,
    marginTop: 16
  }}>Texto que se firma</div>
            <div style={{
    background: 'var(--pg-canon)',
    border: '1px solid var(--pg-edge)',
    borderRadius: 8,
    padding: 10,
    fontSize: 12.5,
    wordBreak: 'break-all',
    fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
    color: canonical ? 'var(--pg-ink)' : 'var(--pg-muted)'
  }}>
              {canonical || 'todavía no hay datos para firmar'}
            </div>
            <div style={{
    fontSize: 12,
    color: 'var(--pg-muted)',
    marginTop: 8
  }}>
              Las claves van ordenadas alfabéticamente y los valores sin codificar.
              Es el mismo texto que tiene que armar tu servidor.
            </div>
            {sig && <div style={{
    marginTop: 10,
    fontSize: 12.5,
    wordBreak: 'break-all',
    fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
    color: 'var(--pg-accent)'
  }}>
                sig = {sig}
              </div>}
          </div>}
      </div>

      <div style={{
    ...box,
    marginTop: 14
  }}>
        <div style={legend}>Enlace resultante</div>
        <div style={{
    background: 'var(--pg-canon)',
    border: '1px solid var(--pg-edge)',
    borderRadius: 8,
    padding: 10,
    fontSize: 12.5,
    wordBreak: 'break-all',
    fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
    color: url ? 'var(--pg-ink)' : 'var(--pg-muted)'
  }}>
          {url || 'escribí el uuid de tu agenda para generar el enlace'}
        </div>
        <div style={{
    display: 'flex',
    gap: 8,
    marginTop: 12,
    flexWrap: 'wrap'
  }}>
          <button style={btn(true)} onClick={() => setPreview(url)} disabled={!url}>
            Ver el calendario
          </button>
          <button style={btn(false)} onClick={copy} disabled={!url}>
            {copied ? 'Copiado' : 'Copiar enlace'}
          </button>
          {url && <a href={url} target="_blank" rel="noopener noreferrer" style={{
    ...btn(false),
    textDecoration: 'none',
    display: 'inline-block'
  }}>
              Abrir en otra pestaña
            </a>}
        </div>
      </div>

      {preview && <div style={{
    ...box,
    marginTop: 14,
    padding: 10
  }}>
          <iframe src={preview} title="Vista previa del calendario" style={{
    width: '100%',
    height: 720,
    border: 0,
    borderRadius: 8,
    background: '#fff'
  }} />
        </div>}
    </div>;
};

## Qué hace

Podés insertar el calendario de una agenda dentro de otro sitio web. Quien lo inserta puede dejar el nombre, el email y el teléfono del cliente ya completos, y guardar información propia en cada reserva (por ejemplo un número de orden interno).

El cliente reserva sin salir del sitio donde estaba.

## Cuándo usarlo

Cuando trabajás con una plataforma o un partner que quiere que sus usuarios reserven sin salir de su sitio: un marketplace, un portal de pacientes, una app propia, etc.

Si solo compartís el link o el QR, no necesitás esto: alcanza con [Compartir tu link de reserva](/primeros-pasos/compartir-link-de-reserva) o [Códigos QR](/publico/codigos-qr).

## Dónde encontrarlo

**Agenda → Configuración → Calendario embebido.**

Ahí encontrás:

* El código para copiar e insertar en tu sitio.
* El interruptor para exigir que los datos del enlace vengan firmados.
* El secreto de firma (aparece difuminado hasta que lo revelás), con opción de regenerarlo.

## Datos ya completos

El sitio que inserta el calendario puede dejar el nombre, el email y el teléfono cargados. Esos campos aparecen en gris y no se pueden editar en Turnito, para que coincidan con los datos del otro sistema.

Si algún dato viene mal formado, ese campo queda editable y el resto sigue bloqueado.

## Información propia en la reserva

El sitio que inserta el calendario puede mandar información suya junto con la reserva (un ID interno, el origen del tráfico, etc.). Cuando la reserva la tiene, la ves:

* En el detalle de la reserva, bajo **Datos externos**.
* En la exportación de reservas, como columnas **Dato externo: …**.

Si la reserva no trae esa información, no aparece nada nuevo en la pantalla ni en el Excel.

## Seguridad

El **secreto de firma** sirve para que quien programa tu sitio firme los datos del enlace desde su servidor. Así un visitante no puede cambiar la URL del calendario y alterar nombre, email o esa información propia.

Conviene activar **Exigir firma** cuando la integración ya esté andando: con eso activo, Turnito rechaza los enlaces con datos sin firma válida.

<Warning>
  **Regenerar el secreto** invalida todos los enlaces firmados que ya estén publicados. Después tenés que pasarle el secreto nuevo a quien programa tu sitio.
</Warning>

### Cómo se genera la firma

Esta parte es para quien programa tu sitio. Pasale este enlace.

La firma se calcula **en el servidor**, nunca en el navegador: si el secreto viaja al navegador, cualquiera lo puede leer y la firma deja de servir.

<Steps>
  <Step title="Armá el texto a firmar">
    Tomá los parámetros `prefill_*` y `metadata_*` que vayas a mandar, más `exp` si usás vencimiento. Ordenalos por nombre alfabéticamente, escapá nombre y valor por separado, y unilos con `&`.

    El escapado es percent-encoding RFC 3986: se codifica todo salvo `A-Za-z0-9-._~`, en hexadecimal mayúscula. Es `rawurlencode()` en PHP y `quote(s, safe='')` en Python. En JavaScript, `encodeURIComponent` deja sin escapar `!'()*` y hay que completarlo — el ejemplo de abajo ya lo hace.

    ```
    Parámetros:
      prefill_name=Juan Pérez
      prefill_email=juan@acme.com
      metadata_internal_id=ORD-1234
      exp=1753900000

    Texto a firmar:
      exp=1753900000&metadata_internal_id=ORD-1234&prefill_email=juan%40acme.com&prefill_name=Juan%20P%C3%A9rez
    ```

    No incluyas `embed` ni ningún otro parámetro que no sea `prefill_*`, `metadata_*` o `exp`.
  </Step>

  <Step title="Calculá el HMAC">
    `sig = HMAC-SHA256(secreto, texto)` en hexadecimal minúscula. Agregá el resultado a la URL como `&sig=...`.

    <Tabs>
      <Tab title="Node.js">
        ```js theme={null}
        const crypto = require('crypto');

        const buildSignedEmbedUrl = (agendaUuid, secret, params) => {
          const signable = {};
          for (const [key, value] of Object.entries(params || {})) {
            if (key === 'exp' || key.startsWith('prefill_') || key.startsWith('metadata_')) {
              if (value !== null && value !== undefined && value !== '') signable[key] = String(value);
            }
          }
          // encodeURIComponent deja sin escapar !'()*, que RFC 3986 sí escapa.
          const enc = (s) =>
            encodeURIComponent(s).replace(/[!'()*]/g, (c) =>
              '%' + c.charCodeAt(0).toString(16).toUpperCase());

          const canonical = Object.keys(signable).sort()
            .map((key) => `${enc(key)}=${enc(signable[key])}`)
            .join('&');
          const sig = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');

          const qs = new URLSearchParams({ embed: '1', ...signable, sig });
          return `https://turnito.app/c/${agendaUuid}?${qs.toString()}`;
        };
        ```
      </Tab>

      <Tab title="Python">
        ```python theme={null}
        import hashlib
        import hmac
        from urllib.parse import quote, urlencode

        def build_signed_embed_url(agenda_uuid: str, secret: str, params: dict) -> str:
            signable = {
                key: str(value)
                for key, value in (params or {}).items()
                if value not in (None, "")
                and (key == "exp" or key.startswith("prefill_") or key.startswith("metadata_"))
            }
            enc = lambda s: quote(s, safe="")
            canonical = "&".join(f"{enc(key)}={enc(signable[key])}" for key in sorted(signable))
            sig = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
            query = urlencode({"embed": "1", **signable, "sig": sig})
            return f"https://turnito.app/c/{agenda_uuid}?{query}"
        ```
      </Tab>

      <Tab title="PHP">
        ```php theme={null}
        <?php
        function buildSignedEmbedUrl(string $agendaUuid, string $secret, array $params): string {
            $signable = [];
            foreach ($params as $key => $value) {
                if ($value === null || $value === '') continue;
                if ($key === 'exp' || str_starts_with($key, 'prefill_') || str_starts_with($key, 'metadata_')) {
                    $signable[$key] = (string) $value;
                }
            }
            ksort($signable);
            $parts = [];
            foreach ($signable as $key => $value) {
                // rawurlencode ya es RFC 3986.
                $parts[] = rawurlencode($key) . '=' . rawurlencode($value);
            }
            $canonical = implode('&', $parts);
            $sig = hash_hmac('sha256', $canonical, $secret);

            $query = http_build_query(array_merge(['embed' => '1'], $signable, ['sig' => $sig]));
            return "https://turnito.app/c/{$agendaUuid}?{$query}";
        }
        ```
      </Tab>
    </Tabs>

    Con el secreto `mi-secreto` y los parámetros del paso anterior, el resultado tiene que ser exactamente:

    ```
    407a8a40d71bdf1d690b65219033cfa29a7869c40318244bdcf1fff8e1c2f4ba
    ```

    Si te da otra cosa, revisá el paso 1: casi siempre es orden de claves, valores sin escapar, o parámetros de más metidos en el texto.
  </Step>

  <Step title="Opcional: hacé que el enlace venza">
    Agregá `exp` con un timestamp Unix. Pasada esa hora, Turnito rechaza el enlace. Conviene usar ventanas cortas, de minutos u horas, no de días.
  </Step>
</Steps>

<Warning>
  Escapar los valores no es un detalle cosmético. Si mandás un valor sin escapar que contenga `&clave=` —por ejemplo una URL de retorno—, alguien puede partirlo e inyectar un dato firmado que vos nunca pusiste, como el email del cliente.
</Warning>

## Probalo acá

Armá el enlace con tus propios datos y mirá cómo queda el calendario antes de pasárselo a quien programa tu sitio.

<EmbedPlayground />

## Páginas relacionadas

* [La página de reserva](/publico/pagina-de-reserva)
* [Códigos QR](/publico/codigos-qr)
* [Compartir tu link de reserva](/primeros-pasos/compartir-link-de-reserva)
* [API pública](/api/index)
