Payments · ACH · Billing gateway · REST

Merchant360 API

Build payments and billing into your own app. Take card-not-present payments and card-present payments on the merchant’s card reader through one API, run ACH debits/credits, refund, and drive invoices, estimates, customers, catalog, subscriptions, appointments, classes, and more – with scoped, rate-limited API keys, idempotent writes, signed webhooks, and a live sandbox.

Overview

Introduction

JSON over HTTPS. All amounts are integers in cents unless noted. Timestamps are ISO 8601 (UTC). Base URL:

https://www.merchant360.com/api/m360/v1

Every write accepts an optional Idempotency-Key header, and every list is paginated. Object responses include an object field naming the type. A machine-readable OpenAPI 3.1 spec lives at GET https://www.merchant360.com/api/m360/v1/openapi.json.

▶ See every tool working live on the examples page.

What this API covers
  • Card-not-present: keyed cards, saved cards (card on file), hosted fields, payment links, checkout sessions, the pay widget, ACH: Online payments.
  • Card-present on PAX terminals, through the same API: POST /payments with a terminal_id sends the sale to the merchant’s reader (A920 MAX, A920 Pro). Your software sends the amount; the reader takes the card, and the card data goes from the reader to the gateway, not through your software. Same key, same payment object, same webhooks: In-person payments.
  • Gift cards and loyalty: balance, load, redeem, void, transfer, issue, points, history: Gift & loyalty.
  • Billing: customers, invoices, estimates, credit memos, subscriptions, tax rates, vendor payments: Billing.
  • Reporting: GET /transactions returns every transaction the merchant ran, all tenders: Transactions feed.
Choose your integration

Answer a few questions about your software and get the ordered list of endpoints to code to, a test key, and a developer kit request.

Open the integration wizard
New here? Get a free test key.

Build against payments, the card vault, hosted fields, checkout, gift cards and loyalty in test mode. No merchant account needed.

Open the developer sandbox →
Which endpoints you need

Choose your integration

The integration wizard asks what your software does and returns the ordered list of endpoints to code to, with links into this reference. The short version:

FieldTypeDescription
Online onlyoptionalcard-not-presentPOST /payments with a card or a card_token from hosted fields, or a hosted page (Checkout Sessions, payment links). Then void / refund and webhooks.
POS, card present onlyoptionalcard-presentGET /terminal/devices, POST /payments with terminal_id, GET /payments/{id} or webhooks, cancel, then void / refund after the sale.
POS, card present + ecommerceoptionalbothBoth lists above, on the same key and the same payment object.
POS, card present + card on fileoptionalbothThe card-present list, plus POST /tokens or save_card on a keyed payment (each with customer_id or customer: a card on file always belongs to a customer) to get a card_token, then POST /payments with card_token for later charges.

Gift cards: Gift. Level II / III data: Level II & III (keyed and card-on-file charges). Tips on the reader: Tips. Keyed entry from your POS: Keyed entry.

Bearer key

Authentication

Create a key in Merchant360 under Developers & API (owner only). Send it as a bearer token on every request:

Authorization: Bearer m360_live_xxx

Keys are per-merchant, scoped, and rate-limited. Missing scope → 403. GET /me echoes the key’s merchant, scopes, and mode.

Two kinds of key use this header. A merchant key (m360_live_ / m360_test_) belongs to one merchant and needs nothing else. A platform key (m360_isv_) belongs to a technology partner and is the bearer token on every request the partner makes; the merchant the call is for is named in a second header, M360-On-Behalf-Of. See Platform keys (ISV).

No merchant yet? Grab a self-serve test key from the sandbox to build against payments, the card vault, hosted fields, checkout, gift cards and loyalty in test mode.

↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/me \
  -H "Authorization: Bearer m360_live_xxx"
Per-key permissions

Scopes

Grant a key only what it needs. :read permits GET, :write permits create/update/delete.

FieldTypeDescription
payments:read / :writeoptionalscopeRead payments; charge cards (keyed, card on file, card-present with terminal_id), authorize / capture / void / refund, cancel a pending reader sale, custom fields, accounting write-back.
cards:read / :writeoptionalscopeCard vault (tokens) + hosted-field sessions.
checkout:read / :writeoptionalscopeHosted Checkout Sessions.
ach:writeoptionalscopeACH debits and credits.
banks:read / :writeoptionalscopeSaved customer bank accounts (the ACH vault).
payment_links:read / :writeoptionalscopeHosted payment links.
customers:read / :writeoptionalscopeCustomer CRM and autopay enrollment.
invoices:read / :writeoptionalscopeInvoices (create, send, text, pay, void, delete).
estimates:read / :writeoptionalscopeEstimates.
credit_memos:read / :writeoptionalscopeCredit memos.
inventory:read / :writeoptionalscopeCatalog products + stock.
tax_rates:read / :writeoptionalscopeTax table.
recurring:read / :writeoptionalscopeSubscriptions, plans and metered usage.
scheduling:read / :writeoptionalscopeAppointments, service types, staff, classes, class sessions + bookings, staff shifts and schedule requests.
reservations:read / :writeoptionalscopeReservations (deposit, scheduled balance, security hold, claims, cancel, reinstate) and rental units.
reports:readoptionalscopeReport summaries and the transactions feed.
egift:readoptionalscopeGift-card balance and history (POST /egift/balance, POST /egift/history).
egift:writeoptionalscopeEvery other gift operation: activate, add value, redeem, void, tip adjust, store credit, deactivate, balance transfer, reissue, expiration, issue.
loyalty:readoptionalscopeLoyalty point balance and history (POST /loyalty/balance, POST /loyalty/history).
loyalty:writeoptionalscopeLoyalty activate, add points, redeem points, refund, transfer, void.
terminal:readoptionalscopeList the merchant’s card readers (GET /terminal/devices) and read earlier-API reader charges (GET /terminal/charges/{id}).
terminal:writeoptionalscopeThe earlier reader endpoints: POST /terminal/charges, cancel, and the reader receipt email. A card-present sale through POST /payments needs payments:write instead.
bnpl:read / :writeoptionalscopeBuy Now, Pay Later (Affirm + Klarna): sessions, charges, refunds.
vendor_payments:read / :writeoptionalscopeAccounts payable: payees, recorded payments, 1099/W-9.
tin_match:writeoptionalscopeIRS TIN Match (POST /tin-match). Granted by EPI only; not available on sandbox keys.
vt_embed:manageoptionalscopeMint, list and revoke Embedded Terminal keys (/vt-embed-keys), including for an ISV acting on behalf of a merchant.
settings:writeoptionalscopeUpdate accepted methods + installments (read is open on any key).
statements:readoptionalscopeDownload the merchant's processing statement PDF by period.
webhooks:manageoptionalscopeRegister/list/delete webhook endpoints.
Sandbox

Test mode

Keys prefixed m360_test_ run in sandbox: requests are validated and shaped exactly like live – payments, tokens, hosted fields, Checkout Sessions, and ACH all simulate end-to-end – but no money moves and no gateway/bank call is made. Test-mode activity is kept separate from the merchant’s live Transactions. Switch to a m360_live_ key to move real money.

Self-serve sandbox keys carry payments:*, cards:*, checkout:*, egift:*, loyalty:* and terminal:read. Gift and loyalty test cards: a number ending 0000 is not found (404); gift balance 2500 cents; 1000 points; a card ending 1111 holds 0 points; redeeming more points than the balance returns 402. See Gift cards and Loyalty. Card-present test sales: Card-present sale.

Test cards. Use any future expiry and any CVV. Unknown card numbers approve with a full AVS match; these exercise each decline and AVS path (declines return 402 with decline_code; test approvals carry avs_result):

Card numberOutcomeCode
4242 4242 4242 4242Approved (AVS: address + ZIP match)00 / Y
4000 0000 0000 0010Approved – AVS: address matches, ZIP does not00 / A
4000 0000 0000 0028Approved – AVS: ZIP matches, address does not00 / Z
4000 0000 0000 0036Approved – AVS: no match00 / N
4000 0000 0000 0002Declined – do not honor05
4000 0000 0000 9995Declined – insufficient funds51
4000 0000 0000 0069Declined – expired card54
4000 0000 0000 0127Declined – incorrect CVV82

The same numbers work on every test-mode surface – the API, hosted Checkout Sessions, and plugin test connections. A card_token containing the word decline also simulates a decline.

Test objects are unmistakable by id prefix – test_txn_ (payments), test_auth_ (authorizations), test_ach_ / ach_test (ACH), card_test_ (saved cards), test_re_ (refunds).

A920 MAX · A920 Pro · staging gateway

Developer kit

To build card-present, you need a reader. A developer kit is a debug PAX A920 MAX or A920 Pro connected to the Handpoint staging gateway. Electronic Payments registers the device on that gateway and connects it to a Merchant360 developer account: API calls with that account’s key reach the reader and run real card reads against test hosts. No money moves.

  1. Get a test key in the sandbox. Test keys simulate a reader: send terminal_id: "TEST/SIM0001" on POST /payments and build the whole flow before the device arrives.
  2. Request a developer kit on the wizard’s setup step. EPI sales confirms the model, price and shipping.
  3. Connect it to Merchant360. Get the 8-digit activation code from EPI, or from the ISV portal (Devices, Activation code). On the reader open Settings, Connect to Merchant360, and enter the code. The reader then appears in GET /terminal/devices and in the ISV portal.
  4. Go live with a production reader, a live key (m360_live_ or an ISV platform key), and a boarded merchant. A debug unit stays on staging.
Envelope

Errors

Every error – validation, auth, decline, rate limit – comes back in the same envelope: { error: { message, code } }. The code is a stable machine-readable string that derives from the HTTP status (400 → bad_request, 401 → unauthorized, 402 → payment_declined, and so on), so you can branch on either the status or the code. message is human-readable; log it, don’t parse it.

FieldTypeDescription
400 bad_requestoptionalstatusMalformed or missing parameters.
401 unauthorizedoptionalstatusMissing/invalid key, or not a Merchant360 key.
402 payment_declinedoptionalstatusThe card or refund was declined.
403 insufficient_scopeoptionalstatusThe key lacks the required scope.
404 not_foundoptionalstatusNo such object for this merchant.
409 conflictoptionalstatusIdempotency-Key in progress, or a booking clash.
422 unprocessableoptionalstatusValid shape but rejected by policy (limits, state).
429 rate_limitedoptionalstatusPer-key rate limit exceeded.

A declined card is an error, not a status: POST /payments returns 402 payment_declined with a decline_code saying why, and no payment object is created. Some 4xx responses carry a more specific code than the table above (e.g. 422 idempotency_mismatch, 409 already_settled) – those are called out on the endpoints that raise them.

↑ Request – what you send
{
  "error": {
    "message": "Your card was declined.",
    "code": "payment_declined"
  }
}
Safe retries

Idempotency

Send an Idempotency-Key header (any unique string, e.g. a UUID) on any POST. The request runs once per (merchant, key): once the original finishes, a retry with the same key replays the stored response instead of acting again. While the first attempt is still in flight, a concurrent duplicate returns 409 idempotency_in_progress; back off briefly and retry to receive the stored result. Reusing a key against a different method or path returns 422 idempotency_mismatch – mint a fresh key per logical operation.

Idempotency-Key: 8f14e45f-cea1-4a3d-9f2e-1b2c3d4e5f60
Lists

Pagination

Query
FieldTypeDescription
pageoptionalinteger1-based page number. Default 1.
page_sizeoptionalintegerItems per page, 1–100. Default 25.
↑ Request – what you send
{ "data": [ … ], "page": 1, "page_size": 25, "total": 132, "has_more": true }
/webhooks · webhooks:manage

Webhooks

POST/webhooksRegister an endpoint (returns the signing secret, shown once).
GET/webhooksList endpoints.
DELETE/webhooks/{id}Remove an endpoint.
Body
FieldTypeDescription
urlrequiredstringHTTPS endpoint to receive events.
eventsoptionalstring[]Event types to receive. Omit for all.

Each delivery is a POST whose body is an event envelope – { "id": "evt_…", "object": "event", "type": "payment.succeeded", "created": "…", "merchant_id": "…", "data": { … } } – where data is the full object the event is about (a payment, an ach_payment, a checkout session, …), so you rarely need a follow-up GET. Two headers ride along:

M360-Signature: t=<unix>,v1=<hex>
M360-Event: payment.succeeded

Verify by computing HMAC_SHA256(secret, `${t}.{raw_body}`) with your whsec_ secret and comparing to v1 – sign the raw body bytes, before any JSON parsing.

Respond 2xx quickly and do your work async. Anything else – including a timeout – is retried with backoff at roughly 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, and 12 h, up to 8 total attempts. Delivery ordering is not guaranteed (a retried ach.queued can arrive after ach.settled), and retries mean you can see the same event twice – process events idempotently by id. Endpoints must be https.

Event types
FieldTypeDescription
payment.succeededoptionaleventA card charge succeeded. For a card-reader sale (channel card_present) the data is the payment object.
payment.failedoptionaleventA card-reader sale was declined. Data = the payment object.
payment.canceledoptionaleventA card-reader sale was cancelled (on the reader, through POST /payments/{id}/cancel, or reversed by the reader). Data = the payment object; cancel_source is terminal or pos.
payment.authorizedoptionaleventA hold was placed (pre-auth).
payment.capturedoptionaleventA prior authorization was captured.
payment.voidedoptionaleventA hold or unsettled charge was voided.
payment.refundedoptionaleventA payment was refunded.
card.createdoptionaleventA reusable card was vaulted (save_card / POST /tokens).
invoice.createdoptionaleventAn invoice was created.
invoice.sentoptionaleventAn invoice was sent to the customer (emailed or texted).
invoice.paidoptionaleventAn invoice was paid in full.
invoice.voidedoptionaleventAn invoice was voided.
estimate.acceptedoptionaleventA customer e-signed and accepted an estimate.
subscription.createdoptionaleventA subscription started.
subscription.canceledoptionaleventA subscription was canceled.
ach.queuedoptionaleventAn ACH debit/credit was queued.
ach.settledoptionaleventAn ACH entry settled – funds moved. Your “funds are good” signal.
ach.returnedoptionaleventThe bank returned an ACH entry (payload carries return.code).
payment_link.paidoptionaleventA payment link was paid (instantly by card; on settlement by bank).
checkout.session.completedoptionaleventA customer paid a hosted Checkout Session.
webhook.testoptionaleventA test event you fired from the dashboard (Send test event).

Terminal-keyed sales: the data of a payment.succeeded fired by a virtual-terminal or Handpoint sale carries origin (EMBED_VT / EMBED_HP / M360_VT / M360_HP / ISO_VT) and embed_key_id – an embedding integration should keep events whose origin starts with EMBED_ and whose embed_key_id matches its own key, and drop the rest.

Every event payload includes a top-level merchant_id naming the merchant it belongs to – most useful for platform (ISV) webhooks, which receive events across many merchants on one endpoint.

▶ Try the live webhook inspector – fire signed events and watch them verify.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/webhooks \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://yourapp.com/hooks/m360",
  "events": [
    "payment.succeeded",
    "invoice.created"
  ]
}'
POST/GET /payments · payments:*

Payments

POST/paymentsCharge a card. Returns 201, or 402 on decline.
GET/paymentsList recent payments.
POST/accounting/record-paymentWrite-back: post the payment into the merchant's connected QuickBooks/Xero, applied to THEIR invoice by number. reference = your txn id (also the duplicate guard). GET /settings tells you which provider is connected (accounting.provider) and whether the merchant allows unattended recording (accounting.auto_record_from_extension). Send auto:true when no human confirmed the invoice number – that path requires the opt-in and refuses already-paid invoices instead of double-posting.
POST/servicetitan/record-paymentWrite-back for a charge taken against a ServiceTitan invoice: resolves the invoice number to its ServiceTitan id and posts a payment split onto it. Same body + auto semantics as the accounting write-back; GET /settings reports servicetitan.connected + servicetitan.auto_record_from_extension. Use THIS instead of /accounting/record-payment for ServiceTitan charges – ServiceTitan owns the invoice and its own accounting export carries the payment downstream, so writing to both would record it twice.

Card payments are synchronous – the response you get back is the final answer, and there is nothing to poll. A successful charge returns 201 with status: "succeeded"; the funds are committed to the merchant’s next settlement batch. A decline is not a status: it surfaces as a 402 error with code: "payment_declined" and a decline_code telling you why, and no payment object is created. On GET /payments, status is the derived succeeded or failed, plus a settled boolean that flips once the batch settles.

You don’t need webhooks to confirm a card charge – but payment.succeeded fires on every successful sale (and card.created when save_card vaults the card), which is the clean feed for reconciliation and for systems that don’t sit on the request path.

Origin attribution. Sales keyed on a Merchant360 terminal carry an origin on the payment object – so software embedding the Embedded Terminal can pull ONLY its own sales and ignore activity that originated elsewhere. Values:

origin values
FieldTypeDescription
EMBED_VToptionalstringKeyed on YOUR embedded terminal (embed_key_id names the embed key that carried the session).
EMBED_HPoptionalstringPushed to a physical Handpoint device from your embedded terminal.
M360_VToptionalstringKeyed on the in-app Merchant360 virtual terminal.
M360_HPoptionalstringPushed to a Handpoint device from the in-app hardware terminal.
API_HPoptionalstringPushed to a Handpoint device through the API (POST /payments with terminal_id).
TERMINALoptionalstringRun on the Handpoint device itself (card-present list only).
ISO_VToptionalstringKeyed on the sales office's ISO360 terminal view of this account.

GET /payments accepts ?origin=EMBED_VT and ?embed_key=<key id> filters, and every row serializes origin + embed_key_id (null on flows that aren’t terminal-keyed – invoices, pay links, API charges – which identify by their own objects/events instead).

Present the card ONE of four ways: a keyed card, a saved card_token (a card on file – see Cards), a card-present card_present capture from a terminal or mobile SDK, or a terminal_id that sends the sale to the merchant’s card reader (see Card-present overview). Exactly one is required.

Card-present (preview). card_present carries the EMV kernel output – emv_data is the DE 55 ICC TLV payload (hex), track_2 the chip’s track-2 image, ksn the DUKPT Key Serial Number for encrypting readers – and M360 forwards it to the Cygma host as ICCSystemRelatedData / Track2Data / KeySerialNumber with the matching POSEntryMode. Test keys simulate approvals today; live keys return card_present_unavailable (503) until EMV certification and the P2PE decryption key are in place – contact EPI to join the pilot.

Body
FieldTypeDescription
amountrequiredintegerAmount to charge, in cents (> 0).
currencyoptionalstringOnly usd (default).
terminal_idoptionalstringSend the sale to a card reader: an id from GET /terminal/devices, or default. Returns 202 with status pending. Use INSTEAD of card. See Card-present sale.
card_tokenoptionalstringCharge a saved card on file. Use INSTEAD of card. From save_card or POST /tokens. No customer is needed to charge a saved card; save_card is ignored with card_token.
card_present.entry_modeoptionalstringCard-present read type: chip, contactless, emv_fallback, swipe, or track1. Preview.
card_present.emv_dataoptionalstringDE 55 ICC TLV payload (hex) from the EMV kernel. Required for chip / contactless / emv_fallback.
card_present.track_2optionalstringTrack-2 data (raw swipe, or the chip's track-2 image on an EMV read).
card_present.track_1optionalstringTrack-1 data (track1 entry mode).
card_present.ksnoptionalstringDUKPT Key Serial Number when the capture device encrypts (P2PE).
card.numberoptionalstringPAN, digits (spaces ok). Required unless card_token or card_present is given.
card.exp_monthoptionalstringTwo digits, 01–12. Required with card.
card.exp_yearoptionalstringTwo or four digits (27 or 2027). Required with card.
card.cvvoptionalstringSecurity code.
cardholder.nameoptionalstringFull name; split into first/last.
cardholder.emailoptionalstringReceipt/AVS.
cardholder.phoneoptionalstringContact.
cardholder.zipoptionalstringBilling ZIP (AVS).
cardholder.addressoptionalstringBilling street (AVS).
sales_taxrequired to qualifyintegerTax portion in cents. Activates Level II – see Level II & III.
invoice_numberoptionalstringYour invoice / order reference, max 120 chars. Sent to the processor as the invoice reference and echoed back on the payment. Supersedes description, which still works.
customer_codeoptionalstringYour identifier for the customer, max 64 chars. Stored with the payment and echoed back, so you can reconcile without keeping a side table.
line_itemsrequired to qualifyarrayLevel III line-item detail, max 15 items. Full field reference, tier requirements, and a complete example: Level II & III.
freightoptionalintegerTotal freight / shipping cents included in amount (Level III header field).
descriptionoptionalstringReference shown on the transaction (≤120 chars). Also used as the Level II/III merchant order reference.
save_cardoptionalbooleanVault the card and return a reusable card_token for future charges/subscriptions. A card on file always belongs to a customer: requires customer_id or customer. Without either the request returns 400 customer_required before anything is charged. A one-time sale without save_card keeps its gateway token on the transaction for refund and void only; that is not a saved card.
customer_idoptionalstringAn existing customer of the merchant (from POST /customers or GET /customers). The saved card is attached to it. Not the merchant’s customer: 404 customer_not_found.
customeroptionalobject{ name, email?, phone?, address?: { line1, line2, city, state, zip } }. The customer is created with the card, matched first to an existing customer of the merchant by email or phone so it does not duplicate. name is required (400 customer_invalid without it). Test keys (m360_test_) run the same checks but write no customer.
surchargeoptionalstringKeyed charges only. "auto" applies the merchant’s surcharge program server-side: BIN-gated to CREDIT cards, capped at 3%, and auto-dropped (clean resubmit) if the network refuses the fee. amount stays the BASE owed; the response’s amount is the total charged, itemized via amount_base / amount_surcharge / surcharge_pct. Card-brand rules require disclosing the fee before charging – quote it first (below).
Response
FieldTypeDescription
idoptionalstringPayment id. Use it for refunds, voids, and lookups.
statusoptionalstringsucceeded on approval. A decline returns HTTP 402 with an error envelope, not this object.
amountoptionalintegerTotal charged in cents. With surcharge this is the total; amount_base and amount_surcharge ride alongside.
card.last4optionalstringLast four of the PAN used.
invoice_numberoptionalstringEcho of what you sent (or of the legacy description field). Null when neither was supplied.
customer_codeoptionalstringEcho of what you sent. Null when not supplied.
customeroptionalobjectGET only. { name, email } of the payer, resolved from the invoice, payment link, checkout session, reservation, saved card or terminal entry behind the payment. Null when nothing named a customer.
descriptionoptionalstringGET only. What the payment was for, from the same record as the customer. Null when none was recorded.
processor.auth_codeoptionalstringIssuer approval code. This is the number to print on a receipt and to quote when disputing.
processor.response_codeoptionalstringRaw network response code (00 approved, 10 partial approval).
processor.response_messageoptionalstringHuman-readable result from the host. Log it; don't parse it.
processor.avs_resultoptionalstringAddress-verification verdict. Common values: Y address and ZIP match, Z ZIP only, A address only, N neither, U unavailable.
processor.cvv_resultoptionalstringSecurity-code verdict: M match, N no match, P not processed, S should be present but was not, U issuer unavailable.
processor.rrnoptionalstringRetrieval reference number. The strongest key for locating this transaction with the processor later.
card_tokenoptionalstringSaved-card token when save_card or card_token was used. vault_token is a back-compat alias.
metadataoptionalobjectYour key/value metadata, echoed back.
modeoptionalstringlive or test.

The response returns the saved card as card_token; vault_token is kept as a back-compat alias for the same value. With surcharge: "auto" no reusable token is returned – vault the card via POST /tokens when you need both. surcharge: "auto" composes with line_items – a surcharged sale still carries the Level III packet.

GET /payments – query
FieldTypeDescription
page / page_sizeoptionalintegerPagination.
viaoptionalstringFilter to payments whose metadata via equals this value – e.g. the Payment Extension lists only its own charges with via=payment-extension.
GET /payments/surcharge-quote – query
FieldTypeDescription
amountrequiredintegerBase cents owed.
binrequiredstringThe card’s first 6–8 digits – never send a full PAN here. Returns { surcharge_cents, total_cents, pct, card_type, brand } using the exact logic surcharge: "auto" applies at charge time, so the quote and the charge always agree. Show the itemized fee to the cardholder before you call POST /payments.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 2500,
  "card": {
    "number": "4242424242424242",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123"
  },
  "cardholder": {
    "name": "Jane Doe",
    "zip": "90210",
    "email": "jane@example.com"
  },
  "description": "Order #1042",
  "save_card": true,
  "customer": {
    "name": "Jane Doe",
    "email": "jane@example.com"
  }
}'
Interchange qualification · commercial cards

