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
| Endpoint | O que faz |
|---|---|
POST /v1/events/{eventId}/pdv/payment-intents | Declara 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
- JavaScript
- Python
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 }'
const response = await fetch(`http://127.0.0.1:8787/v1/events/${eventId}/pdv/payment-intents`, {
method: "POST",
headers: {
Authorization: `Bearer ${pdvAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ amountMinor: 2500 }),
});
const { intentId, qrPayload, socketGrant, expiresAt } = await response.json();
import requests
response = requests.post(
f"http://127.0.0.1:8787/v1/events/{event_id}/pdv/payment-intents",
headers={"Authorization": f"Bearer {pdv_access_token}"},
json={"amountMinor": 2500},
)
intent = response.json()
{
"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)
| Endpoint | O 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):
- 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 APIWebSocketdo navegador não permite header customizado nenhum no handshake, só esse jeito funciona). - O
socketGrantdevolvido 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
{ "status": "approved", "availableAmountMinor": 16750 }
{ "status": "declined", "reason": "INSUFFICIENT_BALANCE" }
{ "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.
{
"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.