FulaYaAPI
REST · JSON · HTTPS

Documentación de la API

Cobra en dólares por PayPal o tarjeta desde tu tienda, tu app o tu sistema, y deja que FulaYa entregue el dinero en Cuba: CUP, MLC, Clásica, saldo móvil o efectivo.

URL basehttps://api.fulaya.app/api/v1
  • Sandbox completo

    Claves fy_test_ y pagos simulados.

  • Webhooks firmados

    HMAC-SHA256 con rotación sin cortes.

  • Idempotente

    Reintenta sin cobrar dos veces.

Primeros pasos

Introducción

Cobra en dólares por PayPal o tarjeta y deja que tu cliente o beneficiario reciba en moneda cubana: CUP por transferencia, MLC, tarjeta Clásica, saldo móvil o efectivo a domicilio.

La API de FulaYa es una API REST con respuestas JSON. Tu servidor crea un cobro (payment_intent) con el importe en USD; FulaYa te devuelve una checkout_url alojada en fulaya.app donde el comprador paga, y se encarga de convertir el neto y entregarlo en la moneda que el beneficiario configuró.

  1. 1

    Tu servidor

    Creas el cobro

    POST /store/payment-intents con el importe y tu id de pedido.

  2. 2

    Tu web o app

    Rediriges al checkout

    Envías al comprador a la checkout_url que devuelve la API.

  3. 3

    FulaYa → tu servidor

    Recibes el webhook

    payment_intent.paid llega firmado cuando el dinero ya entró.

  4. 4

    Opcional

    Consultas el estado

    GET /store/payment-intents/{id} cuando quieras confirmarlo.

DatoValor
URL basehttps://api.fulaya.app/api/v1
Checkout alojadohttps://fulaya.app/pay/{id}
Versión actual2026-10-01
FormatoJSON · UTF-8 · HTTPS obligatorio

Primeros pasos

Inicio rápido

Tu primer cobro de prueba en cinco minutos, sin mover dinero real.

  1. 1

    Crea tu cuenta

    Regístrate en fulaya.app. Con la cuenta ya puedes trabajar en modo prueba.

  2. 2

    Configura tu método de cobro

    En Panel → Métodos indica dónde recibes la moneda cubana. Sin método, la API responde 409 payout_method_missing al crear cobros.

  3. 3

    Crea una clave de prueba

    En Panel → Desarrollo genera una clave TEST (fy_test_…); ahí mismo crearás la LIVE cuando pases a producción. Copia el secreto en ese momento: no se vuelve a mostrar. Guárdalo como variable de entorno de tu servidor.

    Shell
    export FULAYA_API_KEY="fy_test_ab12cd34ef56ab78:SECRETO"
  4. 4

    Crea tu primer cobro

    Devuelve un objeto payment_intent con su checkout_url.

    curl -X POST "https://api.fulaya.app/api/v1/store/payment-intents" \
      -H "Authorization: Bearer $FULAYA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: pedido-1042" \
      -d '{
        "amount_usd": 25,
        "concept": "Pedido #1042",
        "reference": "1042"
      }'
  5. 5

    Simula el pago

    Abre la checkout_url y pulsa Simular pago aprobado, o llama a /simulate desde tu servidor.

    curl -X POST "https://api.fulaya.app/api/v1/store/payment-intents/pi_8fK2mQx71LdA/simulate" \
      -H "Authorization: Bearer $FULAYA_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "outcome": "paid"
      }'
  6. 6

    Recibe el webhook

    Registra un destino en Panel → Desarrollo —sin usar la API key— o por la API de Webhooks, y verás llegar payment_intent.paid firmado. Verifica la firma y marca el pedido como pagado.

Primeros pasos

Autenticación

Cada petición lleva tu clave en la cabecera Authorization como token Bearer.

HTTP
GET /api/v1/store/account HTTP/1.1
Host: api.fulaya.app
Authorization: Bearer fy_test_xxxxxxxxxxxxxxxx:SECRETO

La clave tiene el formato {prefijo}:{secreto}. El prefijo (fy_test_… o fy_live_…) identifica la clave y su entorno; el secreto es lo que la autentica.

  • El secreto solo se muestra una vez, al crear la clave. Si lo pierdes, crea otra y revoca la anterior.
  • Guárdalo en una variable de entorno de tu servidor. Nunca lo incluyas en el navegador, en una app móvil ni en un repositorio.
  • Revocar una clave desde el panel tiene efecto inmediato: la siguiente petición con ella falla con 401 key_revoked.