Level II & Level III

Card networks price commercial cards (business / corporate / purchasing) by how much transaction data is submitted – the three data levels below. You send the business data on POST /payments; the gateway assembles and transmits the full processor packet. Nothing else changes about the call.

The three data levels – what you send on POST /payments
FieldTypeDescription
Level Ioptionaldata levelA plain charge – amount + card. No additional data submitted.
Level IIoptionaldata levelAdd sales_tax (the tax portion of amount, in cents) and a description. The gateway completes the rest of the Level II data set automatically.
Level IIIoptionaldata levelAdd line_items (with commodity codes) + freight on top of Level II. Line-item detail is transmitted to the network per item.

How it comes together – you send vs the gateway adds

You send (API fields)The gateway adds (certified Cygma packet)
sales_taxSales tax amount + collected indicator, merchant order reference, customer code, e-commerce indicator (Level II set)
line_items[]Per-item Level III TLV records (product code, description, commodity, quantity, unit of measure, amounts, debit/credit + discount indicators) – max 15 items
freightFreight amount + credit/debit indicator, plus discount and duty header fields with their indicators
descriptionMerchant order / customer reference number
cardholder.zip / merchant profileDestination + ship-from postal codes, destination country, order date, entry coding
line_items[] – each item
FieldTypeDescription
descriptionrequiredstring ≤26What was sold. 26 characters is the card-network line-description limit.
unit_costrequiredintegerCents per unit.
commodity_coderequired to qualifystring ≤15NIGP commodity code (numeric). The parser accepts its absence, but the card networks require it for the Level III rate – without it the line downgrades. Look up codes in the M360 inventory item editor, or download the full list (2,839 codes): Cygma docs → Level III.
quantityrecommendedinteger ≥1Whole units. Defaults to 1 when omitted.
amountauto-derivedintegerExtended line total in cents. Defaults to quantity × unit_cost − discount – send it only when your own math differs (rounding).
product_codeauto-derivedstring ≤12SKU / product code. Derived from the description when omitted.
unit_of_measureauto-derivedstring ≤12Unit code – EA each (default), BX box, HR hour, LB pound…
discountoptionalintegerLine discount cents (already reflected in the line total). Defaults to 0.

Rules that matter

  • Amounts must reconcile. amount (the charge) = line totals + freight + sales_tax. The example: $89.99 + $5.95 + $7.76 = $103.70.
  • Max 15 line items – the processor limit. Longer orders: send the 15 largest lines; totals still reconcile via the header amounts.
  • Send Level III only on full payments. A partial payment can’t reconcile to the line items – send sales_tax (prorated) alone and it qualifies at Level II.
  • Consumer cards are unaffected. The extra data is ignored on non-commercial cards – always safe to send.
  • Qualification is decided at settlement, not authorization. The approval response looks identical either way; the qualification level appears in the merchant’s interchange detail.
  • Works with surcharge: "auto" and on invoice payments (invoice line items carry Level III automatically when the invoice is paid in full).
Level II – POST /payments ($134.00 sale incl. $11.56 tax)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 14556,
  "sales_tax": 1156,
  "invoice_number": "INV-2001",
  "customer_code": "ACME-0042",
  "card": {
    "number": "4242424242424242",
    "exp_month": "03",
    "exp_year": "2028",
    "cvv": "123"
  },
  "cardholder": {
    "name": "MICHAEL NARDY",
    "zip": "11933",
    "address": "1161 SCOTT AVE"
  }
}'
Level III – POST /payments ($89.99 item + $5.95 freight + $7.76 tax = $103.70)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 10370,
  "sales_tax": 776,
  "freight": 595,
  "description": "INV-2002",
  "card": {
    "number": "4242424242424242",
    "exp_month": "03",
    "exp_year": "2028",
    "cvv": "123"
  },
  "cardholder": {
    "name": "MICHAEL NARDY",
    "zip": "11933",
    "address": "1161 SCOTT AVE"
  },
  "line_items": [
    {
      "description": "WHITENING GEL KIT 22PCT",
      "commodity_code": "27055",
      "quantity": 1,
      "unit_cost": 8999
    }
  ]
}'
Level III, multi-item – POST /payments ($84.00 lines + $4.99 freight + $7.25 tax = $96.24)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 9624,
  "sales_tax": 725,
  "freight": 499,
  "description": "INV-2005",
  "card": {
    "number": "4242424242424242",
    "exp_month": "03",
    "exp_year": "2028",
    "cvv": "123"
  },
  "cardholder": {
    "name": "MICHAEL NARDY",
    "zip": "11933"
  },
  "line_items": [
    {
      "description": "STERILIZATION POUCHES BX",
      "commodity_code": "46590",
      "unit_of_measure": "BX",
      "quantity": 2,
      "unit_cost": 2450
    },
    {
      "description": "HOME CARE KIT",
      "commodity_code": "27055",
      "quantity": 1,
      "unit_cost": 4000,
      "discount": 500,
      "amount": 3500
    }
  ]
}'
Fuel · hotel · auto rental · airline · and more

Industry addenda

An industry addendum describes what was bought, in the vocabulary the card networks defined for that trade – gallons and a pump price for fuel, arrival and departure dates for a hotel, a ticket number and an itinerary for an airline. It is the same mechanism as Level III: the charge approves either way, and the data qualifies the transaction for the industry interchange rate.

Send it as an industry object on POST /v1/payments. type names the industry; every other key is one of that industry’s fields. M360 validates the object, scales each value to the implied decimals its field uses, pads it to width, and adds the IndustryIndicator the processor needs. You send readable values – "4.2980", "2026-09-01" – and never a zero-filled integer.

Rules that matter

  • Unknown field names are rejected. A typo returns a 400 naming the field rather than being dropped. Dropping it would authorize the sale, miss the industry rate, and leave you nothing to debug – the failure would show up weeks later in an interchange statement.
  • Money and quantities are decimal STRINGS ("3.4900"), unlike amount which stays an integer number of cents. The scales differ per FIELD, and a single fuel object carries three of them – unit price to four places, quantity to three, the sale amount to two. M360 reads the scale off the field definition so you never have to; send the number as you would write it.
  • Dates are YYYY-MM-DD and times are HH:MM.
  • An empty addendum is rejected. Naming an industry and sending no fields tells the processor to expect a hotel and then describes none. Send at least one field or omit industry entirely.
  • Keyed charges only – with or without surcharge: "auto". A card_token charge has no packet hook for the addendum today.
  • Not every brand carries every industry. Where a profile or a field is defined by only some networks, the tables below say so, and M360 omits it on a card outside that set rather than sending data the network will not read. The clearest case is fuel: "FUEL" is the Mastercard and Discover profile, and Visa fuel data rides "FLEET" – there is no separate Visa fuel profile. Send the one that matches the card.
  • Consumer cards are unaffected – the extra data is ignored, so it is always safe to send.
  • Qualification is decided at settlement, not authorization. The approval looks identical either way.

Fuel and fleet product codes come from the Visa Fleet 2.0 tables, which are a different catalogue from the NIGP commodity codes used by Level III – and the fuel and non-fuel tables are separate namespaces from each other, so the same two characters mean different things in each. The full tables, with Conexxus equivalents: visa-fleet-product-codes.xlsx.

Industries carried
FieldTypeDescription
"type": "FUEL"optionalJ26 = 8Fuel — Pump and product detail for a fuel purchase. Mastercard and Discover carry different subsets; Visa fuel rides the Fleet profile. 14 fields.
"type": "AIRLINE"optionalJ26 = 1Airline — Ticket, passenger and itinerary detail. Captures the ticket header and the first air segment. 29 fields.
"type": "HEALTHCARE"optionalJ26 = 18Healthcare — Provider and payer identifiers. Per-line healthcare eligibility is set on the Level III grid. 3 fields.
"type": "CRUISE"optionalJ26 = 19Cruise — Sailing, itinerary and the air leg to the port, plus agency identifiers. 19 fields.
"type": "RAIL"optionalJ26 = 15Rail — Ticket, journey and service detail for rail travel. 19 fields.
"type": "ELECTRIC_FUEL"optionalJ26 = 26Electric vehicle charging — EV charging session — connector, energy, timings and station capacity. Required for MCC 5552 in Europe now and in the US by 2030. 15 fields.
"type": "TRAVEL"optionalJ26 = 17Travel agency — Agency identifiers and the service fee charged on a travel booking. 7 fields.
"type": "INSURANCE"optionalJ26 = 22Insurance — Policy, insured party and premium detail. 7 fields.
"type": "TELEPHONE"optionalJ26 = 14Telephone — Originating and destination numbers for a call-based charge. 3 fields.
"type": "TICKET_ENTERTAINMENT"optionalJ26 = 16Ticketing / entertainment — Event, venue and ticket detail. 9 fields.
"type": "VISA_TRANSPORT_ANCILLARY"optionalJ26 = 23Transport ancillary — Baggage, seating or other purchases attached to a travel document rather than the ticket. 4 fields.
"type": "HOTEL"optionalJ26 = 4Hotel / lodging — Lodging enhanced data - the stay, the room, and itemised incidentals. Carried on all brands. 27 fields.
"type": "AUTO_RENTAL"optionalJ26 = 6Auto rental — Vehicle rental enhanced data - agreement, pickup and return detail, distance and adjustments. 21 fields.
"type": "FLEET"optionalJ26 = 25Fleet — Visa Fleet enhanced data — fuel type, quantity, pricing and the driver/vehicle prompts a fleet card asks for. 20 fields.
FuelJ26 = 814 fields · MC · DISC

Pump and product detail for a fuel purchase. Mastercard and Discover carry different subsets; Visa fuel rides the Fleet profile.

Consistency: fuel_quantity × fuel_unit_price must equal the amount authorized and fuel_sale_amount. Solve to the field’s own precision — the rounded product will not always reproduce the amount to the cent, and the amount is what is charged.

Site
FieldTypeDescription
company_brand_namerecommendedB62 · an4Brand — Brand at the pump, 4 characters (e.g. SHEL). Pre-filled from the merchant name.
purchase_timerecommendedB63 · n4 · HHMMPurchase time — Local time at the pump, HH:MM. MC only.
fuel_service_typerecommendedB64 · an1 · enumService type Values: S, F, H. MC only.
Product
FieldTypeDescription
fuel_coderequired to qualifyB69 · an2 · enumFuel code — Visa Fuel Type Code. 121 defined values. DISC only.
fuel_unit_pricerequired to qualifyB71 · n12 · 4dp impliedPrice per gallon — Dollars per gallon, e.g. 3.499. MC only.
fuel_quantityrequired to qualifyB72 · n6 · 3dp impliedQuantity (gallons) — Gallons dispensed, e.g. 12.153. MC only.
fuel_sale_amountrecommendedB73 · n12 · 2dp impliedFuel sale amount — The fuel portion of the sale. MC only.
Tax
FieldTypeDescription
total_tax_amountrecommendedB65 · n12 · 2dp impliedTotal tax
total_tax_collect_indicatorrecommendedB66 · an1 · enumTax collected Values: Y, N. MC only.
state_sales_tax_amountrecommendedB67 · n12 · 2dp impliedState sales tax — Pre-filled from the merchant's default tax rate in Settings. DISC only.
state_sales_tax_idrequired to qualifyB68 · an1State tax ID — One character. DISC only.
tax_exempt_numberrequired to qualifyB70 · n12Tax exempt number — Digits only, up to 12. DISC only.
Vehicle
FieldTypeDescription
odometer_readingrequired to qualifyH165 · n7Odometer — Whole miles. Digits only - no commas, no decimals.
Tax
FieldTypeDescription
exempt_indicatorrequired to qualify— · an1Exempt indicator
AirlineJ26 = 129 fields · All brands

Ticket, passenger and itinerary detail. Captures the ticket header and the first air segment.

Ticket
FieldTypeDescription
ticket_numberrequired to qualifyC02 · an15Ticket number
passenger_namerequired to qualifyC10 · an25Passenger name
transaction_typerequired to qualifyC01 · an2Transaction type
document_typerequired to qualifyC03 · an2Document type
ticket_issue_daterequired to qualifyC08 · n8 · YYYYMMDDIssue date
ticket_issue_cityrequired to qualifyC07 · an18Issue city
ticketing_carrierrequired to qualifyC06 · an25Ticketing carrier
iata_coderequired to qualifyC05 · n8IATA code
electronic_ticketrecommendedC14 · an1 · enumElectronic ticket Values: E, P.
restricted_ticketrequired to qualifyC75 · an1 · enumRestricted ticket Values: N, R.
number_in_partyrecommendedC09 · n3Passengers
total_farerecommendedC78 · n12 · 2dp impliedTotal fare
Itinerary
FieldTypeDescription
total_segmentsrecommendedC15 · n2Air segments — Total legs on the ticket. Only the first is captured here.
departure_locationrequired to qualifyC18 · an5From (airport)
arrival_locationrequired to qualifyC20 · an5To (airport)
departure_daterequired to qualifyC19 · n8 · YYYYMMDDDeparture date
departure_timerequired to qualifyC79 · n4 · HHMMDeparture time — HH:MM
arrival_timerequired to qualifyC80 · n4 · HHMMArrival time — HH:MM
segment_carrierrequired to qualifyC21 · an4Carrier
flight_numberrequired to qualifyC24 · an6Flight number
class_of_servicerequired to qualifyC23 · an3Class of service
fare_basisrequired to qualifyC22 · an15Fare basis
segment_farerequired to qualifyC25 · n12 · 2dp impliedSegment fare
stop_overrequired to qualifyC17 · an1 · enumStopover Values: O, X.
Agency
FieldTypeDescription
travel_agency_coderequired to qualifyC73 · an8Agency code
travel_agency_namerequired to qualifyC74 · an25Agency name
customer_coderequired to qualifyC71 · an17Customer code
ticket_change_indicatorrequired to qualifyC77 · an1Ticket change
credit_reason_indicatorrequired to qualifyC76 · an1Credit reason
HealthcareJ26 = 183 fields · All brands

Provider and payer identifiers. Per-line healthcare eligibility is set on the Level III grid.

Provider
FieldTypeDescription
provider_idrequired to qualifyP27 · an15Provider ID — Visa healthcare provider identifier. VISA only.
service_type_coderequired to qualifyP28 · an4Service type VISA only.
payer_idrequired to qualifyP31 · an15Payer ID
CruiseJ26 = 1919 fields · All brands

Sailing, itinerary and the air leg to the port, plus agency identifiers.

Booking
FieldTypeDescription
passenger_namerequired to qualifyC38 · an25Passenger name
ticket_numberrequired to qualifyC39 · an15Ticket number
cruise_namerequired to qualifyC50 · an25Cruise / ship name
departure_daterequired to qualifyC46 · n8 · YYYYMMDDDeparture date
return_daterequired to qualifyC47 · n8 · YYYYMMDDReturn date
number_of_daysrequired to qualifyC49 · n3Nights
total_costrecommendedC48 · n12 · 2dp impliedTotal cost
class_coderequired to qualifyC45 · an3Class
travel_packagerequired to qualifyC37 · an1 · enumTravel package Values: Y, N.
Itinerary
FieldTypeDescription
destination_coderequired to qualifyC41 · an5Destination
city_namerequired to qualifyC53 · an18City
region_coderequired to qualifyC51 · an3Region
country_coderequired to qualifyC52 · an3Country
Air
FieldTypeDescription
departure_airportrequired to qualifyC42 · an5Departure airport
air_carrier_coderequired to qualifyC43 · an4Air carrier
flight_numberrequired to qualifyC44 · an6Flight number
depart_daterequired to qualifyC40 · n8 · YYYYMMDDFlight date
Agency
FieldTypeDescription
iata_carrier_coderequired to qualifyC35 · an4IATA carrier
iata_agency_numberrequired to qualifyC36 · an8IATA agency number
RailJ26 = 1519 fields · All brands

Ticket, journey and service detail for rail travel.

Ticket
FieldTypeDescription
transaction_typerequired to qualifyC26 · an2Transaction type
ticket_numberrequired to qualifyC27 · an15Ticket number
passenger_namerequired to qualifyC28 · an25Passenger name
carrier_coderequired to qualifyC29 · an4Carrier
issuer_namerequired to qualifyC30 · an25Issuer name
issuer_cityrequired to qualifyC31 · an18Issuer city
Journey
FieldTypeDescription
departure_locationrequired to qualifyC32 · an5From
arrival_locationrequired to qualifyC34 · an5To
departure_daterequired to qualifyC33 · n8 · YYYYMMDDDeparture date
rail_classrequired to qualifyC60 · an3Class
number_of_adultsrecommendedC58 · n3Adults
number_of_childrenrequired to qualifyC59 · n3Children
Service
FieldTypeDescription
traveller_namerequired to qualifyC54 · an25Traveller name
service_ticket_numrequired to qualifyC55 · an15Service ticket number
service_typerequired to qualifyC56 · an3Service type
service_naturerequired to qualifyC57 · an3Service nature
service_amountrequired to qualifyC61 · n12 · 2dp impliedService amount
service_amount_signrequired to qualifyC62 · an1 · enumAmount sign Values: D, C.
procedure_idrequired to qualifyC63 · an8Procedure ID
Electric vehicle chargingJ26 = 2615 fields · All brands

EV charging session — connector, energy, timings and station capacity. Required for MCC 5552 in Europe now and in the US by 2030.

Consistency: quantity × unit_price must equal the amount authorized and total_including_tax. Solve to the field’s own precision — the rounded product will not always reproduce the amount to the cent, and the amount is what is charged.

Session
FieldTypeDescription
connector_typerequired to qualifyS30 · an3 · enumConnector type 9 defined values.
unit_of_measurerecommendedS37 · an1 · enumUnit of measure — Electric sessions bill by kWh or by minute. Values: W, C.
quantityrequired to qualifyS42 · n12 · 4dp impliedQuantity (kWh)
unit_pricerequired to qualifyS39 · n12 · 4dp impliedPrice per kWh
total_including_taxrecommendedS48 · n12 · 2dp impliedTotal including tax
start_timerequired to qualifyS46 · n4 · HHMMCharge start — HH:MM
finish_timerequired to qualifyS47 · n4 · HHMMCharge finish — HH:MM
total_charging_timerequired to qualifyS45 · n6Charging time (min)
total_time_plugged_inrequired to qualifyS44 · n6Plugged in (min) — Can exceed charging time - idle minutes are often billed separately.
Station
FieldTypeDescription
max_power_dispensedrequired to qualifyS31 · n6Max power dispensed (kW)
power_capacityrequired to qualifyS36 · n6Station capacity (kW) — May exceed max dispensed when the site manages power.
charging_reason_coderequired to qualifyS35 · an3 · enumCharging reason — Only when the session ended abnormally. 10 defined values.
Vehicle
FieldTypeDescription
est_miles_addedrequired to qualifyS34 · n6Est. miles added
est_vehicle_miles_availablerequired to qualifyS32 · n6Est. range on leaving
carbon_footprintrequired to qualifyS33 · n12Carbon avoided (g CO2e)
Travel agencyJ26 = 177 fields · All brands

Agency identifiers and the service fee charged on a travel booking.

Agency
FieldTypeDescription
agency_coderequired to qualifyH101 · an8Agency code
agency_namerecommendedH102 · an25Agency name
agency_seq_numberrequired to qualifyH085 · an8Sequence number
Fee
FieldTypeDescription
fee_amountrequired to qualifyH086 · n12 · 2dp impliedAgency fee
fee_amount_signrecommendedH087 · an1 · enumFee sign Values: D, C.
fee_raterequired to qualifyH088 · n6 · 2dp impliedFee rate (%)
fee_descriptionrecommendedH089 · an25Fee description
InsuranceJ26 = 227 fields · All brands

Policy, insured party and premium detail.

Policy
FieldTypeDescription
policy_numberrequired to qualifyH148 · an25Policy number
additional_policy_numberrequired to qualifyH152 · an25Additional policy number
type_of_policyrequired to qualifyH153 · an25Policy type
name_of_insuredrequired to qualifyH154 · an30Name of insured
Premium
FieldTypeDescription
premium_frequencyrequired to qualifyH151 · an12 · enumPremium frequency Values: Monthly, Quarterly, Annual, Single.
insurance_amountrecommendedH077 · n12 · 2dp impliedPremium amount
insurance_indicatorrecommendedH131 · an1 · enumInsurance indicator Values: Y, N.
TelephoneJ26 = 143 fields · All brands

Originating and destination numbers for a call-based charge.

Call
FieldTypeDescription
call_from_phone_numberrequired to qualifyJ73 · n15 · digits onlyCall from
call_to_phone_numberrequired to qualifyJ77 · n15 · digits onlyCall to
phone_card_idrequired to qualifyJ78 · an20Phone card ID
Ticketing / entertainmentJ26 = 169 fields · All brands

Event, venue and ticket detail.

Event
FieldTypeDescription
event_namerecommendedJ60 · an25Event name
event_daterequired to qualifyJ61 · n8 · YYYYMMDDEvent date
event_locationrequired to qualifyJ64 · an25Venue
event_region_coderequired to qualifyJ65 · an3Region
event_country_coderequired to qualifyJ66 · an3Country
Tickets
FieldTypeDescription
ticket_quantityrecommendedJ63 · n4Tickets
individual_ticket_pricerequired to qualifyJ62 · n12 · 2dp impliedPrice per ticket
ticket_typerequired to qualifyC86 · an4Ticket type
issuer_addressrequired to qualifyC83 · an25Issuer address
Transport ancillaryJ26 = 234 fields · All brands

Baggage, seating or other purchases attached to a travel document rather than the ticket.

Document
FieldTypeDescription
ticket_document_numberrequired to qualifyC65 · an15Ticket document number
additional_document_numberrequired to qualifyC69 · an15Additional document number
passenger_namerequired to qualifyC68 · an25Passenger name
credit_reason_indicatorrequired to qualifyC70 · an1Credit reason
Hotel / lodgingJ26 = 427 fields · All brands

Lodging enhanced data - the stay, the room, and itemised incidentals. Carried on all brands.

Stay
FieldTypeDescription
arrival_daterequired to qualifyH016 · n8 · YYYYMMDDArrival date
departure_daterequired to qualifyH017 · n8 · YYYYMMDDDeparture date
folio_numberrequired to qualifyH018 · an12Folio number
room_raterequired to qualifyH021 · n12 · 2dp impliedRoom rate (nightly)
room_taxrequired to qualifyH022 · n12 · 2dp impliedRoom tax
number_of_roomsrecommendedH008 · n3Rooms booked
number_of_adultsrequired to qualifyH009 · n3Adults
no_show_indicatorrecommendedH011 · an1 · enumNo-show Values: N, Y.
Room
FieldTypeDescription
room_typerequired to qualifyH006 · an12Room type
bed_typerequired to qualifyH005 · an12Bed type
room_locationrequired to qualifyH004 · an12Room location
smoking_preferencerequired to qualifyH007 · an1 · enumSmoking Values: N, S.
rate_typerequired to qualifyH012 · an12Rate type
program_coderequired to qualifyH023 · an12Program code
promotional_coderequired to qualifyH001 · an12Promotional code
corporate_client_coderequired to qualifyH003 · an12Corporate client code
Incidentals
FieldTypeDescription
phone_chargesrequired to qualifyH024 · n12 · 2dp impliedPhone
restaurant_chargesrequired to qualifyH025 · n12 · 2dp impliedRestaurant / room service
mini_bar_chargesrequired to qualifyH026 · n12 · 2dp impliedMini bar
laundry_chargesrequired to qualifyH027 · n12 · 2dp impliedLaundry
gift_shop_chargesrequired to qualifyH030 · n12 · 2dp impliedGift shop
movie_chargesrequired to qualifyH032 · n12 · 2dp impliedMovies
health_club_chargesrequired to qualifyH033 · n12 · 2dp impliedHealth club
valet_parking_chargesrequired to qualifyH034 · n12 · 2dp impliedValet parking
cash_disbursement_chargesrequired to qualifyH035 · n12 · 2dp impliedCash disbursement
other_chargesrequired to qualifyH028 · n12 · 2dp impliedOther
adjustment_amountrequired to qualifyH020 · n12 · 2dp impliedAdjustment
Auto rentalJ26 = 621 fields · All brands

