Pular para o conteúdo principal

PDV — Pagamento dinâmico via QR/NFC (participante aprova pelo app)

Fluxo pra participante com app: o PDV declara o valor, gera um payload (QR na tela ou transmitido por NFC — o app do participante trata os dois do mesmo jeito) e espera a aprovação. Sem PIN aqui — o login do participante no próprio app já é o fator (ver Participante → Pagar via QR ou NFC pro lado de quem aprova).

PDV declara o valor
→ POST .../pdv/payment-intents devolve qrPayload + socketGrant
→ PDV mostra o QR (ou transmite por NFC) e abre o WebSocket com o socketGrant
→ participante lê o payload, resolve, confirma e aprova no próprio app
→ o servidor notifica o PDV pelo WebSocket com o resultado
EndpointO que faz
POST /v1/events/{eventId}/pdv/payment-intentsDeclara um valor, gera um token de uso único (o payload do QR/NFC) e um grant assinado de vida curta pra abrir o canal de notificação.
GET /v1/events/{eventId}/pdv/payment-intents/{intentId}Consulta o status atual — usar como fallback manual se o WebSocket não notificar a tempo (ver passo 3).

1. Criar o payment intent​

curl -X POST http://127.0.0.1:8787/v1/events/{eventId}/pdv/payment-intents \
-H "Authorization: Bearer <accessToken do PDV>" \
-H "Content-Type: application/json" \
-d '{ "amountMinor": 2500 }'
Resposta
{
"intentId": "018f26d7-8a7b-7e7d-8c96-4f0b8d0f2b13",
"qrPayload": "wallet://payment?v=1&event=018f26d7-8a7b-7e7d-8c96-4f0b8d0f2b10&intent=Xk3zP9q...vFh2A",
"socketGrant": "<jwt de vida curta>",
"expiresAt": "2026-09-09T12:05:00.000Z"
}

qrPayload é exatamente o que vira o QR na tela (ou é transmitido por NFC) — não contém o valor, só o eventId e um token de uso único; só o participante descobre o valor, resolvendo o token no próprio app. socketGrant é um JWT que só serve pra abrir o canal do passo 2 — guarda em memória, não precisa persistir, e expira junto com o intent (poucos minutos).

2. Abrir o canal de notificação (WebSocket, em wallet-edge)​

EndpointO que faz
GET /v1/events/{eventId}/pdv/payment-intents/{intentId}/socket?grant={socketGrant}Upgrade pra WebSocket. Entrega exatamente uma mensagem quando o intent for resolvido (aprovado, recusado ou expirado), depois pode fechar.

Essa rota vive em wallet-edge (não em wallet-api) e exige dois credenciais — os dois precisam bater com o mesmo PDV e o mesmo evento, senão a conexão é recusada antes do upgrade (401/403):

  1. O próprio accessToken do PDV (passo de login) — como header Authorization: Bearer (app nativo Android/iOS, que controla a conexão WebSocket na mão e pode mandar qualquer header) ou, alternativamente, como query param ?access_token= (cliente web/navegador — a API WebSocket do navegador não permite header customizado nenhum no handshake, só esse jeito funciona).
  2. O socketGrant devolvido na criação, sempre como query param ?grant=.
wss://<wallet-edge>/v1/events/{eventId}/pdv/payment-intents/{intentId}/socket
?grant={socketGrant}
&access_token={accessToken} // só necessário pra clientes web
Mensagem: aprovado
{ "status": "approved", "availableAmountMinor": 16750 }
Mensagem: recusado
{ "status": "declined", "reason": "INSUFFICIENT_BALANCE" }
Mensagem: expirado
{ "status": "expired" }

O servidor guarda o resultado assim que ele acontece — mesmo que o PDV não esteja conectado no momento, reconectar no mesmo intentId antes do intent expirar entrega a mensagem já registrada imediatamente ao abrir o socket.

:::info Operacional: wallet-api precisa saber onde fica o wallet-edge Quem resolve o intent (aprova/recusa) é sempre wallet-api, mas quem entrega a notificação em tempo real é wallet-edge — então wallet-api precisa avisar wallet-edge quando isso acontece, via uma chamada interna assinada. Isso depende da variável de ambiente WALLET_EDGE_ORIGIN estar configurada no ambiente do wallet-api (aponta pra URL pública do wallet-edge). Sem ela, o aviso vira um no-op silencioso: o pagamento é processado normalmente, só a notificação em tempo real nunca chega — o sintoma é o WebSocket conectado, mas nenhuma mensagem nunca aparece. O fallback do passo 3 continua funcionando normalmente mesmo assim. :::

3. Fallback: consultar o status manualmente​

Se o WebSocket nunca conectar ou cair no meio do caminho, consulte o status diretamente — recomendação: esperar uns 30 segundos desde a criação antes de consultar, pra dar tempo do participante escanear e aprovar.

Resposta de GET .../payment-intents/{intentId}
{
"intentId": "018f26d7-8a7b-7e7d-8c96-4f0b8d0f2b13",
"amountMinor": 2500,
"status": "pending",
"declineReason": null,
"expiresAt": "2026-09-09T12:05:00.000Z"
}

status é pending | approved | declined | expired; declineReason só vem preenchido quando status é declined. 404 PAYMENT_INTENT_NOT_FOUND se o intentId não existe ou não foi criado por este PDV.

