Skip to main content
Un payment intent representa una petición de cobro concreta: un monto, los activos que el pagador puede usar, metadata opcional, y las direcciones a las que enviar fondos. Creado vía POST /payment-intents, leído vía GET /payment-intents/{id}, listado vía GET /payment-intents. El mismo envelope lo devuelven los tres endpoints + el simulador sandbox + cualquier evento del log relacionado al intent. Construye tu cliente alrededor de este envelope y tienes un solo decoder para cada entry point.

El envelope

Todo campo abajo está siempre presente en la respuesta, con null cuando no aplica. Es deliberado — código del partner que hace response.metadata sin guard no se rompe en intents sin metadata. Estilo Stripe.
{
  "id":            "ef0cb276-399a-4c21-b33b-ea63ddc48246",   // UUID string
  "object":        "payment_intent",                          // literal fijo
  "amount":        5.0,                                       // monto pedido
  "currency":      "USDT",                                    // asset principal, MAYÚSCULAS
  "status":        "pending",                                 // pending | completed | expired | failed
  "ref_code":      "EDEA9",                                   // id de 5 chars para hosted page
  "external_ref":  "user_42",                                 // tu clave de atribución (o null)
  "description":   null,                                      // proporcionado por el partner (o null)
  "metadata":      null,                                      // objeto opcional del partner (o null)
  "addresses": [                                              // destinos por (asset, red)
    {
      "asset":   "USDT",
      "network": "sol",                                       // código lowercase (o null en race del catálogo)
      "address": "zSAND9FBqeoVb9mDr2tBKWiBtPGZfq8f"           // wallet on-chain, verbatim
    }
  ],
  "created_at":    "2026-06-09T19:40:13.042621Z",
  "expires_at":    "2026-06-10T19:40:13.195347Z",
  "completed_at":  null,                                      // se setea al pasar a completed

  "deposit":       null,                                      // detalles del ÚLTIMO crédito on-chain
                                                              // (ver "El objeto deposit" abajo)

  "amount_received":   0.0,                                   // suma acumulada de depósitos completados
  "amount_remaining":  5.0,                                   // max(0, amount - amount_received)
  "partial_payment":   false                                  // booleano canónico "está incompleto"
}

Transiciones de estado

                    POST /payment-intents


                       ┌──────────┐
                       │ pending  │  ◄──── intents arrancan aquí
                       └────┬─────┘

        ┌───────────────────┼────────────────────┐
        │                   │                    │
        │ (acumulado >=     │ (expires_at        │ (depósito parcial
        │  amount, dentro   │  alcanzado sin     │  llega; el intent
        │  de 0.01 de       │  pago completo)    │  se queda aquí)
        │  tolerancia)      │                    │
        ▼                   ▼                    │
   ┌──────────┐       ┌──────────┐               │
   │completed │       │ expired  │               │
   └──────────┘       └──────────┘               │

                       (sin transición) ◄────────┘
failed está reservado para uso futuro; ningún path actual lo emite. Construye tu cliente para ignorar estados futuros desconocidos sin romper.

Campos cumulativos

Los tres campos cumulativos cierran el gap del pago parcial: un depósito de 3contraunintentde3 contra un intent de 5 es estructuralmente distinto de “todavía no llegó nada”.
CampoQué esCuándo usarlo
amount_receivedSuma de depósitos completados contra este intent. Arranca en 0.0.Mostrar “recibido hasta ahora” en la UI parcial.
amount_remainingmax(0, amount - amount_received). Sin tolerancia aplicada. Clampea a 0 en sobre-pago.Mostrar “envía $X más para completar”.
partial_paymenttrue sólo si amount_received > 0 AND status == 'pending'.El booleano canónico para “renderiza UI parcial”.
Usa partial_payment como el boolean de dispatch — respeta transitivamente la tolerancia de 0.01 que usa el backend para decidir completion. Un intent dentro de tolerancia ya pasó a completed, así que partial_payment es false y el booleano te dice renderiza éxito, no parcial.

Ciclo de pago parcial

Ejemplo concreto: intent de 5,pagadorenvıˊa5, pagador envía 3 y luego $2.
T=0      POST /payment-intents → minteado
         status=pending  amount_received=0.0  partial_payment=false  amount_remaining=5.0

T=10s    Depósito de $3 aterriza on-chain
         status=pending  amount_received=3.0  partial_payment=true   amount_remaining=2.0
         deposit = { amount_received: 3.0, ... }    ◄── detalles del último crédito

T=60s    Depósito de $2 aterriza on-chain
         status=completed completed_at=<T+60s>  partial_payment=false  amount_remaining=0.0
         amount_received=5.0
         deposit = { amount_received: 2.0, ... }    ◄── ÚLTIMO, no acumulado

         (webhook payment.completed dispara aquí)
Dos cosas para anotar:
  1. deposit es el ÚLTIMO crédito, no el acumulado. En el flujo parcial→completo, deposit.amount_received muestra los 2(eluˊltimo).Elamountreceivedtoplevelmuestraelacumulado2 (el último). El `amount_received` top-level muestra el acumulado 5. No sumes deposit.amount_received entre polls — usa el campo top-level.
  2. La tolerancia es invisible para ti. Un depósito de 4.995contraunintentde4.995 contra un intent de 5.00 pasa el status a completed (dentro de la tolerancia de 0.01 que usa el flujo de crédito). partial_payment es false. Confía en el booleano; no reimplementes el threshold del lado del cliente.

Polling

Un widget poleando GET /payment-intents/{id} para estado debe despachar en un árbol de decisión pequeño:
async function poll(intentId: string) {
  const intent = await fetch(`/api/zopay/status/${intentId}`).then(r => r.json());

  if (intent.status === "completed")  return onSuccess(intent.deposit);
  if (intent.status === "expired")    return intent.amount_received > 0
                                          ? onExpiredWithCredits(intent.amount_received)
                                          : onExpired();
  if (intent.partial_payment)         return showPartialUI(
                                          intent.amount_received,
                                          intent.amount_remaining
                                        );

  // status === "pending", amount_received === 0 → intent fresco, esperando primer crédito.
  return showWaitingUI();
}
El SDK del navegador hace esto por ti — ver mountPaymentWidget y normalizeIntent.

El objeto deposit

deposit se puebla cuando un crédito aterriza. En el flujo parcial→completo refleja el ÚLTIMO crédito en cada lectura (no el acumulado). Usa amount_received (top-level) para acumulado.
{
  "deposit": {
    "amount_received":   5.0,                                  // amount de ESTE crédito, no acumulado
    "currency":          "USDT",
    "network":           "sol",
    "address_receiver":  "zSAND9FBqeoVb9mDr2tBKWiBtPGZfq8f",
    "address_sender":    "zSAND_simulator_sender",             // "..._simulator_sender" en sandbox
    "tx_hash":           "5cc7a1fa..."                         // hash on-chain (determinístico en sandbox)
  }
}
Este objeto es idéntico campo-por-campo al shape data del evento webhook payment_intent.completed. Partners que hacen reconciliación pull (GET) y push (webhook) pueden usar un solo decoder para los dos.

Transacciones atribuidas a Connect

Cada fila que devuelve GET /transactions carga un campo payment_intent_id. Si es no-null, esa transacción fue un crédito contra el intent nombrado (flujo SDK-driven). Si es null, la transacción vino por otro path (depósito directo a dirección org-owned, transferencia, funding interno). Úsalo para deep-linkear filas de transacción a su intent originador en los dashboards del partner.

Ver también