Vehicle rental enhanced data - agreement, pickup and return detail, distance and adjustments.

Agreement
FieldTypeDescription
agreement_numberrequired to qualifyB01 · an25Agreement number — Rental agreement number signed by the cardholder.
rate_indicatorrequired to qualifyB38 · an1 · enumRate type Values: D, W, M.
raterequired to qualifyB39 · n12 · 2dp impliedRate
vehicle_class_idrequired to qualifyB14 · an4Vehicle class
driver_tax_numberrequired to qualifyB22 · an20Driver tax number
Pickup
FieldTypeDescription
pickup_daterequired to qualifyB06 · n8 · YYYYMMDDPickup date
pickup_timerequired to qualifyB07 · n4 · HHMMPickup time — HH:MM
pickup_locationrequired to qualifyB02 · an26Location
pickup_cityrequired to qualifyB03 · an18City
pickup_regionrequired to qualifyB04 · an3State / region
pickup_countryrequired to qualifyB05 · an3Country
Return
FieldTypeDescription
return_daterequired to qualifyB11 · n8 · YYYYMMDDReturn date
return_timerequired to qualifyB12 · n4 · HHMMReturn time — HH:MM
dropoff_locationrequired to qualifyB19 · an26Drop-off location
return_cityrequired to qualifyB08 · an25City
return_regionrequired to qualifyB09 · an3State / region
return_countryrequired to qualifyB10 · an3Country
distancerequired to qualifyB15 · n5Distance travelled — Whole units.
distance_uomrecommendedB16 · an1 · enumDistance unit Values: M, K.
adjustment_indicatorrequired to qualifyB17 · an1Adjustment type
adjustment_amountrequired to qualifyB18 · n12 · 2dp impliedAdjustment amount
FleetJ26 = 2520 fields · VISA

Visa Fleet enhanced data — fuel type, quantity, pricing and the driver/vehicle prompts a fleet card asks for.

Consistency: quantity × per_unit_cost must equal the amount authorized and gross_fuel_price. Solve to the field’s own precision — the rounded product will not always reproduce the amount to the cent, and the amount is what is charged.

Purchase
FieldTypeDescription
business_application_idrecommendedP25 · an2Business application — Fleet business application identifier. F1 per Cygma's Visa Fleet sample.
Product
FieldTypeDescription
fleet_fuel_typeoptionalH157 · an2Fuel type (2-char) — Two-character fuel type, e.g. GA. Sent alongside the expanded fuel type. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only.
Purchase
FieldTypeDescription
type_of_purchaserequired to qualifyS51 · an1 · enumType of purchase — MANDATORY on fleet. Drives which of the fields below Visa requires. Values: 1, 2, 3, 4.
expanded_fuel_typeoptionalS25 · an4 · enumFuel grade — Grade name as Cygma's host reads it (HIGH, REG). Required when type of purchase is 1, 3 or 4; blank when 2. Values: REG, MID, HIGH, DSL. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - not sent on a non-fuel purchase.
service_typeoptionalS52 · an1 · enumService type Values: S, F, H. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - not sent on a non-fuel purchase.
unit_of_measureoptionalS37 · an1 · enumUnit of measure 7 defined values. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - not sent on a non-fuel purchase.
quantityoptionalS53 · n12 · 2dp impliedQuantity (gallons) — Gallons dispensed, two decimals, e.g. 16.25. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - Visa requires 0 here on a non-fuel purchase.
per_unit_costoptionalS54 · n12 · 2dp impliedPrice per gallon — Dollars per gallon, two decimals, e.g. 3.72. Also sent as FleetUnitPrice (S39). Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - Visa requires 0 here on a non-fuel purchase.
gross_fuel_priceoptionalS55 · n12 · 2dp impliedGross fuel price — Quantity x price per gallon. With local tax, the sale amount is this plus the tax. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only - Visa requires 0 here on a non-fuel purchase.
net_fuel_priceoptionalH162 · n12 · 2dp impliedNet fuel price — Fuel price excluding taxes. Cygma's working packet sends it equal to the gross. Applies only when type_of_purchase is “1” or “3” or “4”. Fuel only.
Tax
FieldTypeDescription
local_tax_indicatorrecommended— · an1 · enumLocal tax included Values: 0, 1.
local_tax_amountrecommended— · n12 · 2dp impliedLocal tax — With local tax: gross fuel price + this = the sale amount.
vat_tax_raterecommendedH166 · an4VAT rate
Non-fuel
FieldTypeDescription
gross_non_fuel_priceoptionalH163 · n12 · 2dp impliedGross non-fuel price — Required when type of purchase is 2 or 3. Sum of the line items, inclusive of taxes. Applies only when type_of_purchase is “2” or “3”. Non-fuel only - Visa requires 0 here on a fuel-only purchase.
net_non_fuel_priceoptionalH164 · n12 · 2dp impliedNet non-fuel price — Optional, exclusive of taxes. Applies only when type_of_purchase is “2” or “3”. Non-fuel only.
Vehicle
FieldTypeDescription
odometer_readingrequired to qualifyH165 · an7Odometer — Whole miles. Digits only - no commas, no decimals.
employee_numberrequired to qualifyS26 · an12Employee number — When the card prompts for it. No default: Cygma's working packet does not send it (2026-10-05).
trailer_numberrequired to qualifyS27 · an16Trailer number — When the card prompts for it.
prompted_data1required to qualifyS28 · an20Prompted data 1
prompted_data2required to qualifyS29 · an20Prompted data 2

The industry dictionary also enumerates Visa Limited Data, Mail order / telephone order, Retail, Temporary services. Those are classifications rather than data sets – they carry no fields, so there is nothing to send.

↑ Request – what you send
// POST /v1/payments: a fuel sale on a MASTERCARD.
//
// Prices and quantities are DECIMAL STRINGS here, unlike
// the cents integer used for amount. M360 scales each one
// to the implied decimals its own field uses — and those
// differ within this one object (4, 3 and 2 places).

{
  "amount": 1500,
  "card": { "number": "5454545454545454", "exp_month": "12", "exp_year": "2028" },
  "industry": {
    "type": "FUEL",
    "company_brand_name": "SHEL",
    "purchase_time": "14:32",
    "fuel_service_type": "S",
    "fuel_unit_price": "3.4900",
    "fuel_quantity": "4.298",
    "fuel_sale_amount": "15.00",
    "total_tax_amount": "1.17",
    "total_tax_collect_indicator": "Y",
    "odometer_reading": "84231"
  }
}
Two-step · payments:write

Authorize & capture

POST/payments/authorizePlace a hold (no money moves): card keyed, card_token saved, or terminal_id on a card reader (Pre-auth on the reader).
POST/payments/{id}/incrementRaise the hold – incremental auth (bar tab / hospitality): { "amount": 500, "card": {…} }.
POST/payments/{id}/captureSettle up to the authorized amount (partial ok): { "amount": 4200 }.
POST/payments/{id}/voidRelease the hold (no body).
POST/payments/{id}/tip-adjustAdd a written tip to an unsettled sale: { "tip_amount": 500 }.

Authorize places a hold and returns status: "requires_capture" with amount_capturable – no funds move until you capture. Capture the full amount or less; capturing less settles that amount and releases the remainder to the cardholder automatically. A void releases the entire hold. If you never do either, the issuer expires the hold on its own (typically ~7 days) – nothing to clean up on your side, but the funds stay unavailable to the cardholder until then.

Authorization lifecycle
FieldTypeDescription
requires_captureoptionalstatusHold placed; amount_capturable shows what you can still capture.
succeededoptionalstatusCaptured (full or partial) – the captured amount joins the next settlement batch.

Every transition has a webhook: payment.authorized when the hold lands, payment.captured on capture, payment.voided on release.

POST /payments/authorize – body
FieldTypeDescription
amountrequiredintegerAmount to authorize, in cents (> 0).
currencyoptionalstringOnly usd (default).
card.numberoptionalstringPAN (keyed). Send exactly one of card, card_token or terminal_id.
card_tokenoptionalstringA card saved online (save_card on a keyed charge, or POST /tokens). No CVV is sent. A card saved at a terminal can’t be pre-authorized: 400 invalid_card_token.
terminal_idoptionalstringCard present: the hold runs on the reader (202 pending, then requires_capture). See Pre-auth on the reader.
card.exp_monthoptionalstringWith card: two digits, 01–12.
card.exp_yearoptionalstringWith card: two or four digits.
card.cvvoptionalstringSecurity code.
cardholder.*optionalobjectSame shape as POST /payments (name/email/phone/zip/address, for AVS).
descriptionoptionalstringReference on the transaction (≤120 chars).
POST /payments/{id}/capture – body
FieldTypeDescription
amountoptionalintegerCents to capture. Omit for the full authorized amount; cannot exceed it.
tip_amountoptionalintegerCard-present (tp_) holds only: tip cents added on top of amount. On a card-not-present capture use /tip-adjust afterwards.

Incremental authorization (bar tabs, hospitality): raise an open hold before capturing. {id} is the original authorization. amount is the ADDITIONAL cents; the gateway tracks and sends the cumulative total per the Cygma incremental spec. The card must be presented again (card keyed or card_token saved) – holds don’t retain it. Example flow: authorize $10.00 → increment $5.00 (tab $15.00) → increment $10.00 (tab $25.00) → capture $25.00 → tip-adjust to $30.00. Requires the authorization class enabled on the merchant’s terminal profile – contact EPI to enable it.

POST /payments/{id}/increment – body
FieldTypeDescription
amountrequiredintegerADDITIONAL cents to authorize (> 0).
card / card_tokenrequiredobject / stringThe card again – keyed, or a saved card on file.
cumulative_amountoptionalintegerOverride the computed running total (original + approved increments + this amount).
descriptionoptionalstringReference on the increment transaction.

Tip adjust (restaurant receipt flow): after capturing a sale, add the written tip while the sale is still in the open batch. The tip is itemized to the network (DE54 type 43). After batch close the amount is final – a late tip needs a separate charge.

POST /payments/{id}/tip-adjust – body
FieldTypeDescription
tip_amountrequiredintegerTip cents being added (> 0).
amountoptionalintegerCorrected TOTAL cents including the tip; defaults to the sale amount + tip_amount.

Void vs refund: void releases a hold or reverses a same-batch charge before settlement. Once a charge has settled, use a refund – a void on a settled transaction returns 409.

Authorize – POST /payments/authorize ($10.00 hold)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/authorize \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1000,
  "card": {
    "number": "4242424242424242",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123"
  },
  "description": "TAB 14"
}'
Authorize a saved card – POST /payments/authorize { card_token }
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/authorize \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1000,
  "card_token": "card_6a1f0c2e-9b3d-4e7a-8c5f-2d1e0b9a7c43",
  "description": "TAB 15"
}'
Increment – POST /payments/{id}/increment (+$5.00 → tab $15.00)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/AUTH_ID/increment \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 500,
  "card": {
    "number": "4242424242424242",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123"
  }
}'
Capture – POST /payments/{id}/capture (close the $25.00 tab)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/AUTH_ID/capture \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 2500
}'
Tip adjust – POST /payments/{id}/tip-adjust (+$5.00 tip → $30.00)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/TXN_ID/tip-adjust \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "tip_amount": 500
}'
Void – POST /payments/{id}/void (release the hold)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/AUTH_ID/void \
  -H "Authorization: Bearer m360_live_xxx"
POST /payments/{id}/refund · /void · payments:write

Refunds & voids

POST/payments/{id}/refundRefund a settled charge (full or partial; omit amount for full).
POST/payments/{id}/voidVoid a hold or unsettled charge (no body; 409 if already settled).

The dividing line is settlement. A void works on an authorization or an unsettled sale – it cancels the transaction before it batches, so nothing ever appears on the cardholder’s statement. Once the sale has settled, a void returns 409 already_settled; use a refund instead, which moves money back to the cardholder as its own transaction.

Use a void before settlement (releases a hold / reverses a same-batch sale) and a refund after. A pre-settlement refund is itself processed as a reversal.

A refund is its own object (object: "refund", status: "succeeded") – full, or partial by passing an amount smaller than the charge; you can refund the remainder later. Refunds are gated by the merchant’s channel profile: when the program has refunds disabled, the API returns 403 not_permitted_by_channel even with a valid key and scope. Both moves have webhooks – payment.refunded and payment.voided – so downstream systems hear about reversals they didn’t initiate.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/{id}/refund \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 2500
}'
/tokens · cards:*

Cards & tokenization (vault)

Tokenization turns a card into a reusable card token – a handle for a card on file. Tokenizing needs no amount and no charge: POST /tokens vaults any card outright (the token can be charged or removed later). You can also mint a token as a side effect of a sale by setting save_card on a payment. Charge a saved token by passing card_token to POST /payments, or reference one from a subscription. The raw PAN is never returned – only brand + last4 + expiry.

A card on file always belongs to a customer. POST /tokens, save_card on a payment, and POST /hosted/sessions each require customer_id (an existing customer of the merchant) or customer (created with the card, matched first to an existing customer by email or phone). Without either: 400 customer_required. The card object, and the card.created webhook, carry a customer: { name, email } block. Charging a saved card with card_token needs no customer.

Vaulting a card fires a card.created webhook whichever way you mint it. Deleting a token is a soft revoke – the card stops being chargeable, but history that references it stays intact – and it’s idempotent: deleting an already-deleted token succeeds.

POST /tokens – body (cards:write)
FieldTypeDescription
card.numberrequiredstringPAN to vault, digits (spaces ok).
card.exp_monthrequiredstringTwo digits, 01–12.
card.exp_yearrequiredstringTwo or four digits (27 or 2027).
cardholder.nameoptionalstringLabel for the saved card.
cardholder.zipoptionalstringBilling ZIP stored for AVS on future charges.
cardholder.addressoptionalstringBilling street stored for AVS.
set_defaultoptionalbooleanMake this the merchant's default card on file.
customer_idoptionalstringAn existing customer of the merchant (from POST /customers or GET /customers). Not the merchant’s customer: 404 customer_not_found.
customeroptionalobject{ name, email?, phone?, address?: { line1, line2, city, state, zip } }. Created with the card, matched first to an existing customer by email or phone. name is required (400 customer_invalid). Send neither: 400 customer_required. Test keys run the same checks but write no customer.

Cards saved at a terminal. save_card: true on a card-present sale (terminal_id) also mints a card token. Each card carries source: online (keyed save_card or POST /tokens) or terminal. A terminal-saved card charges through POST /payments {card_token} only. See Card on file from the reader.

▶ Try the live vault demo – save a card, then charge it repeatedly.

POST/tokensTokenize a card without charging it (cards:write).
GET/tokensList saved cards on file (cards:read). ?search= filters by cardholder or the linked customer's name/email; each card carries a customer block when attached to one – powers “charge a card on file” pickers.
GET/tokens/{id}Retrieve one saved card (cards:read), including cards saved under the merchant’s customers.
DELETE/tokens/{id}Remove a saved card (cards:write), including cards saved under the merchant’s customers.

Tokenizing a card without a charge (POST /tokens) is temporarily unavailable in live mode – it returns 503 tokenization_unavailable until the tokenization service is certified. Test mode returns a simulated token, so you can build the full flow today; saving a card via save_card on a live charge also works today.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/tokens \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "card": {
    "number": "4242424242424242",
    "exp_month": "12",
    "exp_year": "2027"
  },
  "cardholder": {
    "name": "Jane Doe",
    "zip": "90210"
  },
  "customer": {
    "name": "Jane Doe",
    "email": "jane@example.com"
  },
  "set_default": true
}'
Browser tokenization · keep the PAN off your servers

Hosted fields

Hosted fields render the card inputs in a Merchant360-hosted iframe embedded on your page; the card is entered on the Merchant360 origin. The browser exchanges the card for a reusable card_token; you then charge that token server-side with your secret key. Your page handles the token only (SAQ A eligible).

Try the live demo →Runs the real embed in test mode – no key needed.

Two calls make it work:

POST/hosted/sessionsYour server mints a short-lived client_token for a customer (cards:write). Never expose your secret key to the browser – this token is what the browser uses.
POST/hosted/tokenizeCalled by the iframe (not you) to turn the card into a card_token.
POST /hosted/sessions – body
FieldTypeDescription
expires_inoptionalintegerClient-token lifetime in seconds. Default 900 (15 min); clamped to 60–3600.
customer_idoptionalstringAn existing customer of the merchant. The card tokenized with this client token is saved under it. Not the merchant’s customer: 404 customer_not_found.
customeroptionalobject{ name, email?, phone?, address? }. Created (or matched by email or phone) and carried in the signed client token. name is required (400 customer_invalid). Send neither: 400 customer_required.

The response adds customer_id (null in test mode). In live mode, a client token minted without a customer gets 400 customer_required from /hosted/tokenize.

merchant360.js
FieldTypeDescription
Merchant360(clientToken)optionalfnCreate an instance from the client token minted in step 1.
.hostedFields({ style })optionalfnCreate the fields. style.accent + style.fontFamily theme them.
.mount(selector)optionalfnInsert the iframe into your page.
.on('change', cb)optionalfnLive field validity → { complete, brand }.
.tokenize()optionalfnPromise → { card_token, last4, brand }, or rejects with an error.

The client token authorizes tokenization only; it cannot charge. It lives 15 minutes by default (expires_in clamps to 60–3600 seconds); if the customer outlasts the session, tokenize() rejects with an expiry error and you mint a new session and re-initialize. Live tokenization is temporarily unavailable and returns 503 until the tokenization service is certified; use a test-mode key (the session inherits the key’s mode and returns a simulated token) to build the full browser flow today. 3-D Secure is not part of this flow (it requires a separate EMV 3DS certification).

↑ Request – what you send
// 1) On YOUR SERVER, mint a short-lived client token with your secret key:
//    POST https://www.merchant360.com/api/m360/v1/hosted/sessions  { "customer": { "name": "Jane Doe", "email": "jane@example.com" } }
//      →  { "client_token": "hfs_…", "expires_at": "…", "customer_id": "…" }

// 2) On YOUR PAGE, drop in the fields:
<script src="https://www.merchant360.com/merchant360.js"></script>
<div id="card"></div>
<button id="save" disabled>Save card</button>

<script>
  var m360   = Merchant360(CLIENT_TOKEN);          // from step 1
  var fields = m360.hostedFields({ style: { accent: "#2563eb" } });
  fields.mount("#card");
  fields.on("change", function (e) {                // { complete, brand }
    document.getElementById("save").disabled = !e.complete;
  });
  document.getElementById("save").addEventListener("click", function () {
    fields.tokenize()
      .then(function (r) {                           // { card_token, last4, brand }
        // POST r.card_token to YOUR server; charge it with your secret key:
        //   POST https://www.merchant360.com/api/m360/v1/payments  { "amount": 2500, "card_token": r.card_token }
      })
      .catch(function (err) { alert(err.message); });
  });
</script>
/checkout/sessions · checkout:*

Checkout Sessions

A Checkout Session is a fully Cygma-hosted payment page. Create one server-side, then redirect the customer to the returned url. They pay on our page; we charge, mark the session complete, fire checkout.session.completed, and redirect to your success_url. Pass either an amount or a line_items array (their sum becomes the total). Unlike a payment link, a session is single-use and redirect-oriented (success/cancel URLs + a completion webhook).

A session is open until it becomes complete (paid) or expired. Completion is an atomic, single-use claim: exactly one charge can ever complete a session, so a double-submit or a customer paying from two tabs cannot pay twice – and if the charge itself fails, the session reopens so the customer can try again. Expiry (expires_in_minutes, default 24 hours, max 30 days) is evaluated lazily on access, like payment links.

Pass the customer when you have them. An optional customer object – { name, email, phone } – pre-fills the hosted form and powers wallet features: the phone is the buyer’s cell, used as the text-message verification anchor when they save their card with Pay with Vault (absent a phone, the hosted page collects one under the consent box).

Fulfil on the webhook, not the redirect. A customer can pay and close the tab before ever reaching your success_url – the redirect is a courtesy; checkout.session.completed is the truth. Verify the signature, look up the session by id, then ship the order.

POST/checkout/sessionsCreate a session; returns the hosted url to redirect to.
GET/checkout/sessionsList sessions.
GET/checkout/sessions/{id}Retrieve one – poll for status/payment (or use the webhook).
Body
FieldTypeDescription
amountoptionalintegerTotal to collect, in cents. Required unless line_items is given.
line_itemsoptionalobject[][{ name, amount (cents), quantity }]. Their sum is the total.
success_urlrequiredstringRedirect after payment. {CHECKOUT_SESSION_ID} is substituted; otherwise session_id is appended.
cancel_urloptionalstringWhere the customer returns if they cancel.
descriptionoptionalstringShown on the hosted page (≤300 chars).
customer.name / emailoptionalstringPre-fill the payer's details.
customer.phoneoptionalstringThe buyer's cell. Powers Pay with Vault: it's the text-message verification anchor for saving and re-using cards. Pass it whenever you have it (e.g. the billing phone from your order form).
client_reference_idoptionalstringYour own id, echoed back on the session + webhook.
metadataoptionalobjectArbitrary key/values echoed back.
expires_in_minutesoptionalintegerAuto-expire the session (default 1440 = 24h).

Gift cards. When the merchant runs an eGift program, the hosted page also offers “Have a Merchant360 gift card?” – the payer can cover part or all of the session with stored value, and any card surcharge computes on the card portion only. The completed session itemizes it: amount_gift (stored-value portion, 0 when none), amount_charged (total collected = gift + card incl. surcharge), and payment.gift ({ last4, amount, transaction_id }) on the object and the checkout.session.completed webhook. A session fully covered by a gift card completes with no card at all (payment.card.last4 null). Refunding a gift-paid order? Void the redemption with POST /egift/void using payment.gift.transaction_id.

Bank (ACH) and dual pricing. When the merchant offers bank payments (Billing Tools → Settings → Payment methods, with the account’s ACH capability on), the hosted page shows a Card / Bank chooser like the invoice and payment-link pages. A bank payment is not instant: the session moves to status: processing / payment_status: processing the moment the customer submits, the customer is sent to your success_url, and checkout.session.completed fires when the debit settles (a few business days) with payment.method: "bank" – so hold fulfilment on processing and ship on the event, exactly as you would for a card. A returned debit reopens the session (unpaid again). When the merchant runs dual pricing and the session offers an alternative tender (bank, or debit cards priced as cash), the page shows both posted prices and a card pays the card price: amount_charged = amount_total + amount_dual_pricing. That differential is the merchant’s posted card price, not a surcharge – reconcile it as a Card price adjustment, never as “Surcharge” (which is what amount_surcharge is for, and the two never appear together). Bank and dual pricing stay off for test sessions and for sessions minted with metadata.source starting woocommerce until the plugin supports them.

Test-mode keys create is_test sessions: card 4242 4242 4242 4242 approves, 4000 0000 0000 0002 declines, and any test gift card carries a $25.00 balance (ending 0000 = not found) – no money moves. Listen for checkout.session.completed (or poll the session) to fulfil the order.

▶ Try the live demo store – click Buy, pay on the hosted page, land back with the result.

Pay with Vault on your checkout

Every hosted session automatically offers Pay with Vault – the Merchant360 network wallet. A returning customer types the email or mobile number on their wallet, receives a one-time text, and pays with a saved card in seconds; a new customer can save their card at payment with one checkbox. There is no integration work: it appears on the redirect flow, the popup button, WooCommerce, and the Pay Widget – anywhere a session renders.

