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

# Implementar el embed firmado

> Contrato HMAC, cadena canónica y ejemplos para precargar datos de forma segura.

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>Usá solamente un secreto descartable recién generado.</strong> Acá se usa
              dentro de tu navegador y no se envía a ningún lado, pero deja de estar confinado
              a tu servidor. Cuando termines, regeneralo desde{' '}
              <strong>Agenda → Configuración → Calendario embebido</strong> y guardá el nuevo
              únicamente en tu backend.
            </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 cada clave y valor usa
              percent-encoding RFC 3986. 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>;
};

El embed firmado permite precargar datos del cliente y adjuntar metadata sin confiar en parámetros modificables por el navegador.

## Obtener la configuración

El owner debe abrir **Agenda → Configuración → Calendario embebido**. Desde ahí copia el código, revela el secreto y, después de validar la integración, activa **Exigir firma**.

<Warning>
  El secreto de producción solo puede vivir en tu backend o secret manager. No lo incluyas en JavaScript del navegador, HTML, aplicaciones móviles ni repositorios.
</Warning>

## Parámetros aceptados

| Parámetro          | Uso                                    |
| ------------------ | -------------------------------------- |
| `embed=1`          | Activa la vista embebida; no se firma  |
| `prefill_name`     | Precarga el nombre                     |
| `prefill_email`    | Precarga el email                      |
| `prefill_phone`    | Precarga el teléfono                   |
| `metadata_<clave>` | Guarda un dato externo en la reserva   |
| `exp`              | Timestamp Unix de vencimiento opcional |
| `sig`              | HMAC-SHA256 en hexadecimal minúscula   |

Las claves de metadata se normalizan a minúsculas, admiten `a-z`, `0-9`, `_` y `-`, y se recortan a 40 caracteres. Se guardan hasta 20 claves, con valores de hasta 500 caracteres y un máximo total serializado de 4 KB. Los valores que no cumplen se descartan.

Los datos `prefill_*` solo se fuerzan en el backend cuando la firma es válida. Sin firma pueden verse bloqueados en la interfaz, pero el visitante puede alterar la URL antes de abrirla y el backend no los considera datos confiables.

## Construir la cadena canónica

1. Conservá solamente `prefill_*`, `metadata_*` y `exp`.
2. Convertí cada valor a string. Omití valores nulos.
3. Ordená los pares por nombre de clave ascendente.
4. Aplicá percent-encoding RFC 3986 a cada clave y valor por separado: solo `A-Z a-z 0-9 - . _ ~` quedan sin escapar y el hexadecimal va en mayúscula.
5. Uní los pares como `clave=valor` con `&`.

No incluyas `embed` ni `sig`.

```text theme={null}
exp=1753900000
metadata_internal_id=ORD-1234
prefill_email=juan@acme.com
prefill_name=Juan Pérez
```

La cadena canónica es:

```text theme={null}
exp=1753900000&metadata_internal_id=ORD-1234&prefill_email=juan%40acme.com&prefill_name=Juan%20P%C3%A9rez
```

Calculá `HMAC-SHA256(secreto, cadena_canónica)` y agregá el digest hexadecimal como `sig`.

Con el secreto `mi-secreto`, la firma del ejemplo es:

```text theme={null}
407a8a40d71bdf1d690b65219033cfa29a7869c40318244bdcf1fff8e1c2f4ba
```

## Generar una URL en Node.js

```js theme={null}
const crypto = require('crypto');

const rfc3986 = (value) =>
  encodeURIComponent(value).replace(
    /[!'()*]/g,
    (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
  );

const buildSignedEmbedUrl = (agendaUuid, secret, input) => {
  const params = Object.fromEntries(
    Object.entries(input)
      .filter(([key, value]) =>
        value !== null &&
        value !== undefined &&
        (key === 'exp' || key.startsWith('prefill_') || key.startsWith('metadata_')))
      .map(([key, value]) => [key, String(value)]),
  );

  const canonical = Object.keys(params)
    .sort()
    .map((key) => `${rfc3986(key)}=${rfc3986(params[key])}`)
    .join('&');
  const sig = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');
  const query = new URLSearchParams({ embed: '1', ...params, sig });

  return `https://turnito.app/c/${encodeURIComponent(agendaUuid)}?${query}`;
};
```

## Generar una URL en Python

```python theme={null}
import hashlib
import hmac
from urllib.parse import quote, urlencode


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

## Definir vencimiento

Mandá `exp` como timestamp Unix en segundos y firmalo junto con los demás parámetros. Si está vencido o no es un entero válido, Turnito rechaza la reserva. Usá ventanas cortas, de minutos u horas.

## Interpretar la validación

* Firma válida: Turnito fuerza los datos precargados y guarda la metadata normalizada.
* Firma inválida o vencida: la reserva se rechaza.
* Firma ausente con **Exigir firma** activo: la reserva se rechaza cuando la URL incluye parámetros firmables.
* Embed sin `prefill_*`, `metadata_*` ni `exp`: no exige firma y funciona como un calendario común.

## Probar la URL

Este probador calcula la firma en tu navegador. Usalo únicamente con un secreto descartable recién generado y antes de publicar la integración. Cuando termines, regenerá el secreto y guardá el nuevo solo en tu backend.

<Warning>
  Nunca pegues en el probador un secreto que ya esté en producción.
</Warning>

<EmbedPlayground />

## Diagnosticar una firma inválida

Compará, en este orden:

1. El conjunto exacto de parámetros firmados.
2. El orden alfabético de las claves.
3. El percent-encoding RFC 3986 de claves y valores.
4. El timestamp `exp` en segundos.
5. El secreto vigente de esa agenda.

Si regenerás el secreto, todas las URLs firmadas con el anterior dejan de funcionar.
