PaymentsIn-Person PaymentsHandle responses

Handle responses

Classify success and failure outcomes, manage timeouts, and handle partial authorizations.

View as MarkdownInstall skills

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
StatusMeaning
createdSession created on your backend; terminal has not yet received it
pendingTerminal received the session and is waiting for card presentation
processingCard presented; authorization request in flight to the issuer
completedAuthorization approved; funds captured (or authorized for manual capture)
failedCard declined or transaction error
cancelledSession 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

declineCodeTypeMeaningRecommended action
card_declinedSoftGeneric issuer decline — no specific reason givenAsk customer to try a different card
insufficient_fundsSoftInsufficient balance or credit limit reachedAsk customer to use a different card
expired_cardSoftCard past expiry dateAsk customer for a current card
incorrect_pinSoftWrong PIN entered (after all PIN attempts exhausted)Ask customer to try a different card
do_not_honourSoftIssuer decline without specific reason — often a temporary holdAsk customer to contact their bank or try a different card
lost_or_stolenHardCard flagged as lost or stolen by the issuerDo not retry; follow your security policy
restricted_cardHardCard restricted by issuer for this merchant categoryDo not retry
fraud_declineHardTransaction flagged by VINR risk engine or issuerDo not retry; contact VINR support if unexpected
invalid_cardHardCard number, expiry, or CVV failed basic validationAsk customer to try a different card
communication_errorOperationalTerminal lost connectivity mid-transactionRetry once with a new session; check terminal network
terminal_busyOperationalAnother session is already active on this terminalWait and retry; or route to a different terminal
terminal_offlineOperationalTerminal not reachable from VINR cloudCheck terminal power and network; use local mode as fallback
session_expiredOperationalCustomer did not present card within the timeout window (60 s)Create a new session when ready
processing_errorOperationalInternal VINR errorRetry 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 normally

For 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

Was this page helpful?
Edit on GitHub

Last updated on

On this page