Two things to know as an integrator: (1) pass customer.phone when you have it – verification codes are delivered by text message; without a phone on the session, the hosted page asks the payer for their cell under the save-my-card box. (2) The wallet lives on the hosted page only – there are no API endpoints to look up, verify, or charge a customer's Vault wallet. Sessions on surcharge programs, open-amount links, gift-covered payments, and test mode skip the wallet.

Checkout Button (popup)

Prefer the customer to stay on your page, PayPal-style? Load m360-checkout.js and the same session opens in a centered popup; when the customer pays, the popup closes and your onComplete fires (closing the popup without paying fires onCancel). Your backend still creates the session with your secret key – the snippet either takes a pre-created url or calls a sessionEndpoint on your server that returns {"url": …}.

<script src="https://cygma.cloud/m360-checkout.js"></script>
<button id="pay-btn">Pay with Merchant360</button>
<script>
  M360Checkout.attach('#pay-btn', {
    sessionEndpoint: '/api/create-checkout-session',   // your backend: POST /v1/checkout/sessions, respond {"url": session.url}
    onComplete: function (r) {
      // UX signal only — confirm via the checkout.session.completed webhook before fulfilling.
      window.location.href = '/thanks';
    },
    onCancel: function () { /* customer closed the popup */ },
  });
</script>

As with the redirect flow, fulfil on the webhook, not the callback – onComplete can be spoofed by a hostile browser; checkout.session.completed cannot.

Inline (embedded iframe)

Want the pay surface embedded on your page rather than a popup or a redirect? Render the session url in an iframe with ?embed=1. The framed page keeps itself sized by posting { type: 'm360:checkout:resize', height } to window.parent, and on success posts { type: 'm360:checkout:complete', nonce, token } to window.parent (no redirect happens inside the frame – your page decides what to do next). Pass a random pnonce and check it echoes back. The card is entered inside this cross-origin frame.

const pnonce = crypto.randomUUID();
const iframe = document.createElement('iframe');
iframe.src = session.url + '&embed=1&pnonce=' + pnonce;   // session.url from POST /v1/checkout/sessions
iframe.style.width = '100%'; iframe.style.border = '0';
document.querySelector('#pay').appendChild(iframe);

const origin = new URL(session.url).origin;
window.addEventListener('message', (e) => {
  if (e.origin !== origin) return;
  if (e.data.type === 'm360:checkout:resize') iframe.style.height = e.data.height + 'px';
  if (e.data.type === 'm360:checkout:complete' && e.data.nonce === pnonce) {
    // UX signal only — confirm via the checkout.session.completed webhook before fulfilling.
    window.location.href = '/thanks';
  }
});

This is the mode the Merchant360 for WooCommerce plugin uses for its inline checkout. Same rule: fulfil on the webhook, not the message.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/checkout/sessions \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 4999,
  "description": "Order #1234",
  "success_url": "https://yourapp.com/thanks?session_id={CHECKOUT_SESSION_ID}",
  "cancel_url": "https://yourapp.com/cart"
}'
Embed · pay-widget.js · POST /api/cygma/v1/charge

Pay Widget

The Pay Widget is a drop-in, embeddable checkout: paste the two-line snippet into any website and it renders a complete, branded payment form in an iframe served from cygma.cloud/{merchantId}. Unlike payment links (single-use URLs) or Checkout Sessions (server-created, cart-shaped), the widget is a permanent fixture on the merchant’s own page – same embed, unlimited payments. If you only want secure card fields inside your own UI, use hosted fields instead.

Everything except the four data- attributes is configured in Merchant360 → Pay Widget – enabled modes, min/max amounts, brand color, receipt addresses, membership programs – and is read server-side on every render, so config changes take effect immediately without touching the embed. The iframe auto-sizes (a cygma:resize postMessage), shows the receipt inline on success, and emails the customer a PDF receipt. The hosted form is protected by Cloudflare Turnstile, a honeypot, and per-IP velocity limits.

Pricing programs. A merchant on a surcharge program sees the itemized credit-card surcharge quoted live from the card number, before paying (debit is never surcharged). A merchant on dual pricing who treats debit cards like cash sees both posted prices – credit price and debit / cash price – and the card number decides which one the button charges. (Dual pricing without that setting does not apply here: the widget’s one-time modes have no bank tender, so there would be no lower price the customer could actually pay.) Subscriptions keep their own card-vs-bank pricing on the plan picker.

Embed attributes
FieldTypeDescription
data-merchantrequiredstringYour merchant UUID (shown on the Pay Widget page).
data-moderequiredstringOne of the modes below (must be enabled for your account).
data-amountoptionalintegerCents – FIXED_AMOUNT mode only.
data-descriptionoptionalstringShown on the form and the receipt.
Modes
FieldTypeDescription
FIXED_AMOUNToptionalmodeYou set the amount; the customer just pays.
DONATIONoptionalmodePreset amount chips (data-suggested, whole dollars) + custom amount within your min/max.
EXTERNAL_INVOICEoptionalmodeCustomer types your invoice number + amount (pre-fill with ?invoice= and ?memo=).
M360_INVOICEoptionalmodeLook-up-my-invoice: customer finds their open Merchant360 invoice by number + email and pays the balance.
SUBSCRIPTIONoptionalmodeCustomer joins a membership program you define (first charge now, card vaulted, auto-bills on schedule).
SAVE_CARDoptionalmodeVault a card without charging – temporarily unavailable; contact EPI to enable card-on-file.

Server-to-server: the widget also has a REST endpoint for merchants who collect card details in their own backend – POST https://www.cygma.cloud/api/cygma/v1/charge, authenticated with the widget’s cyg_… bearer key (a separate key from your m360_ API keys: minted, rotated, and revoked on the Pay Widget page, shown once at issuance). It accepts the FIXED_AMOUNT, DONATION, and EXTERNAL_INVOICE modes; invoice-lookup and subscription enrollment are hosted-flow only. Failures return 401 (bad key), 400 (validation), or 402 (processing failure).

POST /api/cygma/v1/charge – body
FieldTypeDescription
moderequiredstringFIXED_AMOUNT | DONATION | EXTERNAL_INVOICE.
amountCentsrequiredinteger100 – 100,000,000 (must fit your configured min/max).
customerrequiredobject{ name, email, phone? } – the receipt goes to this email.
cardrequiredobject{ number, expMonth, expYear, cvv, postalCode, street1?, city?, state? }.
descriptionoptionalstringShown on the receipt.
externalInvoiceRefoptionalstringEXTERNAL_INVOICE – your invoice number (search-indexed in M360).
externalInvoiceMemooptionalstringEXTERNAL_INVOICE – freeform memo.

How do you know it worked? Widget payments confirm synchronously – the inline receipt (hosted) or the {ok:true} response (API) – and every successful charge also fires payment.succeeded (a full look-up-my-invoice payment additionally fires invoice.paid), so widget activity lands in the same webhook feed as your API charges. Refunds and the fraud ledger live on the Merchant360 → Pay Widget page.

↑ Request – what you send
<!-- Drop-in checkout: two lines on any page. -->
<div data-cygma-pay
     data-merchant="YOUR_MERCHANT_UUID"
     data-mode="FIXED_AMOUNT"
     data-amount="2500"
     data-description="Invoice #1042"></div>
<script src="https://cygma.cloud/pay-widget.js" async></script>
POST /ach/debits · /ach/credits · GET /ach/{id} · ach:write

ACH debits & credits

POST/ach/debitsPull from a customer bank account.
POST/ach/creditsPush to a customer bank account (same body).
GET/ach/{id}Retrieve an entry – poll its status, or use the webhooks below.
GET/ach/routing/{number}Routing-number lookup: Mod-10 checksum + FedACH bank name – show “Debiting <bank>” before you submit. Any valid key; no scope.
POST/ach/authorization-requestsDon't hold the customer's authorization? Email them a secure page instead – they add their own bank, sign the Nacha consent, and the one-time debit runs automatically on approval. Body: { amount, customer: { name, email }, description? }. The signed authorization is stored on file for you.

ACH is a delayed-notification payment method: creating a debit or credit tells you the entry was accepted, not that money moved. A successful create returns 201 with status: "queued" and an id (ach_…) – hold on to it. Entries batch to the bank at 4:00 PM ET each banking day; standard entries settle the next banking day. Same-day entries must be queued before noon ET – after that they automatically downgrade to standard. Once submitted, the entry is processing; settlement typically lands in 1–2 banking days, moving it to settled. If the bank rejects it, the entry becomes returned with a return: { code, description } explaining why.

Lifecycle
FieldTypeDescription
queuedoptionalstatusAccepted, waiting for the next 4:00 PM ET submission window. No money has moved.
processingoptionalstatusSubmitted to the bank; awaiting settlement (typically 1–2 banking days).
settledoptionalstatusFunds moved; settled_at is set. A late bank return can still move a settled entry to returned – see below.
returnedoptionalstatusThe bank returned the entry; return.code / return.description say why.
canceledoptionalstatusCanceled before submission. Terminal.
failedoptionalstatusRejected during submission. Terminal.

How do you know it worked? You can poll GET /ach/{id}, but we recommend webhooks: subscribe to ach.queued, ach.settled, and ach.returned and you hear about every transition without polling – each payload is the same ach_payment object the GET returns. Treat ach.settled as your “funds are good” signal, not the 201.

A 202 with status: "held" means the entry is under risk review before queueing – id is null at this point. After review it either queues normally or is declined; no action is needed from you.

Returns can arrive well after settlement – a consumer unauthorized return may come up to 60 days after the statement date, so don’t treat settled as unconditionally final on debits. Insufficient-funds returns (R01/R09) are automatically re-presented up to two more times, about 2 banking days apart; the original entry’s resubmitted_as links to the new entry. All other return codes are final.

sec_code is resolved server-side from the account holder type (business → CCD, consumer → PPD/WEB) and returned on the entry – you never set it. Credits are subject to the merchant’s credit policy; both directions obey per-transaction / monthly volume limits. ▶ Try the live demo.

GET /ach/{id} – response
FieldTypeDescription
idoptionalstringEntry id (ach_…).
objectoptionalstringach_payment.
kindoptionalstringdebit or credit.
statusoptionalstringqueued · processing · settled · returned · canceled · failed.
amount_centsoptionalintegerAmount in cents.
currencyoptionalstringusd.
sec_codeoptionalstringPPD / WEB / CCD – resolved server-side (above).
run_dateoptionalstringBanking day the entry submits (or submitted) to the bank.
createdoptionalstringISO timestamp.
settled_atoptionalstringISO timestamp once settled; null before.
returnoptionalobject{ code, description } once returned; null otherwise.
resubmitted_asoptionalstringId of the automatic re-presentment when an R01/R09 was retried.
ACH errors
FieldTypeDescription
400 no_authoptionalerrorDebits require a stored customer authorization.
403 not_enabled / not_allowedoptionalerrorACH is an EPI-approved capability, not on by default – the merchant isn't enabled for ACH, or not for this direction (debit vs credit).
422 over_limitoptionalerrorPer-transaction or monthly volume cap exceeded.
422 not_validatedoptionalerrorThe bank account hasn't completed verification – a prenote takes 1–2 banking days.
502 queue_failedoptionalerrorThe entry could not be queued.

Charging a saved bank? Every successful create returns a bank_account_id (a bank_… token) for the account it used. Pass that back as bank_account_id on a later debit/credit to charge the same account again without re-sending the routing/account – the stored account is used server-side and never re-transmitted. When you pass bank_account_id, the bank_account block is not required.

Body
FieldTypeDescription
amountrequiredintegerCents (> 0).
bank_account_idoptionalstringCharge a saved bank on file (a bank_… token from a prior response). Use INSTEAD of bank_account; when present the keyed fields below aren’t needed.
bank_account.routingoptionalstring9-digit ABA routing number. Required unless bank_account_id is given.
bank_account.accountoptionalstringAccount number. Required unless bank_account_id is given.
bank_account.holder_nameoptionalstringName on the account. Required unless bank_account_id is given.
bank_account.typeoptionalstringchecking (default) or savings.
bank_account.holder_typeoptionalstringconsumer (PPD/WEB, default) or business (CCD).
authorization.frequencyoptionalstringone_time (default) or recurring.
descriptionoptionalstringNACHA entry description (≤10 chars).

The create response also includes bank_account_id (the reusable bank_… token for the account charged). A 404 not_found means the bank_account_id isn’t a saved bank on this merchant.

Receipts. A bank debit settles a couple of business days out, so any receipt is emailed on settlement, not at submit. When you charge a saved bank by bank_account_id and the entry isn’t tied to an invoice or payment link, the receipt goes to the receipt_email stored on that saved bank (set it when you create the bank token). If no receipt email is on file, no receipt is sent. Invoice/payment-link debits keep receipting the customer on that document.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/ach/debits \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 5000,
  "bank_account": {
    "routing": "021000021",
    "account": "123456789",
    "type": "checking",
    "holder_name": "Jane Doe",
    "holder_type": "consumer"
  },
  "authorization": {
    "frequency": "one_time"
  },
  "description": "INVOICE42"
}'
/banks · /banks/{id} · banks:*

Saved banks (vault)

A bank token is a reusable handle for a customer bank account on file – the ACH analogue of a card token. Storing a bank moves no money (a $0 prenote validation fires). Charge a saved bank by passing its bank_account_id to POST /ach/debits or /ach/credits, so you never re-send the routing/account. The full account number is encrypted and never returned – only the routing (public ABA) and last 4.

POST/banksStore a bank on file without charging (banks:write). Returns the bank_… token.
GET/banksList saved banks (banks:read).
GET/banks/{id}Retrieve one saved bank (banks:read).
DELETE/banks/{id}Remove a saved bank – a soft archive (banks:write).
POST /banks – body (banks:write)
FieldTypeDescription
bank_account.routingrequiredstring9-digit ABA routing number.
bank_account.accountrequiredstringAccount number (stored encrypted; only last 4 is returned).
bank_account.holder_namerequiredstringName on the account.
bank_account.typeoptionalstringchecking (default) or savings.
bank_account.holder_typeoptionalstringconsumer (PPD/WEB, default) or business (CCD).
receipt_emailoptionalstringOptional. Where the receipt is emailed when this bank is charged standalone (a debit not tied to an invoice or payment link). Sent on settlement.
Bank object
FieldTypeDescription
idoptionalstringThe bank_… token – pass it as bank_account_id on an ACH debit/credit.
objectoptionalstringAlways bank_account.
holder_name / routing / last4optionalstringAccount holder, public ABA routing, and last 4 (never the full account).
account_type / holder_typeoptionalstringchecking/savings and consumer/business.
sec_codeoptionalstringPPD / CCD derived from holder_type (informational).
validation_statusoptionalstringunverified → prenote_pending → prenote_verified / verified (or failed). A prenote takes 1–2 banking days.
is_defaultoptionalbooleanThe merchant's default bank for this customer scope.
receipt_emailoptionalstringThe email a standalone-charge receipt is sent to (null when none is on file), echoed back from the create body.

Storing a bank needs the account’s ACH capability to be enabled (like the ACH endpoints) – otherwise POST /banks answers 403 not_enabled. Test-mode keys return a simulated bank_test_…. A charge against a saved bank still obeys the debit-authorization and volume rules of the ACH endpoints.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/banks \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "bank_account": {
    "routing": "021000021",
    "account": "123456789",
    "type": "checking",
    "holder_name": "Jane Doe",
    "holder_type": "consumer"
  }
}'
/bnpl · bnpl:read / bnpl:write

Buy Now, Pay Later

Offer Affirm and Klarna at checkout. BNPL is a two-step, widget-based flow – the payer authorizes in the provider’s UI, which yields a token you capture into a charge:

  1. Create a session – POST /bnpl/sessions for an amount. Klarna returns a client_token for the Klarna SDK; Affirm returns a public_key + script_url for affirm.js (Affirm has no server session).
  2. Render the widget on your checkout with that token/key. The payer authorizes and the widget hands you a token – Affirm a checkout_token, Klarna an authorization_token.
  3. Capture – POST /bnpl/charges with that token. We authorize + capture at the provider and book the charge. Pass invoice_id to also mark a Merchant360 invoice paid.

Settlement is outside the card rails. Affirm and Klarna deposit directly to the merchant’s bank, so a BNPL charge is its own resource, separate from /payments, and it does not appear in card reporting. A test key uses the provider sandbox; a live key cannot refund a test charge.

Session body – POST /bnpl/sessions
FieldTypeDescription
providerrequiredstring"affirm" or "klarna".
amountrequiredintegerCents (>0). Affirm's practical floor is $50.
invoice_numberoptionalstringShown on the Klarna order line. Defaults to "API".
customer.emailoptionalstringOptional – passed to Klarna as the billing email.
Charge body – POST /bnpl/charges
FieldTypeDescription
providerrequiredstring"affirm" or "klarna".
amountrequiredintegerCents (>0) – must match the amount the widget authorized.
tokenrequiredstringAffirm checkout_token / Klarna authorization_token from the widget.
invoice_idoptionalstringOptional – a Merchant360 invoice to mark paid with the same bookkeeping as the pay page.
customeroptionalobjectOptional name + email stored on the charge.
POST/bnpl/sessionsStart a checkout. Returns client_token (Klarna) or public_key + script_url (Affirm) for the widget.
POST/bnpl/chargesCapture the widget's token into a charge; optionally mark an invoice paid. Idempotent.
GET/bnpl/chargesList BNPL charges (newest first, paginated).
GET/bnpl/charges/{id}Fetch a single charge.
POST/bnpl/charges/{id}/refundRefund full (omit amount) or partial. Un-books any invoice it paid. Idempotent.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/bnpl/charges \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "provider": "klarna",
  "amount": 12000,
  "token": "authorization_token_from_widget",
  "invoice_id": "inv_…"
}'
POST /payments + terminal_id · payments:* · terminal:read

Card-present overview

For merchants with a PAX card reader (A920 MAX, A920 Pro), POST /payments with a terminal_id sends the sale to the physical device instead of keying a card. This is a semi-integrated flow over this REST API: your software never talks to the reader or to Handpoint, and needs no Handpoint key. The customer taps or inserts on the reader; the card data goes from the reader to the gateway and does not pass through this API or your software. The request and the result use the same payment object as a keyed charge, with channel set to card_present and an id that starts tp_. The same key also takes card-not-present payments, gift cards and billing calls.

1. Your server
POST /payments with amount + terminal_id. Gets 202 pending.
2. Merchant360
Records the payment, then hands the sale to the reader. Resolves the result whether or not you poll.
3. The reader
Runs the card (tap, chip, swipe; keyed only if your POS allowed it). Returns the result to Merchant360.

What runs on the reader. A reader runs one of two payment apps. Your integration is the same for both:

  • Merchant360 Payment App. The reader takes the sale from Merchant360’s queue and runs the Merchant360 flow (tip, the merchant’s pricing program, loyalty, gift). It supports the per-sale terminal_options and entry_mode below and the POS Listener screen.
  • Handpoint Payments App. Merchant360 pushes the sale to the reader through Handpoint Cloud (the reader is in Integrated mode). The stock app has no Cancel or Key enter card buttons, so terminal_options have no effect on it; entry_mode: "keyed" opens Handpoint’s manual entry screen.

Gateways. Production readers connect to the Handpoint production gateway and move real money. Developer kits (debug units) connect to the Handpoint staging gateway, which accepts only test (demo) merchants and moves no money. Credentials do not work across the two gateways. See Developer kit.

The call returns 202 with status pending while the customer presents the card. The result arrives as a webhook (payment.succeeded, payment.failed or payment.canceled, whose data is the payment object) or by polling GET /payments/{id} every 2 to 3 seconds. A sale is never cancelled on a timer: one with no final result 360 seconds after it was sent stays pending and is flagged for manual reconciliation. If your request times out before you get the 202, see Recovery.

Statuses
FieldTypeDescription
pendingoptionalstatusSent to the reader; waiting for the card. requires_review: true means no final answer 360 seconds after the send: flagged for manual reconciliation, still pending.
requires_captureoptionalstatusA pre-authorization hold the reader approved (sent with POST /payments/authorize). No money moved; capture or void it. See Pre-auth.
succeededoptionalstatusApproved. amount is the approved amount: it includes an on-reader tip and any fee the merchant’s pricing program adds on the device. amount_requested is what you sent.
failedoptionalstatusDeclined, or no approval evidence from the gateway. processor.raw_status carries the gateway’s word.
canceledoptionalstatusCancelled on the reader, through POST /payments/{id}/cancel, or by the reader itself (a terminal-initiated reversal). cancel_source says which.
voidedoptionalstatusReversed before settlement. A partial void keeps succeeded with amount_voided set.
refunded / partially_refundedoptionalstatusRefunds applied; amount_refunded is the total.
GET/terminal/devicesList paired readers (Devices).
POST/paymentsWith terminal_id: send a sale to a reader (202, pending). Sale.
POST/payments/authorizeWith terminal_id: a pre-authorization hold on the reader (202, pending, then requires_capture). Pre-auth.
POST/payments/{id}/captureCapture a card-present hold, with an optional tip_amount. No card needed.
POST/payments/{id}/incrementRaise a card-present hold. No card needed.
GET/payments/{id}One payment, keyed or card-present. A pending tp_ payment is read from the gateway live.
POST/payments/{id}/cancelStop a pending sale. Cancel, void, refund.
POST/payments/{id}/voidUnsettled, within 24 hours, full or partial. No card needed.
POST/payments/{id}/refundBody { amount? }. Refunds the card that paid without the card present; 202 pending when the acquirer requires the card on the reader.
GET/payments?channel=card_presentReader sales: from the API, the Merchant360 terminal, and sales run on the device itself.
POST/terminal/charges/{id}/receiptEmail the customer a branded receipt for an approved sale: body { email, name? }.

Earlier endpoints. POST /terminal/charges and GET /terminal/charges/{id} (scope terminal:*, ids are the reader’s result id, statuses approved / declined / cancelled) remain available. Sales sent through them are recorded and fire the same webhooks. The Payment Extension's “Send to card terminal” button uses them.

Send a sale to the reader – POST /payments
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "invoice_number": "INV-1234",
  "metadata": {
    "order_id": "1234"
  }
}'
Read the result – GET /payments/{id}
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \
  -H "Authorization: Bearer m360_live_xxx"
POST /payments · terminal_id · payments:write

Card-present sale

Send the amount and the reader. Exactly one of card, card_token, card_present or terminal_id is allowed per call. Send an Idempotency-Key (your order id): a retry with the same key returns the original tp_ payment and does not send a second sale to the reader.

Body (card-present)
FieldTypeDescription
amountrequiredintegerAmount to send to the reader, in cents (> 0).
terminal_idrequiredstringAn id from GET /terminal/devices, or default for the reader the merchant marked as default (else the first reader on the account).
tip.promptoptionalbooleanShow the reader’s tip screen. See Tips.
terminal_options.allow_canceloptionalbooleanShow the Cancel Sale button on the reader. Omitted: the merchant’s terminal profile setting “Cancel button on the terminal” (default true). Merchant360 Payment App only.
terminal_options.allow_keyedoptionalbooleanShow the Key enter card button. Default false. Requires MOTO enabled for the merchant. See Keyed entry.
entry_modeoptionalstringcard (default) or keyed. keyed opens manual card entry directly and requires MOTO enabled.
save_cardoptionalbooleanKeep the card read on the reader as a card on file. The approved payment carries card_token; charge it later with POST /payments {card_token}. Requires customer_id or customer: the card is saved under that customer (400 customer_required without either, before the sale is sent to the reader). Not with tip.prompt. See Card on file.
customer_id / customeroptionalstring / objectWith save_card: an existing customer of the merchant, or { name, email?, phone?, address? } to create one (matched first by email or phone). See Cards.
invoice_numberoptionalstringYour reference, echoed on the payment.
customer_codeoptionalstringYour customer reference, echoed on the payment. With sales_tax or tax_exempt, its first 25 characters go to the gateway as the Level 2 purchase order number.
sales_taxoptionalintegerLevel 2: tax cents included in amount. Sends Level 2 data with the sale.
tax_exemptoptionalbooleanLevel 2: the purchase is tax exempt. Sends Level 2 data with the sale.
metadataoptionalobjectSame rules as a keyed charge.

