PlutoPay Docs
API Reference Dashboard ↗

#Terminal (in-person)

Accept card-present payments on a physical reader, driven from your own software. Your system creates the payment and hands it to the reader; the customer taps; the result comes back to you by webhook and is available by polling.

#Before you start: getting a reader

Physical readers are registered by PlutoPay. Email info@plutopayus.com with the reader's serial number and the address it will live at. We register it on the platform and link it to your account; it then appears in your Dashboard under Terminals and in GET /v1/terminal/readers. There is no self-service registration for physical readers yet.

Simulated readers, for test mode, you can create yourself — see Testing without a reader below.

#1 · Find your reader

GET /v1/terminal/readers
{
  "data": [
    {
      "id": "01a0983f-…",
      "name": "Office Reader",
      "serial_number": "STR70Z1D525003156",
      "model": "stripe_s700",
      "status": "online",
      "processor_terminal_id": "tmr_GqKoBAkoz3WmBy",
      "last_seen_at": "2026-09-13T00:51:43+00:00"
    }
  ]
}

Two ids, and they are used in different places:

Field Used for
id (PlutoPay UUID) terminal_id when creating the payment — attributes the transaction to this reader
processor_terminal_id (tmr_…) reader_id when processing on the reader

A test key lists test-mode readers; a live key lists live ones.

#2 · Create the payment

POST /v1/terminal/create-payment
Field Type Required Description
amount integer Cents. Minimum 50.
terminal_id uuid optional The reader's PlutoPay id. Step 3 attributes the transaction to the reader it is processed on, so this only matters when you want the attribution before step 3 — for example, to report on a payment that was created but never sent to a reader.
currency string Defaults to your account currency.
description string
customer_id uuid
metadata object Your own keys. Put your order or ticket id here (for example pos_order_id) — it is returned on every webhook and API read, and it is what lets you reconcile a payment to a sale.

Send an Idempotency-Key header. Your software will retry on a timeout, and without the key a retry creates a second payment. See Idempotency.

{
  "data": {
    "id": "019c8044-…",
    "reference": "txn_…",
    "payment_intent_id": "pi_…",
    "client_secret": "pi_…_secret_…",
    "amount": 4750,
    "currency": "usd",
    "status": "pending"
  }
}

payment_intent_id is what step 3 needs. client_secret is for SDK-driven readers and can be ignored on this path.

#3 · Hand it to the reader

POST /v1/terminal/process-payment
Field Type Required Value
payment_intent_id string payment_intent_id from step 2
reader_id string processor_terminal_id from step 1 — the tmr_…, not the UUID
{ "data": { "reader_id": "tmr_…", "status": "in_progress", "action_type": "process_payment_intent" } }

The reader now shows the amount and waits for a card.

#4 · What happens next

The reader's action moves through these states; you observe them through the transaction, not the reader:

Reader Transaction status You see it via
waiting for a card pending
card presented, processing pending
approved and captured succeeded payment.succeeded webhook · GET /v1/transactions/{id}
card declined failed (failure_code, e.g. card_declined) payment.failed webhook · GET /v1/transactions/{id}
customer cancels on the reader stays pending nothing fires — see below

A cancel on the device is silent. PlutoPay does not consume reader-side events, so when the customer presses cancel on the reader the transaction simply stays pending. Treat a pending that outlives your own timeout as abandoned: cancel it with POST /v1/transactions/{id}/cancel (step 5). Our own sweep also cancels stale pending terminal payments, but yours should not wait for it.

Listen for the webhook and poll as a fallback. Both carry card_brand, card_last_four, receipt_url, platform_fee_cents and tip_amount. Tipping on the reader changes the final amount — read amount and tip_amount from the result, not from what you sent.

There is no endpoint that reports the reader's own action state. If you need "is the reader still waiting?", poll the transaction: pending means yes.

#5 · Cancelling

The customer walked away:

POST /v1/transactions/{id}/cancel
{ "reason": "customer_left" }        // optional

For a terminal payment this resets the reader first, then cancels the payment. Read reader_reset on the response:

reader_reset Meaning
true Screen cleared, payment canceled. Done.
false Payment canceled, but the reader could not be reached (warning says why). A card presented now is declined; the screen clears on its own timeout.
null No reader was attributed to this payment — it was never sent to one with process-payment, and no terminal_id was given at creation. There is nothing to reset; if a reader is showing it anyway, POST /v1/terminal/cancel-action.

Two outcomes you must handle, both 409:

error.code What happened What to do
authorization_in_progress The customer had already tapped. The reader refuses to interrupt an authorisation, and so do we — the payment is not canceled. Poll the transaction. It will be succeeded or failed within seconds. If it succeeded and must not stand, refund it.
already_captured The tap completed and we captured before your cancel arrived. Refund it.

This is the race a POS always has — a tap and a cancel in the same second — and it is decided in the customer's favour by design. Your cancel button should expect it.

Cancelling an already-canceled payment returns it with 200; retries are safe.

The reader is stuck (showing "processing" after a network blip, or an amount nobody is going to pay, with no transaction to cancel):

POST /v1/terminal/cancel-action
{ "reader_id": "tmr_…" }

Clears the reader and touches nothing else. Also 409 authorization_in_progress if a card is mid-authorisation.

A reader reset does not stop a payment. If a card has been read, the authorisation completes and captures even after the reader reports itself reset — observed 6 times in 8 on a simulated reader. To abandon a payment, use POST /v1/transactions/{id}/cancel, which resets the reader and cancels the intent. Use cancel-action alone only when there is no payment to cancel.

#6 · Refunds

POST /v1/transactions/{id}/refunds

Full or partial (amount). Send an Idempotency-Key; a retried refund without one is a second refund. Details in Payments → Refund.

#Testing without a reader

Test mode does not need a physical device. With a test key:

1. Create a simulated reader — once.

POST /v1/terminals
{ "name": "Simulated reader", "registration_code": "simulated-wpe" }

This registers a simulated WisePOS E against your account's test-mode location. If you get location is required, your account has no test location yet — email us and we will add one; it takes a minute. The reader then appears in GET /v1/terminal/readers with your test key.

2. Run steps 2 and 3 above with the simulated reader's tmr_….

3. Present a test card:

POST /v1/terminal/simulate-payment
{ "reader_id": "tmr_…" }

The payment succeeds, payment.succeeded fires to your test webhook endpoint, and GET /v1/transactions/{id} shows visa ••4242, the receipt URL and the fee. That is the whole production loop, minus the tap.

POST /v1/terminals with a live key registers a physical reader by its registration code and requires a live location on your account — which is why physical readers are registered by us for now.