HTTPCódigoCuándo
401authentication_failedClave ausente, mal formada o incorrecta. Siempre el mismo mensaje, para no dar pistas.
401key_revokedLa clave existió pero fue revocada.
403account_disabledLa cuenta del comercio está suspendida: ninguna de sus claves funciona.
429too_many_auth_failuresMás de 10 fallos de autenticación por minuto desde la misma IP o contra la misma clave.

Primeros pasos

Entornos

Pruebas y producción son dos mundos separados: misma API, distintas claves y datos aislados.

PruebasProducción
Prefijo de clavefy_test_fy_live_
livemodefalsetrue
DineroNo se mueve dinero realDinero real
CheckoutBanner «Modo prueba» y botón «Simular pago aprobado»Pago real por PayPal o tarjeta
/simulateDisponible403 livemode_forbidden

Checklist de paso a producción

  • Crea una clave fy_live_ y guárdala como secreto del servidor de producción.
  • Registra un destino de webhook live: tiene su propio secreto whsec_…, distinto del de pruebas.
  • Verifica la firma Fulaya-Signature en cada evento antes de confiar en él.
  • Envía Idempotency-Key al crear cobros (el id de tu pedido es ideal).
  • Maneja reintentos y duplicados: deduplica por id de evento.
  • Sirve tu endpoint de webhooks y tus return_url por HTTPS.

Fundamentos

Idempotencia

Reintenta sin miedo a cobrar dos veces: la misma clave de idempotencia devuelve el mismo cobro.

Envía la cabecera Idempotency-Key (de 1 a 255 caracteres ASCII imprimibles) en POST /store/payment-intents. FulaYa guarda la clave junto con una huella del cuerpo de la petición.

SituaciónResultado
Misma clave y mismo cuerpoDevuelve el cobro original con la cabecera Idempotent-Replayed: true.
Misma clave y cuerpo distinto422 idempotency_key_reused.
Clave vacía, demasiado larga o con caracteres no imprimibles400 idempotency_key_invalid.
  • El ámbito es por API key y las claves no caducan.
  • Recomendación: usa el id de tu pedido (pedido-1042). Así un doble clic o un reintento de red nunca crea un segundo cobro.
  • Las cancelaciones (/cancel) son idempotentes por naturaleza: cancelar dos veces devuelve el mismo cobro cancelado.
Respuesta repetida
HTTP
HTTP/1.1 201 Created
Idempotent-Replayed: true
X-Request-Id: req_6sYk2Lw9QeTb
Content-Type: application/json

Fundamentos

Límites de uso

Cupos por capas, en ventanas de 60 segundos. Las lecturas y las escrituras cuentan por separado.

ÁmbitoLecturasEscriturasNotas
Por IP300300Peticiones totales por IP, sumando lecturas y escrituras.
Por API key30060Cada clave por separado.
Por cuenta600120Suma de todas tus claves del mismo entorno.
Simulaciones—30/simulate, por clave.
Fallos de autenticación—10Por IP; después 429 too_many_auth_failures.

Pruebas y producción tienen cupos de cuenta separados: saturar el sandbox nunca frena tus cobros reales.

CabeceraSignificado
X-RateLimit-LimitCupo de la ventana que más se está acercando a su límite.
X-RateLimit-RemainingPeticiones que te quedan en esa ventana.
X-RateLimit-ResetSegundos hasta que la ventana se reinicia.
Retry-AfterSolo en 429 rate_limited: segundos que debes esperar.

Fundamentos

Errores

Códigos HTTP convencionales y un cuerpo de error estable que tu código puede leer.

Cuerpo de error
JSON
{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_invalid",
    "message": "amount_usd debe estar entre 1 y 5000.",
    "param": "amount_usd"
  },
  "detail": "amount_usd debe estar entre 1 y 5000.",
  "request_id": "req_6sYk2Lw9QeTb"
}

Cada respuesta incluye la cabecera X-Request-Id, igual al campo request_id. Inclúyelo cuando escribas a soporte: nos permite encontrar tu petición al instante. detail repite el mensaje por compatibilidad; programa contra error.code.

typeHTTPSignificado
authentication_error401La clave falta, es incorrecta o fue revocada.
permission_error403La clave es válida pero no puede hacer esa operación.
invalid_request_error400 / 404 / 409 / 422Parámetros, recurso inexistente o estado incompatible.
rate_limit_error429Superaste un límite de uso.
api_error5xxFallo nuestro. Reintenta con backoff.

Códigos

