Saltar al contenido
ApiPay Hub · Docs

Receta: FastAPI#

Un proyecto completo con FastAPI y el SDK apipay, usando el cliente asíncrono y su lifespan para que el pool de conexiones se cierre bien. Cinco archivos.

mi-tienda/
├── requirements.txt
├── .env.example
└── app/
    ├── __init__.py
    ├── config.py     variables de entorno, validadas al arrancar
    ├── store.py      persistencia de juguete (en produccion, tu base de datos)
    ├── pagina.py     el HTML del checkout, sin un solo campo de tarjeta
    └── main.py       las tres rutas

requirements.txt#

apipay>=1.0.0,<2
fastapi>=0.115
uvicorn[standard]>=0.34

httpx llega como dependencia de apipay: es la única que el SDK necesita en runtime, y le da cliente síncrono y asíncrono con la misma superficie.

.env.example#

# Backoffice > API keys. La sk_ jamas llega al navegador.
APIPAY_SECRET_KEY=sk_test_EJEMPLO000000000000000000000000
APIPAY_PUBLIC_KEY=pk_test_EJEMPLO000000000000000000000000

# Backoffice > Webhooks. Se muestra UNA sola vez al crear el endpoint.
APIPAY_WEBHOOK_SECRET=whsec_test_EJEMPLO0000000000000000000

# URL publica de esta app: la usan return_url y el endpoint de webhooks.
PUBLIC_BASE_URL=http://127.0.0.1:8000

app/config.py#

"""Configuracion leida del entorno y validada al importar: nada de fallos tardios."""

from __future__ import annotations

import os
from dataclasses import dataclass


def _requerido(nombre: str) -> str:
    valor = os.environ.get(nombre, "")
    if not valor:
        raise RuntimeError(f"Falta la variable de entorno {nombre}")
    return valor


@dataclass(frozen=True, slots=True)
class Config:
    secret_key: str
    public_key: str
    webhook_secret: str
    public_base_url: str


CONFIG = Config(
    secret_key=_requerido("APIPAY_SECRET_KEY"),
    public_key=_requerido("APIPAY_PUBLIC_KEY"),
    webhook_secret=_requerido("APIPAY_WEBHOOK_SECRET"),
    public_base_url=_requerido("PUBLIC_BASE_URL"),
)

app/store.py#

"""Persistencia de juguete.

En produccion cada funcion de aqui es una consulta a tu base de datos, y
``registrar_evento`` es un INSERT con UNIQUE(event_id) dentro de la MISMA
transaccion que el efecto de negocio: los reintentos de entrega pueden llegar a
instancias distintas del proceso.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

Estado = Literal["pendiente", "pagada", "fallida", "reembolsada"]


@dataclass(slots=True)
class Orden:
    referencia: str
    amount_minor: int
    currency: str
    estado: Estado = "pendiente"
    intent_id: str | None = None
    client_secret: str | None = None
    idempotency_key: str | None = None


_ordenes: dict[str, Orden] = {}
_eventos: set[str] = set()


def obtener_o_crear(referencia: str, amount_minor: int, currency: str) -> Orden:
    orden = _ordenes.get(referencia)
    if orden is None:
        orden = Orden(referencia=referencia, amount_minor=amount_minor, currency=currency)
        _ordenes[referencia] = orden
    return orden


def por_intent(intent_id: str) -> Orden | None:
    return next((o for o in _ordenes.values() if o.intent_id == intent_id), None)


def registrar_evento(event_id: str) -> bool:
    """True la primera vez que se ve el evento; False en los reintentos."""
    if event_id in _eventos:
        return False
    _eventos.add(event_id)
    return True

app/pagina.py#

"""HTML del checkout.

PCI DSS SAQ A: aqui no hay ningun input de tarjeta, ni CVV, ni autocomplete de
tarjeta. La captura ocurre dentro del iframe alojado de la pasarela.
"""

from __future__ import annotations

import json

from .store import Orden


def pagina_checkout(orden: Orden, public_key: str) -> str:
    total = (
        f"{orden.amount_minor:,}".replace(",", ".")
        if orden.currency == "CLP"
        else f"{orden.amount_minor / 100:,.2f}"
    )
    return f"""<!doctype html>
