PaymentsIn-Person PaymentsAccept a payment

Accept a payment

Create a terminal payment session, present it to the terminal, and verify the result.

View as MarkdownInstall skills

Accepting an in-person payment with a VINR terminal follows three steps: your backend creates a payment session and instructs the terminal, the terminal handles the card interaction with the customer, and your backend confirms the result via a webhook. For a deeper look at how cloud-mode and local-mode terminals differ, see the architecture overview.

Step 1 — Create a terminal paymentAsk

Call vinr.terminal.payments.create from your server. Never call terminal APIs from a client — your secret key must stay on the backend.

import { Vinr } from '@vinr/sdk';

const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });

const terminalPayment = await vinr.terminal.payments.create({
  terminalId: 'term_01HZ5QXYZ',
  amount: 2500,
  currency: 'USD',
  reference: 'order_8821',
});

console.log(terminalPayment.id);
console.log(terminalPayment.status);

The response is a terminal payment object with status: "pending". The terminal receives the instruction automatically via the cloud connection; no separate push step is required in cloud mode.

{
  "id": "tpay_01HZ5QA7BK",
  "terminalId": "term_01HZ5QXYZ",
  "amount": 2500,
  "currency": "USD",
  "reference": "order_8821",
  "captureMethod": "automatic",
  "status": "pending",
  "createdAt": "2026-05-31T14:23:00Z"
}

Step 2 — Terminal executes the transactionAsk

Once the session is pending, the terminal's screen activates and prompts the customer to present their card or device. VINR terminals support three entry methods:

  • Contactless (NFC) — tap a card, phone, or wearable
  • Chip + PIN — insert an EMV chip card and enter the PIN
  • Magnetic stripe — swipe a card (fallback only)

All supported devices — Nexgo N92, Nexgo N86Pro, Nexgo CT20, Nexgo CT20P, and Ciontek CM30 — handle all three methods. The terminal selects the optimal entry method for the card presented.

Do not poll the terminal payment object to check completion. Polling adds latency, burns API quota, and creates race conditions. Use webhooks instead — they deliver the result within milliseconds of the card interaction completing.

Step 3 — Verify via webhookAsk

Register an HTTPS endpoint in the Dashboard under Developers → Webhooks and subscribe to terminal_payment.completed and terminal_payment.failed. VINR delivers one of these events as soon as the terminal finishes the interaction.

import { Vinr } from '@vinr/sdk';

const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get('vinr-signature') ?? '';

  const event = vinr.webhooks.constructEvent(
    rawBody,
    signature,
    process.env.VINR_WEBHOOK_SECRET,
  );

  if (event.type === 'terminal_payment.completed') {
    const tp = event.data.object;
    await fulfillOrder(tp.reference, tp.amountCaptured);
  }

  if (event.type === 'terminal_payment.failed') {
    const tp = event.data.object;
    await notifyStaff(tp.reference, tp.declineCode);
  }

  return new Response(null, { status: 200 });
}

A terminal_payment.completed payload looks like this:

{
  "id": "evt_01HZ5QB2CC",
  "type": "terminal_payment.completed",
  "createdAt": "2026-05-31T14:23:07Z",
  "data": {
    "object": {
      "id": "tpay_01HZ5QA7BK",
      "terminalId": "term_01HZ5QXYZ",
      "amount": 2500,
      "amountCaptured": 2500,
      "currency": "USD",
      "reference": "order_8821",
      "captureMethod": "automatic",
      "status": "completed",
      "entryMethod": "contactless",
      "last4": "4242",
      "brand": "visa",
      "createdAt": "2026-05-31T14:23:00Z",
      "completedAt": "2026-05-31T14:23:07Z"
    }
  }
}

Always verify the webhook signature with vinr.webhooks.constructEvent before trusting the payload. See Webhooks for endpoint configuration and retry behaviour.

Capture modesAsk

By default, VINR captures the funds automatically the moment the card interaction completes (captureMethod: "automatic"). If your workflow requires a review step before funds move — for example, a restaurant that adds a tip adjustment after the customer signs — use manual capture.

const terminalPayment = await vinr.terminal.payments.create({
  terminalId: 'term_01HZ5QXYZ',
  amount: 2500,
  currency: 'USD',
  reference: 'order_8821',
  captureMethod: 'automatic',
});

Funds settle as soon as terminal_payment.completed fires. No further API calls are required.

const terminalPayment = await vinr.terminal.payments.create({
  terminalId: 'term_01HZ5QXYZ',
  amount: 2500,
  currency: 'USD',
  reference: 'order_8821',
  captureMethod: 'manual',
});

await vinr.terminal.payments.capture(terminalPayment.id, {
  amount: 2750,
});

After terminal_payment.completed the payment sits at status: "authorized". Call capture to move funds, optionally adjusting the amount upward (for tip) within the permitted tolerance. Uncaptured authorizations expire after 7 days.

Terminal payment create fieldsAsk

Prop

Type

Handle failuresAsk

When a payment fails, the terminal_payment.failed event includes a declineCode that explains the reason. Use it to decide whether to retry.

declineCodeMeaningAction
card_declinedGeneric issuer declineAsk customer to try a different card
insufficient_fundsInsufficient balanceAsk customer to use a different card
expired_cardCard past expiry dateAsk customer for a current card
lost_or_stolenCard flagged by issuerDo not retry; follow your security policy
communication_errorTerminal lost connectivity mid-transactionRetry the same session once, then contact support
terminal_busyAnother session is active on this terminalWait and retry

Catch the terminal_payment.failed event in your webhook handler.

Read event.data.object.declineCode to classify the failure.

For soft declines (card_declined, insufficient_funds, expired_card), create a new terminal payment session and prompt the customer to present a different card. Do not reuse the failed session ID.

For hard declines (lost_or_stolen) or operational errors, surface the appropriate message to staff and log the event for reconciliation.

VINR does not automatically retry failed terminal payments. Each retry must be a new vinr.terminal.payments.create call with a fresh session.

Test in sandboxAsk

Use a sandbox secret key (sk_test_...) to test against simulated terminals without real hardware.

const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });

const terminalPayment = await vinr.terminal.payments.create({
  terminalId: 'term_test_simulator',
  amount: 1000,
  currency: 'USD',
  reference: 'sandbox_order_001',
});

The sandbox terminal ID term_test_simulator is always available on every test account and requires no physical device. Use the following test card numbers to drive specific outcomes:

Card numberOutcome
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Card declined
4000 0000 0000 9995Insufficient funds
4000 0000 0000 0069Expired card

Webhook events are delivered to your registered test endpoint in real time. For full test card reference and network-specific scenarios, see Testing your integration.

Next stepsAsk

Was this page helpful?
Edit on GitHub

Last updated on

On this page