docs
Webhooks V2

TRANSFER

Visión General

El webhook TRANSFER se envía a las URLs registradas en el evento cash_out con el formato PIX BACEN, cuando se procesa una transferencia PIX iniciada por su aplicación en POST /api/dict/pix. Informa el resultado: éxito (LIQUIDATED) o falla (ERROR).

Cuándo se envía

  • Transferencia PIX liquidada (LIQUIDATED)
  • Transferencia PIX rechazada o con falla (ERROR)

Garantía de estado final

Toda transferencia aceptada (HTTP 201) que sea rechazada de forma definitiva por el proveedor de pago emite un webhook TRANSFER con status: ERROR y data.errorCode. El rechazo del proveedor nunca es silencioso.

De ahí se deriva el contrato de lectura de la respuesta síncrona de POST /api/dict/pix:

  • 4xx síncrono = rechazo declarado, estado FINAL de la solicitud enviada. O la solicitud fue rechazada antes de que existiera la transferencia (ej.: 400 PIX_INSUFFICIENT_BALANCE, 400 PIX_DUPLICATE_EXTERNAL_ID, 422 PIX_KEY_NOT_FOUND), y en ese caso no hay webhook, porque no se creó ninguna transferencia, o el proveedor rechazó la transferencia ya creada, y entonces el webhook ERROR también llega. En ambos casos puede cerrar la solicitud de inmediato. Corregir e intentar de nuevo con un nuevo x-idempotency-key vale solo para el rechazo anterior a la creación; cuando el rechazo vino del proveedor, espere el webhook ERROR antes de cualquier nuevo intento. Excepción: 429 TOO_MANY_PIX_KEYS_NOT_FOUND y 422 PIX_KEY_NOT_FOUND con details.cached: true son rechazos anteriores a la creación de la solicitud. No se creó nada y el envío puede repetirse después del Retry-After.
  • 5xx síncrono nunca es estado final. Significa "indeterminado": el pago puede haber salido. No cancele la solicitud ni la trate como falla. Espere el webhook TRANSFER (LIQUIDATED o ERROR), correlacionando por data.idempotencyKey. Intentar de nuevo con otra clave de idempotencia genera riesgo de pago duplicado.
  • El estado final viene solo del webhook, nunca se infiere de un 5xx síncrono.

La garantía anterior cubre el rechazo definitivo del proveedor. Fallas internas de la plataforma pueden cerrar la transferencia como ERROR sin emitir webhook. Si recibió un 5xx síncrono y ningún webhook llegó en algunos minutos, consulte la transacción en GET https://api.fypay.me/api/pix/transaction/{id} (con el x-idempotency-key) o contacte al soporte en suporte@fypay.me [POR DEFINIR] informando el x-idempotency-key. No intente de nuevo por otro medio sin esa confirmación.

Estructura del Payload

{
  "data": {
    "id": 12345,
    "txId": "550e8400-e29b-41d4-a716-446655440000",
    "pixKey": "destino@email.com",
    "status": "LIQUIDATED",
    "payment": {
      "amount": "100.5",
      "currency": "BRL"
    },
    "mismatchAmount": null,
    "refunds": [],
    "createdAt": "2024-01-15T10:30:00.000Z",
    "errorCode": null,
    "endToEndId": "E12345678202401151030abcdefghijk",
    "providerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "ticketData": {},
    "webhookType": "TRANSFER",
    "debtorAccount": {
      "ispb": null,
      "name": null,
      "bankName": null,
      "issuer": null,
      "number": null,
      "document": null,
      "accountType": null
    },
    "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
    "creditDebitType": "DEBIT",
    "creditorAccount": {
      "ispb": "12345678",
      "name": "João Silva",
      "bankName": "Banco Exemplo S.A.",
      "issuer": "000",
      "number": "123456",
      "document": "12345678909",
      "accountType": null
    },
    "localInstrument": "DICT",
    "transactionType": "PIX",
    "remittanceInformation": "Pago referente a la factura 12345"
  },
  "type": "TRANSFER"
}

Campos Importantes

typestring

Siempre "TRANSFER".

data.idnumber

ID de la transacción en FyPay. Mismo valor de id en la respuesta de POST /api/dict/pix. Puede ser null cuando la transferencia fue rechazada antes de crearse; en ese caso correlacione por el idempotencyKey.

