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
| Endpoint | What it does |
|---|---|
POST /v1/events/{eventId}/pdv/payment-intents | Declares 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
- JavaScript
- Python
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 }'
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": "<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)
| Endpoint | What 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):
- The PDV's own accessToken (from login) — as an
Authorization: Bearerheader (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 nativeWebSocketAPI doesn't allow any custom header on the handshake, so this is the only way that works there). - The
socketGrantreturned 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
{ "status": "approved", "availableAmountMinor": 16750 }
{ "status": "declined", "reason": "INSUFFICIENT_BALANCE" }
{ "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.
{
"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.