Skip to main content

PDV — Dynamic payment via QR/NFC (participant approves from the app)

Flow for an app-holding participant: the PDV declares the amount, generates a payload (a QR on screen or transmitted over NFC — the participant's app treats both the same) and waits for approval. No PIN here — the participant's own app login is already the factor (see Participant → Pay via QR or NFC for the approving side).

PDV declares the amount
→ POST .../pdv/payment-intents returns qrPayload + socketGrant
→ PDV shows the QR (or transmits over NFC) and opens the WebSocket with the socketGrant
→ participant reads the payload, resolves it, confirms, and approves from their own app
→ the server notifies the PDV over the WebSocket with the outcome
EndpointWhat it does
POST /v1/events/{eventId}/pdv/payment-intentsDeclares an amount, generates a single-use token (the QR/NFC payload) and a short-lived signed grant to open the notification channel.
GET /v1/events/{eventId}/pdv/payment-intents/{intentId}Checks the current status — use as a manual fallback if the WebSocket doesn't notify in time (see step 3).

1. Create the payment intent​

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

qrPayload is exactly what becomes the on-screen QR (or gets transmitted over NFC) — it doesn't contain the amount, just the eventId and a single-use token; only the participant finds out the amount, by resolving the token from their own app. socketGrant is a JWT that only opens the channel in step 2 — keep it in memory, no need to persist it, and it expires along with the intent (a few minutes).

2. Open the notification channel (WebSocket, on wallet-edge)​

EndpointWhat it does
GET /v1/events/{eventId}/pdv/payment-intents/{intentId}/socket?grant={socketGrant}Upgrades to a WebSocket. Delivers exactly one message once the intent is resolved (approved, declined, or expired), then it can be closed.

This route lives on wallet-edge (not wallet-api) and requires two credentials — both must match the same PDV and the same event, otherwise the connection is rejected before the upgrade (401/403):

  1. The PDV's own accessToken (from login) — as an Authorization: Bearer header (a native Android/iOS app, which drives the WebSocket connection directly and can send any header it wants) or, alternatively, as an ?access_token= query param (a web/browser client — the browser's native WebSocket API doesn't allow any custom header on the handshake, so this is the only way that works there).
  2. The socketGrant returned at creation, always as the ?grant= query param.
wss://<wallet-edge>/v1/events/{eventId}/pdv/payment-intents/{intentId}/socket
?grant={socketGrant}
&access_token={accessToken} // only needed for web clients
Message: approved
{ "status": "approved", "availableAmountMinor": 16750 }
Message: declined
{ "status": "declined", "reason": "INSUFFICIENT_BALANCE" }
Message: expired
{ "status": "expired" }

The server stores the outcome as soon as it happens — even if the PDV isn't connected at that moment, reconnecting on the same intentId before the intent expires delivers the already-recorded message immediately on socket open.

:::info Operational: wallet-api needs to know where wallet-edge is wallet-api is always the one resolving the intent (approve/decline), but wallet-edge is the one delivering the real-time notification — so wallet-api needs to tell wallet-edge when that happens, via a signed internal call. This depends on the WALLET_EDGE_ORIGIN environment variable being configured in wallet-api's environment (pointing at wallet-edge's public URL). Without it, that notification silently no-ops: the payment is still processed normally, only the real-time notification never arrives — the symptom is a connected WebSocket that never receives any message. The fallback in step 3 still works fine either way. :::

3. Fallback: check the status manually​

If the WebSocket never connects or drops midway, check the status directly — recommendation: wait around 30 seconds from creation before checking, to give the participant time to scan and approve.

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

status is pending | approved | declined | expired; declineReason is only set when status is declined. 404 PAYMENT_INTENT_NOT_FOUND if the intentId doesn't exist or wasn't created by this PDV.

If nobody ever resolves the intent (the participant never scans/confirms), status only actually flips to expired once an internal wallet-api process notices it's overdue — it runs about every 15 seconds, so there's a small window (much shorter than the 30s wait recommended above) where a check made right at the deadline can still return pending for a moment after expiresAt has already passed.

:::info PDV app responsibility This page describes the protocol (the QR/NFC payload, the WebSocket, the fallback). Connecting the WebSocket, handling dropped connections, and implementing the ~30s fallback timer is the PDV app's responsibility — it's not part of this API. :::

Full example (React Native / Expo)​

Reference implementation of steps 1 through 3 above, in TypeScript — creates the intent, opens the WebSocket, and resolves with the outcome, falling back if the socket doesn't deliver anything in 30s.

One important difference from a web client: React Native's WebSocket is not the browser's — it accepts a third options argument with custom headers, so the PDV's accessToken goes straight into the Authorization header, just like a regular HTTP call. No need for ?access_token= in the query string (that's only the workaround required for browsers, which don't expose that option).

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);
});
}

Usage in a screen component:

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

if (outcome.status === "approved") {
// success screen
} else if (outcome.status === "declined") {
// outcome.reason, e.g. "INSUFFICIENT_BALANCE"
} else {
// "expired" — let the operator try again
}

Note that the status-endpoint fallback doesn't return availableAmountMinor (only the WebSocket's real-time message carries that) — that's why PaymentIntentOutcome marks the field optional; don't fabricate a value when the outcome arrives via the fallback path.