Level 2 on card present. Send sales_tax and / or tax_exempt, with the purchase order number in customer_code; the payment echoes sales_tax and tax_exempt. customer_code alone stays reference data. If the gateway rejects the Level 2 data the sale is refused with 502 terminal_error and the gateway’s message, and no payment is created. Level III (line_items, freight) is keyed and card-on-file only.

Payment object (card-present fields)
FieldTypeDescription
idoptionalstringtp_… (tp_test_… in test mode).
channeloptionalstringcard_present.
statusoptionalstringSee the status table.
amount / amount_requestedoptionalintegerApproved cents (tip and device fee included) / what you sent.
amount_tipoptionalintegerPresent when the customer added a tip on the reader.
amount_surcharge / surcharge_reasonoptionalinteger / stringPresent when the merchant's pricing program asked the gateway for a fee: the fee charged (0 when removed, for example on a debit card) and why.
terminaloptionalobject{ id, serial } on the create response; { serial } on reads.
cardoptionalobject{ brand, last4 } once the card is read; null while pending. A sale that saved the card adds exp_month and exp_year.
card_tokenoptionalstring | nullThe saved card (card_…) on a save_card sale once it is approved and the gateway returned a token; null otherwise. Same field as a keyed save_card.
processoroptionalobject{ auth_code, transaction_guid, raw_status } once resolved; null while pending. A keyed sale with ZIP / street entered also carries avs_result (match, street_only, zip_only, no_match, unavailable) and avs_text, the processor’s words (e.g. EXACT MATCH), when the processor returned an AVS answer. No CVV result is returned.
terminal_optionsoptionalobject | null{ allow_cancel, allow_keyed } as applied to this sale; null when the POS sent none.
entry_modeoptionalstringcard or keyed, as requested.
entry_methodoptionalstring | nullHow the card was read: chip, contactless, swipe, keyed, or null when not known.
requires_reviewoptionalbooleantrue only while status is pending and the sale has been flagged for manual reconciliation. See Recovery.
cancel_sourceoptionalstring | nullOn canceled: pos (POST /payments/{id}/cancel), terminal (the clerk pressed Cancel Sale, or the reader reversed the sale), or null when not known.
cancel_reason_code / cancel_reasonoptionalstring | nullOn a reader-initiated reversal: the code and a short reason (table in Cancel).
amount_voidedoptionalintegerAfter a partial void: cents reversed. amount is what is left.
sales_tax / tax_exemptoptionalinteger / booleanPresent when the sale carried Level 2 data.
originoptionalstringAPI_HP for a sale sent through this API.
settledoptionalbooleantrue once the batch closed.
Errors
FieldTypeDescription
400optionalbad_requestMore than one of card / card_token / card_present / terminal_id; surcharge, line_items, freight or industry sent with terminal_id (the reader applies the merchant’s pricing program from its terminal profile); an unknown terminal_options key or a non-boolean value; an entry_mode other than card / keyed; entry_mode: "keyed" with tip.prompt: true; a non-boolean tax_exempt.
400optionalcustomer_requiredsave_card: true without customer_id or customer. A card on file always belongs to a customer. Nothing is sent to the reader.
400optionalcustomer_invalidcustomer without a name.
404optionalcustomer_not_foundcustomer_id is not a customer of the merchant.
422optionaltip_with_save_cardsave_card: true with tip.prompt: true. The gateway takes no tip screen on a sale that saves the card.
422optionalkeyed_with_save_cardsave_card: true with entry_mode: "keyed" or terminal_options.allow_keyed. The card is saved from a card read only.
422optionaltokenization_not_enabledsave_card: saving cards is not enabled for the merchant at the gateway. No payment is created.
422optionalterminal_app_update_requiredsave_card on a reader whose Merchant360 Payment App is older than 2.43.4. No payment is created.
400optionalterminal_unavailableThe account has no card-present reader.
402optionalpayment_declinedFraud Tools single-transaction limits, checked before the reader is contacted.
409optionalterminal_unavailableCard-present terminals are not provisioned on the account.
422optionalmoto_not_enabled“Keyed entry needs MOTO enabled for this merchant.” terminal_options.allow_keyed: true or entry_mode: "keyed" on a merchant without MOTO enabled. Also save_card: true without MOTO (a saved card is charged as a card-not-present sale). Returned before any payment is created; test keys apply the same rule.
502optionalterminal_errorThe gateway refused the sale (for example invalid Level 2 data). No payment is created.

Test mode. Test keys return tp_test_… ids that resolve on the first GET: succeeded with VISA 4242, or failed when amount is 4100. Any terminal_id is accepted in test mode; use TEST/SIM0001, the simulated reader test keys see on GET /terminal/devices. Self-serve sandbox keys include terminal:read.

Sale on a named reader
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "invoice_number": "INV-1234",
  "customer_code": "CUST-88",
  "metadata": {
    "order_id": "1234"
  }
}'
Sale on the merchant's default reader
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "default"
}'
Sale with per-sale terminal options
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "terminal_options": {
    "allow_cancel": false,
    "allow_keyed": true
  },
  "entry_mode": "card"
}'
Level 2 on a card-present sale
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 10800,
  "terminal_id": "PAXA920MAX/2740199794",
  "sales_tax": 800,
  "tax_exempt": false,
  "customer_code": "PO-4471"
}'
Test mode – amount 4100 declines
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 4100,
  "terminal_id": "TEST/SIM0001"
}'
Error – terminal not provisioned (409)
↑ Request – what you send
{
  "error": {
    "message": "Card-present terminals aren't provisioned on this account.",
    "code": "terminal_unavailable"
  }
}
Sale that saves the card – save_card
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "save_card": true,
  "customer": {
    "name": "Jordan Ellis",
    "email": "jordan@example.com"
  }
}'
Error – save_card with tip.prompt (422)
↑ Request – what you send
{
  "error": {
    "message": "`save_card` and `tip.prompt` can't be combined on a `terminal_id` sale.",
    "code": "tip_with_save_card"
  }
}
terminal_options.allow_keyed · entry_mode · MOTO

Keyed entry on the reader

Keyed entry on the reader is off by default. It can only be turned on by your POS, per sale, through this API. There is no terminal setting and no terminal-profile (TMS) setting that turns it on, so a person at the reader cannot start a keyed sale that the POS did not ask for.

  • terminal_options.allow_keyed: true shows a Key enter card button next to tap / insert. The clerk can still take the card normally.
  • entry_mode: "keyed" opens manual card entry directly.
  • Both require MOTO enabled for the merchant. Without it the API answers 422 moto_not_enabled and nothing is sent to the reader. MOTO is enabled by EPI on the merchant’s account.
  • A keyed sale is a card-not-present transaction for interchange and dispute purposes. entry_method on the result is keyed.

MOTO for the merchant means the terminal profile allows MOTO and Handpoint MOTO is enabled for the merchant. On the Merchant360 Payment App keyed entry runs the app’s MOTO flow for the same amount and the same tp_ payment. On the Handpoint Payments App, entry_mode: "keyed" opens Handpoint’s manual entry screen (moToSale without a card token) and allow_keyed has no button to show. Merchant360 reads keyed results on Handpoint Cloud by result id, because Handpoint’s known issue KI-2026-008 drops the transaction reference on moToSale; nothing changes for your integration.

Offer a Key enter card button on this sale
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "terminal_options": {
    "allow_keyed": true
  }
}'
Open manual entry directly
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "entry_mode": "keyed"
}'
Result of a keyed sale – GET /payments/{id}
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \
  -H "Authorization: Bearer m360_live_xxx"
Error – MOTO not enabled (422)
↑ Request – what you send
{
  "error": {
    "message": "Keyed entry needs MOTO enabled for this merchant.",
    "code": "moto_not_enabled"
  }
}
save_card + terminal_id · card_token

Card on file from the reader

save_card: true on a card-present sale keeps the card the customer tapped or inserted as a card on file, saved under the customer named by customer_id or customer (one of them is required). When the sale is approved the payment carries card_token (the same field a keyed save_card returns) and card.exp_month / card.exp_year, and card.created fires with source: "terminal" and the customer block. Charge the card later with POST /payments {card_token} and no reader, exactly like a card saved online.

  • How it is charged. The reader’s gateway (Handpoint, EPI acquirer) issues the token and keeps the card in its vault. Merchant360 charges it through the same gateway as a card-not-present sale on the merchant’s account. The charge is a tp_ payment with channel: "card_not_present" and entry_mode / entry_method card_on_file; void and refund it like any tp_ payment (Cancel, void, refund).
  • Customer. customer_id (an existing customer) or customer (created with the card, matched first by email or phone). Without either: 400 customer_required. Test keys run the same check but write no customer.
  • Requirements. MOTO enabled for the merchant (the later charge is card not present) and card saving enabled at the gateway. A reader running the Merchant360 Payment App needs version 2.43.4 or later.
  • No tip screen. save_card can’t be combined with tip.prompt (422 tip_with_save_card).
  • Card read only. save_card can’t be combined with keyed entry (entry_mode: "keyed" or allow_keyed): 422 keyed_with_save_card.
  • If no token comes back. The sale is still a sale: status succeeded with card_token: null.
  • Where a terminal-saved card works. POST /payments {card_token}, GET /tokens, GET /tokens/{id} and DELETE /tokens/{id}. It is not accepted by POST /payments/authorize, invoice pay, autopay, subscriptions or /increment (400 invalid_card_token); for those, save the card online (Cards).
  • Card data. No card number or security code reaches your software or Merchant360. Merchant360 stores the token, brand, last 4 and expiry month / year.
POST /payments {card_token} with a terminal-saved card: responses
FieldTypeDescription
201optionalsucceededThe gateway approved the charge. payment.succeeded fires with the same object.
202optionalpendingNo answer from the gateway. Merchant360 resolves the payment by its reference; poll GET /payments/{id} or wait for the webhook. Do not charge again.
402optionalpayment_declinedDeclined. The body carries payment (the failed tp_ id); payment.failed fires.
400optionalinvalid_card_tokenUnknown card, or the gateway did not accept the token (save the card again).
403optionalbilling_tools_offBilling tools are off for the merchant.
409optionalterminal_unavailableCard-present terminals are not provisioned on the account.
422optionalmoto_not_enabledMOTO is not enabled for the merchant at the gateway.
502 / 503optionalgateway_error / gateway_unavailableThe gateway refused the request or its card vault is unavailable. Nothing was charged.

Test mode. A test save_card sale returns a card_test_… token at once; POST /payments with any card_test_… token approves, and one containing decline declines.

1. Sale that saves the card
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1235" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "save_card": true,
  "customer": {
    "name": "Jordan Ellis",
    "email": "jordan@example.com"
  }
}'
2. Result – GET /payments/{id} (or payment.succeeded)
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \
  -H "Authorization: Bearer m360_live_xxx"
Webhook – card.created
↑ Request – what you send
{
  "id": "card_0b6f3a52-7c1e-4d0a-9a4f-6e2d1c3b5a77",
  "object": "card",
  "brand": "VISA",
  "last4": "4242",
  "exp_month": "08",
  "exp_year": "29",
  "name": "VISA ending 4242",
  "is_default": false,
  "source": "terminal",
  "customer": {
    "name": "Jordan Ellis",
    "email": "jordan@example.com"
  },
  "created": "2026-10-20T15:30:12.000Z"
}
3. Charge the saved card later – POST /payments {card_token}
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1290" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 2600,
  "card_token": "card_0b6f3a52-7c1e-4d0a-9a4f-6e2d1c3b5a77",
  "invoice_number": "INV-1290"
}'
List saved cards – GET /tokens
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/tokens \
  -H "Authorization: Bearer m360_live_xxx"
Remove a saved card – DELETE /tokens/{id}
↑ Request – what you send
curl -X DELETE https://www.merchant360.com/api/m360/v1/tokens/card_0b6f3a52-7c1e-4d0a-9a4f-6e2d1c3b5a77 \
  -H "Authorization: Bearer m360_live_xxx"
Error – the gateway refused the saved card (400)
↑ Request – what you send
{
  "error": {
    "message": "The payment gateway did not accept this saved card. Save the card again.",
    "code": "invalid_card_token"
  }
}
Test mode
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "TEST/SIM0001",
  "save_card": true,
  "customer": {
    "name": "Jordan Ellis",
    "email": "jordan@example.com"
  }
}'
POST /payments/authorize + terminal_id · capture · increment · void

Pre-auth on the reader

POST /payments/authorize with a terminal_id places a hold on the card the customer taps or inserts. The call works like a card-present sale: 202 with status pending, then payment.authorized (status requires_capture) or payment.failed / payment.canceled. No money moves until you capture. The capture, increment and void calls take the tp_ id and need no card and no reader. This is the same API as the card-not-present Authorize & capture.

  • Capture: { amount?, tip_amount? }. amount defaults to the whole hold and cannot exceed it; less is a partial capture and the rest is released. tip_amount is added on top of amount. The payment becomes succeeded with amount = captured + tip; payment.captured fires.
  • Increment: { amount } is the ADDITIONAL cents. amount_capturable is the new hold. payment.authorized fires with amount_incremented.
  • Void: no body. Releases an open hold, or reverses a captured pre-auth before the batch closes. Full amount only; after the batch closes, refund the capture.
  • Expiry: holds expire in 7 to 30 days by issuer. capture_before is the merchant’s terminal-profile pre-auth expiry (default 7 days); after it, capture and increment return 409 authorization_expired.
  • Requirements: Pre-auth enabled on the merchant’s terminal profile (its minimum and maximum apply). A reader running the Merchant360 Payment App needs version 2.43.4 or later. Card read only: no entry_mode: "keyed", allow_keyed, save_card or tip.prompt on a hold (400).
  • Refund of an uncaptured hold returns 409 requires_capture: capture or void it.
Payment object (pre-auth fields)
FieldTypeDescription
statusoptionalstringpending → requires_capture → succeeded (captured) or voided.
capture_methodoptionalstringmanual on a hold.
amount_authorizedoptionalinteger | nullThe hold, including approved increments.
amount_capturableoptionalintegerWhat can still be captured; 0 once captured or voided.
captured / captured_at / amount_capturedoptionalboolean / string / integerSet on capture. amount_captured excludes the tip.
capture_beforeoptionalstringISO time after which capture and increment are refused.
Errors
FieldTypeDescription
400optionalbad_requestKeyed entry, save_card or tip.prompt on a hold; card / card_token / cumulative_amount on a card-present increment; a bad amount or tip_amount.
400optionalamount_exceeds_authorizationCapture over the hold.
400optionalamount_too_small / amount_too_largeOutside the terminal profile's pre-auth minimum / maximum.
409optionalauthorization_pendingThe hold has not finished on the reader yet.
409optionalalready_captured / already_voidedThe hold was captured or released already.
409optionalauthorization_expiredPast capture_before, or the gateway no longer has the hold.
422optionalpreauth_not_enabledPre-auth is off on the terminal profile or at the gateway. No payment is created.
422optionalterminal_app_update_requiredThe reader's Merchant360 Payment App is older than 2.43.4. No payment is created.
422optionalpartial_void_not_supportedamount on a pre-auth void. Capture less instead.

Test mode. Test keys return tp_test_auth_… ids that read as requires_capture on the first GET (failed when amount is 4100); capture, increment and void answer at once.

1. Place a $50.00 hold on the reader
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/authorize \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: tab-14" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 5000,
  "terminal_id": "PAXA920MAX/2740199794",
  "invoice_number": "TAB-14"
}'
2. Hold approved – GET /payments/{id} (or payment.authorized)
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06 \
  -H "Authorization: Bearer m360_live_xxx"
Raise the hold +$15.00 – POST /payments/{id}/increment
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06/increment \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1500
}'
Capture $62.00 + $9.00 tip – POST /payments/{id}/capture
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06/capture \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 6200,
  "tip_amount": 900
}'
Release the hold – POST /payments/{id}/void
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06/void \
  -H "Authorization: Bearer m360_live_xxx"
Webhook – payment.captured
↑ Request – what you send
{
  "id": "tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06",
  "object": "payment",
  "channel": "card_present",
  "status": "succeeded",
  "amount": 7100,
  "amount_requested": 5000,
  "amount_refunded": 0,
  "currency": "usd",
  "terminal": {
    "serial": "2740199794"
  },
  "card": {
    "brand": "VISA",
    "last4": "4242"
  },
  "card_token": null,
  "capture_method": "manual",
  "amount_authorized": 6500,
  "amount_capturable": 0,
  "captured": true,
  "capture_before": "2026-10-27T15:30:00.000Z",
  "processor": {
    "auth_code": "A1B2C3",
    "transaction_guid": "7c0e3d52-1f4a-4b8e-9a6d-2e5f8c1b4a90",
    "raw_status": "AUTHORISED"
  },
  "invoice_number": "TAB-14",
  "customer_code": null,
  "metadata": {
    "invoice_number": "TAB-14"
  },
  "origin": "API_HP",
  "terminal_options": null,
  "entry_mode": "card",
  "entry_method": "chip",
  "requires_review": false,
  "settled": false,
  "mode": "live",
  "created": "2026-10-20T15:30:00.000Z",
  "amount_tip": 900,
  "amount_captured": 6200,
  "captured_at": "2026-10-20T22:10:00.000Z",
  "authorization": "tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06"
}
Error – capture over the hold (400)
↑ Request – what you send
{
  "error": {
    "message": "Capture exceeds the authorized amount.",
    "code": "amount_exceeds_authorization"
  }
}
Error – pre-auth not enabled (422)
↑ Request – what you send
{
  "error": {
    "message": "Pre-authorization is not enabled for this merchant's terminals.",
    "code": "preauth_not_enabled"
  }
}
POST /payments/{id}/cancel · /void · /refund

Cancel, void and refund

Before the card is presented: cancel. POST /payments/{id}/cancel (no body) stops the reader and then reads the gateway:

  • Not charged: the payment ends canceled with cancel_source: "pos".
  • The customer completed it just before the cancel landed: Merchant360 voids it and returns voided with voided_after_cancel: true. A cancelled sale never leaves a charge.
  • That automatic void was refused: 409 charged_before_cancel with the payment in the body. Void or refund it yourself.

Cancel on the reader. The Merchant360 Payment App shows a Cancel Sale button while it waits for the card. The merchant’s terminal profile sets the default (“Cancel button on the terminal”, on by default); terminal_options.allow_cancel on the sale overrides it. A clerk cancel ends the payment canceled with cancel_source: "terminal" and fires payment.canceled. On the Handpoint Payments App terminal_options are stored and echoed but have no effect, and a cancel there may come back with cancel_source: null. Once the card is being read the reader cannot stop, and the sale continues to its result. The Handpoint Payments App has its own cancel key that cannot be hidden.

The reader reversed the sale. A reader can reverse an approved sale itself (for example, a chip card pulled out early). The payment ends canceled with cancel_source: "terminal", a cancel_reason_code and a cancel_reason. The card was not charged: do not void, and start a new sale if the customer still wants to pay.

cancel_reason_code
FieldTypeDescription
4021optionalcodeTimed out after approval.
4351optionalcodeChip card removed before the sale finished.
4352optionalcodeChip card declined after the host approved.
4353optionalcodeSignature not provided in time.
4354optionalcodeSignature declined or partial approval rejected.
4580optionalcodeProcessing error on the terminal.
4002optionalcodeSuspected terminal malfunction.

After the sale: void or refund. Neither needs the card.

  • POST /payments/{id}/void with no body: full amount, before settlement, within 24 hours. Returns the payment with status voided.
  • POST /payments/{id}/void with { amount }: partial void of a card-present sale. One per sale, not on American Express, unsettled, within 24 hours, nothing refunded yet, and partial reversal enabled for the merchant. The payment stays succeeded; amount_voided is the cents reversed and amount what is left. payment.voided fires with partial: true and amount_remaining.
  • POST /payments/{id}/refund with { amount? } (omit for the full amount): refunds the card that paid. When the acquirer requires the card, the refund goes to the reader and returns 202 with status pending.
  • Both are subject to the merchant’s channel profile (403 not_permitted_by_channel when refunds are off).
Errors
FieldTypeDescription
400optionalnot_cancelableThe id is not a card-present (tp_) payment. Keyed payments resolve synchronously: void or refund them.
404optionalnot_foundNo such payment for this merchant.
409optionalnot_cancelableThe payment is no longer pending. Void or refund it.
409optionalcharged_before_cancelCharged before the cancel reached the reader and the automatic void failed.
409optionalterminal_unavailableCard-present terminals are not provisioned on the account.
Void errors (card present)
FieldTypeDescription
400optionalamount_exceeds_originalThe partial amount is more than the sale.
422optionalpartial_void_not_supportedAmerican Express; partial reversal not enabled for the merchant; or amount on a payment that is not card-present.
409optionalalready_partially_voidedThe sale already has a partial void.
409optionalalready_voidedThe sale was already voided.
409optionalnot_voidableThe gateway has no record of the sale yet, or cannot find it. Refund instead after settlement.
409optionalterminal_busyThe reader is busy. Retry shortly.
Cancel a pending sale – POST /payments/{id}/cancel
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/cancel \
  -H "Authorization: Bearer m360_live_xxx"
Cancel landed after the customer paid (auto-voided)
↑ Request – what you send
{
  "id": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
  "object": "payment",
  "channel": "card_present",
  "status": "voided",
  "amount": 1800,
  "amount_requested": 1800,
  "amount_refunded": 0,
  "currency": "usd",
  "terminal": {
    "serial": "2740199794"
  },
  "card": {
    "brand": "VISA",
    "last4": "4242"
  },
  "processor": {
    "auth_code": "A1B2C3",
    "transaction_guid": "1a41d9f0-cf72-11f0-95b2-770b7d1d8e67",
    "raw_status": "CAPTURED"
  },
  "invoice_number": "INV-1234",
  "customer_code": null,
  "metadata": {
    "order_id": "1234",
    "invoice_number": "INV-1234"
  },
  "origin": "API_HP",
  "terminal_options": null,
  "entry_mode": "card",
  "entry_method": "chip",
  "requires_review": false,
  "settled": false,
  "mode": "live",
  "created": "2026-10-20T15:30:00.000Z",
  "voided_after_cancel": true
}
Clerk pressed Cancel on the reader – payment.canceled data
↑ Request – what you send
{
  "id": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
  "object": "payment",
  "channel": "card_present",
  "status": "canceled",
  "amount": 1800,
  "amount_requested": 1800,
  "amount_refunded": 0,
  "currency": "usd",
  "terminal": {
    "serial": "2740199794"
  },
  "card": null,
  "processor": {
    "auth_code": null,
    "transaction_guid": null,
    "raw_status": "CANCELLED"
  },
  "invoice_number": null,
  "customer_code": null,
  "metadata": null,
  "origin": "API_HP",
  "terminal_options": {
    "allow_cancel": true,
    "allow_keyed": false
  },
  "entry_mode": "card",
  "entry_method": null,
  "requires_review": false,
  "settled": false,
  "mode": "live",
  "created": "2026-10-20T15:30:00.000Z",
  "cancel_source": "terminal",
  "cancel_reason_code": null,
  "cancel_reason": null
}
Reader reversed the sale itself (chip removed early)
↑ Request – what you send
{
  "id": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
  "object": "payment",
  "channel": "card_present",
  "status": "canceled",
  "amount": 1800,
  "amount_requested": 1800,
  "amount_refunded": 0,
  "currency": "usd",
  "terminal": {
    "serial": "2740199794"
  },
  "card": null,
  "processor": {
    "auth_code": null,
    "transaction_guid": null,
    "raw_status": "CANCELLED"
  },
  "invoice_number": null,
  "customer_code": null,
  "metadata": null,
  "origin": "API_HP",
  "terminal_options": null,
  "entry_mode": "card",
  "entry_method": null,
  "requires_review": false,
  "settled": false,
  "mode": "live",
  "created": "2026-10-20T15:30:00.000Z",
  "cancel_source": "terminal",
  "cancel_reason_code": "4351",
  "cancel_reason": "Chip card removed before the sale finished."
}
Void after the sale – POST /payments/{id}/void
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/void \
  -H "Authorization: Bearer m360_live_xxx"