Se ninguém nunca resolve o intent (participante não escaneia/confirma), o status só vira expired de fato quando um processo interno do wallet-api detecta o vencimento — roda a cada ~15 segundos, então existe uma pequena janela (bem menor que os 30s de espera recomendados acima) em que uma consulta bem no limite do prazo ainda pode devolver pending por alguns instantes depois do expiresAt já ter passado.

:::info Responsabilidade do app do PDV Esta página descreve o protocolo (o payload do QR/NFC, o WebSocket, o fallback). Conectar o WebSocket, tratar quedas de conexão e implementar o timer do fallback de ~30s é responsabilidade do app do PDV — não faz parte desta API. :::

Exemplo completo (React Native / Expo)​

Implementação de referência dos passos 1 a 3 acima, em TypeScript — cria o intent, abre o WebSocket e resolve com o resultado, caindo pro fallback se o socket não entregar nada em 30s.

Uma diferença importante em relação a um cliente web: o WebSocket do React Native não é o mesmo do navegador — ele aceita um terceiro argumento options com headers customizados, então o accessToken do PDV vai direto no header Authorization, igual numa chamada HTTP comum. Não precisa do ?access_token= na query string (esse é só o contorno necessário pra navegador, que não expõe essa opção).

type PaymentIntentCreatedResponse = {
intentId: string;
qrPayload: string;
socketGrant: string;
expiresAt: string;
};

type PaymentIntentStatusResponse = {
intentId: string;
amountMinor: number;
status: "pending" | "approved" | "declined" | "expired";
declineReason: string | null;
expiresAt: string;
};

type PaymentIntentOutcome =
| { status: "approved"; availableAmountMinor?: number }
| { status: "declined"; reason: string | null }
| { status: "expired" };

const WALLET_EDGE_BASE_URL = "https://wallet-edge.ivexa.workers.dev";
const FALLBACK_DELAY_MS = 30_000;

async function createPaymentIntent(
eventId: string,
accessToken: string,
amountMinor: number,
): Promise<PaymentIntentCreatedResponse> {
const response = await fetch(
`${WALLET_EDGE_BASE_URL}/v1/events/${eventId}/pdv/payment-intents`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ amountMinor }),
},
);

if (!response.ok) {
throw new Error(`Failed to create payment intent (${response.status})`);
}

return response.json();
}

async function fetchPaymentIntentStatus(
eventId: string,
intentId: string,
accessToken: string,
): Promise<PaymentIntentStatusResponse> {
const response = await fetch(
`${WALLET_EDGE_BASE_URL}/v1/events/${eventId}/pdv/payment-intents/${intentId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);

if (!response.ok) {
throw new Error(`Failed to fetch payment intent status (${response.status})`);
}

return response.json();
}

/**
* Declares the amount, opens the real-time notification channel, and
* resolves with the outcome — either delivered by the WebSocket, or,
* if that never arrives, by falling back to a single status check
* ~30s after creation.
*/
export async function chargeViaDynamicQr(
eventId: string,
accessToken: string,
amountMinor: number,
onQrReady: (qrPayload: string) => void,
): Promise<PaymentIntentOutcome> {
const intent = await createPaymentIntent(eventId, accessToken, amountMinor);
onQrReady(intent.qrPayload); // render this as a QR, e.g. with react-native-qrcode-svg

return new Promise((resolve) => {
let settled = false;

// React Native's WebSocket (unlike a browser's) accepts a third
// "options" argument with custom headers, so the PDV's own token goes
// in the Authorization header — no need for the ?access_token= query
// param that browser-based clients require instead.
const socket = new WebSocket(
`${WALLET_EDGE_BASE_URL.replace(/^http/, "ws")}/v1/events/${eventId}` +
`/pdv/payment-intents/${intent.intentId}/socket` +
`?grant=${encodeURIComponent(intent.socketGrant)}`,
undefined,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);

const settle = (outcome: PaymentIntentOutcome) => {
if (settled) return;
settled = true;
clearTimeout(fallbackTimer);
socket.close();
resolve(outcome);
};

socket.onmessage = (event) => {
settle(JSON.parse(event.data as string) as PaymentIntentOutcome);
};

socket.onerror = () => {
// Swallowed — the fallback timer below still covers this case.
// Worth logging for your own observability.
};

// Fallback: if the socket never delivers anything (dropped connection,
// network blocking the upgrade, app backgrounded, etc.), ask the
// authoritative status endpoint once, ~30s after creation.
const fallbackTimer = setTimeout(async () => {
try {
const current = await fetchPaymentIntentStatus(
eventId,
intent.intentId,
accessToken,
);
if (current.status !== "pending") {
settle(
current.status === "declined"
? { status: "declined", reason: current.declineReason }
: { status: current.status }, // "approved" | "expired"
);
}
} catch {
// Network hiccup on the fallback check — leave the socket open a
// little longer, it might still deliver the real message.
}
}, FALLBACK_DELAY_MS);
});
}

Uso num componente de tela:

const outcome = await chargeViaDynamicQr(eventId, accessToken, 2500, (qrPayload) => {
setQrPayload(qrPayload); // <QRCode value={qrPayload} />
});

if (outcome.status === "approved") {
// tela de sucesso
} else if (outcome.status === "declined") {
// outcome.reason, ex.: "INSUFFICIENT_BALANCE"
} else {
// "expired" — deixe o operador tentar de novo
}

Note que o fallback via status endpoint não devolve availableAmountMinor (só a mensagem em tempo real do WebSocket traz esse dado) — por isso o tipo PaymentIntentOutcome marca esse campo como opcional; não invente um valor quando o resultado vier pelo caminho do fallback.