codeHTTPQué hacer
authentication_failed401Revisa el formato {prefijo}:{secreto} y la variable de entorno.
key_revoked401Crea una clave nueva en el panel.
account_disabled403Tu cuenta está suspendida. Escribe a soporte.
too_many_auth_failures429Espera un minuto; revisa qué proceso envía credenciales malas.
livemode_forbidden403Operación solo de pruebas (p. ej. /simulate) llamada con clave live.
parameter_invalid400 / 422Corrige el campo indicado en error.param.
resource_missing404El id no existe o pertenece al otro entorno.
payout_method_missing409Configura tu método de cobro en Panel → Métodos.
intent_not_cancellable409El cobro ya está pagado o cerrado.
idempotency_key_reused422Usa una clave nueva o repite el cuerpo original.
idempotency_key_invalid4001–255 caracteres ASCII imprimibles.
url_not_allowed400La URL no es HTTPS o apunta a un host privado o local.
webhook_endpoint_limit409Ya tienes 10 destinos en este entorno; borra alguno.
rate_limited429Respeta Retry-After.
internal_error500Reintenta con backoff y, si persiste, escríbenos con el request_id.

Referencia

Cuenta

Comprueba con qué comercio y entorno está autenticada tu clave.

Obtener la cuenta

GET/store/account

Ideal como comprobación de salud al arrancar tu servidor: confirma que la clave es válida, su entorno y si ya tienes método de cobro.

Petición
curl "https://api.fulaya.app/api/v1/store/account" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "object": "account",
  "merchant": {
    "display_name": "Mi Tienda",
    "kind": "store",
    "verified": false
  },
  "key": {
    "prefix": "fy_test_ab12cd34ef56ab78",
    "livemode": false,
    "label": "Servidor"
  },
  "payout_configured": true,
  "rate_limits": {
    "window_seconds": 60,
    "ip": 300,
    "key": {
      "read": 300,
      "write": 60
    },
    "account": {
      "read": 600,
      "write": 120
    },
    "simulate": 30
  }
}

Campos destacados

ParámetroDescripción
merchant.kindenum
freelancer, creator, remittance o store.
payout_configuredboolean
false si aún no configuraste método de cobro: crear cobros fallará con 409 payout_method_missing.
rate_limitsobject
Tus cupos por ventana de window_seconds: por IP, por clave, por cuenta y de simulaciones.

Referencia

Cobros

El objeto payment_intent representa un cobro en USD de principio a fin, desde que lo creas hasta que el beneficiario recibe la moneda cubana.

El objeto payment_intent

ParámetroDescripción
idstring
Identificador con prefijo pi_.
objectstring
Siempre payment_intent.
statusenum
Estado actual. Ver estados.
livemodeboolean
true en producción, false en pruebas.
amount_usdnumber
Importe en USD que paga el comprador.
checkout_urlstring
Página alojada a la que rediriges al comprador.
referencestring | null
Tu id de pedido.
conceptstring | null
Texto que ve el comprador en el checkout.
metadataobject
Tus datos libres, devueltos tal cual.
return_urlstring | null
Adónde vuelve el comprador tras pagar.
cancel_urlstring | null
Adónde vuelve si abandona.
created_atdatetime
ISO 8601, UTC.
paid_atdatetime | null
Momento en que el dinero del comprador entró.
payoutobject | null
{ amount, coin_tick, applied_rate }: lo que recibe el beneficiario, en qué moneda y a qué tasa. null hasta que hay cotización; en los eventos de webhook siempre viene el objeto.
Ejemplo
JSON
{
  "id": "pi_8fK2mQx71LdA",
  "object": "payment_intent",
  "status": "PAID",
  "livemode": false,
  "amount_usd": 25,
  "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
  "reference": "1042",
  "concept": "Pedido #1042",
  "metadata": {
    "cliente": "77"
  },
  "return_url": "https://tienda.example/gracias",
  "cancel_url": "https://tienda.example/carrito",
  "created_at": "2026-10-01T14:03:11Z",
  "paid_at": "2026-10-01T14:06:52Z",
  "payout": {
    "amount": 9125,
    "coin_tick": "BANK_CUP",
    "applied_rate": 365
  }
}

Crear un cobro

POST/store/payment-intents

Crea el cobro y devuelve su checkout_url. Envía siempre Idempotency-Key.

Cuerpo