Partial void – POST /payments/{id}/void { amount }
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/void \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1000
}'
Partial void – payment.voided data
↑ Request – what you send
{
  "payment": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
  "amount": 1000,
  "channel": "card_present",
  "partial": true,
  "amount_remaining": 1500
}
Partial void errors
↑ Request – what you send
{
  "error": {
    "message": "The void amount is more than the sale.",
    "code": "amount_exceeds_original"
  }
}
Refund after the sale – POST /payments/{id}/refund
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/refund \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 500
}'
Refund that needs the card on the reader (202)
↑ Request – what you send
{
  "object": "refund",
  "payment": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
  "amount": 500,
  "status": "pending",
  "channel": "card_present",
  "mode": "live"
}
Lost responses · timeouts · retries

Recovery and reconciliation

A card-present sale takes seconds to minutes and crosses three systems, so plan for a lost response. Merchant360 keeps the authoritative record: it creates the payment before the reader sees the sale, and it resolves the sale on its own (it reads the gateway after the 202, and a background job reconciles against the gateway’s transaction feed). You never have to recover from the reader or Handpoint yourself.

Rules

  1. Always send an Idempotency-Key on POST /payments (your order id). If the create times out or the connection drops, resend the identical request with the same key: you get the original tp_ payment back, and no second sale reaches the reader. A different key is a new sale.
  2. Store the tp_ id as soon as the 202 arrives.
  3. Wait for the result by webhook (payment.succeeded, payment.failed, payment.canceled), by polling GET /payments/{id} every 2 to 3 seconds, or both. Card-present typically resolves in 10 to 30 seconds. A sale is never cancelled on a timer: with no final result 360 seconds after the send it stays pending with requires_review: true, flagged for manual reconciliation. It can still resolve later (the gateway’s transaction feed or the reader’s result), and the webhook fires when it does.
  4. requires_review: true: do not start a new sale for that order until the payment resolves; contact EPI support if it stays flagged. Never assume either outcome.
  5. Treat only succeeded as paid. failed and canceled mean no charge; you may start a new sale (new idempotency key). Do not start a new sale while the first is still pending: cancel it first.
  6. A cancel can resolve to voided. If the customer paid just before your cancel, Merchant360 voids the charge and says so (voided_after_cancel: true).
  7. Reconcile daily. GET /transactions?tender=card_present lists every reader sale, including sales run on the device itself; payment_id is the tp_ id. Upsert by id.

Webhook deliveries retry with backoff and can arrive out of order or more than once: process them idempotently by event id and read the payment’s current status. See Webhooks.

Retry the create with the same Idempotency-Key
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "invoice_number": "INV-1234"
}'
Poll the payment – GET /payments/{id}
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \
  -H "Authorization: Bearer m360_live_xxx"
Find it by your reference – GET /payments?channel=card_present
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments?channel=card_present&page_size=25 \
  -H "Authorization: Bearer m360_live_xxx"
Reconcile the day – GET /transactions?tender=card_present
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/transactions?tender=card_present&from=2026-10-20&to=2026-10-20 \
  -H "Authorization: Bearer m360_live_xxx"
tip.prompt

Tips on the reader

tip: { prompt: true } shows the reader’s tip screen with the merchant’s terminal tip settings: its tip percentages (up to 4, none above its maximum tip %), a custom amount, and skip. There is no tip screen when the merchant’s tips are off, set to the printed receipt, or have suggestions off.

The approved amount includes the tip; amount_tip itemizes it and amount_requested is what you sent. In the example, an $18.00 sale with a 15% tip approves for $20.70.

Tip percentages and limits are set on the merchant’s terminal profile by the merchant’s sales office, not per sale. A written tip after a keyed sale uses POST /payments/{id}/tip-adjust, before batch close.

Prompt for a tip – POST /payments
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "tip": {
    "prompt": true
  }
}'
Result with a tip – GET /payments/{id}
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \
  -H "Authorization: Bearer m360_live_xxx"
Standalone · Integrated · POS Listener

Terminal modes and POS Listener

Each reader’s mode is a terminal profile setting (terminalMode), set by the merchant’s sales office in ISO360, by EPI, or by a connected ISV in the ISV portal. The mode decides what the Merchant360 Payment App shows; your API calls are the same in every mode.

Modes
FieldTypeDescription
StandaloneoptionalmodeThe clerk runs sales on the reader itself (keypad, history, reports). The reader does not wait for a POS. API sales still arrive.
IntegratedoptionalmodeThe reader waits for sales from a POS or Merchant360 and shows the payment screen when one arrives. Branded with the sales office's logo. Staff tiles (history, reports, settings) stay available.
POS ListeneroptionalmodeA bare screen for a POS-driven counter (an ISV's POS, a medical office): the reader shows only a waiting screen until your POS sends a sale. No staff tiles.

What POS Listener shows

  • Waiting screen: the merchant’s logo large at the top (the Business Profile logo; else the sales office’s terminal logo; else the Merchant360 logo), the idle message centered (default “Ready for Payment”, up to 40 characters), and the ISV’s logo at the bottom when the merchant is connected to an ISV and the profile shows it.
  • Payment screen: a sale from your POS goes straight to card read: the amount, “Tap or insert card”, a Cancel Sale button (unless cancel is off) and a Key enter card button (only when the sale allowed keyed entry). The same payment screen is used in Integrated mode, after the clerk presses Card.
  • Staff reach the normal home screen with a 3-second press on the top logo, then the supervisor PIN when one is set. The reader returns to the waiting screen from the back arrow, or after 2 minutes untouched.
  • The default idle message shows in the terminal’s language; a custom message shows as typed.
Terminal profile settings (per merchant, overridable per device)
FieldTypeDescription
terminalModeoptionalStandalone | Integrated | POS ListenerIntegrated and POS Listener both put the reader in Handpoint cloud mode.
listenerCancelButtonoptionalboolean“Cancel button on the terminal”. Default true. Overridden per sale by terminal_options.allow_cancel.
listenerIdleMessageoptionaltext ≤40Default "Ready for Payment". POS Listener only. The merchant can also edit it in Merchant360.
listenerShowIsvLogooptionalbooleanDefault true. POS Listener only. The logo is the one the ISV uploads on the ISV portal Branding page.

ISV portal. ISVs see their connected merchants and readers in the ISV portal in ISO360: staging and production devices, online status, activation codes, the Merchant360 Payment App settings an ISV may change (mode, the POS Listener settings, receipts, loyalty and display options; pricing, MOTO, tips and security settings stay with EPI), card-present payments, webhook deliveries, and the ISV logo.

Keyed entry is never a profile setting. The Key enter card button appears only when your POS sends terminal_options.allow_keyed: true (or entry_mode: "keyed") on that sale, and only when MOTO is enabled for the merchant. See Keyed entry.

On Handpoint Cloud, Integrated and POS Listener both put the reader in Handpoint’s cloud (integrated) mode. The Handpoint Payments App has no POS Listener screen, logos, or Cancel / Key enter card buttons; those exist only in the Merchant360 Payment App.

Sale to a POS Listener reader, clerk cannot cancel, keyed allowed
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 1800,
  "terminal_id": "PAXA920MAX/2740199794",
  "terminal_options": {
    "allow_cancel": false,
    "allow_keyed": true
  }
}'
iframe · /embed/vt · vtk_ keys

Embedded Terminal

Drop the merchant’s full virtual terminal into your product as an iframe: card + Check (ACH), dual pricing, the saved-card vault, receipts – every charge settles on the merchant’s own account. The merchant mints the key in Merchant360 → Billing Tools → Settings → Embedded terminal and registers the domains your product runs on.

<iframe
  src="https://www.merchant360.com/embed/vt?key=vtk_…&header=1"
  style="width:100%;min-height:960px;border:0"
></iframe>
Query params
FieldTypeDescription
keyrequiredstringThe embed key. vtk_test_… keys load directly for development; live vtk_… keys only work inside a registered domain (Referer-checked before any session exists).
headeroptionalstring1 shows the merchant’s name above the terminal. OFF by default – the embed ships clean, with only a “Payments powered by Merchant360” mark under the payment button.

The key bootstraps a 30-minute origin-bound session cookie scoped to /embed; *.example.com domain entries match subdomains. Keys can be revoked at any time. In Handpoint device mode (offered automatically when the merchant has provisioned readers) the cardholder taps/dips on the reader.

Getting the sales back: register a webhook and keep payment.succeeded events whose origin is EMBED_VT / EMBED_HP and whose embed_key_id matches your key – or poll GET /payments with ?origin=EMBED_VT&embed_key=<key id>. Sales keyed anywhere else (the in-app terminal, invoices, pay links) do not match this filter.

GET /terminal/devices · terminal:read

Readers (devices)

GET /terminal/devices lists the merchant’s paired readers. Use terminal_id on POST /payments. available: false with an empty list means the merchant has no card-present provisioning: hide the “send to reader” option.

Device fields
FieldTypeDescription
terminal_idoptionalstringPass as terminal_id. Format <model>/<serial>.
nameoptionalstringThe label the merchant set on the Card readers card, else the serial.
nickname / locationoptionalstring | nullThe raw fields behind name.
model / serialoptionalstring | nullFor example PAXA920MAX / 2740199794.
default_terminal_idoptionalstring | nullThe reader terminal_id: "default" resolves to.

Readers are boarded, assigned to the merchant, and connected to Merchant360 by the merchant’s sales office, by EPI, or by a connected ISV in the ISV portal (Register a device; Devices, Activation code). Production readers run on the Handpoint production gateway. Staging (debug) readers are registered on the Handpoint staging gateway by Electronic Payments. The Merchant360 Payment App connects with an 8-digit activation code entered on the reader (Settings, Connect to Merchant360); a code is single use. Virtual terminals are never listed. Sandbox (test) keys see one simulated reader.

List readers – GET /terminal/devices
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/terminal/devices \
  -H "Authorization: Bearer m360_live_xxx"
No card-present on the account
↑ Request – what you send
{
  "object": "terminal.device_list",
  "available": false,
  "default_terminal_id": null,
  "devices": []
}
Test mode
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/terminal/devices \
  -H "Authorization: Bearer m360_test_xxx"