<html lang="es-CL">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Pagar orden {orden.referencia}</title>
  </head>
  <body>
    <h1>Orden {orden.referencia}</h1>
    <p>Total: {total} {orden.currency}</p>
    <p id="apipay-error" role="alert"></p>
    <div id="apipay-checkout"></div>

    <script src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js" crossorigin="anonymous" defer></script>
    <script defer>
      addEventListener('DOMContentLoaded', function () {{
        var errorBox = document.querySelector('#apipay-error');
        ApiPay.init({{ publicKey: {json.dumps(public_key)} }}).checkout({{
          clientSecret: {json.dumps(orden.client_secret)},
          container: '#apipay-checkout',
          locale: 'es-CL',
          onSuccess: function (r) {{
            location.assign('/gracias?pi=' + encodeURIComponent(r.paymentIntentId));
          }},
          onError: function (e) {{
            // Decidir SIEMPRE por code, nunca por el texto del mensaje.
            errorBox.textContent = (e.code === 'intent_expired' || e.code === 'session_invalid')
              ? 'La sesion de pago expiro. Recarga la pagina.'
              : 'No pudimos procesar el pago. Prueba con otro medio.';
          }},
          onCancel: function () {{ location.assign('/carro'); }},
        }});
      }});
    </script>
  </body>