ParámetroDescripción
amount_usdnumberrequerido
Entre 1 y 5000, máximo 2 decimales.
conceptstring
Hasta 200 caracteres.
referencestring
Tu id de pedido, hasta 120 caracteres.
customer_emailemail
Correo del comprador, opcional.
return_urlurl
HTTPS. En pruebas se admite http://localhost.
cancel_urlurl
HTTPS. En pruebas se admite http://localhost.
metadataobject
Hasta 20 claves; valores string, número o booleano de hasta 500 caracteres; 4 KB en total.
Petición
curl -X POST "https://api.fulaya.app/api/v1/store/payment-intents" \
  -H "Authorization: Bearer $FULAYA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-1042" \
  -d '{
    "amount_usd": 25,
    "concept": "Pedido #1042",
    "reference": "1042",
    "customer_email": "cliente@example.com",
    "return_url": "https://tienda.example/gracias",
    "cancel_url": "https://tienda.example/carrito",
    "metadata": {
      "cliente": "77"
    }
  }'
Respuesta201
JSON
{
  "id": "pi_8fK2mQx71LdA",
  "object": "payment_intent",
  "status": "CREATED",
  "livemode": false,
  "amount_usd": 25,
  "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
  "reference": "1042",
  "concept": "Pedido #1042",
  "metadata": {
    "cliente": "77"
  },
  "return_url": "https://tienda.example/gracias",
  "cancel_url": "https://tienda.example/carrito",
  "created_at": "2026-10-01T14:03:11Z",
  "paid_at": null,
  "payout": null
}

Obtener un cobro

GET/store/payment-intents/{id}

Devuelve el estado actual. Úsalo para confirmar un webhook o cuando el comprador vuelve por return_url.

Petición
curl "https://api.fulaya.app/api/v1/store/payment-intents/pi_8fK2mQx71LdA" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "id": "pi_8fK2mQx71LdA",
  "object": "payment_intent",
  "status": "PAID",
  "livemode": false,
  "amount_usd": 25,
  "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
  "reference": "1042",
  "concept": "Pedido #1042",
  "metadata": {
    "cliente": "77"
  },
  "return_url": "https://tienda.example/gracias",
  "cancel_url": "https://tienda.example/carrito",
  "created_at": "2026-10-01T14:03:11Z",
  "paid_at": "2026-10-01T14:06:52Z",
  "payout": {
    "amount": 9125,
    "coin_tick": "BANK_CUP",
    "applied_rate": 365
  }
}

Listar cobros

GET/store/payment-intents

Lista paginada por cursor, del más reciente al más antiguo.

Parámetros de consulta

ParámetroDescripción
limitinteger
De 1 a 100. Por defecto 20.
starting_afterstring
Cursor: el next_cursor de la página anterior.
statusenum
Filtra por estado, p. ej. PAID.
referencestring
Filtra por tu id de pedido.
Petición
curl "https://api.fulaya.app/api/v1/store/payment-intents?limit=20&starting_after=pi_8fK2mQx71LdA&status=PAID&reference=1042" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "object": "list",
  "data": [
    {
      "id": "pi_8fK2mQx71LdA",
      "object": "payment_intent",
      "status": "PAID",
      "livemode": false,
      "amount_usd": 25,
      "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
      "reference": "1042",
      "concept": "Pedido #1042",
      "metadata": {
        "cliente": "77"
      },
      "return_url": "https://tienda.example/gracias",
      "cancel_url": "https://tienda.example/carrito",
      "created_at": "2026-10-01T14:03:11Z",
      "paid_at": "2026-10-01T14:06:52Z",
      "payout": {
        "amount": 9125,
        "coin_tick": "BANK_CUP",
        "applied_rate": 365
      }
    }
  ],
  "has_more": true,
  "next_cursor": "pi_7cW1nRt20KeB"
}

Cancelar un cobro

POST/store/payment-intents/{id}/cancel

Solo mientras no esté pagado. Si ya lo está, responde 409 intent_not_cancellable. Es idempotente.

Petición
curl -X POST "https://api.fulaya.app/api/v1/store/payment-intents/pi_8fK2mQx71LdA/cancel" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "id": "pi_8fK2mQx71LdA",
  "object": "payment_intent",
  "status": "CANCELLED",
  "livemode": false,
  "amount_usd": 25,
  "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
  "reference": "1042",
  "concept": "Pedido #1042",
  "metadata": {
    "cliente": "77"
  },
  "return_url": "https://tienda.example/gracias",
  "cancel_url": "https://tienda.example/carrito",
  "created_at": "2026-10-01T14:03:11Z",
  "paid_at": null,
  "payout": null
}

Simular un desenlace

POST/store/payment-intents/{id}/simulate

Solo pruebas. Recorre los estados intermedios hasta el desenlace pedido y emite los webhooks correspondientes. La respuesta añade simulated_steps con los estados recorridos. Con clave live responde 403 livemode_forbidden.

Cuerpo