Email a receipt – POST /terminal/charges/{id}/receipt
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/terminal/charges/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/receipt \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane@example.com",
  "name": "Jane Doe"
}'
/egift/* · egift:read · egift:write

Gift cards

Check, load, redeem, adjust and move the merchant’s gift-card value with the same m360_ key used for payments. The card must belong to the merchant’s eGift program. Amounts are integer cents. Request fields are camelCase (cardNumber, amountCents); responses are snake_case. The full card number is never returned: responses carry card_last4.

Card input. Single-card routes take cardNumber (12 to 19 digits; spaces and hyphens are stripped) or raw magstripe track1 / track2 from a card reader. Balance, redeem, void and issue take cardNumber only.

POST/egift/balanceBalance inquiry (egift:read).
POST/egift/activateOpening value on a new card. An already-active card gets the value added instead.
POST/egift/add-valueReload. A never-activated card is activated instead. Up to $2,000 per call.
POST/egift/redeemDeduct from a card. Idempotent.
POST/egift/voidUndo a prior redemption: the amount goes back on the card. Idempotent.
POST/egift/tip-adjustAdd or change the tip on a prior redemption.
POST/egift/store-creditLoad merchandise credit (reported separately from purchased value).
POST/egift/deactivateMake a card inactive; refund: true also zeroes the balance.
POST/egift/balance-transferMove value from one existing card to another.
POST/egift/reissueMove the whole balance from an old card to a new card.
POST/egift/expiration-adjustSet or reset the card's expiration.
POST/egift/issueActivate a new card from the merchant's pool and email it to the recipient. No charge. Idempotent.
POST/egift/historyCard activity from Merchant360’s records (egift:read). Gift history.
Request fields
FieldTypeDescription
cardNumberoptionalstring12 to 19 digits. Or track1 / track2 on the single-card load and adjust routes.
amountCentsoptionalintegeractivate 1 to 100000000; add-value 1 to 200000; redeem 1 to 100000000; store-credit as add-value; issue 100 to 50000000. Optional on balance-transfer (omit to move the whole balance).
orderRefoptionalstringYour reference (activate, redeem, issue; up to 80 characters on issue).
transactionIdoptionalstringvoid: the transaction_id of the redemption to undo.
originalTransactionId / tipCentsoptionalstring / integertip-adjust: the redemption and the tip in cents (0 to 100000000).
refundrequiredbooleandeactivate: true zeroes the balance; false keeps it and makes the card inactive.
fromCardNumber / toCardNumberoptionalstringbalance-transfer: two different existing cards (same card is 400).
oldCardNumber / newCardNumberoptionalstringreissue.
newExpirationoptionalstringexpiration-adjust: YYYYMMDD; omit to reset to the program default.
recipient / sender / kindoptionalobject / object / stringissue: recipient.name and recipient.email required, optional recipient.message; sender defaults to the store; kind PURCHASE (default) or STORE_CREDIT.
Response objects
FieldTypeDescription
egift_balanceoptionalobjectcard_last4, balance_cents.
egift_loadoptionalobjectactivate / add-value: transaction_id, amount_loaded_cents, balance_after_cents, operation (activate | add_value), switched_from (the route you called when the operation switched, else null).
egift_redemptionoptionalobjecttransaction_id (keep it for void and tip-adjust), amount_redeemed_cents, balance_after_cents.
egift_voidoptionalobjecttransaction_id of the void, voided: true.
egift_tip_adjustoptionalobjectoriginal_transaction_id, tip_cents, balance_after_cents.
egift_store_creditoptionalobjectamount_loaded_cents, balance_after_cents.
egift_deactivationoptionalobjectrefund, balance_after_cents.
egift_transferoptionalobjectfrom_card_last4, to_card_last4, amount_cents (null on a whole-balance transfer: the gateway does not echo it), to_balance_after_cents.
egift_reissueoptionalobjectold_card_last4, new_card_last4, balance_after_cents.
egift_expirationoptionalobjectnew_expiration (null after a reset).
egift_cardoptionalobjectissue: gift_card_id, card_last4, balance_cents. The recipient receives the full number by email (branded PDF).
Errors (all gift and loyalty routes)
FieldTypeDescription
400 bad_requestoptionalvalidationCard number not 12 to 19 digits, bad track data, amount or points out of range, missing field.
401 unauthorizedoptionalauthMissing or invalid key.
403 insufficient_scopeoptionalauthThe key lacks egift:* / loyalty:*.
403 billing_tools_offoptionalaccountLive key on a merchant whose Billing Tools are off. Applies to every gift and loyalty POST except balance and history, which stay open.
403 egift_not_enabledoptionalaccountThe merchant has no eGift program.
404 not_foundoptionalcardCard not found or not active in the merchant's program.
409 idempotency_in_progressoptionalidempotencyA duplicate of a request still in flight.
422 idempotency_mismatchoptionalidempotencyAn Idempotency-Key reused on another path.
429 rate_limitedoptionallimitSee the limits below.
502 gateway_error / gateway_timeoutoptionalgatewayThe gift processor did not answer or failed.
402 redeem_failed / void_failedoptionalredeem, voidThe gift processor refused. decline_code carries its reason when it gives one.
403 gatedoptionalissueGift cards are not active for this merchant.
404 original_transaction_not_foundoptionaltip-adjustThe redemption id is unknown.
409 duplicate_loadoptionalactivate, add-valueThe card was loaded for this amount minutes ago. Check the balance before loading again.
409 no_pooloptionalissueNo card numbers left in the merchant's pool.

Limits. Balance: 30 per key per minute, and 5 per shopper IP per 10 minutes when you forward the shopper’s IP in M360-Client-IP. Loads (activate, add-value, store-credit): 60 per key per minute. History: 30 per key per minute. Idempotency: send an Idempotency-Key (your order id) on every write; a retry with the same key replays the stored response.

Test mode (m360_test_ keys, including sandbox keys)
FieldTypeDescription
Card ending 0000optionaltest card404 not_found on every route (history returns an empty list).
Any other cardoptionaltest cardBalance 2500 cents. Loads return 2500 + the amount (activate returns the amount). Redeem returns 2500 minus the amount, not below 0.
transaction_idoptionaltesttest_ + 8 hex characters derived from the request: the same request returns the same id. Issue returns a test_egc_ id and card_last4: "4242".
No side effectsoptionaltestNo gift processor call and nothing recorded.

Gift cards also pay on hosted Checkout Sessions (payment.gift on the session) and appear in the transactions feed as tender egift.

Balance – POST /egift/balance
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/balance \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223"
}'
Activate – POST /egift/activate
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/activate \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: ORDER-1042-load" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "amountCents": 5000,
  "orderRef": "ORDER-1042"
}'
Activate on a card that is already active (value added)
↑ Request – what you send
{
  "object": "egift_load",
  "transaction_id": "A1B2C4",
  "card_last4": "6223",
  "amount_loaded_cents": 5000,
  "balance_after_cents": 9250,
  "operation": "add_value",
  "switched_from": "activate",
  "mode": "live"
}
Add value – POST /egift/add-value
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/add-value \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "amountCents": 1000
}'
Add value from a swipe (track2)
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/add-value \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "track2": ";8609609666096223=49121010000000000000?",
  "amountCents": 1000
}'
Redeem – POST /egift/redeem
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/redeem \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "amountCents": 1500,
  "orderRef": "order-1042"
}'
Void a redemption – POST /egift/void
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/void \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: order-1042-void" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "transactionId": "8813402"
}'
Tip adjust a redemption – POST /egift/tip-adjust
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/tip-adjust \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "originalTransactionId": "A1B2C3",
  "tipCents": 200
}'
Store credit – POST /egift/store-credit
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/store-credit \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "amountCents": 2000
}'
Deactivate with refund – POST /egift/deactivate
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/deactivate \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "refund": true
}'
Deactivate, keep the balance
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/deactivate \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "refund": false
}'
Balance transfer – POST /egift/balance-transfer
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/balance-transfer \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "fromCardNumber": "8609609666096223",
  "toCardNumber": "8609609666096248",
  "amountCents": 2500
}'
Transfer the whole balance
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/balance-transfer \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "fromCardNumber": "8609609666096223",
  "toCardNumber": "8609609666096248"
}'
Reissue to a new card – POST /egift/reissue
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/reissue \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "oldCardNumber": "8609609666096223",
  "newCardNumber": "8609609666096255"
}'
Expiration – POST /egift/expiration-adjust
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/expiration-adjust \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "newExpiration": "20271231"
}'
Reset expiration to the program default
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/expiration-adjust \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223"
}'
Issue a new card by email – POST /egift/issue
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/issue \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: wc_1042" \
  -H "Content-Type: application/json" \
  -d '{
  "amountCents": 5000,
  "recipient": {
    "name": "Jane Doe",
    "email": "jane@example.com",
    "message": "Happy birthday"
  },
  "sender": {
    "name": "Sam Doe",
    "email": "sam@example.com"
  },
  "kind": "PURCHASE",
  "orderRef": "wc_1042"
}'
Issue store credit by email
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/issue \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: rma-2207" \
  -H "Content-Type: application/json" \
  -d '{
  "amountCents": 2599,
  "recipient": {
    "name": "Jane Doe",
    "email": "jane@example.com"
  },
  "kind": "STORE_CREDIT",
  "orderRef": "rma-2207"
}'
Errors
↑ Request – what you send
{
  "error": {
    "message": "`cardNumber` must be 12–19 digits (strip spaces and hyphens).",
    "code": "bad_request"
  }
}
POST /egift/history · egift:read

Gift card history

The card’s activity, newest first, from Merchant360’s own transaction records. Output never contains the full card number.

Coverage. History lists the activity processed through Merchant360 and the eGift API: API calls, Merchant360 terminals, the Merchant360 dashboard and the online gift card store. Activity on the card processed outside Merchant360, for example on another gateway or POS, is not included. Every response carries "coverage": "merchant360".
Body
FieldTypeDescription
cardNumberrequiredstring12 to 19 digits, or track1 / track2.
limitoptionalinteger1 to 100. Default 25.
from / tooptionalstringYYYY-MM-DD (to covers the whole day) or an ISO datetime. Inclusive.
starting_afteroptionalstringThe next_cursor of the previous page (startingAfter also accepted). Must be one of this merchant’s entries.
include_balance_inquiriesoptionalbooleanDefault false.
Entry (egift_history_entry)
FieldTypeDescription
typeoptionalstringactivate, add_value, redeem, void, store_credit, tip_adjust, deactivate, deactivate_refund, transfer_out, transfer_in, reissue_out, reissue_in, expiration_adjust, balance_inquiry (only with include_balance_inquiries).
statusoptionalstringapproved, declined, or error (no answer, or eGift not enabled).
amount_centsoptionalinteger | nullThe transaction amount.
balance_after_centsoptionalinteger | nullThe balance after an approved transaction; null when not approved and on the sending side of a transfer or reissue.
counterpart_card_last4optionalstring | nullThe other card on a transfer or reissue.
referenceoptionalstringThe eGift transaction id (use it for void and tip-adjust).
decline_codeoptionalstring | nullDecline reason when not approved.
sourceoptionalstringapi (an API key, Merchant360 or eGift API), terminal (a Merchant360 Payment App terminal; terminal_serial is set), dashboard (a signed-in Merchant360 user, including the virtual terminal), online (the online gift card store, customer portal, or an automated sync).
terminal_serialoptionalstring | nullSet when source is terminal.

Response: object: "list", data, has_more, next_cursor, coverage, mode. Test keys return two fixed sample entries (a card ending 0000 returns an empty list). 30 requests per key per minute.

History – POST /egift/history
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/history \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "limit": 25,
  "from": "2026-10-01",
  "to": "2026-10-31"
}'
Next page
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/egift/history \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "limit": 25,
  "starting_after": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "include_balance_inquiries": true
}'
/loyalty/* · loyalty:read · loyalty:write

Loyalty points

Points on the merchant’s loyalty cards (the same cards as the gift program), with the same key and the same conventions as gift cards: camelCase requests, snake_case responses, cardNumber or track1 / track2, Idempotency-Key on writes. Points are whole integers.

POST/loyalty/balancePoint balance (loyalty:read).
POST/loyalty/activateOpening balance on a new card (points 0 to 1000000). An active card answers 409 card_already_active: use add-points.
POST/loyalty/add-pointspoints 1 to 1000000, optional orderRef.
POST/loyalty/redeem-pointsDeduct points. Above the balance: 402 insufficient_points.
POST/loyalty/refundReverse a prior points transaction by originalTransactionId; points is required and echoed.
POST/loyalty/transferMove the whole point balance to another card.
POST/loyalty/voidVoid a prior points transaction by transactionId.
POST/loyalty/historyPoint activity from Merchant360’s records (loyalty:read). Loyalty history.
Response objects
FieldTypeDescription
loyalty_balanceoptionalobjectcard_last4, point_balance.
loyalty_activationoptionalobjecttransaction_id, points_loaded, point_balance_after.
loyalty_pointsoptionalobjecttransaction_id, points_added, point_balance_after.
loyalty_redemptionoptionalobjecttransaction_id, points_redeemed, point_balance_after.
loyalty_refundoptionalobjectoriginal_transaction_id, points_refunded, point_balance_after.
loyalty_transferoptionalobjectfrom_card_last4, to_card_last4, to_point_balance_after.
loyalty_voidoptionalobjectvoided_transaction_id, point_balance_after.
Errors (all gift and loyalty routes)
FieldTypeDescription
400 bad_requestoptionalvalidationCard number not 12 to 19 digits, bad track data, amount or points out of range, missing field.
401 unauthorizedoptionalauthMissing or invalid key.
403 insufficient_scopeoptionalauthThe key lacks egift:* / loyalty:*.
403 billing_tools_offoptionalaccountLive key on a merchant whose Billing Tools are off. Applies to every gift and loyalty POST except balance and history, which stay open.
403 egift_not_enabledoptionalaccountThe merchant has no eGift program.
404 not_foundoptionalcardCard not found or not active in the merchant's program.
409 idempotency_in_progressoptionalidempotencyA duplicate of a request still in flight.
422 idempotency_mismatchoptionalidempotencyAn Idempotency-Key reused on another path.
429 rate_limitedoptionallimitSee the limits below.
502 gateway_error / gateway_timeoutoptionalgatewayThe gift processor did not answer or failed.
402 insufficient_pointsoptionalredeem-pointsMore points than the card holds.
404 original_transaction_not_foundoptionalrefund, voidThe transaction id is unknown.
409 card_already_activeoptionalactivateUse add-points.
409 already_voidedoptionalvoidThe transaction was already voided.

Limits. Balance: 30 per key per minute, and 5 per M360-Client-IP per 10 minutes. Point loads (activate, add-points): 120 per key per minute. History: 30 per key per minute.

Test mode (m360_test_ keys, including sandbox keys)
FieldTypeDescription
Card ending 0000optionaltest card404 not_found on every route.
Card ending 1111optionaltest cardHolds 0 points.
Any other cardoptionaltest cardHolds 1000 points. Redeeming more than the balance returns 402 insufficient_points.
transaction_idoptionaltesttest_ + 8 hex characters derived from the request.
Balance – POST /loyalty/balance
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/balance \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223"
}'
Activate – POST /loyalty/activate
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/activate \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "points": 500
}'
Add points – POST /loyalty/add-points
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/add-points \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Idempotency-Key: ORDER-1042-points" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "points": 150,
  "orderRef": "ORDER-1042"
}'
Redeem points – POST /loyalty/redeem-points
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/redeem-points \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "points": 250
}'
Refund points – POST /loyalty/refund
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/refund \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "originalTransactionId": "C7D8E9",
  "points": 250
}'
Transfer – POST /loyalty/transfer
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/transfer \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "fromCardNumber": "8609609666096223",
  "toCardNumber": "8609609666096248"
}'
Void – POST /loyalty/void
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/void \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "transactionId": "B4D5E6"
}'
Errors
↑ Request – what you send
{
  "error": {
    "message": "Not enough points on the card.",
    "code": "insufficient_points"
  }
}
POST /loyalty/history · loyalty:read

Loyalty history

Same request and paging as gift history. Entries carry points and point_balance_after in place of cents.

Coverage. History lists the activity processed through Merchant360 and the eGift API: API calls, Merchant360 terminals, the Merchant360 dashboard and the online gift card store. Activity on the card processed outside Merchant360, for example on another gateway or POS, is not included. Every response carries "coverage": "merchant360".
Body
FieldTypeDescription
cardNumberrequiredstring12 to 19 digits, or track1 / track2.
limitoptionalinteger1 to 100. Default 25.
from / tooptionalstringYYYY-MM-DD (to covers the whole day) or an ISO datetime. Inclusive.
starting_afteroptionalstringThe next_cursor of the previous page (startingAfter also accepted). Must be one of this merchant’s entries.
include_balance_inquiriesoptionalbooleanDefault false.
Entry (loyalty_history_entry)
FieldTypeDescription
typeoptionalstringactivate, add_points, redeem_points, refund, void, transfer_out, transfer_in, balance_inquiry (only with include_balance_inquiries).
statusoptionalstringapproved, declined, error.
points / point_balance_afteroptionalinteger | nullPoints moved, and the balance after an approved transaction.
counterpart_card_last4optionalstring | nullThe other card on a transfer.
reference / decline_codeoptionalstring | nullThe transaction id (for refund and void) / decline reason.
source / terminal_serialoptionalstringapi (an API key, Merchant360 or eGift API), terminal (a Merchant360 Payment App terminal; terminal_serial is set), dashboard (a signed-in Merchant360 user, including the virtual terminal), online (the online gift card store, customer portal, or an automated sync).
History – POST /loyalty/history
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/loyalty/history \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "cardNumber": "8609609666096223",
  "limit": 25
}'
/customers · customers:*

Customers

POST/customersCreate a customer.
GET/customersList customers (?search=).
GET/customers/{id}Retrieve a customer.
PATCH/customers/{id}Update a customer.
GET/customers/{id}/autopayAutopay status (card on file that auto-pays future invoices).
POST/customers/{id}/autopayEnroll a saved card (card_token + consent:true) so future invoices auto-charge on their due date.
DELETE/customers/{id}/autopayTurn autopay off.

Customers have no lifecycle to manage – there is no status beyond active, no webhook to wait on, and creates/updates take effect immediately. Archiving a customer hides them from lists; everything referencing them (payments, invoices, subscriptions) stays intact.

Body (create / update)
FieldTypeDescription
namerequiredstringDisplay name (required on create).
legal_nameoptionalstringLegal/business name.
emailoptionalstringEmail.
phoneoptionalstringPhone.
address.line1optionalstringBilling street.
address.city / state / zipoptionalstringBilling city / state / ZIP.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/customers \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Jane Doe",
  "email": "jane@example.com",
  "phone": "555-0100",
  "address": {
    "line1": "1 Main St",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  }
}'
/invoices · invoices:*

Invoices

POST/invoicesCreate an invoice (add "send": true to email it).
GET/invoicesList invoices (?status=).
GET/invoices/{id}Retrieve an invoice.
POST/invoices/{id}/payCharge a card (card_token or keyed card) against the invoice; flips it to partial/paid.
POST/invoices/{id}/sendEmail the invoice to the customer.
POST/invoices/{id}/textText (SMS) the pay link to the customer – STOP-compliant.
POST/invoices/{id}/voidVoid the invoice (cancels pending links and any reservation it funds).
DELETE/invoices/{id}Delete an invoice that never collected anything – its pay links and reservation go with it. 409 once money moved: void instead.

The response includes the customer’s hosted_url (the pay page). ▶ Try the live demo.

An invoice moves DRAFT → OPEN → PARTIAL → PAID; VOID is terminal. Sending it (send: true on create, or POST /invoices/{id}/send) makes it OPEN; from there the customer drives it by paying on the hosted page. Card payments advance the invoice instantly. Bank/eCheck payments advance it only when the underlying ACH entry settles – delayed by 1–2 banking days, like all ACH – so an OPEN invoice with a pending bank payment is normal, not stuck.

Lifecycle
FieldTypeDescription
DRAFToptionalstatusCreated but not sent; freely editable.
OPENoptionalstatusSent to the customer; awaiting payment on the hosted page.
PARTIALoptionalstatusPartially paid; paid shows cents collected so far.
PAIDoptionalstatusPaid in full. Fires invoice.paid.
VOIDoptionalstatusVoided. Terminal.

How do you know it was paid? Listen for invoice.paid – it fires when the invoice reaches PAID, including when a delayed ACH payment finally settles. invoice.created fires on create. Polling GET /invoices/{id} works too.

Body
FieldTypeDescription
customer.namerequiredstringBill-to name.
customer.email / phoneoptionalstringContact (email needed to send).
customer.address1 / city / state / zipoptionalstringBill-to address.
line_items[]requiredarrayOne or more line items (below).
line_items[].descriptionrequiredstringLine description.
line_items[].quantityoptionalnumberQuantity. Default 1.
line_items[].unit_pricerequiredintegerUnit price in cents.
tax_rate_bpsoptionalintegerInvoice-level tax in basis points.
due_dateoptionalstringDue date, YYYY-MM-DD.
invoice_dateoptionalstringIssue date, YYYY-MM-DD. Default today.
notesoptionalstringCustomer-facing note.
sendoptionalbooleanEmail the invoice on create.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/invoices \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "customer": {
    "name": "Jane Doe",
    "email": "jane@example.com"
  },
  "line_items": [
    {
      "description": "Consulting",
      "quantity": 2,
      "unit_price": 15000
    }
  ],
  "tax_rate_bps": 875,
  "due_date": "2026-08-01",
  "send": true
}'
/estimates · estimates:*

Estimates

POST/estimatesCreate an estimate (identical body to invoices).
GET/estimatesList estimates.
POST/estimates/{id}/acceptMark accepted (customer said yes off-platform). Add "convert": true to also turn it into an invoice.

Estimates share the invoice shape – same body, same line items, same hosted page – but come back as object: "estimate". The customer reviews and accepts the hosted estimate, which converts it to a normal invoice; from that point it follows the invoice lifecycle (and its webhooks) exactly.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/estimates \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "customer": {
    "name": "Jane Doe"
  },
  "line_items": [
    {
      "description": "Project",
      "quantity": 1,
      "unit_price": 250000
    }
  ],
  "send": true
}'
/credit-memos · credit_memos:*

Credit memos

A credit memo credits against an invoice. It comes in three kinds – writeoff, adjustment, chargeback – and applying is a separate step from creating: create a draft and apply it later with POST /credit-memos/{id}/apply, or pass apply: true to do both at once. Applying reduces the invoice balance immediately; voiding a memo reverses its effect.

POST/credit-memosCreate a credit memo.
GET/credit-memosList credit memos.
POST/credit-memos/{id}/applyApply the memo to an invoice.
POST/credit-memos/{id}/voidVoid (reverse) the memo.
Body
FieldTypeDescription
invoice_idrequiredstringThe invoice to credit.
amountrequiredintegerCredit amount in cents (> 0).
kindoptionalstringwriteoff, adjustment (default), or chargeback.
reasonoptionalstringInternal reason (≤500 chars).
applyoptionalbooleanApply immediately (vs. leave as a draft).
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/credit-memos \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "invoice_id": "INVOICE_ID",
  "kind": "adjustment",
  "amount": 500,
  "reason": "Goodwill",
  "apply": true
}'
/subscriptions · recurring:*

Subscriptions

Recurring billing with trials, discounts, usage-based pricing, and end conditions. The example creates a flat monthly plan; usage-based pricing is set via pricing (below). ▶ Try the live demo.

A subscription is active from the moment you create it. A trial does not get its own status – it’s just an active subscription whose first charge is deferred to trial end. A daily billing cron charges everything due, and each successful charge advances next_charge_at on submit. When a card charge declines, the subscription enters the dunning retry ladder and becomes past_due; a successful retry returns it to active, while exhausting the ladder pauses or cancels it per the merchant’s dunning settings. When an end condition is reached (ends.after occurrences, ends.on date, or ends.at_amount cap), it completes naturally as ended.

Statuses
FieldTypeDescription
activeoptionalstatusBilling normally (includes trials – first charge deferred to trial end).
past_dueoptionalstatusA charge declined; the dunning retry ladder is working the balance.
pausedoptionalstatusNo charges until resumed – set manually or by ladder exhaustion.
cancelledoptionalstatusStopped – manually or by ladder exhaustion. Fires subscription.canceled.
endedoptionalstatusNatural completion: occurrence count, end date, or amount cap reached.
failedoptionalstatusBilling stopped after unrecoverable failure.

subscription.created and subscription.canceled fire on those transitions; each cycle’s charge fires the normal payment webhooks. ACH-billed subscriptions inherit ACH’s delayed settlement – a cycle’s debit is queued on the charge date and settles 1–2 banking days later.

Core
FieldTypeDescription
plan_namerequiredstringName of the plan/subscription.
intervalrequiredstringBilling cadence: weekly, biweekly, monthly, quarterly, semiannual, annual, or custom.
interval_daysoptionalintegerDays between charges when interval is custom.
fire_dayoptionalintegerFor weekly, day of week to charge (0=Sun … 6=Sat).
amountoptionalintegerPer-cycle amount in cents. Required for flat pricing; ignored for usage-based.
start_atoptionalstringFirst charge date, YYYY-MM-DD. Default today.
customer_idoptionalstringAttach to a customer.
Payment method
FieldTypeDescription
payment_methodoptionalstringcard (default) or ach.
vault_tokenoptionalstringRequired for card plans – obtain from a payment with save_card: true (with customer_id or customer).
Trial, setup fee & discount
FieldTypeDescription
trial_daysoptionalintegerFree-trial length; the first recurring charge is deferred by this many days.
setup_feeoptionalintegerOne-time fee in cents, billed on the FIRST card charge only (it rides that charge, then never recurs). Ignored for ach – a signed authorization covers a single recurring amount.
discount.percent_offoptionalnumberPercent off every cycle (e.g. 10 = 10%).
discount.amount_offoptionalintegerFlat amount off every cycle, in cents. Use one of percent_off / amount_off.
Usage-based pricing (pricing.*)
FieldTypeDescription
pricing.modeoptionalstringflat (default, uses amount), per_unit, or tiered.
pricing.unit_priceoptionalintegerCents per unit (per_unit mode).
pricing.unit_labeloptionalstringUnit name, e.g. seat.
pricing.tiers[]optionalarrayTiered pricing (tiered mode): [{ up_to, unit_price }]. Use up_to: null for the final open tier.
End condition (ends.*)
FieldTypeDescription
ends.afteroptionalintegerStop after N charges.
ends.onoptionalstringStop on a date, YYYY-MM-DD.
ends.at_amountoptionalintegerStop once this total (cents) has been billed.

Manage

GET/subscriptionsList subscriptions.
GET/subscriptions/{id}Retrieve a subscription.
PATCH/subscriptions/{id}Update status: { "status": "pause" } – accepts active, pause, cancel.
POST/subscriptions/{id}/chargeCharge off-cycle now.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/subscriptions \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "plan_name": "Pro monthly",
  "amount": 2999,
  "interval": "monthly",
  "payment_method": "card",
  "vault_token": "VAULT_TOKEN",
  "trial_days": 14,
  "setup_fee": 500,
  "discount": {
    "percent_off": 10
  },
  "ends": {
    "after": 12
  }
}'
/tax-rates · tax_rates:*

Tax rates

GET/tax-ratesList tax rates.
POST/tax-ratesCreate a tax rate.
PATCH/tax-rates/{id}Update a tax rate.
DELETE/tax-rates/{id}Delete a tax rate.

Apply a rate to an invoice by passing tax_rate_bps on the invoice (basis points; 625 = 6.25%).

Body (create / update)
FieldTypeDescription
namerequiredstringLabel (≤40 chars).
rate_bpsrequiredintegerRate in basis points (625 = 6.25%), 0–5000.
stateoptionalstringTwo-letter state code this rate applies to.
defaultoptionalbooleanMake this the default rate.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/tax-rates \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "TX state",
  "state": "TX",
  "rate_bps": 625,
  "default": true
}'
/vendor-payments · vendor_payments:read / :write

Vendor Payments

Track accounts-payable vendors, record the payments you make them (check / cash / ACH done elsewhere), and produce 1099-NEC recipient copies at year end. The API records payments and books the P&L expense – it does not disburse money (real ACH payout stays in-app, owner-only).

TIN and bank handling: send a payee’s tax_id and bank_account in plaintext; both are encrypted at rest and only the last-4 is ever returned. Storing a bank does not pay it – real ACH disbursement stays in-app and owner-only.

Payee – POST /vendor-payments/payees
FieldTypeDescription
namerequiredstringVendor legal / DBA name.
entity_typeoptionalstringBUSINESS (default) or INDIVIDUAL – drives SSN vs EIN on the 1099.
email / phoneoptionalstringContact info.
addressoptionalobjectline1, line2, city, state, zip – required to file a 1099.
tax_idoptionalstring9-digit SSN/EIN (encrypted at rest; last-4 returned).
is_1099_vendoroptionalbooleanWhether this vendor is 1099-reportable (default true).
bank_accountoptionalobjectrouting_number (9 digits, checksum-validated), account_number (4–17 digits), type – CHECKING (default) or SAVINGS. Send the routing and account together: either alone leaves the stored bank unchanged. Write-only – reads return bank_on_file with the last-4 only.
Payment – POST /vendor-payments/payments
FieldTypeDescription
payee_idrequiredstringThe payee to pay.
amountrequiredintegerCents (>0).
memooptionalstringFree-text note (shown on the P&L expense).
payment_dateoptionalstringYYYY-MM-DD; defaults to today. Determines the 1099 tax year.
reportable_1099optionalbooleanCounts toward the payee's 1099 total. Default true.

A payee gets a 1099-NEC when they’re a 1099 vendor and their reportable total for the year is ≥ $600. GET /vendor-payments/1099s?year=YYYY lists who qualifies with a ready flag (and missing listing TIN/address gaps); the recipient-copy PDF is GET /vendor-payments/payees/{id}/1099.

GET/vendor-payments/payeesList payees (with this-year totals).
POST/vendor-payments/payeesCreate a payee.
GET/vendor-payments/payees/{id}Get a payee.
PATCH/vendor-payments/payees/{id}Update a payee (partial; blank tax_id keeps the stored TIN, absent bank_account keeps the stored bank).
DELETE/vendor-payments/payees/{id}Archive a payee.
GET/vendor-payments/payees/{id}/w9W-9 PDF – ?type=blank (prefilled) or signed (stored).
GET/vendor-payments/payees/{id}/10991099-NEC recipient copy (PDF). ?year=YYYY.
POST/vendor-payments/paymentsRecord a payment (books the P&L expense). Idempotent.
GET/vendor-payments/paymentsList payments. ?payee_id= to filter.
GET/vendor-payments/payments/{id}Get a payment.
GET/vendor-payments/1099s1099 rollup for a tax year (JSON).
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/vendor-payments/payments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "payee_id": "…",
  "amount": 150000,
  "memo": "October retainer",
  "payment_date": "2026-10-01"
}'
/catalog · inventory:*

Catalog & inventory

POST/catalogCreate a catalog item.
GET/catalogList items (?include_pos=true to include register-only menu items).
GET/catalog/{id}Retrieve an item.
PATCH/catalog/{id}Update an item.
DELETE/catalog/{id}Archive an item.
POST/catalog/{id}/stockRecord a stock movement.
Body (create / update)
FieldTypeDescription
namerequiredstringProduct/service name.
pricerequiredintegerUnit price in cents.
costoptionalintegerUnit cost in cents.
skuoptionalstringStock-keeping unit.
categoryoptionalstringCategory label.
descriptionoptionalstringDescription.
typeoptionalstringproduct (default), service, or digital.
taxableoptionalbooleanWhether tax applies. Default true.
track_stockoptionalbooleanTrack on-hand quantity. Default false.
quantityoptionalintegerOpening on-hand (when tracking stock).
pos_itemoptionalbooleanShow in the TableTurn register. Default false (catalog-only).
POST /catalog/{id}/stock – body
FieldTypeDescription
quantityrequiredintegerNon-zero; positive receives, negative removes.
reasonoptionalstringreceived, returned, adjustment (default), or sold.
notesoptionalstringe.g. a PO number.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/catalog \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Widget",
  "sku": "W-1",
  "price": 1999,
  "cost": 800,
  "taxable": true,
  "track_stock": true,
  "quantity": 100
}'
/reservations · reservations:*

Reservations

A reservation is a customer invoice with three money legs on the card the customer keeps on file at booking: a deposit charged when they accept the reservation, the balance charged on a date before the start, and a separate security hold (a pre-authorization, not a charge) placed before the start and released after the end unless a claim captures part or all of it.

POST /reservations creates the invoice and the reservation and emails the customer a link to pay the deposit. On that page the customer sees the deposit, the balance date, the hold amount and window and the merchant's agreement text, and must accept before the deposit charges; the exact text, version, IP and user agent are stored on the reservation. Pass card_vault_token_id (a saved card from /cards) to charge the deposit immediately instead. Amounts are integer cents.

After the deposit the reservation runs on schedule: a reminder three days before the balance date, the balance charge on the date (declines retry on a ladder and the customer receives a pay link), the hold placed on hold.place_on, renewed one day before the card issuer's authorization window closes (hold.auth_window_days: 30 for lodging and vehicle-rental merchant categories, 7 for others, overridable in Reservation settings), and released on hold.release_on. authorizations[] lists every pre-authorization in order; a renewal adds a row and marks the previous one REPLACED.

POST /reservations/{id}/claims captures amount from the live hold with a fixed reason and a description; the remainder of the hold is released and the customer receives a receipt. One claim per hold. Evidence files are attached in the Merchant360 portal. POST /reservations/{id}/balance charges the balance now, POST /reservations/{id}/hold places the hold now, DELETE /reservations/{id}/hold releases it, DELETE /reservations/{id} deletes a reservation that never collected anything (its invoice goes with it; a paid deposit, a placed hold or a captured claim answers 409 – cancel instead), POST /reservations/{id}/cancel cancels (also voids the unpaid invoice) (releases the hold, waives the balance; refund the deposit through /payments/{id}/refund if the agreement calls for it).

Body (POST /reservations)
FieldTypeDescription
customer.namerequiredstringCustomer name. email, phone, address1, city, state, zip are optional; email is needed for the deposit link.
start_at / end_atrequireddate-timeISO 8601 with offset. Read in timezone (default America/New_York) for the day-based legs.
unit_id / quantityoptionalstring / integerA rental unit from GET /rental-units, and how many of it. The unit is held for the dates plus its turnover hours; a create for dates that are reserved or blocked answers 422 with the reason. With unit_id, line_items may be omitted and the stay is priced from the unit's rates.
line_items[]requiredarrayRequired unless unit_id is passed. description, quantity (default 1), unit_price (cents), taxable (default true). Tax follows the customer's address unless tax_rate_bps is passed.
reserved_itemrecommendedstringWhat is reserved: unit, vehicle, equipment, space. Shown to the customer and on receipts.
deposit.mode / deposit.valueoptionalstring / integerPCT (value in basis points, 2500 = 25%), FLAT (value in cents), FULL, or NONE. Default from Reservation settings.
balance_days_beforeoptionalintegerCharge the balance this many days before start_at; 0 = the start date. A booking inside the window charges the balance with the deposit.
hold.amountoptionalintegerSecurity hold in cents; 0 = no hold. hold.days_before and hold.days_after set the place and release dates around start_at and end_at.
send_emailoptionalbooleanDefault true: email the customer the deposit link. Ignored when card_vault_token_id is passed.
card_vault_token_idoptionalstringCharge the deposit now to this saved card and keep it on file for the balance and the hold.

Rental units. GET /rental-units lists what the merchant rents: rate per rate_unit (NIGHT, DAY or HOUR), weekend_rate with weekend_days, seasons[], quantity, minimum and maximum stay, turnover_hours, the flat fee and the unit's hold. Each night or day is priced on its own: season weekend rate, then season rate, then the unit's weekend rate, then its rate; where two seasons overlap the shorter one applies. Pass start_at and end_at to also get available (how many are free, turnover included), unavailable_reason, and a quote with the exact line_items and pre-tax subtotal a create would produce. Units are managed in the Merchant360 portal.

Moving. PATCH /reservations/{id} with start_at, end_at and optionally unit_id moves a reservation. The price does not change; the balance date and the hold's place and release dates move with the new dates. price_difference in the response is what today's rates would have charged for the new dates minus the invoice subtotal, for information only. A unit that is taken for the new dates answers 409 move_refused. notify: false skips the customer email. Add reprice: true to change the length of the stay: the invoice is re-priced in place for the new dates, the difference is added to the balance (or charged now if the balance date has passed) or refunded to the card, and the response carries previous_total, charged, charge_declined, refunded and refund_due. A reservation priced by hand, discounted, or carrying catalog items answers 409 amend_refused.

Unpaid online bookings and reinstating. A reservation booked on the merchant's public page holds its dates until pay_by. The customer is emailed a warning when a quarter of that time is left, and if the deposit is still unpaid at pay_by the reservation is cancelled (reservation.cancelled, reason "Online booking not paid in time") and the dates reopen. POST /reservations/{id}/reinstate puts any cancelled reservation back with the same number, dates, price and invoice: the dates are re-checked, and it answers 409 reinstate_refused when they were booked meanwhile, when money was refunded for the cancellation, or when the stay has started. notify: false skips the customer email.

Cancelling with a refund. Each reservation carries cancel_policy, the merchant's cancellation policy as it stood when the reservation was booked: NONE (no online cancellation) or TIERS, where cancelling days_before or more days ahead refunds refund_pct percent of what was paid. POST /reservations/{id}/cancel accepts refund: "policy" refunds what that policy returns right now, an integer refunds that many cents (capped at what was paid), and "none" (the default) refunds nothing. The hold is released first. refunded is what reached the card; refund_due is money owed back that could not be sent automatically and is waiting for the merchant.

Customer page. GET /reservations/{id} returns manage_url when the merchant has customer rescheduling on. It is the customer's own page for the reservation: what they booked, the payment schedule, the deposit link while it is unpaid, and changing the start date inside the merchant's limits. A move made there arrives as reservation.moved like any other.

Body (POST /reservations/{id}/claims)
FieldTypeDescription
amountrequiredintegerCents, at most the live hold amount.
reasonrequiredstringDAMAGE, CLEANING, MISSING_PROPERTY, UNAUTHORIZED_GUESTS, or OTHER.
descriptionrequiredstringWhat happened. Shown on the customer's receipt.

Webhooks: reservation.booked, reservation.balance_charged, reservation.hold_placed, reservation.hold_released, reservation.claim_captured, reservation.cancelled, reservation.moved, reservation.amended, reservation.reinstated. Status values: reservation PENDING | BOOKED | COMPLETED | CANCELLED; balance NONE | SCHEDULED | CHARGED | DECLINED | PAID | WAIVED; hold NONE | SCHEDULED | AUTHORIZED | RE_AUTHORIZED | DECLINED | PARTIALLY_CAPTURED | CAPTURED | RELEASED | EXPIRED.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/reservations \
  -H "Authorization: Bearer m360_live_..." \
  -H "Idempotency-Key: rsv-2026-09-11-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": { "name": "Dana Whitfield", "email": "dana@example.com", "phone": "6315550142" },
    "reserved_item": "Lakeside cabin 4",
    "start_at": "2026-10-09T16:00:00-04:00",
    "end_at": "2026-10-12T11:00:00-04:00",
    "timezone": "America/New_York",
    "guest_count": 4,
    "line_items": [
      { "description": "Lakeside cabin 4, 3 nights", "quantity": 3, "unit_price": 32500 },
      { "description": "Cleaning fee", "quantity": 1, "unit_price": 15000, "taxable": false }
    ],
    "deposit": { "mode": "PCT", "value": 2500 },
    "balance_days_before": 7,
    "hold": { "amount": 50000, "days_before": 1, "days_after": 2 }
  }'
/appointments · scheduling:*

Appointments

POST/appointmentsBook an appointment.
GET/appointmentsList appointments (?from= & ?to=).
GET/appointments/{id}Retrieve an appointment.
PATCH/appointments/{id}Reschedule / update.
GET/service-typesList bookable service types (durations, prices, colors, buffers).
GET/staffList bookable staff members (Scheduling v2 – includes name-only staff; resolve an appointment's assignee.staff_id here).

Appointment status values are the lowercase lifecycle – scheduled → confirmed → in_progress → completed, with canceled and no_show as exits – and you drive every transition yourself via PATCH; nothing advances automatically. Booking a slot the assignee already occupies returns 409 conflict; pass force: true to double-book deliberately.

Body
FieldTypeDescription
titlerequiredstringWhat the appointment is.
start_atrequiredstringISO datetime.
end_atrequiredstringISO datetime, after start.
service_type_idoptionalstringA service from GET /service-types.
customer_idoptionalstringAttach to an existing customer.
customer_name / email / phoneoptionalstringInline customer details.
assigned_user_idoptionalstringStaff member assigned.
assignee_nameoptionalstringFree-text assignee name.
locationoptionalstringLocation/address text.
priceoptionalintegerPrice in cents.
notesoptionalstringInternal notes.
forceoptionalbooleanBook even if the assignee is already busy (else 409).
PATCH /appointments/{id} – body
FieldTypeDescription
statusrequiredstringscheduled, confirmed, in_progress, completed, canceled, no_show.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/appointments \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "Install",
  "start_at": "2026-08-01T14:00:00Z",
  "end_at": "2026-08-01T15:00:00Z",
  "customer_name": "Jane Doe",
  "assigned_user_id": "STAFF_USER_ID",
  "price": 12000
}'
/classes · scheduling:*

Classes

Capacity-capped group sessions – free RSVPs or paid bookings – shown on the merchant’s public booking page and anywhere the booking widget embeds. A class is the template (title, capacity, pricing); a class session is a dated occurrence; a class booking is one attendee’s seat. Seats are claimed atomically across API and public bookings.

GET/classesList classes (?active= filter).
POST/classesCreate a class.
GET/classes/{id}Retrieve a class.
PATCH/classes/{id}Update (partial). Deactivate with active: false – classes with booking history are never deleted.
GET/class-sessionsList sessions with spots_left (?class_id=, ?from=, ?to=).
POST/class-sessionsSchedule sessions – repeat_weeks creates a weekly series.
GET/class-sessions/{id}Retrieve a session.
PATCH/class-sessions/{id}Cancel / reinstate (status: canceled | scheduled).
GET/class-sessions/{id}/bookingsSession roster (all statuses).
POST/class-sessions/{id}/bookingsAdd an attendee (walk-in / phone booking).
GET/class-bookings/{id}Retrieve a booking.
DELETE/class-bookings/{id}Remove an attendee – frees the seat; the waitlist promotes.

Payments: API bookings do not collect payment – the public /book page owns card collection (full price or deposit at booking, with Vault quick-pay). Adding an attendee over the API registers them with paid: 0. Public-side require_email / require_phone rules don’t apply to API bookings.

Full sessions: POST /class-sessions/{id}/bookings returns 409 sold_out when no seats remain – pass waitlist: true to join the waitlist instead. Removing a confirmed attendee promotes the earliest waitlisted person automatically: free classes auto-confirm them (with a “you’re in” email); paid classes email a first-come booking link. Session cancels over the API do not email the roster – cancel from the app to notify attendees.

POST /classes – body
FieldTypeDescription
titlerequiredstringClass name (≤120 chars).
descriptionoptionalstringShown in the public booking modal.
instructor_staff_idoptionalstringA staff id from GET /staff.
capacityoptionalintegerMax people per session. Default 10.
payment_modeoptionalstringfree (RSVP only), full (pay price to book), deposit (pay deposit to book). Default free.
priceoptionalintegerCents. Required > 0 for full / deposit.
depositoptionalintegerCents due at booking (deposit mode); can't exceed price.
coloroptionalstring#rrggbb calendar color.
require_email / require_phoneoptionalbooleanPublic bookings must include an email / mobile number.
activeoptionalbooleanBookable on the public calendar. Default true.
POST /class-sessions – body
FieldTypeDescription
class_idrequiredstringThe class to schedule.
start_atrequiredstringISO datetime.
duration_minoptionalintegerDefault 60.
capacity_overrideoptionalintegerOverride the class capacity for these sessions.
repeat_weeksoptionalinteger1–52; >1 creates a weekly series sharing a series_id.
POST /class-sessions/{id}/bookings – body
FieldTypeDescription
namerequiredstringAttendee name.
emailoptionalstringAttendee email (used for waitlist promotion emails).
phoneoptionalstringAttendee mobile number.
marketing_opt_inoptionalbooleanAdds them to the class marketing list export.
waitlistoptionalbooleanJoin the waitlist instead of failing with 409 sold_out when full.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/classes \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "Beginner Pottery Wheel",
  "description": "Two hours on the wheel – clay and tools provided.",
  "capacity": 8,
  "payment_mode": "full",
  "price": 4500,
  "color": "#1d4ed8"
}'
/shifts · scheduling:*

Staff shifts

The merchant’s employee work schedule (Team Schedule) – the same rows the Merchant360 grid and the TableTurn shift schedule edit. A shift belongs to one worker: a login team member (u:<user_id>) or a register PIN (c:<cashier_id>); worker_key: null is an open shift nobody has claimed. New shifts are drafts until published; staff only ever see published shifts.

GET/shiftsList shifts in a window (?from=, ?to=, ISO; default this week + next). Includes the roster of worker keys.
POST/shiftsCreate a draft shift.
GET/shifts/{id}Retrieve a shift.
PATCH/shifts/{id}Update (partial): times, break_min, role, note, or reassign via worker_key.
DELETE/shifts/{id}Remove a shift.
POST/shifts/publishPublish every draft in { from, to }. Each scheduled worker with a mobile number on file is texted.
GET/schedule-requestsStaff time-off, drop, pick-up and handoff requests (?status=pending).
POST/schedule-requests/{id}Decide: { decision: "approve" | "deny", note? }. The worker is texted. A handoff the coworker has not accepted yet returns 409.
FieldTypeDescription
worker_keyoptionalstring | nullu:<user_id> or c:<cashier_id> from the roster; null = open shift.
starts_at / ends_atoptionalISO datetimeEnd after start, at most 24 hours.
break_minoptionalintegerUnpaid break minutes, netted out of hours and labor cost.
roleoptionalstringPosition label shown to the worker (Server, Front desk).
statusoptionaldraft | publishedRead-only; flip with POST /shifts/publish.
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/shifts \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "worker_key": "c:6f1c…",
  "starts_at": "2026-09-02T14:00:00Z",
  "ends_at": "2026-09-02T22:00:00Z",
  "break_min": 30,
  "role": "Server"
}'
GET /transactions · reports:read

Transactions feed

Every transaction the merchant ran, across every tender, in one shape – the feed to build your own reporting on. It is the same data the merchant’s own transaction report and CSV export in Merchant360 are built from.

tender
FieldTypeDescription
cardoptionaltenderCard-not-present: keyed, saved card, hosted fields, payment links, checkout, pay widget, eGift purchases paid by card.
card_presentoptionaltenderA sale on the merchant’s card reader – sent through this API, run from the Merchant360 terminal, or run on the device itself. payment_id is the tp_ id.
achoptionaltenderBank debits and credits. bank.last4 is the account’s last 4.
cashoptionaltenderCash on the Merchant360 Payment App or TableTurn Lite POS, and cash recorded against an invoice.
checkoptionaltenderA check recorded against an invoice.
gift_cardoptionaltenderGift card used as the tender at the POS.
egiftoptionaltendereGift card activity: SALE = redemption, VOID = a redemption reversed, GIFT_ISSUE = card activated, GIFT_LOAD = value added. Balance inquiries and card-to-card transfers are not included.
otheroptionaltenderAnother method recorded against an invoice.
Query
FieldTypeDescription
from / tooptionaldateYYYY-MM-DD, Eastern-time days, inclusive. Default: the last 7 days. At most 92 days per request.
tenderoptionalstringOne tender from the table above.
typeoptionalstringSALE, REFUND or VOID.
page / page_sizeoptionalintegerpage_size up to 500 (default 25). Newest first.
Fields
FieldTypeDescription
idoptionalstringStable: <source>_<id>. Upsert on it.
statusoptionalstringsucceeded, failed, pending, processing (ACH in flight), settled (ACH), returned (ACH), voided, refunded, partially_refunded, canceled. raw_status is the source’s own word.
amountoptionalintegerCents. amount_base / amount_tip / amount_surcharge itemize it where the source records them; amount_refunded is refunded against the row.
payment_idoptionalstringFor card and card-present rows: the id to use with GET /payments/{id}, void and refund. Null for other tenders.
truncatedoptionalbooleantrue when one source returned more than 50,000 rows for the window. Request a shorter window.

Keeping a copy in sync. A transaction can change after you first read it (a refund, a void, ACH settling or returning). Pull a trailing window – for example the last 7 days – on a schedule and upsert by id; ACH can return up to 60 days after the debit. For events as they happen, subscribe to webhooks.

Scope reports:read. An ISV platform key reads a merchant’s transactions only when the partner’s channel profile enables real-time data (403 not_permitted_by_channel otherwise), the same rule as /reports. Read only: there is no API that records a cash or check payment.

↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/transactions?from=2026-10-01&to=2026-10-05&page_size=2 \
  -H "Authorization: Bearer m360_live_xxx"
GET /reports/summary · reports:read

Reports

Query
FieldTypeDescription
fromoptionalstringISO datetime start. Default 30 days ago.
tooptionalstringISO datetime end. Default now.
↑ Request – what you send
{ "object": "report_summary", "period": { "from": "…", "to": "…" }, "gross_sales": 128400, "transaction_count": 37, "currency": "usd" }
/statements · statements:read

Statements

Download the merchant’s own processing statement as a branded PDF. GET /statements lists the periods a statement exists for (newest first); GET /statements/pdf?year=YYYY&month=MM streams that month’s PDF. The statement is always the merchant behind the key – there’s no cross-merchant access.

Query – GET /statements/pdf
FieldTypeDescription
yearrequiredintegerFour-digit year, e.g. 2026.
monthrequiredinteger1–12.
GET/statementsList available statement periods (period, year, month, pdf_url).
GET/statements/pdfDownload the statement PDF for a period (application/pdf). 404 if none.
↑ Request – what you send
curl -X GET https://www.merchant360.com/api/m360/v1/statements \
  -H "Authorization: Bearer m360_live_xxx"
GET · PATCH /settings

Settings

Read the merchant’s billing settings that affect the API – accepted methods, ACH direction policy, dual pricing, surcharge program, split-into-payments (installments) config, and business/legal profile. Any valid key can read its own merchant.

Surcharge. surcharge.enabled + surcharge.percent describe a credit-card surcharge program (mutually exclusive with dual_pricing). Use it to display the surcharge to shoppers. Whether a specific card is actually surcharged (credit yes, debit no, and any code-97 drop) is decided by BIN on the hosted pay surface at charge time, so the amount finally charged may differ from a naive amount × percent – reconcile to the charged total, don’t assume it.

Updating settings. PATCH /settings (scope settings:write) changes the two blocks the merchant controls in-app – payments (accepted methods) and installments. It’s a partial merge, so send only what changes. Pricing programs (dual pricing / surcharge), the business profile, and account capabilities are compliance/underwriting-controlled and are not API-writable. Enabling accept_bank_ach requires the account’s ACH capability (else 422).

↑ Request – what you send
{ "object": "settings",
  "business": { "dba_name": "Acme Co", "legal_name": "Acme Inc", "email": "…", "phone": "…", "address": {…}, "legal_address": {…} },
  "payments": { "accept_card": true, "accept_bank_ach": true, "accept_cash_check": false, "ach_allow_debit": true, "ach_allow_credit": false },
  "dual_pricing": { "enabled": true, "card_adjustment": 399 },
  "surcharge": { "enabled": false, "percent": 0, "credit_only": true },
  "installments": { "enabled": true, "min_cents": 5000, "max_payments": 4, "interval_days": 30 } }
/custom-fields · payments:*

Custom fields

Custom fields collect extra info at pay time (a PO number, a policy #, a table). Define a field once and it renders on the payer surfaces you enable, plus the staff virtual terminal. Captured values come back as metadata on the payment – and you can also set metadata directly on a charge without defining a field.

A field is asked for on every way to pay on an enabled surface: card, bank (ACH), Pay with Vault, Apple Pay, Affirm and Klarna. Two staff surfaces are off unless you turn them on: card_reader and mobile_terminal (the Merchant360 mobile terminal app). The values are also returned as custom_fields (an object of key to value, empty when none) on the invoice, payment link and Checkout Session objects and in their invoice.paid, payment_link.paid and checkout.session.completed events, so a bank payment, which has no card payment object, still carries them.

metadata you send on POST /payments or on a Checkout Session stays free-form, with one rule: when a key is one of the merchant's dropdown fields, the value must be one of that field's options (matched without regard to case and stored as the option is spelled). Anything else answers 400 invalid_metadata and lists the options. Required fields are not enforced on API calls.

GET/custom-fieldsList field definitions.
POST/custom-fieldsCreate a field.
GET/custom-fields/{id}Retrieve a field.
PATCH/custom-fields/{id}Update a field.
DELETE/custom-fields/{id}Remove a field.
Body
FieldTypeDescription
labelrequiredstringShown to the payer (≤60 chars). The stable key is derived from it.
typeoptionalstring"text" (default) or "select".
optionsoptionalstring[]Choices for a "select" field (2–25).
requiredoptionalbooleanWhether the payer must fill it in.
surfacesoptionalobjectWhere it renders – payment_links, invoices, estimates, checkout, pay_widget, virtual_terminal (each defaults to true).
receiptsoptionalobjectWhere the captured value prints – customer_receipt (default false), merchant_receipt (default true).
accounting_classoptionalboolean"select" fields only. The captured option posts as the QuickBooks Online Class on synced invoice lines and settlement journal lines, and as the Xero tracking option (category named after the field’s label). One field per merchant – setting it clears the flag on the others.

Filter GET /payments by a captured value with metadata[<key>]=<value> (for example ?metadata[payment_for]=Golf%20Outing). Several keys combine with AND.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/custom-fields \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "label": "PO Number",
  "type": "text",
  "required": true,
  "surfaces": {
    "payment_links": true,
    "invoices": true
  }
}'
/tin-match · tin_match:write

TIN Match

Verify a legal name + TIN (SSN or EIN) against the IRS TIN Match service before 1099 filing. This scope is granted by EPI (it is not available in the self-serve sandbox).

Body
FieldTypeDescription
namerequiredstringThe legal name on the TIN (business or individual).
tinrequiredstring9-digit SSN or EIN (dashes optional).
tin_typeoptionalstring"SSN" / "EIN" / "ITIN". Omit and the IRS resolves which file to check.
Response
FieldTypeDescription
objectoptionalstringAlways "tin_match".
matchedoptionalbooleantrue only on an exact name + TIN match (response codes 0, 6, 7, 8).
verdictoptionalstringMATCH / MISMATCH / NOT_ISSUED / INVALID_INPUT / DUPLICATE / ERROR.
response_codeoptionalnumber | nullThe raw IRS numeric code (see the table below). null when the request never reached the IRS.
descriptionoptionalstringPlain-language explanation of the code, safe to log or surface to staff.
tin_last4optionalstring | nullLast 4 digits of the submitted TIN, for your own reconciliation. The full TIN is never returned.
sourceoptionalstringLIVE when the IRS was queried, CIRCUIT_OPEN if our rate-limit guard short-circuited the call, DISABLED if matching is turned off.
checked_atoptionalstringISO 8601 timestamp of the check.
IRS response codes
response_codeverdictmatchedMeaning
0MATCHtrueTIN and Name combination matches IRS records.
1INVALID_INPUTfalseTIN is missing or not 9-digit numeric.
2NOT_ISSUEDfalseTIN entered is not currently issued by the IRS.
3MISMATCHfalseTIN and Name combination does not match IRS records.
4INVALID_INPUTfalseInvalid TIN Matching request.
5DUPLICATEfalseDuplicate request – the IRS caps repeats per 24h; we cache to avoid tripping it.
6MATCHtrueMatched on SSN (returned when tin_type is omitted/unknown).
7MATCHtrueMatched on EIN (returned when tin_type is omitted/unknown).
8MATCHtrueMatched on both SSN and EIN.

Identical checks are cached for 24 hours: a repeat of the same name + TIN returns the cached verdict, with source reflecting the cache.

↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/tin-match \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Acme LLC",
  "tin": "123456789",
  "tin_type": "EIN"
}'
One key, many merchants · for technology partners

Platform keys (ISV)

A platform key (m360_isv_…) lets a technology partner (ISV) act on behalf of the merchants in its own book through one integration, instead of collecting a separate key from every merchant.

Example. A POS company with 500 merchants gets ONE key. On each request it adds a header naming which of its merchants the call is for, and it can charge a card, save a card, send an invoice, pull a report, or receive webhooks for that merchant. The key can only reach merchants connected to the partner (by an explicit grant, or by being boarded under the partner’s office), and what it may do on each merchant is bounded by that merchant’s channel profile. Platform keys are issued by EPI or your ISO (not created in the sandbox).

Naming the merchant per request

Every request carries two headers. Authorization is always the partner’s own platform key; a merchant’s key is never used or collected. M360-On-Behalf-Of names the merchant the call is for. The key can only reach a merchant it’s connected to – otherwise 403 no_grant. Everything else about the request (path, body, scopes, response) is identical to the same call made with that merchant’s own key.

Headers
FieldTypeDescription
AuthorizationrequiredstringBearer m360_isv_…: the partner’s platform key, on every request.
M360-On-Behalf-OfrequiredstringThe target merchant’s Merchant360 id, the id field from GET /accounts. This is not the processing MID. Required on every merchant-scoped call with a platform key.
# Charge a card for one of your merchants
curl -X POST https://www.merchant360.com/api/m360/v1/payments   -H "Authorization: Bearer m360_isv_xxx"   -H "M360-On-Behalf-Of: 6f2c0f4e-…"   -H "Idempotency-Key: order-1001"   -d '{ "amount": 2500, "card_token": "tok_…" }'

Connected accounts

GET /accounts lists every merchant the key can act on. It’s partner-wide, so it takes no M360-On-Behalf-Of header.

Account fields
FieldTypeDescription
idoptionalstringThe merchant id – pass it as M360-On-Behalf-Of.
dba_nameoptionalstringThe merchant's business name.
linked_viaoptionalstringgrant (explicit access) or boarded (boarded under the partner's office).
realtime_data / volume_30doptionalobjectWith ?include=volume: a 30-day gross + count, only when the merchant enables realtime data for the partner (else realtime_data:false).

Setting a merchant up

Nothing is minted per merchant for the API: once a merchant appears in GET /accounts, every call above works with the header. The one per-merchant credential is the Embedded Terminal key, and you mint it yourself: POST /vt-embed-keys with M360-On-Behalf-Of and the domains your software runs on. The reply carries the key once and the iframe snippet.

curl -X POST https://www.merchant360.com/api/m360/v1/vt-embed-keys   -H "Authorization: Bearer m360_isv_xxx"   -H "M360-On-Behalf-Of: MERCHANT_ID"   -d '{ "name": "Acme POS", "domains": ["app.acmepos.com"] }'
# → { "key": "vtk_…", "embed_snippet": "<iframe src=…>", ... }
POST /vt-embed-keys
FieldTypeDescription
domainsrequiredstring[]Where the terminal may render. *.example.com matches subdomains. Live keys refuse every other host.
nameoptionalstringA label for the key, shown to the merchant.
testoptionalbooleanMint a vtk_test_ key that loads anywhere for development.

Scope vt_embed:manage. GET lists a merchant’s keys and DELETE /vt-embed-keys?id=… revokes one.

ISV portal

Partners also get the ISV portal in ISO360: connected merchants and their integration stage, card readers (staging and production), activation codes, the Merchant360 Payment App settings an ISV may change, card-present payments, webhook deliveries, platform key status, and the logo shown on the POS Listener screen. Platform keys are issued and rotated by Electronic Payments.

Channel guardrails

A partner’s supported feature set is enforced per merchant. If a merchant’s program disables a capability, the API returns 403 not_permitted_by_channel even with a valid key + scope:

FieldTypeDescription
Refunds / voidsoptionalguardrailBlocked when the merchant’s channel disables refunds (a void that only releases an authorization hold is still allowed).
ReportsoptionalguardrailA platform key reads a merchant’s reporting only when that merchant enables realtime data for the partner (default off).

Partner webhooks

Register one webhook endpoint with a platform key (no M360-On-Behalf-Of) and it receives events for all your connected merchants. Each delivery’s payload carries a top-level merchant_id so you know which merchant it’s for. Registration, signing, and retries are otherwise identical to merchant webhooks.

curl -X POST https://www.merchant360.com/api/m360/v1/webhooks \
  -H "Authorization: Bearer m360_isv_xxx" \
  -d '{ "url": "https://yourapp.com/hooks/m360", "events": ["payment.succeeded"] }'
↑ Request – what you send
curl -X POST https://www.merchant360.com/api/m360/v1/payments \
  -H "Authorization: Bearer m360_isv_xxx" \
  -H "M360-On-Behalf-Of: MERCHANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 2500,
  "card_token": "card_…"
}'
7,000+ apps · no code

Zapier

Merchant360 for Zapier connects your account to 7,000+ apps with no code: fire a Zap when a payment succeeds, an invoice is paid, a checkout completes, or an estimate is accepted, and create customers, invoices, payment links, and checkout sessions from any other app’s trigger.

Instant triggers (real webhooks, not polling): New Successful Payment, New Refund, New Invoice, Invoice Paid, Checkout Completed, Payment Link Paid, Estimate Accepted, plus a polling New Customer trigger. Actions: Create Customer, Create Invoice (optionally emailed with its hosted pay link), Create Payment Link, Create Checkout Session. Search: Find Customer.

Connect with your Merchant360 API key (sk_live_... or a sandbox sk_test_...; test keys run every Zap against simulated data). Under the hood each Zap registers a scoped endpoint via POST /webhooks and cleans it up when the Zap is turned off. Nothing to configure here. The integration is in early access: ask your representative for an invite link while the public Zapier directory listing is in review.