Accept bank transfers

Accept bank transfers — a runnable, end-to-end guide verified against the VINR sandbox.

View as MarkdownInstall skills

Bank transfers settle directly from a customer's bank account — low fees, no chargebacks, and high limits, at the cost of being asynchronous. This guide takes you from creating a transfer request to reconciling the incoming funds, runnable against the VINR sandbox.

OverviewAsk

Unlike a card charge, a bank transfer completes when the money actually arrives — which can be seconds (instant rails) or one to three business days (standard rails). VINR gives the customer payment instructions, then watches the banking network and emits a webhook once funds match.

your server          VINR              customer            bank
    │  create payment  │                   │                 │
    │─────────────────►│                   │                 │
    │  instructions    │                   │                 │
    │◄─────────────────│  IBAN + reference │                 │
    │──────────────────────────────────────►│  initiates xfer │
    │                  │                   │────────────────►│
    │  payment.completed (webhook)          │   funds settle  │
    │◄─────────────────│◄──────────────────────────────────────│
    │  fulfil order    │                   │                 │

Because settlement is asynchronous, fulfilment always happens on the webhook — never on the redirect.

Supported railsAsk

VINR auto-selects the rail from the customer's country and your currency. You can also pin one with paymentMethod.

RailpaymentMethodRegionSettlementRefundable
SEPA Credit Transfersepa_credit_transferEU / EEA1–2 business daysYes
SEPA Instantsepa_instantEU / EEASecondsYes
Faster Paymentsfaster_paymentsUKMinutesYes
ACH Creditach_creditUS1–3 business daysYes

Bank transfers are a push method: the customer initiates the transfer from their banking app using the IBAN and reference VINR returns. Amounts are integers in minor units — 1000 is EUR 10.00.

Create a transfer requestAsk

On your backend, create a payment with the bank-transfer method and read the bankTransfer instructions from the response.

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

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

export async function POST(req: Request) {
  const { orderId } = await req.json();

  const payment = await vinr.payments.create(
    {
      amount: 250000,                  // €2,500.00
      currency: 'EUR',
      paymentMethod: 'sepa_credit_transfer',
      description: `Invoice ${orderId}`,
      metadata: { orderId },
    },
    { idempotencyKey: `transfer-${orderId}` },   // safe to retry
  );

  // payment.status === 'pending' until funds arrive
  return Response.json({
    iban: payment.bankTransfer.iban,
    reference: payment.bankTransfer.reference,   // customer MUST include this
    amount: payment.amount,
  });
}

The customer must include the exact reference in their transfer — that's how VINR matches incoming funds to this pay_…. Surface it prominently in your UI and any emailed instructions.

A bank transfer starts in pending and may stay there for days. Do not block the customer or expire their order prematurely — set generous timeouts and reconcile on the webhook.

Reconciling incoming fundsAsk

Fulfil from the webhook so it happens exactly once, regardless of when the money lands. See the payment lifecycle for every event.

export async function POST(req: Request) {
  const event = vinr.webhooks.verify(
    await req.text(),
    req.headers.get('x-vinr-signature'),
  );

  switch (event.type) {
    case 'payment.completed':
      // full amount matched the reference — release the order
      await fulfillOrder(event.data.metadata.orderId);
      break;
    case 'payment.partially_paid':
      // underpayment: ask the customer to send the remainder
      await requestRemainder(event.data);
      break;
    case 'payment.expired':
      // no funds within the window — cancel or follow up
      await cancelOrder(event.data.metadata.orderId);
      break;
  }
  return new Response('OK', { status: 200 });
}

To reconcile in bulk — for accounting exports or a back-office dashboard — list payments by status and rail:

const pending = await vinr.payments.list({
  status: 'pending',
  paymentMethod: 'sepa_credit_transfer',
  limit: 100,
});

Refunds & returnsAsk

Because there's no card to reverse, a refund pushes funds back to the originating account VINR captured during settlement — no customer action needed.

const refund = await vinr.refunds.create({
  payment: 'pay_3xK9...',
  amount: 250000,            // omit for a full refund
  reason: 'order_canceled',
});
// refund.id → re_… , refund.status === 'pending' until the return settles

A refund settles on the same rail as the original transfer; watch refund.completed to confirm. Bank-initiated returns (closed account, recall) arrive as a payment.returned event after the funds had already completed — treat these like a clawback and reverse fulfilment.

Test itAsk

The sandbox simulates settlement so you don't have to wait. After creating a transfer, trigger an outcome from the Dashboard test panel or with the SDK:

curl -X POST https://sandbox.api.vinr.com/v1/test/bank_transfers/settle \
  -H "X-Api-Key: $VINR_SECRET_KEY" \
  -d payment=pay_3xK9... \
  -d outcome=completed   # or: partially_paid | expired | returned

Each outcome fires the matching webhook within a few seconds, so you can exercise your reconciliation logic end-to-end.

Go liveAsk

Swap to live keys

Replace your sandbox VINR_SECRET_KEY with the live key from the Dashboard.

Register your production webhook

Point a webhook endpoint at your live URL and subscribe to payment.completed, payment.partially_paid, payment.expired, and payment.returned.

Walk the go-live checklist

Confirm timeouts, partial-payment handling, and return handling with the go-live checklist.

Next stepsAsk

Was this page helpful?
Edit on GitHub

Last updated on

On this page