ParámetroDescripción
outcomeenumrequerido
paid, completed, expired, disputed o refund_pending.
Petición
curl -X POST "https://api.fulaya.app/api/v1/store/payment-intents/pi_8fK2mQx71LdA/simulate" \
  -H "Authorization: Bearer $FULAYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "outcome": "paid"
  }'
Respuesta200
JSON
{
  "id": "pi_8fK2mQx71LdA",
  "object": "payment_intent",
  "status": "PAID",
  "livemode": false,
  "amount_usd": 25,
  "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
  "reference": "1042",
  "concept": "Pedido #1042",
  "metadata": {
    "cliente": "77"
  },
  "return_url": "https://tienda.example/gracias",
  "cancel_url": "https://tienda.example/carrito",
  "created_at": "2026-10-01T14:03:11Z",
  "paid_at": "2026-10-01T14:06:52Z",
  "payout": {
    "amount": 9125,
    "coin_tick": "BANK_CUP",
    "applied_rate": 365
  },
  "simulated_steps": [
    "QUOTE_LOCKED",
    "AWAITING_PAYMENT",
    "PAID"
  ]
}

Estados del cobro

EstadoSignificado
CREATEDCobro creado; el comprador aún no abrió el checkout.
QUOTE_LOCKEDTasa fijada para este cobro.
AWAITING_PAYMENTEl comprador está en el checkout.
PAIDEl dinero del comprador ya entró. Aquí marcas tu pedido como pagado.
PAYOUT_QUEUEDEntrega al beneficiario en cola.
PAYOUT_LISTEDOferta de entrega publicada en el mercado P2P.
PAYOUT_MATCHEDUn comprador P2P tomó la oferta y está pagando al beneficiario.
AWAITING_ADMIN_REVIEWRevisando el comprobante de la entrega.
COMPLETEDEl beneficiario ya recibió la moneda cubana.
EXPIREDEl comprador no pagó a tiempo.
CANCELLEDCancelado antes del pago.
DISPUTEDEl pago está en disputa con el procesador.
REFUND_PENDINGReembolso al comprador en curso.
CASH_PENDING_DELIVERYEntrega en efectivo pendiente de salir.
CASH_OUT_FOR_DELIVERYEl mensajero va camino del beneficiario.
CASH_DELIVEREDEfectivo entregado en mano.

Referencia

Tasas

Tasas indicativas por moneda de destino, para mostrar a tu cliente cuánto recibirá.

Obtener las tasas

GET/store/rates

Son indicativas: la tasa definitiva se fija en cada cobro (QUOTE_LOCKED) y queda en payout.applied_rate.

Petición
curl "https://api.fulaya.app/api/v1/store/rates" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "object": "list",
  "rates": [
    {
      "coin_tick": "BANK_CUP",
      "coin_label": "CUP por transferencia",
      "unit": "CUP",
      "decimals": 2,
      "market_rate": 372,
      "applied_rate": 365,
      "rate_source": "p2p",
      "per_100_usd": 36500
    },
    {
      "coin_tick": "MLC",
      "coin_label": "MLC",
      "unit": "MLC",
      "decimals": 2,
      "market_rate": 1.12,
      "applied_rate": 1.1,
      "rate_source": "p2p",
      "per_100_usd": 110
    }
  ],
  "indicative": true
}

Referencia

Webhooks

FulaYa avisa a tu servidor de cada cambio relevante con una petición POST firmada.

Eventos

EventoCuándo
payment_intent.createdSe creó un cobro.
payment_intent.paidEl dinero del comprador entró. Marca el pedido como pagado.
payment_intent.completedEl beneficiario recibió la moneda cubana.
payment_intent.expiredEl comprador no pagó a tiempo.
payment_intent.cancelledEl cobro se canceló.
payment_intent.disputedEl pago entró en disputa.
payment_intent.refund_pendingSe inició un reembolso.
webhook.pingEvento de prueba enviado desde /test.
Payload
JSON
{
  "id": "evt_3Jd9QwPz0kLm",
  "type": "payment_intent.paid",
  "created": 1759500000,
  "livemode": false,
  "api_version": "2026-10-01",
  "data": {
    "id": "pi_8fK2mQx71LdA",
    "object": "payment_intent",
    "status": "PAID",
    "livemode": false,
    "amount_usd": 25,
    "checkout_url": "https://fulaya.app/pay/pi_8fK2mQx71LdA",
    "reference": "1042",
    "concept": "Pedido #1042",
    "metadata": {
      "cliente": "77"
    },
    "return_url": "https://tienda.example/gracias",
    "cancel_url": "https://tienda.example/carrito",
    "created_at": "2026-10-01T14:03:11Z",
    "paid_at": "2026-10-01T14:06:52Z",
    "payout": {
      "amount": 9125,
      "coin_tick": "BANK_CUP",
      "applied_rate": 365
    }
  }
}
Evento de prueba webhook.ping
JSON
{
  "id": "evt_7Rb2VxQm4aHt",
  "type": "webhook.ping",
  "created": 1759500100,
  "livemode": false,
  "api_version": "2026-10-01",
  "data": {
    "endpoint_id": 42,
    "message": "Hola desde FulaYa"
  }
}