data.txIdstring

El valor del x-idempotency-key enviado en la solicitud.

data.idempotencyKeystring

El valor del x-idempotency-key enviado en la solicitud. Use este campo para correlacionar el webhook con su transferencia.

data.endToEndIdstring

End to End ID del PIX. Disponible cuando la transferencia llega al proveedor; puede ser null en fallas.

data.statusstring

Estado de la transferencia:

  • LIQUIDATED: transferencia confirmada
  • ERROR: falla en la transferencia
  • PENDING: aún en procesamiento
data.paymentobject

data.creditorAccountobject

Datos de quien recibió.

data.debtorAccountobject

Su cuenta. Todos los campos vienen null.

data.creditDebitTypestring

Siempre "DEBIT".

data.errorCodestring

Código de error cuando status es ERROR; null en los demás casos. El conjunto de códigos depende del proveedor de liquidación.

data.remittanceInformationstring

Descripción de la transferencia (campo description enviado en la solicitud).

Procesando el Webhook

Ejemplo Node.js

interface TransferWebhook {
  type: 'TRANSFER';
  data: {
    id: number | null;
    status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
    payment: {
      amount: string;
      currency: 'BRL';
    };
    endToEndId: string | null;
    idempotencyKey: string | null;
    creditorAccount: {
      name: string | null;
      document: string | null;
    };
    errorCode: string | null;
  };
}

async function handleTransfer(webhook: TransferWebhook) {
  const { data } = webhook;

  const transfer = await findTransferByIdempotencyKey(data.idempotencyKey);

  if (!transfer) {
    console.warn(`Transferencia no encontrada: ${data.idempotencyKey}`);
    return;
  }

  if (data.status === 'LIQUIDATED') {
    await updateTransfer(transfer.id, {
      status: 'COMPLETED',
      endToEndId: data.endToEndId,
    });
    return;
  }

  if (data.status === 'ERROR') {
    await updateTransfer(transfer.id, {
      status: 'FAILED',
      errorCode: data.errorCode,
    });
  }
}

Ejemplo Python

def handle_transfer(webhook: dict):
    data = webhook['data']

    transfer = find_transfer_by_idempotency_key(data['idempotencyKey'])

    if not transfer:
        print(f"Transferencia no encontrada: {data['idempotencyKey']}")
        return

    if data['status'] == 'LIQUIDATED':
        update_transfer(
            transfer_id=transfer.id,
            status='COMPLETED',
            e2e_id=data['endToEndId'],
        )
    elif data['status'] == 'ERROR':
        update_transfer(
            transfer_id=transfer.id,
            status='FAILED',
            error_code=data['errorCode'],
        )

Correlación con la Solicitud

Use idempotencyKey para correlacionar el webhook con la solicitud original:

const idempotencyKey = crypto.randomUUID();

await saveTransfer({ idempotencyKey, status: 'PENDING' });

const transfer = await createTransfer(idempotencyKey, {
  pixKey: 'destino@email.com',
  payment: { amount: 100.50 },
});

await updateTransferId(idempotencyKey, transfer.id);

En el webhook TRANSFER, busque por data.idempotencyKey. Guarde la clave antes de llamar a la API, para poder correlacionar incluso si la respuesta síncrona se pierde.

Manejo de Errores

Cuando status es ERROR, registre el errorCode y trate la transferencia como falla definitiva. Los códigos varían según el proveedor; no dependa de una lista fija. Para intentar de nuevo, use un nuevo x-idempotency-key.

if (data.status === 'ERROR') {
  await markTransferFailed(data.idempotencyKey, data.errorCode);
  await notifyUser(data.idempotencyKey, data.errorCode);
}

Idempotencia

El mismo webhook puede llegar más de una vez. Deduplique por la clave de idempotencia y por el estado:

async function handleWebhook(webhook: TransferWebhook) {
  const key = `transfer:${webhook.data.idempotencyKey}:${webhook.data.status}`;

  const isNew = await redis.set(`webhook:${key}`, '1', 'NX', 'EX', 60 * 60 * 24 * 7);
  if (!isNew) return;

  await handleTransfer(webhook);
}

Buenas Prácticas

Próximos Pasos

En esta página