</html>"""

app/main.py#

"""Tres rutas: crear el intent y servir el widget, la pagina de gracias, y el webhook."""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

from apipay import (
    APIConnectionError,
    APIError,
    AsyncApiPay,
    ConfigurationError,
    InvalidPayloadError,
    SignatureVerificationError,
    construct_event,
)
from fastapi import FastAPI, Header, Request, Response
from fastapi.responses import HTMLResponse

from .config import CONFIG
from .pagina import pagina_checkout
from .store import Estado, obtener_o_crear, por_intent, registrar_evento

# Terminado en 00: la pasarela sandbox aprueba. Prueba con 05, 13 y 42.
AMOUNT_MINOR = 1_499_000
CURRENCY = "CLP"


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    # Un cliente por proceso. Una clave pk_ se rechazaria aqui, al construir.
    app.state.apipay = AsyncApiPay(CONFIG.secret_key, timeout=30.0, max_retries=2)
    try:
        yield
    finally:
        await app.state.apipay.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/checkout/{referencia}", response_class=HTMLResponse)
async def checkout(referencia: str, request: Request) -> HTMLResponse:
    """Crea el intent si hace falta y sirve la pagina del widget.

    Esta misma ruta es la pagina de RETORNO de las pasarelas con redireccion: se
    sirve otra vez con el mismo client_secret y el widget dispara el segundo
    confirm con el token_ws que copio de la URL.
    """
    client: AsyncApiPay = request.app.state.apipay
    orden = obtener_o_crear(referencia, AMOUNT_MINOR, CURRENCY)

    if orden.client_secret is None:
        # Clave estable derivada de la orden: si hay que reintentar, se reintenta con
        # ESTA, nunca con una nueva. El SDK no reintenta POST por diseno.
        clave = f"orden-{orden.referencia}-cobro"
        cuerpo = {
            "amount_minor": orden.amount_minor,
            "currency": orden.currency,
            "gateway_id": "sandbox",
            "description": f"Orden {orden.referencia}",
            "customer_email": "cliente@example.com",
            "return_url": f"{CONFIG.public_base_url}/checkout/{orden.referencia}",
            "metadata": {"order_id": orden.referencia},
        }
        try:
            creado = await client.payment_intents.create(cuerpo, idempotency_key=clave)
        except APIConnectionError:
            # No consta si llego. Reintentar con la MISMA clave es seguro: la API
            # devuelve la respuesta original en vez de crear un segundo cobro.
            creado = await client.payment_intents.create(cuerpo, idempotency_key=clave)
        except APIError as error:
            return HTMLResponse(
                f"<h1>No pudimos iniciar el pago</h1><p>code: {error.code}</p>",
                status_code=502,
            )

        intent = creado.resource
        orden.intent_id = intent["id"]
        orden.client_secret = intent.get("client_secret")
        orden.idempotency_key = creado.idempotency_key

    return HTMLResponse(pagina_checkout(orden, CONFIG.public_key))


@app.get("/gracias", response_class=HTMLResponse)
async def gracias(pi: str, request: Request) -> HTMLResponse:
    client: AsyncApiPay = request.app.state.apipay
    # Un GET si se reintenta solo ante 429 y 5xx, con backoff y jitter completo.
    intent = await client.payment_intents.retrieve(pi)
    orden = por_intent(intent["id"])
    estado_orden = orden.estado if orden is not None else "desconocida"
    return HTMLResponse(
        f"<h1>Gracias</h1><p>Pago <code>{intent['id']}</code> en estado "
        f"<strong>{intent['status']}</strong>.</p>"
        f"<p>Orden: <strong>{estado_orden}</strong> (la confirma el webhook).</p>"
    )


@app.post("/webhooks/apipay")
async def webhook(
    request: Request,
    apipay_signature: str = Header(default="", alias="ApiPay-Signature"),
) -> Response:
    # Bytes CRUDOS, antes de cualquier parseo. Nada de request.json() aqui.
    raw = await request.body()

    try:
        # Verifica t= y v1= (HMAC-SHA256 sobre "{t}." + bytes, tolerancia 300 s,
        # varios v1 durante la rotacion, comparacion en tiempo constante).
        event = construct_event(raw, apipay_signature, CONFIG.webhook_secret)
    except SignatureVerificationError:
        return Response(status_code=400)
    except InvalidPayloadError:
        return Response(status_code=400)
    except ConfigurationError:
        # Falta el secreto en el entorno: fallo de despliegue, no del emisor.
        return Response(status_code=500)

    # Deduplicar por evt_ ANTES de tocar la orden: la entrega es at-least-once y
    # la plataforma reintenta hasta cinco veces (1m, 5m, 30m, 2h, 12h).
    if not registrar_evento(event["id"]):
        return Response(status_code=200)

    recurso: dict[str, Any] = event["data"]["object"]
    tipo = event["type"]

    if tipo == "payment_intent.succeeded":
        _marcar(recurso.get("id", ""), "pagada")
    elif tipo in {
        "payment_intent.failed",
        "payment_intent.expired",
        "payment_intent.canceled",
    }:
        _marcar(recurso.get("id", ""), "fallida")
    elif tipo == "refund.succeeded":
        _marcar(recurso.get("payment_intent_id", ""), "reembolsada")
    # Los tipos nuevos del catalogo son un cambio ADITIVO: ignorar sin fallar.

    # 2xx rapido. Emails y facturacion van a una cola.
    return Response(status_code=200)


def _marcar(intent_id: str, estado: Estado) -> None:
    if not intent_id:
        return
    orden = por_intent(intent_id)
    if orden is not None:
        orden.estado = estado

Arrancar#

python -m venv .venv && . .venv/bin/activate     # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env                              # y rellena las claves de modo test

set -a && . ./.env && set +a                      # carga el .env en el entorno
uvicorn app.main:app --reload --port 8000

# En otra terminal, expon el puerto para que la plataforma alcance tu webhook:
# cloudflared tunnel --url http://127.0.0.1:8000
# ...y registra <la-url-publica>/webhooks/apipay en el backoffice.

Abre http://127.0.0.1:8000/checkout/4831 y paga.

Probar los cuatro desenlaces#

La pasarela sandbox decide por los dos últimos dígitos de amount_minor. Cambia AMOUNT_MINOR y usa otra referencia de orden:

AMOUNT_MINORQué pasaEstado final
1_499_000 (…00)Aprueba de inmediatoSUCCEEDED
1_499_005 (…05)402 card_declined en el confirmFAILED
1_499_013 (…13)Timeout; resuelve por webhook a los 60 sFAILED
1_499_042 (…42)REQUIRES_ACTION con redirect_urlSUCCEEDED o FAILED

Conciliación con el iterador de cursor#

Un cron de conciliación no necesita tocar cursor ni next_cursor jamás:

from datetime import UTC, datetime, timedelta

from apipay import ApiPay  # el cliente sincrono va bien para un job

desde = (datetime.now(UTC) - timedelta(days=1)).isoformat()

with ApiPay(CONFIG.secret_key) as client:
    pager = client.transactions.list({"status": "APPROVED", "from": desde, "limit": 100})
    for txn in pager:            # pide la pagina siguiente cuando hace falta
        conciliar(txn["id"], txn["amount_minor"], txn["currency"])

Errores comunes en este stack#

SíntomaCausa
La firma nunca cuadraSe usó await request.json() o un modelo Pydantic en el endpoint
La firma nunca cuadra, y se usa body()Un proxy inverso reescribe el cuerpo
SignatureVerificationError sólo en producciónReloj del servidor desfasado más de 300 s
RuntimeError: Falta la variable... al arrancarEl .env no se cargó en el entorno del proceso
La orden se marca pagada dos vecesFalta la deduplicación por event["id"]
ConfigurationError al construir el clienteSe puso la pk_ en APIPAY_SECRET_KEY

Siguientes pasos#

  • Webhooks — rotación de secreto, reintentos y deduplicación.
  • Referencia de SDKs — el cliente asíncrono y la política de reintentos.
  • Modo test — la tabla completa de sandbox.