Cabeceras enviadas

CabeceraValor
Fulaya-Signaturet=1759500000,v1=<hex>. Durante una rotación puede traer varios v1=: acepta si cualquiera coincide.
Fulaya-EventTipo de evento, p. ej. payment_intent.paid.
Fulaya-Event-IdId del evento (evt_…), estable entre reintentos.
Fulaya-Delivery-AttemptNúmero de intento, empezando por 1.
User-AgentFulaYa-Webhooks/1.0
Content-Typeapplication/json

Verificar la firma

  1. Lee el cuerpo crudo, antes de parsear el JSON.
  2. Extrae t y todos los v1 de Fulaya-Signature.
  3. Calcula HMAC-SHA256 con tu secreto whsec_… sobre la cadena "{t}.{cuerpo_crudo}", en hexadecimal.
  4. Compara en tiempo constante con cada v1; basta con que uno coincida.
  5. Rechaza el evento si |ahora − t| > 300 segundos (protección contra repetición).
Endpoint de webhooks con verificación
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.FULAYA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 300;

function verifySignature(rawBody, header) {
  if (!header) return false;
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!Number.isFinite(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;

  const expected = Buffer.from(
    crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex"),
    "hex",
  );
  // Durante una rotación llegan dos v1: basta con que uno coincida.
  return signatures.some((sig) => {
    const received = Buffer.from(sig, "hex");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

// express.raw y no express.json: la firma se calcula sobre el cuerpo CRUDO.
app.post("/webhooks/fulaya", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verifySignature(rawBody, req.get("Fulaya-Signature"))) {
    return res.status(400).send("firma inválida");
  }
  const event = JSON.parse(rawBody);
  res.sendStatus(200); // responde ya, procesa después
  enqueue(event); // deduplica por event.id
});

Entrega y reintentos

  • Responde 2xx en menos de 10 s. Guarda el evento y procésalo de forma asíncrona.
  • La entrega es al menos una vez y sin orden garantizado: deduplica por id de evento y, si dudas del estado, consulta GET /store/payment-intents/{id}.
  • Reintentos ante error o timeout: inmediato, 30 s, 2 min, 10 min, 1 h, 6 h y 24 h.
  • Si se agotan, el destino queda DEGRADED (verás consecutive_failures y last_failure_at). No se desactiva solo: puedes reenviar entregas a mano con /retry.
  • Máximo 10 destinos por entorno.

Listar destinos

GET/store/webhook-endpoints

Destinos registrados en el entorno de la clave. Nunca devuelve el secreto.

Petición
curl "https://api.fulaya.app/api/v1/store/webhook-endpoints" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "endpoints": [
    {
      "id": 42,
      "object": "webhook_endpoint",
      "url": "https://tienda.example/webhooks/fulaya",
      "events": "payment_intent.paid,payment_intent.completed",
      "status": "ACTIVE",
      "livemode": false,
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "created_at": "2026-10-01T14:10:00Z"
    }
  ]
}

Crear un destino

POST/store/webhook-endpoints

Devuelve el secret (whsec_…) una sola vez. Guárdalo en el servidor que recibe los eventos.

Cuerpo

ParámetroDescripción
urlurlrequerido
HTTPS, puerto 443 y host público. Se rechazan IP privadas, localhost, otros puertos y URL con usuario/contraseña (400 url_not_allowed). El dominio se vuelve a comprobar en cada entrega.
eventsstring
Lista separada por comas, p. ej. payment_intent.paid,payment_intent.completed. Cadena vacía = todos (el destino lo muestra como *).
Petición
curl -X POST "https://api.fulaya.app/api/v1/store/webhook-endpoints" \
  -H "Authorization: Bearer $FULAYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tienda.example/webhooks/fulaya",
    "events": "payment_intent.paid,payment_intent.completed"
  }'
