{"openapi":"3.1.0","paths":{"/health":{"get":{"description":"No comprueba dependencias a proposito: es la ruta del liveness probe, y hacerla profunda convertiria una caida de la base en un reinicio del contenedor. Para saber si el servicio puede atender trafico, usar `/health/ready`.","operationId":"HealthController_check","parameters":[{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"responses":{"200":{"description":"El proceso esta vivo"},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"summary":"Liveness — el proceso responde","tags":["health"],"security":[]}},"/health/ready":{"get":{"description":"Comprueba las dependencias criticas. Responde 503 si alguna no responde.","operationId":"HealthController_ready","parameters":[{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"responses":{"200":{"description":"Listo para recibir trafico"},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"503":{"description":"Alguna dependencia critica no responde","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"summary":"Readiness — el servicio puede atender trafico","tags":["health"],"security":[]}},"/v1/notifications":{"post":{"description":"Encola el envio y responde **202**. El 202 confirma que la peticion se acepto y se encolo, **no que el mensaje llego**: el desenlace de cada destinatario se consulta despues con `GET /v1/notifications?requestId=`, usando el `requestId` que devuelve esta llamada.\n\nLa aplicacion que envia sale de la API key, no del cuerpo: no hay forma de enviar en nombre de otra.\n\n`recipient` (uno) y `recipients` (hasta 200) son **excluyentes**. Con `recipients`, cada destinatario produce su propia notificacion con su propio desenlace: un token invalido no tumba el lote.\n\n**Un campo que el contrato no declara es un 400**, no un campo que se ignora — el `ValidationPipe` global corre con `forbidNonWhitelisted`. Si necesitas mandar algo nuevo, hay que declararlo aqui primero.","operationId":"NotificationsController_send","parameters":[{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendNotificationDto"},"examples":{"push":{"summary":"Push a un dispositivo","description":"El caso mas comun. `to` es el token de registro del aparato.","value":{"channels":["push"],"templateId":"aviso-terminos","locale":"es-MX","recipient":{"push":{"to":"<token-del-dispositivo>"}},"data":{"userName":"Juan"}}},"pushConImagen":{"summary":"Push a un topic, con imagen","description":"`to` y `topic` son excluyentes: exactamente uno. La imagen va en el bloque `push` de la **raiz**, no dentro de `recipient` — es contenido, y una peticion tiene un contenido y hasta 200 destinos. Solo URL `https`, nunca base64: quien la descarga es el telefono del usuario. Si no tienes donde alojarla, subela con `POST /v1/media-assets`.","value":{"channels":["push"],"templateId":"promo-verano","locale":"es-MX","recipient":{"push":{"topic":"miRotoplas_vendedor"}},"data":{"userName":"Juan"},"push":{"image":"https://cdn.rotoplas.com/promos/verano.png"}}},"email":{"summary":"Correo","value":{"channels":["email"],"templateId":"bienvenida","locale":"es-MX","recipient":{"email":{"to":"usuario@ejemplo.com"}},"data":{"userName":"Juan"}}},"variosCanales":{"summary":"Varios canales y varios destinatarios","description":"Los canales se intentan todos para cada destinatario. `recipients` admite hasta 200 y es excluyente con `recipient`.","value":{"channels":["email","sms"],"templateId":"aviso-corte","locale":"es-MX","recipients":[{"email":{"to":"usuario@ejemplo.com"},"sms":{"to":"+521234567890"}},{"email":{"to":"otro@ejemplo.com"},"sms":{"to":"+525555555555"}}],"data":{"fecha":"2026-10-01"}}},"ensayo":{"summary":"Ensayo, sin enviar nada","description":"Con `dryRun` se valida la peticion y se resuelve la plantilla, pero no sale ningun mensaje. Sirve para comprobar una integracion sin molestar a nadie.","value":{"channels":["push"],"templateId":"aviso-terminos","locale":"es-MX","recipient":{"push":{"to":"<token-del-dispositivo>"}},"data":{"userName":"Juan"},"dryRun":true}}}}}},"responses":{"202":{"description":"La peticion se acepto y se encolo. **No significa que el mensaje se entrego.** Guarda el `requestId`: es el agrupador con el que se consulta el desenlace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendNotificationResponseDto"},"example":{"requestId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","notificationIds":["b1a6f2d4-0c3e-4a91-8f7d-5e2c1b0a9d83"],"dryRun":false}}}},"400":{"description":"Validation error. Incluye `PAYLOAD_TOO_DEEP` cuando el cuerpo supera el limite de anidamiento (FMT-SEC-002), y `TEMPLATE_VERSION_UNRESOLVED` cuando se omite `templateVersion` y la plantilla no tiene una version vigente en el catalogo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"API key invalida o ausente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"El cuerpo supera el tamano maximo (FMT-SEC-002). **Desviacion consciente**: el estandar no lista 413 en su tabla de status, pero es el unico semanticamente correcto para este caso.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Se supero el limite de 100 peticiones por minuto **por clave y por ruta**. El cubo es por ruta, no del servicio entero: agotar el de una operacion no bloquea las demas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"X-API-Key":[]}],"summary":"Enviar una notificacion por uno o varios canales","tags":["notifications"]},"get":{"description":"Conteo por estado de todas las notificaciones que produjo un `requestId`, mas la lista de las que fallaron. Los conteos son siempre completos; solo pagina `failures`.","operationId":"NotificationsController_getRequestSummary","parameters":[{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":50,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"Cursor opaco devuelto en meta.nextCursor","schema":{"type":"string"}},{"name":"requestId","required":true,"in":"query","description":"Agrupador devuelto por POST /v1/notifications. Obligatorio: hoy es el unico filtro admitido y la coleccion no se devuelve sin acotar.","schema":{"format":"uuid","type":"string"}},{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"responses":{"200":{"description":"Resumen del lote","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestSummaryResponseDto"}}}},"400":{"description":"Falta `requestId` o no es un UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"API key invalida o ausente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"La peticion no existe **o no es de esta aplicacion**. Los dos casos devuelven lo mismo a proposito: distinguirlos dejaria averiguar que identificadores existen en otra.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Se supero el limite de 100 peticiones por minuto **por clave y por ruta**. El cubo es por ruta, no del servicio entero: agotar el de una operacion no bloquea las demas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"X-API-Key":[]}],"summary":"Resumen de una peticion de envio (lote)","tags":["notifications"]}},"/v1/notifications/{id}/events":{"get":{"description":"Todo lo que le paso a **una** notificacion, del evento mas antiguo al mas reciente: cuando se acepto, cuando salio al proveedor, y que reporto el proveedor despues por webhook.\n\nEl identificador sale de `notificationIds` del 202. Para ver el lote entero de una vez —el conteo por estado y las que fallaron— es `GET /v1/notifications?requestId=`, que es lo normal cuando se envio a varios destinatarios.\n\nEl `metadata` de cada evento **no tiene esquema fijo**: cambia segun el tipo de evento y el proveedor. Leerlo defensivamente.","operationId":"NotificationsController_getEvents","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":50,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"Cursor opaco devuelto en meta.nextCursor","schema":{"type":"string"}},{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"responses":{"200":{"description":"Historial de eventos ordenado cronológicamente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationEventsResponseDto"}}}},"401":{"description":"API key invalida o ausente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"Notificación no encontrada o no pertenece a esta app","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Se supero el limite de 100 peticiones por minuto **por clave y por ruta**. El cubo es por ruta, no del servicio entero: agotar el de una operacion no bloquea las demas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"X-API-Key":[]}],"summary":"Obtener la bitacora completa de una notificacion","tags":["notifications"]}},"/v1/notifications/{id}/status":{"patch":{"description":"Requiere clave **interna**, no una clave de cliente. Una aplicación cliente no puede llamarlo aunque su clave sea válida.","operationId":"NotificationsController_updateStatus","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationStatusDto"}}}},"responses":{"204":{"description":"Estado actualizado"},"401":{"description":"Clave interna inválida o ausente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"404":{"description":"La notificacion no existe","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Se supero el limite de 100 peticiones por minuto **por clave y por ruta**. El cubo es por ruta, no del servicio entero: agotar el de una operacion no bloquea las demas.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"X-API-Key":[]}],"summary":"Actualizar el estado de una notificacion — interno, lo llama Cloud Workflows","tags":["notifications"]}},"/webhooks/sendgrid":{"post":{"description":"**No lo llama una aplicacion cliente: lo llama SendGrid.** Se documenta para que se sepa como se autentica y que forma tiene lo que recibe, no para que nadie lo invoque.\n\nLa peticion se verifica por **firma ECDSA** del proveedor, no por API key: la cabecera `x-twilio-email-event-webhook-signature` sobre el cuerpo crudo. Una firma que no cuadra es un 401 con `INVALID_WEBHOOK_SIGNATURE`.\n\nSendGrid manda los eventos **en lote**, en un arreglo, y reintenta si no recibe 2xx.","operationId":"WebhooksController_handleSendGrid","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}}}}},"responses":{"200":{"description":"Eventos procesados"},"401":{"description":"Firma invalida o no configurada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"SendGridSignature":[]}],"summary":"Recibir los eventos de entrega de SendGrid","tags":["webhooks"]}},"/webhooks/twilio":{"post":{"description":"**Lo llama Twilio, no una aplicacion cliente.** Se verifica con la firma `x-twilio-signature`, que Twilio calcula sobre **la URL completa mas los campos del formulario**: por eso cambiar `WEBHOOK_BASE_URL` invalida las firmas y hay que actualizar el panel de Twilio a la vez.\n\nEl cuerpo llega como `application/x-www-form-urlencoded`, no como JSON.","operationId":"WebhooksController_handleTwilio","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TwilioWebhookDto"}}}},"responses":{"200":{"description":"Actualizacion de estado procesada"},"401":{"description":"Firma invalida o no configurada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"TwilioSignature":[]}],"summary":"Recibir el estado de entrega de SMS de Twilio","tags":["webhooks"]}},"/webhooks/whatsapp":{"get":{"description":"Meta lo invoca al dar de alta la URL de callback. Se autentica con `hub.verify_token`, no con firma: es el unico endpoint de webhooks cuya credencial viaja en la query.","operationId":"WebhooksController_verificarWhatsApp","parameters":[{"name":"hub.challenge","required":true,"in":"query","description":"Valor que hay que devolver tal cual","schema":{"type":"string"}},{"name":"hub.verify_token","required":true,"in":"query","description":"Token compartido, configurado en Meta","schema":{"type":"string"}},{"name":"hub.mode","required":true,"in":"query","schema":{"example":"subscribe","type":"string"}}],"responses":{"200":{"description":"Devuelve el reto en texto plano"},"401":{"description":"Token de verificacion invalido o no configurado","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"MetaVerifyToken":[]}],"summary":"Reto de alta del webhook de WhatsApp Cloud API","tags":["webhooks"]},"post":{"description":"**Lo llama Meta, no una aplicacion cliente.** Se verifica con `x-hub-signature-256`, un HMAC-SHA256 del cuerpo crudo con el App Secret.\n\nLa forma es la de Meta —`entry[].changes[].value`— y llega anidada: un mismo aviso puede traer varios estados. El alta del webhook es el `GET` de esta misma ruta, que responde el reto en texto plano.","operationId":"WebhooksController_handleWhatsApp","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppWebhookDto"}}}},"responses":{"200":{"description":"Estados procesados"},"401":{"description":"Firma invalida o no configurada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"MetaSignature":[]}],"summary":"Recibir los estados de entrega de WhatsApp Cloud API","tags":["webhooks"]}},"/v1/media-assets":{"post":{"description":"Para clientes que no tienen donde alojar la imagen de una notificacion push. Devuelve una URL que se abre **sin credenciales** y que se manda tal cual en `push.image` de `POST /v1/notifications`.\n\nEl objeto se guarda bajo un prefijo propio de la aplicacion, con nombre aleatorio, y **caduca a los 30 dias**. El bucket sirve a Internet porque quien descarga la imagen es el dispositivo del usuario: **no subir nada sensible**.\n\nAndroid recomienda que la imagen no pase de ~1 MB para que el aparato la descargue a tiempo de mostrarla. El tope duro es mayor, pero por encima de ese tamano la notificacion puede acabar viendose sin imagen.","operationId":"MediaController_upload","parameters":[{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"La imagen. PNG, JPEG, GIF o WebP. El tipo se decide leyendo los primeros bytes del archivo: la extension y el Content-Type que declare el cliente se ignoran."}}}}}},"responses":{"201":{"description":"Imagen almacenada","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaAssetResponseDto"}}}},"400":{"description":"No se mando el campo `file`, o el archivo no es una imagen de un tipo admitido (`UNSUPPORTED_IMAGE_TYPE`). **Desviacion consciente**: el estandar de APIs Sincronas no lista 415 en su tabla de status, y 400 si — el payload no cumple la validacion, que es exactamente lo que ocurrio. La precision la lleva el `code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"401":{"description":"API key invalida o ausente","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"413":{"description":"El archivo supera el tamano maximo. **Desviacion consciente**, la misma que declara `POST /v1/notifications`: el estandar no lista 413, pero es el unico semanticamente correcto y el limite se aplica mientras el archivo entra, antes de que exista un recurso al que referirse. Ojo: el limite de cuerpo de 100 kB **no** rige aqui, este endpoint tiene el suyo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"429":{"description":"Se supero el limite de 20 peticiones por minuto por clave. Es mas bajo que el de notificaciones a proposito: cada peticion mueve hasta 5 MB que se almacenan 30 dias. El patron previsto es subir **una** imagen y mandarla a miles de destinatarios.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado, o el archivo no se pudo guardar en el bucket (`MEDIA_STORAGE_UNAVAILABLE`). No lo corrige quien llama.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"security":[{"X-API-Key":[]}],"summary":"Subir una imagen y obtener su URL publica","tags":["media"]}},"/v1/media-assets/{appId}/{objeto}":{"get":{"description":"Ruta **anonima**: la abre el dispositivo del usuario final al desplegar la notificacion, sin credenciales. La URL completa la devuelve `POST /v1/media-assets` y no se construye a mano. Responde 404 tanto si el objeto no existe como si la ruta no tiene la forma que este servicio emite.","operationId":"MediaDownloadController_descargar","parameters":[{"name":"appId","required":true,"in":"path","description":"Prefijo de la aplicacion propietaria.","schema":{"format":"uuid","type":"string"}},{"name":"objeto","required":true,"in":"path","description":"Nombre del objeto: UUID mas extension.","schema":{"example":"9f1c2e5a-3b7d-4c81-9a0e-2f6b8d4c1e07.jpg","type":"string"}},{"in":"header","name":"traceparent","required":false,"schema":{"type":"string","example":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},"description":"Traza W3C. Si se manda, el servicio la continua en vez de abrir una nueva, y la devuelve en la respuesta — el navegador la puede leer porque va en `Access-Control-Expose-Headers`. Si no se manda, el servicio genera una."},{"in":"header","name":"tracestate","required":false,"schema":{"type":"string"},"description":"Estado del proveedor de trazas que acompana a `traceparent` (W3C)."},{"in":"header","name":"X-Trace-Id","required":false,"schema":{"type":"string"},"description":"Alias de compatibilidad, con **menos** precedencia que `traceparent`. Se mantiene durante la ventana de migracion; en integraciones nuevas usar `traceparent`."}],"responses":{"200":{"description":"Los bytes de la imagen."},"404":{"description":"No existe, caduco, o la ruta no tiene esa forma.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}},"500":{"description":"Error no controlado. El detalle no viaja en la respuesta: queda en el log, correlacionado por `meta.traceId`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponseDto"}}}}},"summary":"Descarga una imagen subida previamente","tags":["media"],"security":[]}}},"info":{"title":"rtp-cns — API de cliente","description":"Superficie que consumen las aplicaciones cliente: envio de notificaciones, consulta de su bitacora, carga de imagenes y callbacks de proveedor.\n\nLimites de cuerpo (FMT-SEC-002): toda peticion con cuerpo se rechaza con **413** si supera el tamano maximo, y con **400** y codigo `PAYLOAD_TOO_DEEP` si supera el limite de anidamiento. Ambos se aplican antes de parsear.\n\n**Desviacion consciente:** la tabla de status del estandar de APIs Sincronas no lista 413. Se conserva por ser el unico semanticamente correcto cuando el cuerpo excede el limite: se rechaza en el parser, antes de que exista un recurso al que referirse.\n\nLa carga de archivos (`multipart/form-data`) **no pasa por ese limite y tiene el suyo**, mucho mas alto, declarado en la operacion correspondiente. No es un descuido: los parsers de JSON y de formulario ignoran los cuerpos cuyo tipo no reconocen, asi que el tope de un archivo lo impone quien lo recibe, mientras entra.","version":"1.0.0","contact":{"name":"Arquitectura TI — rtp-cns","url":"https://gitlab.com/infraestructura6863429/arquitectura/rtp-cns","email":"devops@rotoplas.com"}},"tags":[{"name":"notifications","description":"Envio de notificaciones y consulta de lo enviado. El envio es **asincrono**: el `202` confirma que la peticion se acepto y se encolo, no que el mensaje llego. Lo que paso despues se consulta en la bitacora del lote."},{"name":"media","description":"Imagenes de notificacion push. Se sube una vez y se referencia por URL en los envios; la descarga es publica y sin credencial, porque quien la abre es el telefono del destinatario."},{"name":"webhooks","description":"Callbacks que **configuran los proveedores** en sus paneles (SendGrid, Twilio, Meta). No son endpoints que se llamen desde una aplicacion cliente: se listan para que se sepa como se autentican —por firma del proveedor, no por API key— y que forma tiene lo que reciben."},{"name":"health","description":"Sondas de vida y de preparacion. Publicas y sin version en la ruta: las apuntan Cloud Run y el uptime check, que no saben de prefijos."}],"servers":[{"url":"/","description":"Raiz del servicio"}],"components":{"securitySchemes":{"X-API-Key":{"type":"apiKey","in":"header","name":"X-API-Key"},"SendGridSignature":{"type":"apiKey","in":"header","name":"x-twilio-email-event-webhook-signature"},"TwilioSignature":{"type":"apiKey","in":"header","name":"x-twilio-signature"},"MetaSignature":{"type":"apiKey","in":"header","name":"x-hub-signature-256"},"MetaVerifyToken":{"type":"apiKey","in":"query","name":"hub.verify_token"}},"schemas":{"NotificationChannel":{"type":"string","enum":["email","sms","push","whatsapp"],"description":"Channels to send the notification through. Accepted values: email | sms | push | whatsapp. Must not repeat a channel."},"EmailRecipientDto":{"type":"object","properties":{"to":{"type":"string","description":"Direccion de correo del destinatario.","format":"email","example":"usuario@ejemplo.com"}},"required":["to"]},"SmsRecipientDto":{"type":"object","properties":{"to":{"type":"string","description":"Numero del destinatario en formato E.164 — con `+` y codigo de pais, sin espacios ni guiones. Un numero sin `+` lo rechaza el proveedor, no el servicio.","example":"+521234567890"}},"required":["to"]},"PushRecipientDto":{"type":"object","properties":{"to":{"type":"string","description":"Token de registro FCM del dispositivo destino. Excluyente con `topic`: hay que mandar exactamente uno de los dos.","example":"fMEjKtQ1S0-abcdefghijklmnopqrstuvwxyz0123456789"},"topic":{"type":"string","description":"Topic de FCM al que publicar. Excluyente con `to`. Sin el prefijo «/topics/»: la API de FCM lo rechaza. Los dispositivos se suscriben desde la app cliente, no desde el CNS.","example":"miRotoplas_vendedor","maxLength":128,"pattern":"^[a-zA-Z0-9\\-_.~%]+$"}}},"WhatsappRecipientDto":{"type":"object","properties":{"to":{"type":"string","example":"+521234567890","description":"Phone number in E.164 format"}},"required":["to"]},"RecipientDto":{"type":"object","properties":{"email":{"description":"Email recipient","allOf":[{"$ref":"#/components/schemas/EmailRecipientDto"}]},"sms":{"description":"SMS recipient","allOf":[{"$ref":"#/components/schemas/SmsRecipientDto"}]},"push":{"description":"Push notification recipient","allOf":[{"$ref":"#/components/schemas/PushRecipientDto"}]},"whatsapp":{"description":"WhatsApp recipient","allOf":[{"$ref":"#/components/schemas/WhatsappRecipientDto"}]}}},"NotificationPriority":{"type":"string","enum":["CRITICAL","STANDARD"],"description":"Message priority. Accepted values: CRITICAL | STANDARD (case-sensitive)."},"PushContentDto":{"type":"object","properties":{"image":{"type":"string","description":"URL de la imagen que acompaña a la notificación. Solo `https`: Android e iOS descartan sin avisar una imagen servida en claro, y la notificación llegaría sin ella. Firebase acepta la URL, nunca la imagen embebida ni en base64.","example":"https://cdn.rotoplas.com/promos/verano.png","maxLength":2048,"format":"uri"}}},"SendNotificationDto":{"type":"object","properties":{"channels":{"type":"array","description":"Channels to send the notification through. Accepted values: email | sms | push | whatsapp. Must not repeat a channel.","uniqueItems":true,"minItems":1,"maxItems":4,"example":["email","sms","push"],"items":{"$ref":"#/components/schemas/NotificationChannel"}},"templateId":{"type":"string","example":"welcome-email","description":"Template identifier","maxLength":255},"templateVersion":{"type":"string","example":"v2","maxLength":50,"description":"Tag de version de la plantilla. Si se omite, el servicio resuelve la version vigente del catalogo (`latest_version`, o la marcada por defecto). Si la plantilla no tiene ninguna, la peticion se rechaza con 400 `TEMPLATE_VERSION_UNRESOLVED`."},"locale":{"type":"string","example":"es-MX","description":"Locale code (BCP 47)","maxLength":20},"data":{"type":"object","description":"Template variable data","example":{"userName":"Juan","orderNumber":"1234"}},"recipient":{"description":"Un destinatario. Excluyente con `recipients`.","allOf":[{"$ref":"#/components/schemas/RecipientDto"}]},"recipients":{"minItems":1,"maxItems":200,"description":"Varios destinatarios. Excluyente con `recipient`. Cada uno produce una notificacion por canal, con su propio identificador y su propio desenlace.","type":"array","items":{"$ref":"#/components/schemas/RecipientDto"}},"priority":{"example":"STANDARD","default":"STANDARD","description":"Message priority. Accepted values: CRITICAL | STANDARD (case-sensitive).","allOf":[{"$ref":"#/components/schemas/NotificationPriority"}]},"dryRun":{"type":"boolean","default":false,"description":"Dry run — records notification but does not publish to Pub/Sub"},"push":{"description":"Contenido especifico del canal push. Se ignora en el resto de canales, y exige que \"channels\" incluya \"push\".","allOf":[{"$ref":"#/components/schemas/PushContentDto"}]}},"required":["channels","templateId","locale","data"]},"SendNotificationResponseDto":{"type":"object","properties":{"requestId":{"type":"string","description":"Agrupador de todo lo que produjo esta peticion. **Es el campo que hay que guardar**: con el se consulta el desenlace del lote en `GET /v1/notifications?requestId=`. Un envio a 200 destinatarios devuelve un solo `requestId`.","format":"uuid","example":"f47ac10b-58cc-4372-a567-0e02b2c3d479"},"notificationIds":{"description":"Una notificacion por destinatario y canal. Cada una tiene su propio desenlace: que una falle no afecta a las demas. Se consultan de una en una con `GET /v1/notifications/{id}/events`.","example":["b1a6f2d4-0c3e-4a91-8f7d-5e2c1b0a9d83","c2b7e3f5-1d4f-4b02-9e8a-6f3d2c1b0a94"],"type":"array","items":{"type":"string","format":"uuid"}},"dryRun":{"type":"boolean","description":"Si fue un ensayo. Con `true`, la peticion se valido y la plantilla se resolvio, pero **no salio ningun mensaje** — las notificaciones existen y se pueden consultar, pero ningun proveedor las recibio.","example":false}},"required":["requestId","notificationIds","dryRun"]},"BatchFailureDto":{"type":"object","properties":{"notificationId":{"type":"string","format":"uuid"},"channel":{"type":"string","enum":["email","sms","push","whatsapp"]},"status":{"type":"string","enum":["QUEUED","PROCESSING","SENT_TO_PROVIDER","DELIVERED","FAILED","FAILED_PERMANENTLY","REDIRECTED"]},"recipient":{"type":"string","example":"dDl8…SOU2","description":"Destino enmascarado"},"errorCategory":{"type":["object","null"],"example":"PERMANENT_FAILURE"}},"required":["notificationId","channel","status","recipient","errorCategory"]},"RequestSummaryResponseDto":{"type":"object","properties":{"requestId":{"type":"string","format":"uuid"},"total":{"type":"number","example":200,"description":"Notificaciones que produjo la peticion"},"porEstado":{"type":"object","additionalProperties":{"type":"integer"},"example":{"SENT_TO_PROVIDER":198,"FAILED_PERMANENTLY":2},"description":"Conteo por estado. Un estado sin notificaciones no aparece."},"failures":{"type":"array","items":{"$ref":"#/components/schemas/BatchFailureDto"}},"meta":{"type":"object","description":"Paginacion de `failures` unicamente"}},"required":["requestId","total","porEstado","failures","meta"]},"NotificationEventDto":{"type":"object","properties":{"eventId":{"type":"string","description":"Identificador del evento. Unico e inmutable.","format":"uuid","example":"9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d"},"eventType":{"type":"string","description":"Que paso. Es el campo con el que se decide: un envio termina bien cuando llega un evento de entrega, y termina mal cuando llega uno de fallo. Los intermedios cuentan el avance, no el desenlace.","enum":["RECEIVED","QUEUED","PROCESSING","SENT_TO_PROVIDER","DELIVERED","FAILED","FAILED_PERMANENTLY","REDIRECTED","RETRY"]},"actor":{"type":"string","description":"Quien lo registro: el propio servicio, el proveedor por webhook, o el orquestador.","enum":["api","workflow","webhook","system"]},"occurredAt":{"type":"string","description":"Cuando ocurrio, UTC ISO 8601. Es el momento del hecho, no el de su registro: un webhook del proveedor puede llegar minutos despues de lo que describe.","format":"date-time","example":"2026-07-15T18:00:00.000Z"},"metadata":{"type":["object","null"],"description":"Detalle del evento, con forma libre y distinta segun el `eventType` y el proveedor — por ejemplo el motivo de un rebote. **No tiene esquema fijo**: leerlo defensivamente.","example":{"motivo":"invalid-registration-token"}},"traceId":{"type":["object","null"],"description":"Traza de la peticion que origino el evento, si la hubo. Es el dato que hay que citar al reportar una incidencia.","example":"4bf92f3577b34da6a3ce929d0e0e4736"}},"required":["eventId","eventType","actor","occurredAt","metadata","traceId"]},"NotificationEventsResponseDto":{"type":"object","properties":{"notificationId":{"type":"string","description":"La notificacion cuya bitacora se consulto.","format":"uuid","example":"b1a6f2d4-0c3e-4a91-8f7d-5e2c1b0a9d83"},"events":{"description":"Los eventos, del mas antiguo al mas reciente.","type":"array","items":{"$ref":"#/components/schemas/NotificationEventDto"}},"total":{"type":"number","example":4,"description":"Elementos en esta pagina, no en total"},"meta":{"type":"object","description":"Paginacion por cursor opaco (SIN §4.1). nextCursor null = ultima pagina.","example":{"limit":50,"nextCursor":null}}},"required":["notificationId","events","total","meta"]},"UpdateNotificationStatusDto":{"type":"object","properties":{"status":{"type":"string","enum":["PROCESSING","SENT","FAILED","FAILED_PERMANENTLY"]},"errorCategory":{"type":"string","example":"PERMANENT_FAILURE"},"errorMessage":{"type":"string","example":"Template rendering failed"},"traceId":{"type":"string"},"externalMsgId":{"type":"string","example":"SG.abc123","description":"ID del mensaje en el proveedor"},"provider":{"type":"string","example":"sendgrid"},"retryCount":{"type":"number","example":2,"description":"Reintentos consumidos por el orquestador"}},"required":["status"]},"TwilioWebhookDto":{"type":"object","properties":{"MessageSid":{"type":"string","example":"SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","description":"Twilio message SID"},"MessageStatus":{"type":"string","example":"delivered","description":"Message status: queued | sending | sent | delivered | failed | undelivered"},"To":{"type":"string","example":"+525551234567","description":"Destination phone number"},"From":{"type":"string","example":"+12025551234","description":"Twilio sender number"},"SmsSid":{"type":"string","example":"SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},"SmsStatus":{"type":"string","example":"delivered"},"ErrorCode":{"type":"string","example":"30003","description":"Twilio error code (if any)"},"ErrorMessage":{"type":"string","example":"Unreachable destination handset"},"AccountSid":{"type":"string","example":"ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},"ApiVersion":{"type":"string","example":"2010-04-01"},"notification_id":{"type":"string","example":"f47ac10b-58cc-4372-a567-0e02b2c3d479","description":"rtp-cns notification UUID propagado en StatusCallback URL"}},"required":["MessageSid","MessageStatus"]},"WhatsAppStatusDto":{"type":"object","properties":{"id":{"type":"string","example":"wamid.HBgNNTIxNTUxMjM0NTY3OBUCABEYEjc...","description":"Identificador del mensaje en Meta (wamid)"},"status":{"type":"string","example":"delivered","description":"Estado del mensaje: sent | delivered | read | failed"},"timestamp":{"type":"string","example":"1756234567","description":"Epoch en segundos"},"recipient_id":{"type":"string","example":"5215512345678","description":"Telefono del destinatario. Dato personal: no se registra en logs."}},"required":["id","status"]},"WhatsAppChangeValueDto":{"type":"object","properties":{"statuses":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppStatusDto"}}}},"WhatsAppChangeDto":{"type":"object","properties":{"field":{"type":"string","example":"messages"},"value":{"$ref":"#/components/schemas/WhatsAppChangeValueDto"}}},"WhatsAppEntryDto":{"type":"object","properties":{"id":{"type":"string","example":"102290129340398","description":"WhatsApp Business Account ID"},"changes":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppChangeDto"}}}},"WhatsAppWebhookDto":{"type":"object","properties":{"object":{"type":"string","example":"whatsapp_business_account"},"entry":{"type":"array","items":{"$ref":"#/components/schemas/WhatsAppEntryDto"}}},"required":["object","entry"]},"MediaAssetResponseDto":{"type":"object","properties":{"id":{"type":"string","description":"Identificador del archivo subido. Es el nombre del objeto sin el prefijo de la aplicacion ni la extension.","example":"9f1c2e5a-3b7d-4c81-9a0e-2f6b8d4c1e07","format":"uuid"},"url":{"type":"string","description":"URL publica de la imagen. Se abre **sin credenciales** —quien la descarga es el dispositivo del usuario, no el CNS— y es el valor que se manda en `push.image` de `POST /v1/notifications`. Caduca a los 30 dias (ADR-013).\n\nApunta al dominio del servicio y **no** al bucket de Cloud Storage: desde AE-374 el bucket es privado y los bytes salen por el mismo borde que el resto de la API. Usala tal cual llega; no la construyas a mano ni la reescribas.","example":"https://cns-dev.rotoplas.com/v1/media-assets/3f2b1a0c-8d4e-4f19-b6c7-1e2d3a4b5c6d/9f1c2e5a-3b7d-4c81-9a0e-2f6b8d4c1e07.jpg","format":"uri"},"contentType":{"type":"string","description":"Tipo real del archivo, deducido de sus primeros bytes. **Puede no coincidir con el `Content-Type` que declaro el cliente**: manda lo que dicen los bytes, y es tambien el tipo con el que el objeto se sirve.","enum":["image/png","image/jpeg","image/gif","image/webp"],"example":"image/jpeg"},"bytes":{"type":"number","description":"Tamano del archivo en bytes.","example":84213},"expiresAt":{"type":"string","description":"Cuando se borrara el objeto, en UTC ISO 8601 (FMT-003). Es la regla de ciclo de vida del bucket, no una promesa exacta: GCS aplica las reglas de forma asincrona y puede tardar horas en ejecutarlas.","example":"2026-10-04T18:22:41.000Z","format":"date-time"}},"required":["id","url","contentType","bytes","expiresAt"]},"ErrorBodyDto":{"type":"object","properties":{"code":{"type":"string","description":"Codigo semantico de negocio en SCREAMING_SNAKE. **Es la parte estable del error**: un cliente decide que hacer mirando este campo, no el `message`, que esta escrito para una persona y puede cambiar de redaccion sin aviso. Un codigo publicado no se renombra: se agrega uno nuevo y se deja de emitir el viejo.","enum":["MISSING_API_KEY","INVALID_API_KEY","INVALID_WEBHOOK_SIGNATURE","ACCESS_DENIED","RESOURCE_NOT_FOUND","NOTIFICATION_NOT_FOUND","APPLICATION_NOT_FOUND","TEMPLATE_NOT_FOUND","TEMPLATE_VERSION_NOT_FOUND","TEMPLATE_CONTENT_UNAVAILABLE","API_KEY_NOT_FOUND","ADMIN_KEY_NOT_FOUND","CHANNEL_NOT_SUPPORTED","RESOURCE_CONFLICT","TEMPLATE_ALREADY_EXISTS","TEMPLATE_VERSION_ALREADY_EXISTS","DEFAULT_VERSION_NOT_DELETABLE","API_KEY_ALREADY_REVOKED","ADMIN_KEY_ALREADY_REVOKED","LAST_ADMIN_KEY","CHANNEL_DISABLED","PUSH_TOPIC_NOT_REGISTERED","TOPIC_ALREADY_EXISTS","TOPIC_NOT_FOUND","INVALID_CLIENT_IDENTIFIER","INVALID_PUSH_PROFILE","INVALID_FIREBASE_APP_ID","PUSH_CLIENT_APP_NOT_FOUND","PUSH_CLIENT_APP_ALREADY_EXISTS","PUSH_APP_QUOTA_EXCEEDED","FIREBASE_APP_REGISTRY_DENIED","FIREBASE_APP_REGISTRY_INDETERMINATE","FIREBASE_APP_REGISTRY_UNAVAILABLE","REQUEST_NOT_FOUND","VALIDATION_ERROR","CHANNEL_ADDRESS_MISSING","DUPLICATE_RECIPIENT","TEMPLATE_VERSION_UNRESOLVED","PAYLOAD_TOO_LARGE","PAYLOAD_TOO_DEEP","UNSUPPORTED_IMAGE_TYPE","INVALID_CREDENTIAL_FORMAT","PROVIDER_NOT_AVAILABLE","CHANNEL_OPERATION_NOT_SUPPORTED","INVALID_TEST_FIELD","PROVIDER_NOT_CONFIGURED","CHANNEL_CREDENTIAL_REQUIRED","RATE_LIMIT_EXCEEDED","INTERNAL_ERROR","SERVICE_MISCONFIGURED","MEDIA_STORAGE_UNAVAILABLE","CHANNEL_NOT_CONFIGURED","CHANNEL_PROVIDER_SWITCH_FAILED","WORKFLOW_REJECTED","SERVICE_UNAVAILABLE"],"example":"INVALID_API_KEY"},"message":{"type":"string","description":"Mensaje legible para una persona. En los 5xx nunca lleva detalle interno: el detalle queda en el log, correlacionado por `meta.traceId`.","example":"Invalid or missing API key"}},"required":["code","message"]},"ErrorMetaDto":{"type":"object","properties":{"timestamp":{"type":"string","description":"Momento en que se genero la respuesta, UTC ISO 8601 (FMT-003).","format":"date-time","example":"2026-09-17T16:42:11.083Z"},"path":{"type":"string","description":"Ruta que se pidio, tal cual llego al servicio.","example":"/v1/notifications"},"traceId":{"type":"string","description":"Identificador de la traza, cuando la peticion la trae o el servicio la genera. Es el dato que hay que citar al reportar una incidencia: con el se encuentra el detalle interno que la respuesta no lleva. Viaja tambien en la cabecera `traceparent` de la respuesta.","example":"4bf92f3577b34da6a3ce929d0e0e4736"}},"required":["timestamp","path"]},"ErrorResponseDto":{"type":"object","properties":{"data":{"type":"null","description":"Siempre `null` en un error. El envelope es el mismo del exito (§6.1).","example":null},"error":{"$ref":"#/components/schemas/ErrorBodyDto"},"meta":{"$ref":"#/components/schemas/ErrorMetaDto"}},"required":["data","error","meta"]}}},"externalDocs":{"description":"Guia de integracion: que pedirnos y como mandar el primer envio","url":"https://gitlab.com/infraestructura6863429/arquitectura/rtp-cns/-/blob/main/docs/onboarding-apps-push-firebase.md"}}