Webhooks
Webhooks
Con los webhooks recibes notificaciones en tiempo real cuando ocurren eventos en tu workspace; por ejemplo, cuando se escanea un código QR.
Crear un webhook
POST /v1/webhooks{ "url": "https://example.com/webhooks/qr3", "events": ["qr.scanned", "qr.created"], "secret": "my-secret-key-min-16-chars"}Respuesta
{ "id": "wh_abc123", "url": "https://example.com/webhooks/qr3", "events": ["qr.scanned", "qr.created"], "is_active": true, "secret": "my-secret-key-min-16-chars", "secret_hint": "my-s…", "created_at": "2026-03-15T10:00:00.000Z"}Tipos de eventos
| Event | Descripción |
|---|---|
* | Todos los eventos |
qr.created | Código QR creado |
qr.updated | Código QR actualizado (URL, estado, etiquetas) |
qr.deleted | Código QR eliminado |
qr.scanned | Código QR escaneado |
qr.flagged | Código QR marcado como no seguro |
webhook.ping | Ping de prueba (a través del endpoint /ping) |
Formato del payload
Todos los payloads de los webhooks tienen este formato:
{ "id": "evt_4f0c2a91d7e84b3c9a1f", "type": "qr.scanned", "created": "2026-03-15T10:00:00.000Z", "data": { "code_id": "qr_abc123", "short_code": "r7f3Kx", "scanned_at": "2026-03-15T10:00:00.000Z", "country": "AT", "device_type": "mobile", "os": "iOS", "browser": "Safari", "language": "de", "ab_variant": null, "redirected_to": "https://example.com/landing" }}Eventos de código: qr.created y qr.updated
En estos dos eventos, data es el código exactamente tal como lo devuelve GET /v1/codes/:id. Esto incluye appearance, los colores guardados en el código (null representa el negro o blanco predeterminado):
{ "id": "evt_9b2e41c07a5d4f18b3c6", "type": "qr.updated", "created": "2026-09-28T10:00:00.000Z", "data": { "id": "qr_abc123", "short_code": "r7f3Kx", "status": "live", "appearance": { "foreground_color": "#1F4E79", "background_color": null }, "updated_at": "2026-09-28T10:00:00.000Z" }}El ejemplo está abreviado. Todos los campos se encuentran en la API de códigos.
Verificación de firmas
qr3.app firma cada solicitud de webhook con HMAC-SHA256:
X-QR3-Signature: sha256=a1b2c3d4e5f6...X-QR3-Event: qr.scannedX-QR3-Delivery: wdl_abc123X-QR3-Attempt: 1User-Agent: qr3-webhooks/1.0Implementar la verificación
import crypto from "crypto";
function verifySignature( payload: string, signature: string | undefined, secret: string): boolean { const expected = crypto .createHmac("sha256", secret) .update(payload) .digest("hex"); const expectedBytes = Buffer.from(`sha256=${expected}`); const receivedBytes = Buffer.from(signature ?? ""); return ( expectedBytes.length === receivedBytes.length && crypto.timingSafeEqual(expectedBytes, receivedBytes) );}
// In Express: verify the raw body — re-serialised JSON breaks the signature.app.post( "/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); const signature = req.headers["x-qr3-signature"] as string | undefined; if (!verifySignature(raw, signature, process.env.QR3_WEBHOOK_SECRET!)) { return res.status(401).json({ error: "Invalid signature" }); } const event = JSON.parse(raw); console.log(event.type, event.data); res.json({ received: true }); },);import hmacimport hashlib
def verify_signature(payload: str, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest() received = (signature or "").removeprefix("sha256=") return hmac.compare_digest(expected, received)Lógica de reintentos
En caso de errores (HTTP >= 300 o tiempo de espera agotado), qr3.app intentará entregar el mensaje de nuevo:
| Intento | Retraso |
|---|---|
| 1 | De inmediato |
| 2 | 1 minuto |
| 3 | 5 minutos |
Después de 10 errores consecutivos, el webhook se desactivará automáticamente.
Garantía de entrega
qr3.app entrega al menos una vez, no exactamente una. Por eso el mismo evento puede llegar más de una vez a tu endpoint: como reintento tras un error o como repetición de un intento que ya se envió.
- Deduplica con el
iddel payload (evt_…). Pertenece al evento y se mantiene igual en cada intento y en cada repetición.X-QR3-Deliveryidentifica el intento concreto: una repetición de ese intento lleva el mismo valor; un intento nuevo, uno nuevo. Confirma con un 2xx unidque ya conoces, no con un 4xx: un 4xx cuenta como intento fallido y, tras 10 errores consecutivos, el webhook se desactiva automáticamente. - Un intento que sigue en
pendinguna hora después de crearse (por ejemplo, porque su mensaje de la cola se perdió) pasa afailedmediante una limpieza diaria;status_codeyresponse_time_mssiguen siendonull. No es un fallo de tu endpoint y no cuenta para los 10 errores consecutivos. El registro guarda para ello el textoDelivery was not completed in time — closed by cleanup; el endpoint de registros de entrega no lo devuelve. El cierre no es definitivo: si el mensaje llega después, se entrega y su resultado real sustituye ese texto.
Registros de entrega
GET /v1/webhooks/:id/deliveries{ "data": [ { "id": "wdl_abc123", "event_type": "qr.scanned", "status": "success", "status_code": 200, "response_time_ms": 145, "attempt": 1, "created_at": "2026-03-15T10:00:00.000Z" } ]}Los intentos de entrega se conservan 90 días (el día UTC en curso y los 89 anteriores) y después se eliminan automáticamente. Además de los campos mostrados, el registro almacena el payload del evento y el primer kilobyte (1 KB) de la respuesta del endpoint; este endpoint no devuelve ninguno de los dos.
Ping de prueba
Prueba un webhook de inmediato:
POST /v1/webhooks/:id/pingEsto envía un evento webhook.ping a la URL de tu endpoint.