Respuesta201
JSON
{
  "id": 42,
  "object": "webhook_endpoint",
  "url": "https://tienda.example/webhooks/fulaya",
  "events": "payment_intent.paid,payment_intent.completed",
  "status": "ACTIVE",
  "livemode": false,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-10-01T14:10:00Z",
  "secret": "whsec_9b1f…",
  "signature_header": "Fulaya-Signature",
  "signature_tolerance_seconds": 300
}

Borrar un destino

DELETE/store/webhook-endpoints/{id}

Deja de enviar eventos a esa URL de inmediato.

Petición
curl -X DELETE "https://api.fulaya.app/api/v1/store/webhook-endpoints/42" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "deleted": 42
}

Rotar el secreto

POST/store/webhook-endpoints/{id}/rotate-secret

Devuelve un secreto nuevo. El anterior sigue firmando durante 24 h, así que puedes desplegar el nuevo sin perder eventos.

Petición
curl -X POST "https://api.fulaya.app/api/v1/store/webhook-endpoints/42/rotate-secret" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "id": 42,
  "object": "webhook_endpoint",
  "url": "https://tienda.example/webhooks/fulaya",
  "events": "payment_intent.paid,payment_intent.completed",
  "status": "ACTIVE",
  "livemode": false,
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-10-01T14:10:00Z",
  "secret": "whsec_4e7a…",
  "previous_secret_expires_at": "2026-10-02T14:10:00Z"
}

Enviar un evento de prueba

POST/store/webhook-endpoints/{id}/test

Envía un webhook.ping firmado para comprobar tu verificación. Responde 202 con la entrega en cola.

Petición
curl -X POST "https://api.fulaya.app/api/v1/store/webhook-endpoints/42/test" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta202
JSON
{
  "id": 981,
  "object": "webhook_delivery",
  "endpoint_id": 42,
  "event_id": "evt_3Jd9QwPz0kLm",
  "event_type": "payment_intent.paid",
  "payment_intent": "pi_8fK2mQx71LdA",
  "status": "PENDING",
  "attempts": 0,
  "last_status_code": null,
  "last_error": null,
  "next_attempt_at": null,
  "delivered_at": null,
  "created_at": "2026-10-01T14:06:52Z"
}

Historial de entregas

GET/store/webhook-deliveries

Cada intento de entrega con su status (PENDING, SENT, FAILED, ABANDONED), attempts y last_status_code.

Parámetros de consulta

ParámetroDescripción
limitinteger
De 1 a 100. Por defecto 20.
endpoint_idinteger
Filtra por destino.
Petición
curl "https://api.fulaya.app/api/v1/store/webhook-deliveries?limit=20&endpoint_id=42" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta200
JSON
{
  "object": "list",
  "data": [
    {
      "id": 981,
      "object": "webhook_delivery",
      "endpoint_id": 42,
      "event_id": "evt_3Jd9QwPz0kLm",
      "event_type": "payment_intent.paid",
      "payment_intent": "pi_8fK2mQx71LdA",
      "status": "SENT",
      "attempts": 1,
      "last_status_code": 200,
      "last_error": null,
      "next_attempt_at": null,
      "delivered_at": "2026-10-01T14:06:53Z",
      "created_at": "2026-10-01T14:06:52Z"
    }
  ]
}

Reintentar una entrega

POST/store/webhook-deliveries/{id}/retry

Vuelve a enviar el mismo evento, con el mismo id, al destino original. Responde 202 con la entrega.

Petición
curl -X POST "https://api.fulaya.app/api/v1/store/webhook-deliveries/981/retry" \
  -H "Authorization: Bearer $FULAYA_API_KEY"
Respuesta202
JSON
{
  "id": 981,
  "object": "webhook_delivery",
  "endpoint_id": 42,
  "event_id": "evt_3Jd9QwPz0kLm",
  "event_type": "payment_intent.paid",
  "payment_intent": "pi_8fK2mQx71LdA",
  "status": "PENDING",
  "attempts": 2,
  "last_status_code": 200,
  "last_error": null,
  "next_attempt_at": "2026-10-01T14:20:00Z",
  "delivered_at": "2026-10-01T14:06:53Z",
  "created_at": "2026-10-01T14:06:52Z"
}

Más

Seguridad

Buenas prácticas para que una integración con dinero real no tenga sorpresas.

  • Las claves viven solo en tu servidor, en variables de entorno o un gestor de secretos.
  • Rota las claves periódicamente y siempre que alguien con acceso deje el equipo.
  • Usa una clave por tienda y por entorno: si una se filtra, revocas solo esa.
  • Aplica el mínimo privilegio: solo los procesos que crean cobros necesitan la clave.
  • No registres secretos ni cabeceras Authorization en tus logs.
  • Verifica la firma de cada webhook antes de confiar en su contenido.
  • Exige HTTPS en tu endpoint de webhooks y en tus return_url.
  • Nunca pidas ni envíes datos de tarjeta: el pago ocurre en el checkout de FulaYa.

