Handle responses
Classify success and failure outcomes, manage timeouts, and handle partial authorizations.
Every terminal payment ends in one of three ways: completed (funds collected), failed (card declined or error), or cancelled (session abandoned). Your backend receives each outcome as a webhook event. This page explains how to handle each case correctly.
Payment status lifecycleAsk
created → pending → processing → completed
↘ failed
↘ cancelled| Status | Meaning |
|---|---|
created | Session created on your backend; terminal has not yet received it |
pending | Terminal received the session and is waiting for card presentation |
processing | Card presented; authorization request in flight to the issuer |
completed | Authorization approved; funds captured (or authorized for manual capture) |
failed | Card declined or transaction error |
cancelled | Session cancelled before card interaction, or timed out |
Never fulfil an order on pending or processing. Always wait for terminal_payment.completed via webhook before releasing goods or services.
Handling terminal_payment.completedAsk
A completed event means the issuer approved the transaction and — for automatic capture — funds are on the way to your settlement account.
if (event.type === 'terminal_payment.completed') {
const tp = event.data.object;
await db.orders.update(tp.reference, {
status: 'paid',
paymentId: tp.id,
amountCaptured: tp.amountCaptured,
last4: tp.last4,
brand: tp.brand,
});
await printReceipt(tp.terminalId, tp.reference);
}Key fields on the completed object:
Prop
Type
Handling terminal_payment.failedAsk
A failed event carries a declineCode. Use it to decide what to show staff and whether to retry.
if (event.type === 'terminal_payment.failed') {
const tp = event.data.object;
switch (tp.declineCode) {
case 'card_declined':
case 'insufficient_funds':
case 'expired_card':
// Soft decline — ask customer to try another card
await promptAnotherCard(tp.terminalId, tp.reference);
break;
case 'lost_or_stolen':
// Hard decline — do not retry
await notifySecurityTeam(tp);
break;
case 'communication_error':
// Retry once with a new session
await retryPayment(tp.terminalId, tp.amount, tp.currency, tp.reference);
break;
default:
await logForReview(tp);
}
}Decline code reference
declineCode | Type | Meaning | Recommended action |
|---|---|---|---|
card_declined | Soft | Generic issuer decline — no specific reason given | Ask customer to try a different card |
insufficient_funds | Soft | Insufficient balance or credit limit reached | Ask customer to use a different card |
expired_card | Soft | Card past expiry date | Ask customer for a current card |
incorrect_pin | Soft | Wrong PIN entered (after all PIN attempts exhausted) | Ask customer to try a different card |
do_not_honour | Soft | Issuer decline without specific reason — often a temporary hold | Ask customer to contact their bank or try a different card |
lost_or_stolen | Hard | Card flagged as lost or stolen by the issuer | Do not retry; follow your security policy |
restricted_card | Hard | Card restricted by issuer for this merchant category | Do not retry |
fraud_decline | Hard | Transaction flagged by VINR risk engine or issuer | Do not retry; contact VINR support if unexpected |
invalid_card | Hard | Card number, expiry, or CVV failed basic validation | Ask customer to try a different card |
communication_error | Operational | Terminal lost connectivity mid-transaction | Retry once with a new session; check terminal network |
terminal_busy | Operational | Another session is already active on this terminal | Wait and retry; or route to a different terminal |
terminal_offline | Operational | Terminal not reachable from VINR cloud | Check terminal power and network; use local mode as fallback |
session_expired | Operational | Customer did not present card within the timeout window (60 s) | Create a new session when ready |
processing_error | Operational | Internal VINR error | Retry once; if persistent, contact support |
Handling timeouts and connectivity lossAsk
Customer timeout
If the customer does not present a card within 60 seconds (default), the terminal cancels the session and VINR fires terminal_payment.cancelled with cancellationReason: "timeout". Create a new session when the customer is ready.
The timeout is configurable per terminal under Dashboard → Hardware → Terminals → [device] → Session settings.
Connectivity loss mid-transaction
If the terminal loses its cloud connection while a card interaction is in progress:
- Cloud mode: The terminal continues offline authorization if a floor limit is set (see In-person features — Offline payments). The webhook fires once the terminal reconnects.
- Local mode: The transaction completes normally because your server communicates directly with the terminal. The VINR cloud receives the result on next sync.
If connectivity is lost before the card interaction starts, the terminal queues your session and presents it as soon as it reconnects. You receive terminal_payment.completed or terminal_payment.failed at that point.
Partial authorizationAsk
Some debit card issuers approve only part of the requested amount (for example, a $50 transaction approved for $35 because the card has only $35 available). VINR surfaces this as status: "partially_authorized" on the terminal payment object.
if (event.type === 'terminal_payment.completed') {
const tp = event.data.object;
if (tp.status === 'partially_authorized') {
// tp.amountCaptured < tp.amount
const shortfall = tp.amount - tp.amountCaptured;
await promptSplitTender(tp.terminalId, tp.reference, shortfall);
}
}Partial authorization is disabled by default. Enable it under Dashboard → Settings → Payment processing → Partial authorization.
Idempotency and deduplicationAsk
VINR may deliver the same webhook event more than once (for example, if your endpoint times out and the retry fires). Deduplicate using event.id:
const alreadyProcessed = await db.webhookEvents.exists(event.id);
if (alreadyProcessed) {
return new Response(null, { status: 200 });
}
await db.webhookEvents.insert({ id: event.id, processedAt: new Date() });
// ... process normallyFor the API, every terminal/payments/create call accepts an idempotencyKey parameter. Retrying with the same key returns the original response without creating a duplicate session.
const tp = await vinr.terminal.payments.create(
{ terminalId, amount, currency, reference },
{ idempotencyKey: `order_${reference}_attempt_1` },
);Next stepsAsk
Webhooks & notifications
Register endpoints, verify signatures, and replay failed events.
Test your integration
Drive specific success and failure scenarios with sandbox terminals and test cards.
In-person refunds
Issue full or partial refunds against a completed terminal payment.
Last updated on