Más

Changelog

VersiónCambios
2026-10-01v1. Sandbox con fy_test_ y /simulate, idempotencia con huella del cuerpo, límites de uso por capas (IP, clave y cuenta), rotación de secretos de webhook con 24 h de solape y listados paginados por cursor.

Más

Términos de uso de la API

Borrador pendiente de revisión legal

  1. 01Aceptación

    Al crear una clave de API o usar la API de FulaYa aceptas estos términos, además de los Términos y la Política de privacidad generales de FulaYa. Si integras la API en nombre de una empresa, declaras que puedes obligarla a cumplirlos.

  2. 02Uso permitido

    Solo puedes usar la API para crear y gestionar cobros, destinos de webhook y consultas de tu propio comercio, siguiendo esta documentación.

  3. 03Usos prohibidos

    No puedes: usar la API para actividades ilícitas, fraude, lavado de dinero o bienes y servicios prohibidos; revender, sublicenciar o compartir tu acceso o tus claves; hacer scraping de la plataforma o sacar datos fuera de los endpoints documentados; eludir o repartir entre varias claves, cuentas o IP los límites de uso; operar en nombre de terceros sin su autorización expresa y verificable.

  4. 04Credenciales y secretos

    Eres responsable de guardar de forma segura tus claves de API y los secretos de firma de webhook, y de todo lo que se haga con ellos. Si sospechas que una clave se ha filtrado, revócala o rota el secreto de inmediato desde el panel y avísanos. Debes verificar la firma Fulaya-Signature antes de dar por bueno un webhook.

  5. 05Entorno de pruebas

    Las claves de prueba (livemode=false) no mueven dinero real, no publican ofertas y no generan obligaciones de pago. Los cobros simulados no tienen valor monetario ni sirven como comprobante.

  6. 06Tasas indicativas

    Las tasas de cambio y los importes estimados que devuelve la API son orientativos. El importe que se liquida depende de la tasa y las comisiones vigentes cuando se confirma el pago y se ejecuta la oferta P2P, y puede ser distinto del estimado.

  7. 07Límites de uso

    La API aplica límites por IP, por clave y por cuenta. Si los superas, recibirás respuestas 429. Podemos cambiar estos límites para proteger el servicio.

  8. 08Disponibilidad y cambios

    La API se ofrece «tal cual» y no garantizamos un nivel de servicio (SLA) ni que funcione sin interrupciones. Los cambios que rompan la compatibilidad de la v1 se anunciarán con antelación razonable por correo o en esta página, salvo los urgentes por seguridad o por obligación legal.

  9. 09Suspensión

    Podemos limitar, suspender o revocar claves y cuentas, sin aviso previo, si detectamos abuso, riesgo de fraude, incumplimiento de estos términos o un requerimiento de nuestros proveedores o de una autoridad. Mientras se aclare la situación, podemos retener los cobros afectados.

  10. 10Protección de datos

    Envía solo los datos personales estrictamente necesarios para cada cobro. Nunca envíes números de tarjeta, CVV ni credenciales de PayPal o QvaPay. Si nos transmites datos de tus compradores o beneficiarios, garantizas que tienes base legal para hacerlo y que les has informado. FulaYa trata esos datos según su Política de privacidad.

  11. 11Dependencia de terceros

    Los cobros dependen de QvaPay y PayPal, y la liquidación depende de que contrapartes P2P acepten las ofertas. El uso de esos servicios se rige además por sus propios términos. FulaYa no responde por sus caídas, bloqueos, retenciones, cambios de condiciones ni por la conducta de las contrapartes P2P.

  12. 12Limitación de responsabilidad

    En la medida en que la ley lo permita, FulaYa no responde por daños indirectos, lucro cesante ni pérdida de datos derivados del uso de la API. Su responsabilidad total queda limitada a las comisiones que hayas pagado a FulaYa en los [3/12] meses anteriores al hecho.

  13. 13Modificación de los términos

    Podemos actualizar estos términos. La versión y la fecha vigentes se publican en esta página, y seguir usando la API después del aviso implica aceptar los cambios.

  14. 14Contacto

    Para consultas, incidentes de seguridad o avisos legales escribe a soporte@fulaya.app.

Versión 1.0 — Prestador: [razón social, forma jurídica y domicilio] — Ley aplicable y fuero: [a definir].