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.
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/v1Every 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.
- 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 /paymentswith aterminal_idsends 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 /transactionsreturns every transaction the merchant ran, all tenders: Transactions feed.
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 wizardBuild against payments, the card vault, hosted fields, checkout, gift cards and loyalty in test mode. No merchant account needed.
Open the developer sandbox →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:
| Field | Type | Description |
|---|---|---|
Online onlyoptional | card-not-present | POST /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 onlyoptional | card-present | GET /terminal/devices, POST /payments with terminal_id, GET /payments/{id} or webhooks, cancel, then void / refund after the sale. |
POS, card present + ecommerceoptional | both | Both lists above, on the same key and the same payment object. |
POS, card present + card on fileoptional | both | The 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.
Authentication
Create a key in Merchant360 under Developers & API (owner only). Send it as a bearer token on every request:
Authorization: Bearer m360_live_xxxKeys 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.
curl -X GET https://www.merchant360.com/api/m360/v1/me \ -H "Authorization: Bearer m360_live_xxx"
Scopes
Grant a key only what it needs. :read permits GET, :write permits create/update/delete.
| Field | Type | Description |
|---|---|---|
payments:read / :writeoptional | scope | Read 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 / :writeoptional | scope | Card vault (tokens) + hosted-field sessions. |
checkout:read / :writeoptional | scope | Hosted Checkout Sessions. |
ach:writeoptional | scope | ACH debits and credits. |
banks:read / :writeoptional | scope | Saved customer bank accounts (the ACH vault). |
payment_links:read / :writeoptional | scope | Hosted payment links. |
customers:read / :writeoptional | scope | Customer CRM and autopay enrollment. |
invoices:read / :writeoptional | scope | Invoices (create, send, text, pay, void, delete). |
estimates:read / :writeoptional | scope | Estimates. |
credit_memos:read / :writeoptional | scope | Credit memos. |
inventory:read / :writeoptional | scope | Catalog products + stock. |
tax_rates:read / :writeoptional | scope | Tax table. |
recurring:read / :writeoptional | scope | Subscriptions, plans and metered usage. |
scheduling:read / :writeoptional | scope | Appointments, service types, staff, classes, class sessions + bookings, staff shifts and schedule requests. |
reservations:read / :writeoptional | scope | Reservations (deposit, scheduled balance, security hold, claims, cancel, reinstate) and rental units. |
reports:readoptional | scope | Report summaries and the transactions feed. |
egift:readoptional | scope | Gift-card balance and history (POST /egift/balance, POST /egift/history). |
egift:writeoptional | scope | Every other gift operation: activate, add value, redeem, void, tip adjust, store credit, deactivate, balance transfer, reissue, expiration, issue. |
loyalty:readoptional | scope | Loyalty point balance and history (POST /loyalty/balance, POST /loyalty/history). |
loyalty:writeoptional | scope | Loyalty activate, add points, redeem points, refund, transfer, void. |
terminal:readoptional | scope | List the merchant’s card readers (GET /terminal/devices) and read earlier-API reader charges (GET /terminal/charges/{id}). |
terminal:writeoptional | scope | The 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 / :writeoptional | scope | Buy Now, Pay Later (Affirm + Klarna): sessions, charges, refunds. |
vendor_payments:read / :writeoptional | scope | Accounts payable: payees, recorded payments, 1099/W-9. |
tin_match:writeoptional | scope | IRS TIN Match (POST /tin-match). Granted by EPI only; not available on sandbox keys. |
vt_embed:manageoptional | scope | Mint, list and revoke Embedded Terminal keys (/vt-embed-keys), including for an ISV acting on behalf of a merchant. |
settings:writeoptional | scope | Update accepted methods + installments (read is open on any key). |
statements:readoptional | scope | Download the merchant's processing statement PDF by period. |
webhooks:manageoptional | scope | Register/list/delete webhook endpoints. |
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 number | Outcome | Code |
|---|---|---|
| 4242 4242 4242 4242 | Approved (AVS: address + ZIP match) | 00 / Y |
| 4000 0000 0000 0010 | Approved – AVS: address matches, ZIP does not | 00 / A |
| 4000 0000 0000 0028 | Approved – AVS: ZIP matches, address does not | 00 / Z |
| 4000 0000 0000 0036 | Approved – AVS: no match | 00 / N |
| 4000 0000 0000 0002 | Declined – do not honor | 05 |
| 4000 0000 0000 9995 | Declined – insufficient funds | 51 |
| 4000 0000 0000 0069 | Declined – expired card | 54 |
| 4000 0000 0000 0127 | Declined – incorrect CVV | 82 |
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).
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.
- Get a test key in the sandbox. Test keys simulate a reader: send
terminal_id: "TEST/SIM0001"onPOST /paymentsand build the whole flow before the device arrives. - Request a developer kit on the wizard’s setup step. EPI sales confirms the model, price and shipping.
- 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/devicesand in the ISV portal. - 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.
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.
| Field | Type | Description |
|---|---|---|
400 bad_requestoptional | status | Malformed or missing parameters. |
401 unauthorizedoptional | status | Missing/invalid key, or not a Merchant360 key. |
402 payment_declinedoptional | status | The card or refund was declined. |
403 insufficient_scopeoptional | status | The key lacks the required scope. |
404 not_foundoptional | status | No such object for this merchant. |
409 conflictoptional | status | Idempotency-Key in progress, or a booking clash. |
422 unprocessableoptional | status | Valid shape but rejected by policy (limits, state). |
429 rate_limitedoptional | status | Per-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.
{
"error": {
"message": "Your card was declined.",
"code": "payment_declined"
}
}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-1b2c3d4e5f60Pagination
| Field | Type | Description |
|---|---|---|
pageoptional | integer | 1-based page number. Default 1. |
page_sizeoptional | integer | Items per page, 1–100. Default 25. |
{ "data": [ … ], "page": 1, "page_size": 25, "total": 132, "has_more": true }Webhooks
/webhooksRegister an endpoint (returns the signing secret, shown once)./webhooksList endpoints./webhooks/{id}Remove an endpoint.| Field | Type | Description |
|---|---|---|
urlrequired | string | HTTPS endpoint to receive events. |
eventsoptional | string[] | 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.succeededVerify 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.
| Field | Type | Description |
|---|---|---|
payment.succeededoptional | event | A card charge succeeded. For a card-reader sale (channel card_present) the data is the payment object. |
payment.failedoptional | event | A card-reader sale was declined. Data = the payment object. |
payment.canceledoptional | event | A 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.authorizedoptional | event | A hold was placed (pre-auth). |
payment.capturedoptional | event | A prior authorization was captured. |
payment.voidedoptional | event | A hold or unsettled charge was voided. |
payment.refundedoptional | event | A payment was refunded. |
card.createdoptional | event | A reusable card was vaulted (save_card / POST /tokens). |
invoice.createdoptional | event | An invoice was created. |
invoice.sentoptional | event | An invoice was sent to the customer (emailed or texted). |
invoice.paidoptional | event | An invoice was paid in full. |
invoice.voidedoptional | event | An invoice was voided. |
estimate.acceptedoptional | event | A customer e-signed and accepted an estimate. |
subscription.createdoptional | event | A subscription started. |
subscription.canceledoptional | event | A subscription was canceled. |
ach.queuedoptional | event | An ACH debit/credit was queued. |
ach.settledoptional | event | An ACH entry settled – funds moved. Your “funds are good” signal. |
ach.returnedoptional | event | The bank returned an ACH entry (payload carries return.code). |
payment_link.paidoptional | event | A payment link was paid (instantly by card; on settlement by bank). |
checkout.session.completedoptional | event | A customer paid a hosted Checkout Session. |
webhook.testoptional | event | A 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.
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"
]
}'Payments
/paymentsCharge a card. Returns 201, or 402 on decline./paymentsList recent payments./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./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:
| Field | Type | Description |
|---|---|---|
EMBED_VToptional | string | Keyed on YOUR embedded terminal (embed_key_id names the embed key that carried the session). |
EMBED_HPoptional | string | Pushed to a physical Handpoint device from your embedded terminal. |
M360_VToptional | string | Keyed on the in-app Merchant360 virtual terminal. |
M360_HPoptional | string | Pushed to a Handpoint device from the in-app hardware terminal. |
API_HPoptional | string | Pushed to a Handpoint device through the API (POST /payments with terminal_id). |
TERMINALoptional | string | Run on the Handpoint device itself (card-present list only). |
ISO_VToptional | string | Keyed 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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to charge, in cents (> 0). |
currencyoptional | string | Only usd (default). |
terminal_idoptional | string | Send 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_tokenoptional | string | Charge 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_modeoptional | string | Card-present read type: chip, contactless, emv_fallback, swipe, or track1. Preview. |
card_present.emv_dataoptional | string | DE 55 ICC TLV payload (hex) from the EMV kernel. Required for chip / contactless / emv_fallback. |
card_present.track_2optional | string | Track-2 data (raw swipe, or the chip's track-2 image on an EMV read). |
card_present.track_1optional | string | Track-1 data (track1 entry mode). |
card_present.ksnoptional | string | DUKPT Key Serial Number when the capture device encrypts (P2PE). |
card.numberoptional | string | PAN, digits (spaces ok). Required unless card_token or card_present is given. |
card.exp_monthoptional | string | Two digits, 01–12. Required with card. |
card.exp_yearoptional | string | Two or four digits (27 or 2027). Required with card. |
card.cvvoptional | string | Security code. |
cardholder.nameoptional | string | Full name; split into first/last. |
cardholder.emailoptional | string | Receipt/AVS. |
cardholder.phoneoptional | string | Contact. |
cardholder.zipoptional | string | Billing ZIP (AVS). |
cardholder.addressoptional | string | Billing street (AVS). |
sales_taxrequired to qualify | integer | Tax portion in cents. Activates Level II – see Level II & III. |
invoice_numberoptional | string | Your 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_codeoptional | string | Your 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 qualify | array | Level III line-item detail, max 15 items. Full field reference, tier requirements, and a complete example: Level II & III. |
freightoptional | integer | Total freight / shipping cents included in amount (Level III header field). |
descriptionoptional | string | Reference shown on the transaction (≤120 chars). Also used as the Level II/III merchant order reference. |
save_cardoptional | boolean | Vault 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_idoptional | string | An 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. |
customeroptional | object | { 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. |
surchargeoptional | string | Keyed 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). |
| Field | Type | Description |
|---|---|---|
idoptional | string | Payment id. Use it for refunds, voids, and lookups. |
statusoptional | string | succeeded on approval. A decline returns HTTP 402 with an error envelope, not this object. |
amountoptional | integer | Total charged in cents. With surcharge this is the total; amount_base and amount_surcharge ride alongside. |
card.last4optional | string | Last four of the PAN used. |
invoice_numberoptional | string | Echo of what you sent (or of the legacy description field). Null when neither was supplied. |
customer_codeoptional | string | Echo of what you sent. Null when not supplied. |
customeroptional | object | GET 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. |
descriptionoptional | string | GET only. What the payment was for, from the same record as the customer. Null when none was recorded. |
processor.auth_codeoptional | string | Issuer approval code. This is the number to print on a receipt and to quote when disputing. |
processor.response_codeoptional | string | Raw network response code (00 approved, 10 partial approval). |
processor.response_messageoptional | string | Human-readable result from the host. Log it; don't parse it. |
processor.avs_resultoptional | string | Address-verification verdict. Common values: Y address and ZIP match, Z ZIP only, A address only, N neither, U unavailable. |
processor.cvv_resultoptional | string | Security-code verdict: M match, N no match, P not processed, S should be present but was not, U issuer unavailable. |
processor.rrnoptional | string | Retrieval reference number. The strongest key for locating this transaction with the processor later. |
card_tokenoptional | string | Saved-card token when save_card or card_token was used. vault_token is a back-compat alias. |
metadataoptional | object | Your key/value metadata, echoed back. |
modeoptional | string | live 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.
| Field | Type | Description |
|---|---|---|
page / page_sizeoptional | integer | Pagination. |
viaoptional | string | Filter to payments whose metadata via equals this value – e.g. the Payment Extension lists only its own charges with via=payment-extension. |
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Base cents owed. |
binrequired | string | The 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. |
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"
}
}'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.
| Field | Type | Description |
|---|---|---|
Level Ioptional | data level | A plain charge – amount + card. No additional data submitted. |
Level IIoptional | data level | Add 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 IIIoptional | data level | Add 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_tax | Sales 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 |
freight | Freight amount + credit/debit indicator, plus discount and duty header fields with their indicators |
description | Merchant order / customer reference number |
cardholder.zip / merchant profile | Destination + ship-from postal codes, destination country, order date, entry coding |
| Field | Type | Description |
|---|---|---|
descriptionrequired | string ≤26 | What was sold. 26 characters is the card-network line-description limit. |
unit_costrequired | integer | Cents per unit. |
commodity_coderequired to qualify | string ≤15 | NIGP 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. |
quantityrecommended | integer ≥1 | Whole units. Defaults to 1 when omitted. |
amountauto-derived | integer | Extended line total in cents. Defaults to quantity × unit_cost − discount – send it only when your own math differs (rounding). |
product_codeauto-derived | string ≤12 | SKU / product code. Derived from the description when omitted. |
unit_of_measureauto-derived | string ≤12 | Unit code – EA each (default), BX box, HR hour, LB pound… |
discountoptional | integer | Line 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).
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"
}
}'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
}
]
}'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
}
]
}'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"), unlikeamountwhich 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-DDand times areHH: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
industryentirely. - Keyed charges only – with or without
surcharge: "auto". Acard_tokencharge 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.
| Field | Type | Description |
|---|---|---|
"type": "FUEL"optional | J26 = 8 | Fuel — Pump and product detail for a fuel purchase. Mastercard and Discover carry different subsets; Visa fuel rides the Fleet profile. 14 fields. |
"type": "AIRLINE"optional | J26 = 1 | Airline — Ticket, passenger and itinerary detail. Captures the ticket header and the first air segment. 29 fields. |
"type": "HEALTHCARE"optional | J26 = 18 | Healthcare — Provider and payer identifiers. Per-line healthcare eligibility is set on the Level III grid. 3 fields. |
"type": "CRUISE"optional | J26 = 19 | Cruise — Sailing, itinerary and the air leg to the port, plus agency identifiers. 19 fields. |
"type": "RAIL"optional | J26 = 15 | Rail — Ticket, journey and service detail for rail travel. 19 fields. |
"type": "ELECTRIC_FUEL"optional | J26 = 26 | Electric 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"optional | J26 = 17 | Travel agency — Agency identifiers and the service fee charged on a travel booking. 7 fields. |
"type": "INSURANCE"optional | J26 = 22 | Insurance — Policy, insured party and premium detail. 7 fields. |
"type": "TELEPHONE"optional | J26 = 14 | Telephone — Originating and destination numbers for a call-based charge. 3 fields. |
"type": "TICKET_ENTERTAINMENT"optional | J26 = 16 | Ticketing / entertainment — Event, venue and ticket detail. 9 fields. |
"type": "VISA_TRANSPORT_ANCILLARY"optional | J26 = 23 | Transport ancillary — Baggage, seating or other purchases attached to a travel document rather than the ticket. 4 fields. |
"type": "HOTEL"optional | J26 = 4 | Hotel / lodging — Lodging enhanced data - the stay, the room, and itemised incidentals. Carried on all brands. 27 fields. |
"type": "AUTO_RENTAL"optional | J26 = 6 | Auto rental — Vehicle rental enhanced data - agreement, pickup and return detail, distance and adjustments. 21 fields. |
"type": "FLEET"optional | J26 = 25 | Fleet — 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.
| Field | Type | Description |
|---|---|---|
company_brand_namerecommended | B62 · an4 | Brand — Brand at the pump, 4 characters (e.g. SHEL). Pre-filled from the merchant name. |
purchase_timerecommended | B63 · n4 · HHMM | Purchase time — Local time at the pump, HH:MM. MC only. |
fuel_service_typerecommended | B64 · an1 · enum | Service type Values: S, F, H. MC only. |
| Field | Type | Description |
|---|---|---|
fuel_coderequired to qualify | B69 · an2 · enum | Fuel code — Visa Fuel Type Code. 121 defined values. DISC only. |
fuel_unit_pricerequired to qualify | B71 · n12 · 4dp implied | Price per gallon — Dollars per gallon, e.g. 3.499. MC only. |
fuel_quantityrequired to qualify | B72 · n6 · 3dp implied | Quantity (gallons) — Gallons dispensed, e.g. 12.153. MC only. |
fuel_sale_amountrecommended | B73 · n12 · 2dp implied | Fuel sale amount — The fuel portion of the sale. MC only. |
| Field | Type | Description |
|---|---|---|
total_tax_amountrecommended | B65 · n12 · 2dp implied | Total tax |
total_tax_collect_indicatorrecommended | B66 · an1 · enum | Tax collected Values: Y, N. MC only. |
state_sales_tax_amountrecommended | B67 · n12 · 2dp implied | State sales tax — Pre-filled from the merchant's default tax rate in Settings. DISC only. |
state_sales_tax_idrequired to qualify | B68 · an1 | State tax ID — One character. DISC only. |
tax_exempt_numberrequired to qualify | B70 · n12 | Tax exempt number — Digits only, up to 12. DISC only. |
| Field | Type | Description |
|---|---|---|
odometer_readingrequired to qualify | H165 · n7 | Odometer — Whole miles. Digits only - no commas, no decimals. |
| Field | Type | Description |
|---|---|---|
exempt_indicatorrequired to qualify | — · an1 | Exempt indicator |
AirlineJ26 = 129 fields · All brands
Ticket, passenger and itinerary detail. Captures the ticket header and the first air segment.
| Field | Type | Description |
|---|---|---|
ticket_numberrequired to qualify | C02 · an15 | Ticket number |
passenger_namerequired to qualify | C10 · an25 | Passenger name |
transaction_typerequired to qualify | C01 · an2 | Transaction type |
document_typerequired to qualify | C03 · an2 | Document type |
ticket_issue_daterequired to qualify | C08 · n8 · YYYYMMDD | Issue date |
ticket_issue_cityrequired to qualify | C07 · an18 | Issue city |
ticketing_carrierrequired to qualify | C06 · an25 | Ticketing carrier |
iata_coderequired to qualify | C05 · n8 | IATA code |
electronic_ticketrecommended | C14 · an1 · enum | Electronic ticket Values: E, P. |
restricted_ticketrequired to qualify | C75 · an1 · enum | Restricted ticket Values: N, R. |
number_in_partyrecommended | C09 · n3 | Passengers |
total_farerecommended | C78 · n12 · 2dp implied | Total fare |
| Field | Type | Description |
|---|---|---|
total_segmentsrecommended | C15 · n2 | Air segments — Total legs on the ticket. Only the first is captured here. |
departure_locationrequired to qualify | C18 · an5 | From (airport) |
arrival_locationrequired to qualify | C20 · an5 | To (airport) |
departure_daterequired to qualify | C19 · n8 · YYYYMMDD | Departure date |
departure_timerequired to qualify | C79 · n4 · HHMM | Departure time — HH:MM |
arrival_timerequired to qualify | C80 · n4 · HHMM | Arrival time — HH:MM |
segment_carrierrequired to qualify | C21 · an4 | Carrier |
flight_numberrequired to qualify | C24 · an6 | Flight number |
class_of_servicerequired to qualify | C23 · an3 | Class of service |
fare_basisrequired to qualify | C22 · an15 | Fare basis |
segment_farerequired to qualify | C25 · n12 · 2dp implied | Segment fare |
stop_overrequired to qualify | C17 · an1 · enum | Stopover Values: O, X. |
| Field | Type | Description |
|---|---|---|
travel_agency_coderequired to qualify | C73 · an8 | Agency code |
travel_agency_namerequired to qualify | C74 · an25 | Agency name |
customer_coderequired to qualify | C71 · an17 | Customer code |
ticket_change_indicatorrequired to qualify | C77 · an1 | Ticket change |
credit_reason_indicatorrequired to qualify | C76 · an1 | Credit reason |
HealthcareJ26 = 183 fields · All brands
Provider and payer identifiers. Per-line healthcare eligibility is set on the Level III grid.
| Field | Type | Description |
|---|---|---|
provider_idrequired to qualify | P27 · an15 | Provider ID — Visa healthcare provider identifier. VISA only. |
service_type_coderequired to qualify | P28 · an4 | Service type VISA only. |
payer_idrequired to qualify | P31 · an15 | Payer ID |
CruiseJ26 = 1919 fields · All brands
Sailing, itinerary and the air leg to the port, plus agency identifiers.
| Field | Type | Description |
|---|---|---|
passenger_namerequired to qualify | C38 · an25 | Passenger name |
ticket_numberrequired to qualify | C39 · an15 | Ticket number |
cruise_namerequired to qualify | C50 · an25 | Cruise / ship name |
departure_daterequired to qualify | C46 · n8 · YYYYMMDD | Departure date |
return_daterequired to qualify | C47 · n8 · YYYYMMDD | Return date |
number_of_daysrequired to qualify | C49 · n3 | Nights |
total_costrecommended | C48 · n12 · 2dp implied | Total cost |
class_coderequired to qualify | C45 · an3 | Class |
travel_packagerequired to qualify | C37 · an1 · enum | Travel package Values: Y, N. |
| Field | Type | Description |
|---|---|---|
destination_coderequired to qualify | C41 · an5 | Destination |
city_namerequired to qualify | C53 · an18 | City |
region_coderequired to qualify | C51 · an3 | Region |
country_coderequired to qualify | C52 · an3 | Country |
| Field | Type | Description |
|---|---|---|
departure_airportrequired to qualify | C42 · an5 | Departure airport |
air_carrier_coderequired to qualify | C43 · an4 | Air carrier |
flight_numberrequired to qualify | C44 · an6 | Flight number |
depart_daterequired to qualify | C40 · n8 · YYYYMMDD | Flight date |
| Field | Type | Description |
|---|---|---|
iata_carrier_coderequired to qualify | C35 · an4 | IATA carrier |
iata_agency_numberrequired to qualify | C36 · an8 | IATA agency number |
RailJ26 = 1519 fields · All brands
Ticket, journey and service detail for rail travel.
| Field | Type | Description |
|---|---|---|
transaction_typerequired to qualify | C26 · an2 | Transaction type |
ticket_numberrequired to qualify | C27 · an15 | Ticket number |
passenger_namerequired to qualify | C28 · an25 | Passenger name |
carrier_coderequired to qualify | C29 · an4 | Carrier |
issuer_namerequired to qualify | C30 · an25 | Issuer name |
issuer_cityrequired to qualify | C31 · an18 | Issuer city |
| Field | Type | Description |
|---|---|---|
departure_locationrequired to qualify | C32 · an5 | From |
arrival_locationrequired to qualify | C34 · an5 | To |
departure_daterequired to qualify | C33 · n8 · YYYYMMDD | Departure date |
rail_classrequired to qualify | C60 · an3 | Class |
number_of_adultsrecommended | C58 · n3 | Adults |
number_of_childrenrequired to qualify | C59 · n3 | Children |
| Field | Type | Description |
|---|---|---|
traveller_namerequired to qualify | C54 · an25 | Traveller name |
service_ticket_numrequired to qualify | C55 · an15 | Service ticket number |
service_typerequired to qualify | C56 · an3 | Service type |
service_naturerequired to qualify | C57 · an3 | Service nature |
service_amountrequired to qualify | C61 · n12 · 2dp implied | Service amount |
service_amount_signrequired to qualify | C62 · an1 · enum | Amount sign Values: D, C. |
procedure_idrequired to qualify | C63 · an8 | Procedure 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.
| Field | Type | Description |
|---|---|---|
connector_typerequired to qualify | S30 · an3 · enum | Connector type 9 defined values. |
unit_of_measurerecommended | S37 · an1 · enum | Unit of measure — Electric sessions bill by kWh or by minute. Values: W, C. |
quantityrequired to qualify | S42 · n12 · 4dp implied | Quantity (kWh) |
unit_pricerequired to qualify | S39 · n12 · 4dp implied | Price per kWh |
total_including_taxrecommended | S48 · n12 · 2dp implied | Total including tax |
start_timerequired to qualify | S46 · n4 · HHMM | Charge start — HH:MM |
finish_timerequired to qualify | S47 · n4 · HHMM | Charge finish — HH:MM |
total_charging_timerequired to qualify | S45 · n6 | Charging time (min) |
total_time_plugged_inrequired to qualify | S44 · n6 | Plugged in (min) — Can exceed charging time - idle minutes are often billed separately. |
| Field | Type | Description |
|---|---|---|
max_power_dispensedrequired to qualify | S31 · n6 | Max power dispensed (kW) |
power_capacityrequired to qualify | S36 · n6 | Station capacity (kW) — May exceed max dispensed when the site manages power. |
charging_reason_coderequired to qualify | S35 · an3 · enum | Charging reason — Only when the session ended abnormally. 10 defined values. |
| Field | Type | Description |
|---|---|---|
est_miles_addedrequired to qualify | S34 · n6 | Est. miles added |
est_vehicle_miles_availablerequired to qualify | S32 · n6 | Est. range on leaving |
carbon_footprintrequired to qualify | S33 · n12 | Carbon avoided (g CO2e) |
Travel agencyJ26 = 177 fields · All brands
Agency identifiers and the service fee charged on a travel booking.
| Field | Type | Description |
|---|---|---|
agency_coderequired to qualify | H101 · an8 | Agency code |
agency_namerecommended | H102 · an25 | Agency name |
agency_seq_numberrequired to qualify | H085 · an8 | Sequence number |
| Field | Type | Description |
|---|---|---|
fee_amountrequired to qualify | H086 · n12 · 2dp implied | Agency fee |
fee_amount_signrecommended | H087 · an1 · enum | Fee sign Values: D, C. |
fee_raterequired to qualify | H088 · n6 · 2dp implied | Fee rate (%) |
fee_descriptionrecommended | H089 · an25 | Fee description |
InsuranceJ26 = 227 fields · All brands
Policy, insured party and premium detail.
| Field | Type | Description |
|---|---|---|
policy_numberrequired to qualify | H148 · an25 | Policy number |
additional_policy_numberrequired to qualify | H152 · an25 | Additional policy number |
type_of_policyrequired to qualify | H153 · an25 | Policy type |
name_of_insuredrequired to qualify | H154 · an30 | Name of insured |
| Field | Type | Description |
|---|---|---|
premium_frequencyrequired to qualify | H151 · an12 · enum | Premium frequency Values: Monthly, Quarterly, Annual, Single. |
insurance_amountrecommended | H077 · n12 · 2dp implied | Premium amount |
insurance_indicatorrecommended | H131 · an1 · enum | Insurance indicator Values: Y, N. |
TelephoneJ26 = 143 fields · All brands
Originating and destination numbers for a call-based charge.
| Field | Type | Description |
|---|---|---|
call_from_phone_numberrequired to qualify | J73 · n15 · digits only | Call from |
call_to_phone_numberrequired to qualify | J77 · n15 · digits only | Call to |
phone_card_idrequired to qualify | J78 · an20 | Phone card ID |
Ticketing / entertainmentJ26 = 169 fields · All brands
Event, venue and ticket detail.
| Field | Type | Description |
|---|---|---|
event_namerecommended | J60 · an25 | Event name |
event_daterequired to qualify | J61 · n8 · YYYYMMDD | Event date |
event_locationrequired to qualify | J64 · an25 | Venue |
event_region_coderequired to qualify | J65 · an3 | Region |
event_country_coderequired to qualify | J66 · an3 | Country |
| Field | Type | Description |
|---|---|---|
ticket_quantityrecommended | J63 · n4 | Tickets |
individual_ticket_pricerequired to qualify | J62 · n12 · 2dp implied | Price per ticket |
ticket_typerequired to qualify | C86 · an4 | Ticket type |
issuer_addressrequired to qualify | C83 · an25 | Issuer address |
Transport ancillaryJ26 = 234 fields · All brands
Baggage, seating or other purchases attached to a travel document rather than the ticket.
| Field | Type | Description |
|---|---|---|
ticket_document_numberrequired to qualify | C65 · an15 | Ticket document number |
additional_document_numberrequired to qualify | C69 · an15 | Additional document number |
passenger_namerequired to qualify | C68 · an25 | Passenger name |
credit_reason_indicatorrequired to qualify | C70 · an1 | Credit reason |
Hotel / lodgingJ26 = 427 fields · All brands
Lodging enhanced data - the stay, the room, and itemised incidentals. Carried on all brands.
| Field | Type | Description |
|---|---|---|
arrival_daterequired to qualify | H016 · n8 · YYYYMMDD | Arrival date |
departure_daterequired to qualify | H017 · n8 · YYYYMMDD | Departure date |
folio_numberrequired to qualify | H018 · an12 | Folio number |
room_raterequired to qualify | H021 · n12 · 2dp implied | Room rate (nightly) |
room_taxrequired to qualify | H022 · n12 · 2dp implied | Room tax |
number_of_roomsrecommended | H008 · n3 | Rooms booked |
number_of_adultsrequired to qualify | H009 · n3 | Adults |
no_show_indicatorrecommended | H011 · an1 · enum | No-show Values: N, Y. |
| Field | Type | Description |
|---|---|---|
room_typerequired to qualify | H006 · an12 | Room type |
bed_typerequired to qualify | H005 · an12 | Bed type |
room_locationrequired to qualify | H004 · an12 | Room location |
smoking_preferencerequired to qualify | H007 · an1 · enum | Smoking Values: N, S. |
rate_typerequired to qualify | H012 · an12 | Rate type |
program_coderequired to qualify | H023 · an12 | Program code |
promotional_coderequired to qualify | H001 · an12 | Promotional code |
corporate_client_coderequired to qualify | H003 · an12 | Corporate client code |
| Field | Type | Description |
|---|---|---|
phone_chargesrequired to qualify | H024 · n12 · 2dp implied | Phone |
restaurant_chargesrequired to qualify | H025 · n12 · 2dp implied | Restaurant / room service |
mini_bar_chargesrequired to qualify | H026 · n12 · 2dp implied | Mini bar |
laundry_chargesrequired to qualify | H027 · n12 · 2dp implied | Laundry |
gift_shop_chargesrequired to qualify | H030 · n12 · 2dp implied | Gift shop |
movie_chargesrequired to qualify | H032 · n12 · 2dp implied | Movies |
health_club_chargesrequired to qualify | H033 · n12 · 2dp implied | Health club |
valet_parking_chargesrequired to qualify | H034 · n12 · 2dp implied | Valet parking |
cash_disbursement_chargesrequired to qualify | H035 · n12 · 2dp implied | Cash disbursement |
other_chargesrequired to qualify | H028 · n12 · 2dp implied | Other |
adjustment_amountrequired to qualify | H020 · n12 · 2dp implied | Adjustment |
Auto rentalJ26 = 621 fields · All brands
Vehicle rental enhanced data - agreement, pickup and return detail, distance and adjustments.
| Field | Type | Description |
|---|---|---|
agreement_numberrequired to qualify | B01 · an25 | Agreement number — Rental agreement number signed by the cardholder. |
rate_indicatorrequired to qualify | B38 · an1 · enum | Rate type Values: D, W, M. |
raterequired to qualify | B39 · n12 · 2dp implied | Rate |
vehicle_class_idrequired to qualify | B14 · an4 | Vehicle class |
driver_tax_numberrequired to qualify | B22 · an20 | Driver tax number |
| Field | Type | Description |
|---|---|---|
pickup_daterequired to qualify | B06 · n8 · YYYYMMDD | Pickup date |
pickup_timerequired to qualify | B07 · n4 · HHMM | Pickup time — HH:MM |
pickup_locationrequired to qualify | B02 · an26 | Location |
pickup_cityrequired to qualify | B03 · an18 | City |
pickup_regionrequired to qualify | B04 · an3 | State / region |
pickup_countryrequired to qualify | B05 · an3 | Country |
| Field | Type | Description |
|---|---|---|
return_daterequired to qualify | B11 · n8 · YYYYMMDD | Return date |
return_timerequired to qualify | B12 · n4 · HHMM | Return time — HH:MM |
dropoff_locationrequired to qualify | B19 · an26 | Drop-off location |
return_cityrequired to qualify | B08 · an25 | City |
return_regionrequired to qualify | B09 · an3 | State / region |
return_countryrequired to qualify | B10 · an3 | Country |
distancerequired to qualify | B15 · n5 | Distance travelled — Whole units. |
distance_uomrecommended | B16 · an1 · enum | Distance unit Values: M, K. |
adjustment_indicatorrequired to qualify | B17 · an1 | Adjustment type |
adjustment_amountrequired to qualify | B18 · n12 · 2dp implied | Adjustment 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.
| Field | Type | Description |
|---|---|---|
business_application_idrecommended | P25 · an2 | Business application — Fleet business application identifier. F1 per Cygma's Visa Fleet sample. |
| Field | Type | Description |
|---|---|---|
fleet_fuel_typeoptional | H157 · an2 | Fuel 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. |
| Field | Type | Description |
|---|---|---|
type_of_purchaserequired to qualify | S51 · an1 · enum | Type of purchase — MANDATORY on fleet. Drives which of the fields below Visa requires. Values: 1, 2, 3, 4. |
expanded_fuel_typeoptional | S25 · an4 · enum | Fuel 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_typeoptional | S52 · an1 · enum | Service 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_measureoptional | S37 · an1 · enum | Unit 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. |
quantityoptional | S53 · n12 · 2dp implied | Quantity (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_costoptional | S54 · n12 · 2dp implied | Price 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_priceoptional | S55 · n12 · 2dp implied | Gross 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_priceoptional | H162 · n12 · 2dp implied | Net 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. |
| Field | Type | Description |
|---|---|---|
local_tax_indicatorrecommended | — · an1 · enum | Local tax included Values: 0, 1. |
local_tax_amountrecommended | — · n12 · 2dp implied | Local tax — With local tax: gross fuel price + this = the sale amount. |
vat_tax_raterecommended | H166 · an4 | VAT rate |
| Field | Type | Description |
|---|---|---|
gross_non_fuel_priceoptional | H163 · n12 · 2dp implied | Gross 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_priceoptional | H164 · n12 · 2dp implied | Net non-fuel price — Optional, exclusive of taxes. Applies only when type_of_purchase is “2” or “3”. Non-fuel only. |
| Field | Type | Description |
|---|---|---|
odometer_readingrequired to qualify | H165 · an7 | Odometer — Whole miles. Digits only - no commas, no decimals. |
employee_numberrequired to qualify | S26 · an12 | Employee number — When the card prompts for it. No default: Cygma's working packet does not send it (2026-10-05). |
trailer_numberrequired to qualify | S27 · an16 | Trailer number — When the card prompts for it. |
prompted_data1required to qualify | S28 · an20 | Prompted data 1 |
prompted_data2required to qualify | S29 · an20 | Prompted 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.
// 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"
}
}Authorize & capture
/payments/authorizePlace a hold (no money moves): card keyed, card_token saved, or terminal_id on a card reader (Pre-auth on the reader)./payments/{id}/incrementRaise the hold – incremental auth (bar tab / hospitality): { "amount": 500, "card": {…} }./payments/{id}/captureSettle up to the authorized amount (partial ok): { "amount": 4200 }./payments/{id}/voidRelease the hold (no body)./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.
| Field | Type | Description |
|---|---|---|
requires_captureoptional | status | Hold placed; amount_capturable shows what you can still capture. |
succeededoptional | status | Captured (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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to authorize, in cents (> 0). |
currencyoptional | string | Only usd (default). |
card.numberoptional | string | PAN (keyed). Send exactly one of card, card_token or terminal_id. |
card_tokenoptional | string | A 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_idoptional | string | Card present: the hold runs on the reader (202 pending, then requires_capture). See Pre-auth on the reader. |
card.exp_monthoptional | string | With card: two digits, 01–12. |
card.exp_yearoptional | string | With card: two or four digits. |
card.cvvoptional | string | Security code. |
cardholder.*optional | object | Same shape as POST /payments (name/email/phone/zip/address, for AVS). |
descriptionoptional | string | Reference on the transaction (≤120 chars). |
| Field | Type | Description |
|---|---|---|
amountoptional | integer | Cents to capture. Omit for the full authorized amount; cannot exceed it. |
tip_amountoptional | integer | Card-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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | ADDITIONAL cents to authorize (> 0). |
card / card_tokenrequired | object / string | The card again – keyed, or a saved card on file. |
cumulative_amountoptional | integer | Override the computed running total (original + approved increments + this amount). |
descriptionoptional | string | Reference 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.
| Field | Type | Description |
|---|---|---|
tip_amountrequired | integer | Tip cents being added (> 0). |
amountoptional | integer | Corrected 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.
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"
}'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"
}'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"
}
}'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
}'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
}'curl -X POST https://www.merchant360.com/api/m360/v1/payments/AUTH_ID/void \ -H "Authorization: Bearer m360_live_xxx"
Refunds & voids
/payments/{id}/refundRefund a settled charge (full or partial; omit amount for full)./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.
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
}'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.
| Field | Type | Description |
|---|---|---|
card.numberrequired | string | PAN to vault, digits (spaces ok). |
card.exp_monthrequired | string | Two digits, 01–12. |
card.exp_yearrequired | string | Two or four digits (27 or 2027). |
cardholder.nameoptional | string | Label for the saved card. |
cardholder.zipoptional | string | Billing ZIP stored for AVS on future charges. |
cardholder.addressoptional | string | Billing street stored for AVS. |
set_defaultoptional | boolean | Make this the merchant's default card on file. |
customer_idoptional | string | An existing customer of the merchant (from POST /customers or GET /customers). Not the merchant’s customer: 404 customer_not_found. |
customeroptional | object | { 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.
/tokensTokenize a card without charging it (cards:write)./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./tokens/{id}Retrieve one saved card (cards:read), including cards saved under the merchant’s customers./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.
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
}'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:
/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./hosted/tokenizeCalled by the iframe (not you) to turn the card into a card_token.| Field | Type | Description |
|---|---|---|
expires_inoptional | integer | Client-token lifetime in seconds. Default 900 (15 min); clamped to 60–3600. |
customer_idoptional | string | An 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. |
customeroptional | object | { 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.
| Field | Type | Description |
|---|---|---|
Merchant360(clientToken)optional | fn | Create an instance from the client token minted in step 1. |
.hostedFields({ style })optional | fn | Create the fields. style.accent + style.fontFamily theme them. |
.mount(selector)optional | fn | Insert the iframe into your page. |
.on('change', cb)optional | fn | Live field validity → { complete, brand }. |
.tokenize()optional | fn | Promise → { 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).
// 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
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.
/checkout/sessionsCreate a session; returns the hosted url to redirect to./checkout/sessionsList sessions./checkout/sessions/{id}Retrieve one – poll for status/payment (or use the webhook).| Field | Type | Description |
|---|---|---|
amountoptional | integer | Total to collect, in cents. Required unless line_items is given. |
line_itemsoptional | object[] | [{ name, amount (cents), quantity }]. Their sum is the total. |
success_urlrequired | string | Redirect after payment. {CHECKOUT_SESSION_ID} is substituted; otherwise session_id is appended. |
cancel_urloptional | string | Where the customer returns if they cancel. |
descriptionoptional | string | Shown on the hosted page (≤300 chars). |
customer.name / emailoptional | string | Pre-fill the payer's details. |
customer.phoneoptional | string | The 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_idoptional | string | Your own id, echoed back on the session + webhook. |
metadataoptional | object | Arbitrary key/values echoed back. |
expires_in_minutesoptional | integer | Auto-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.
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"
}'Payment links
A payment link is a fixed-amount hosted pay page at merchant360.com/pay/{token} – share the returned url by email, SMS, or embed. For itemized bills use invoices (each has its own hosted URL). For an embeddable, multi-mode checkout (donations, save-a-card, look-up-my-invoice), use the hosted pay widget.
/payment-linksCreate a link (amount 0 = payer chooses)./payment-linksList links./payment-links/{id}Retrieve one link./payment-links/{id}/textText (SMS) the link to the customer – STOP-compliant; pending links only.A link is born pending and ends as paid, cancelled, or expired. It becomes paid the moment the customer completes payment on the hosted page – a card payment flips it instantly, a bank (eCheck) payment flips it when the ACH entry settles. payment_link.paid fires on the transition (the event data is the same object GET /payment-links returns); links tied to an invoice also fire invoice.paid. Expiry (expires_in_days) is evaluated lazily: the status flips to expired when the link is next accessed after its deadline, not on a background timer, so an untouched link can read pending past its expiry until someone opens it.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to collect, in cents (> 0). |
descriptionoptional | string | Shown on the pay page (≤200 chars). |
expires_in_daysoptional | integer | Auto-expire the link after N days. |
customer.name / email / phoneoptional | string | Pre-fill the payer's details. |
curl -X POST https://www.merchant360.com/api/m360/v1/payment-links \
-H "Authorization: Bearer m360_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"description": "Deposit",
"expires_in_days": 30
}'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.
| Field | Type | Description |
|---|---|---|
data-merchantrequired | string | Your merchant UUID (shown on the Pay Widget page). |
data-moderequired | string | One of the modes below (must be enabled for your account). |
data-amountoptional | integer | Cents – FIXED_AMOUNT mode only. |
data-descriptionoptional | string | Shown on the form and the receipt. |
| Field | Type | Description |
|---|---|---|
FIXED_AMOUNToptional | mode | You set the amount; the customer just pays. |
DONATIONoptional | mode | Preset amount chips (data-suggested, whole dollars) + custom amount within your min/max. |
EXTERNAL_INVOICEoptional | mode | Customer types your invoice number + amount (pre-fill with ?invoice= and ?memo=). |
M360_INVOICEoptional | mode | Look-up-my-invoice: customer finds their open Merchant360 invoice by number + email and pays the balance. |
SUBSCRIPTIONoptional | mode | Customer joins a membership program you define (first charge now, card vaulted, auto-bills on schedule). |
SAVE_CARDoptional | mode | Vault 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).
| Field | Type | Description |
|---|---|---|
moderequired | string | FIXED_AMOUNT | DONATION | EXTERNAL_INVOICE. |
amountCentsrequired | integer | 100 – 100,000,000 (must fit your configured min/max). |
customerrequired | object | { name, email, phone? } – the receipt goes to this email. |
cardrequired | object | { number, expMonth, expYear, cvv, postalCode, street1?, city?, state? }. |
descriptionoptional | string | Shown on the receipt. |
externalInvoiceRefoptional | string | EXTERNAL_INVOICE – your invoice number (search-indexed in M360). |
externalInvoiceMemooptional | string | EXTERNAL_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.
<!-- 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>ACH debits & credits
/ach/debitsPull from a customer bank account./ach/creditsPush to a customer bank account (same body)./ach/{id}Retrieve an entry – poll its status, or use the webhooks below./ach/routing/{number}Routing-number lookup: Mod-10 checksum + FedACH bank name – show “Debiting <bank>” before you submit. Any valid key; no scope./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.
| Field | Type | Description |
|---|---|---|
queuedoptional | status | Accepted, waiting for the next 4:00 PM ET submission window. No money has moved. |
processingoptional | status | Submitted to the bank; awaiting settlement (typically 1–2 banking days). |
settledoptional | status | Funds moved; settled_at is set. A late bank return can still move a settled entry to returned – see below. |
returnedoptional | status | The bank returned the entry; return.code / return.description say why. |
canceledoptional | status | Canceled before submission. Terminal. |
failedoptional | status | Rejected 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.
| Field | Type | Description |
|---|---|---|
idoptional | string | Entry id (ach_…). |
objectoptional | string | ach_payment. |
kindoptional | string | debit or credit. |
statusoptional | string | queued · processing · settled · returned · canceled · failed. |
amount_centsoptional | integer | Amount in cents. |
currencyoptional | string | usd. |
sec_codeoptional | string | PPD / WEB / CCD – resolved server-side (above). |
run_dateoptional | string | Banking day the entry submits (or submitted) to the bank. |
createdoptional | string | ISO timestamp. |
settled_atoptional | string | ISO timestamp once settled; null before. |
returnoptional | object | { code, description } once returned; null otherwise. |
resubmitted_asoptional | string | Id of the automatic re-presentment when an R01/R09 was retried. |
| Field | Type | Description |
|---|---|---|
400 no_authoptional | error | Debits require a stored customer authorization. |
403 not_enabled / not_allowedoptional | error | ACH 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_limitoptional | error | Per-transaction or monthly volume cap exceeded. |
422 not_validatedoptional | error | The bank account hasn't completed verification – a prenote takes 1–2 banking days. |
502 queue_failedoptional | error | The 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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Cents (> 0). |
bank_account_idoptional | string | Charge 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.routingoptional | string | 9-digit ABA routing number. Required unless bank_account_id is given. |
bank_account.accountoptional | string | Account number. Required unless bank_account_id is given. |
bank_account.holder_nameoptional | string | Name on the account. Required unless bank_account_id is given. |
bank_account.typeoptional | string | checking (default) or savings. |
bank_account.holder_typeoptional | string | consumer (PPD/WEB, default) or business (CCD). |
authorization.frequencyoptional | string | one_time (default) or recurring. |
descriptionoptional | string | NACHA 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.
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"
}'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.
/banksStore a bank on file without charging (banks:write). Returns the bank_… token./banksList saved banks (banks:read)./banks/{id}Retrieve one saved bank (banks:read)./banks/{id}Remove a saved bank – a soft archive (banks:write).| Field | Type | Description |
|---|---|---|
bank_account.routingrequired | string | 9-digit ABA routing number. |
bank_account.accountrequired | string | Account number (stored encrypted; only last 4 is returned). |
bank_account.holder_namerequired | string | Name on the account. |
bank_account.typeoptional | string | checking (default) or savings. |
bank_account.holder_typeoptional | string | consumer (PPD/WEB, default) or business (CCD). |
receipt_emailoptional | string | Optional. Where the receipt is emailed when this bank is charged standalone (a debit not tied to an invoice or payment link). Sent on settlement. |
| Field | Type | Description |
|---|---|---|
idoptional | string | The bank_… token – pass it as bank_account_id on an ACH debit/credit. |
objectoptional | string | Always bank_account. |
holder_name / routing / last4optional | string | Account holder, public ABA routing, and last 4 (never the full account). |
account_type / holder_typeoptional | string | checking/savings and consumer/business. |
sec_codeoptional | string | PPD / CCD derived from holder_type (informational). |
validation_statusoptional | string | unverified → prenote_pending → prenote_verified / verified (or failed). A prenote takes 1–2 banking days. |
is_defaultoptional | boolean | The merchant's default bank for this customer scope. |
receipt_emailoptional | string | The 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.
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"
}
}'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:
- Create a session –
POST /bnpl/sessionsfor an amount. Klarna returns aclient_tokenfor the Klarna SDK; Affirm returns apublic_key+script_urlfor affirm.js (Affirm has no server session). - 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 anauthorization_token. - Capture –
POST /bnpl/chargeswith that token. We authorize + capture at the provider and book the charge. Passinvoice_idto 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.
| Field | Type | Description |
|---|---|---|
providerrequired | string | "affirm" or "klarna". |
amountrequired | integer | Cents (>0). Affirm's practical floor is $50. |
invoice_numberoptional | string | Shown on the Klarna order line. Defaults to "API". |
customer.emailoptional | string | Optional – passed to Klarna as the billing email. |
| Field | Type | Description |
|---|---|---|
providerrequired | string | "affirm" or "klarna". |
amountrequired | integer | Cents (>0) – must match the amount the widget authorized. |
tokenrequired | string | Affirm checkout_token / Klarna authorization_token from the widget. |
invoice_idoptional | string | Optional – a Merchant360 invoice to mark paid with the same bookkeeping as the pay page. |
customeroptional | object | Optional name + email stored on the charge. |
/bnpl/sessionsStart a checkout. Returns client_token (Klarna) or public_key + script_url (Affirm) for the widget./bnpl/chargesCapture the widget's token into a charge; optionally mark an invoice paid. Idempotent./bnpl/chargesList BNPL charges (newest first, paginated)./bnpl/charges/{id}Fetch a single charge./bnpl/charges/{id}/refundRefund full (omit amount) or partial. Un-books any invoice it paid. Idempotent.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_…"
}'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.
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_optionsandentry_modebelow 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_optionshave 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.
| Field | Type | Description |
|---|---|---|
pendingoptional | status | Sent 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_captureoptional | status | A pre-authorization hold the reader approved (sent with POST /payments/authorize). No money moved; capture or void it. See Pre-auth. |
succeededoptional | status | Approved. 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. |
failedoptional | status | Declined, or no approval evidence from the gateway. processor.raw_status carries the gateway’s word. |
canceledoptional | status | Cancelled on the reader, through POST /payments/{id}/cancel, or by the reader itself (a terminal-initiated reversal). cancel_source says which. |
voidedoptional | status | Reversed before settlement. A partial void keeps succeeded with amount_voided set. |
refunded / partially_refundedoptional | status | Refunds applied; amount_refunded is the total. |
/payments/authorizeWith terminal_id: a pre-authorization hold on the reader (202, pending, then requires_capture). Pre-auth./payments/{id}/captureCapture a card-present hold, with an optional tip_amount. No card needed./payments/{id}/incrementRaise a card-present hold. No card needed./payments/{id}One payment, keyed or card-present. A pending tp_ payment is read from the gateway live./payments/{id}/voidUnsettled, within 24 hours, full or partial. No card needed./payments/{id}/refundBody { amount? }. Refunds the card that paid without the card present; 202 pending when the acquirer requires the card on the reader./payments?channel=card_presentReader sales: from the API, the Merchant360 terminal, and sales run on the device itself./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.
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"
}
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \ -H "Authorization: Bearer m360_live_xxx"
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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to send to the reader, in cents (> 0). |
terminal_idrequired | string | An id from GET /terminal/devices, or default for the reader the merchant marked as default (else the first reader on the account). |
tip.promptoptional | boolean | Show the reader’s tip screen. See Tips. |
terminal_options.allow_canceloptional | boolean | Show 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_keyedoptional | boolean | Show the Key enter card button. Default false. Requires MOTO enabled for the merchant. See Keyed entry. |
entry_modeoptional | string | card (default) or keyed. keyed opens manual card entry directly and requires MOTO enabled. |
save_cardoptional | boolean | Keep 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 / customeroptional | string / object | With 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_numberoptional | string | Your reference, echoed on the payment. |
customer_codeoptional | string | Your 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_taxoptional | integer | Level 2: tax cents included in amount. Sends Level 2 data with the sale. |
tax_exemptoptional | boolean | Level 2: the purchase is tax exempt. Sends Level 2 data with the sale. |
metadataoptional | object | Same 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.
| Field | Type | Description |
|---|---|---|
idoptional | string | tp_… (tp_test_… in test mode). |
channeloptional | string | card_present. |
statusoptional | string | See the status table. |
amount / amount_requestedoptional | integer | Approved cents (tip and device fee included) / what you sent. |
amount_tipoptional | integer | Present when the customer added a tip on the reader. |
amount_surcharge / surcharge_reasonoptional | integer / string | Present 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. |
terminaloptional | object | { id, serial } on the create response; { serial } on reads. |
cardoptional | object | { brand, last4 } once the card is read; null while pending. A sale that saved the card adds exp_month and exp_year. |
card_tokenoptional | string | null | The 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. |
processoroptional | object | { 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_optionsoptional | object | null | { allow_cancel, allow_keyed } as applied to this sale; null when the POS sent none. |
entry_modeoptional | string | card or keyed, as requested. |
entry_methodoptional | string | null | How the card was read: chip, contactless, swipe, keyed, or null when not known. |
requires_reviewoptional | boolean | true only while status is pending and the sale has been flagged for manual reconciliation. See Recovery. |
cancel_sourceoptional | string | null | On 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_reasonoptional | string | null | On a reader-initiated reversal: the code and a short reason (table in Cancel). |
amount_voidedoptional | integer | After a partial void: cents reversed. amount is what is left. |
sales_tax / tax_exemptoptional | integer / boolean | Present when the sale carried Level 2 data. |
originoptional | string | API_HP for a sale sent through this API. |
settledoptional | boolean | true once the batch closed. |
| Field | Type | Description |
|---|---|---|
400optional | bad_request | More 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. |
400optional | customer_required | save_card: true without customer_id or customer. A card on file always belongs to a customer. Nothing is sent to the reader. |
400optional | customer_invalid | customer without a name. |
404optional | customer_not_found | customer_id is not a customer of the merchant. |
422optional | tip_with_save_card | save_card: true with tip.prompt: true. The gateway takes no tip screen on a sale that saves the card. |
422optional | keyed_with_save_card | save_card: true with entry_mode: "keyed" or terminal_options.allow_keyed. The card is saved from a card read only. |
422optional | tokenization_not_enabled | save_card: saving cards is not enabled for the merchant at the gateway. No payment is created. |
422optional | terminal_app_update_required | save_card on a reader whose Merchant360 Payment App is older than 2.43.4. No payment is created. |
400optional | terminal_unavailable | The account has no card-present reader. |
402optional | payment_declined | Fraud Tools single-transaction limits, checked before the reader is contacted. |
409optional | terminal_unavailable | Card-present terminals are not provisioned on the account. |
422optional | moto_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. |
502optional | terminal_error | The 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.
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"
}
}'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"
}'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"
}'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"
}'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": {
"message": "Card-present terminals aren't provisioned on this account.",
"code": "terminal_unavailable"
}
}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": {
"message": "`save_card` and `tip.prompt` can't be combined on a `terminal_id` sale.",
"code": "tip_with_save_card"
}
}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: trueshows 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_enabledand 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_methodon the result iskeyed.
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.
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
}
}'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"
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \ -H "Authorization: Bearer m360_live_xxx"
{
"error": {
"message": "Keyed entry needs MOTO enabled for this merchant.",
"code": "moto_not_enabled"
}
}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 withchannel: "card_not_present"andentry_mode/entry_methodcard_on_file; void and refund it like anytp_payment (Cancel, void, refund). - Customer.
customer_id(an existing customer) orcustomer(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_cardcan’t be combined withtip.prompt(422 tip_with_save_card). - Card read only.
save_cardcan’t be combined with keyed entry (entry_mode: "keyed"orallow_keyed):422 keyed_with_save_card. - If no token comes back. The sale is still a sale:
statussucceededwithcard_token: null. - Where a terminal-saved card works.
POST /payments {card_token},GET /tokens,GET /tokens/{id}andDELETE /tokens/{id}. It is not accepted byPOST /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.
| Field | Type | Description |
|---|---|---|
201optional | succeeded | The gateway approved the charge. payment.succeeded fires with the same object. |
202optional | pending | No answer from the gateway. Merchant360 resolves the payment by its reference; poll GET /payments/{id} or wait for the webhook. Do not charge again. |
402optional | payment_declined | Declined. The body carries payment (the failed tp_ id); payment.failed fires. |
400optional | invalid_card_token | Unknown card, or the gateway did not accept the token (save the card again). |
403optional | billing_tools_off | Billing tools are off for the merchant. |
409optional | terminal_unavailable | Card-present terminals are not provisioned on the account. |
422optional | moto_not_enabled | MOTO is not enabled for the merchant at the gateway. |
502 / 503optional | gateway_error / gateway_unavailable | The 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.
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"
}
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \ -H "Authorization: Bearer m360_live_xxx"
{
"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"
}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"
}'curl -X GET https://www.merchant360.com/api/m360/v1/tokens \ -H "Authorization: Bearer m360_live_xxx"
curl -X DELETE https://www.merchant360.com/api/m360/v1/tokens/card_0b6f3a52-7c1e-4d0a-9a4f-6e2d1c3b5a77 \ -H "Authorization: Bearer m360_live_xxx"
{
"error": {
"message": "The payment gateway did not accept this saved card. Save the card again.",
"code": "invalid_card_token"
}
}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"
}
}'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? }.amountdefaults to the whole hold and cannot exceed it; less is a partial capture and the rest is released.tip_amountis added on top ofamount. The payment becomessucceededwithamount= captured + tip;payment.capturedfires. - Increment:
{ amount }is the ADDITIONAL cents.amount_capturableis the new hold.payment.authorizedfires withamount_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_beforeis the merchant’s terminal-profile pre-auth expiry (default 7 days); after it, capture and increment return409 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_cardortip.prompton a hold (400). - Refund of an uncaptured hold returns
409 requires_capture: capture or void it.
| Field | Type | Description |
|---|---|---|
statusoptional | string | pending → requires_capture → succeeded (captured) or voided. |
capture_methodoptional | string | manual on a hold. |
amount_authorizedoptional | integer | null | The hold, including approved increments. |
amount_capturableoptional | integer | What can still be captured; 0 once captured or voided. |
captured / captured_at / amount_capturedoptional | boolean / string / integer | Set on capture. amount_captured excludes the tip. |
capture_beforeoptional | string | ISO time after which capture and increment are refused. |
| Field | Type | Description |
|---|---|---|
400optional | bad_request | Keyed 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. |
400optional | amount_exceeds_authorization | Capture over the hold. |
400optional | amount_too_small / amount_too_large | Outside the terminal profile's pre-auth minimum / maximum. |
409optional | authorization_pending | The hold has not finished on the reader yet. |
409optional | already_captured / already_voided | The hold was captured or released already. |
409optional | authorization_expired | Past capture_before, or the gateway no longer has the hold. |
422optional | preauth_not_enabled | Pre-auth is off on the terminal profile or at the gateway. No payment is created. |
422optional | terminal_app_update_required | The reader's Merchant360 Payment App is older than 2.43.4. No payment is created. |
422optional | partial_void_not_supported | amount 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.
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"
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06 \ -H "Authorization: Bearer m360_live_xxx"
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
}'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
}'curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_9d2c7e14-3b6a-4f0e-8c51-7a2e9b4d1c06/void \ -H "Authorization: Bearer m360_live_xxx"
{
"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": {
"message": "Capture exceeds the authorized amount.",
"code": "amount_exceeds_authorization"
}
}{
"error": {
"message": "Pre-authorization is not enabled for this merchant's terminals.",
"code": "preauth_not_enabled"
}
}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
canceledwithcancel_source: "pos". - The customer completed it just before the cancel landed: Merchant360 voids it and returns
voidedwithvoided_after_cancel: true. A cancelled sale never leaves a charge. - That automatic void was refused:
409 charged_before_cancelwith thepaymentin 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.
| Field | Type | Description |
|---|---|---|
4021optional | code | Timed out after approval. |
4351optional | code | Chip card removed before the sale finished. |
4352optional | code | Chip card declined after the host approved. |
4353optional | code | Signature not provided in time. |
4354optional | code | Signature declined or partial approval rejected. |
4580optional | code | Processing error on the terminal. |
4002optional | code | Suspected terminal malfunction. |
After the sale: void or refund. Neither needs the card.
POST /payments/{id}/voidwith no body: full amount, before settlement, within 24 hours. Returns the payment with statusvoided.POST /payments/{id}/voidwith{ 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 stayssucceeded;amount_voidedis the cents reversed andamountwhat is left.payment.voidedfires withpartial: trueandamount_remaining.POST /payments/{id}/refundwith{ amount? }(omit for the full amount): refunds the card that paid. When the acquirer requires the card, the refund goes to the reader and returns202with statuspending.- Both are subject to the merchant’s channel profile (
403 not_permitted_by_channelwhen refunds are off).
| Field | Type | Description |
|---|---|---|
400optional | not_cancelable | The id is not a card-present (tp_) payment. Keyed payments resolve synchronously: void or refund them. |
404optional | not_found | No such payment for this merchant. |
409optional | not_cancelable | The payment is no longer pending. Void or refund it. |
409optional | charged_before_cancel | Charged before the cancel reached the reader and the automatic void failed. |
409optional | terminal_unavailable | Card-present terminals are not provisioned on the account. |
| Field | Type | Description |
|---|---|---|
400optional | amount_exceeds_original | The partial amount is more than the sale. |
422optional | partial_void_not_supported | American Express; partial reversal not enabled for the merchant; or amount on a payment that is not card-present. |
409optional | already_partially_voided | The sale already has a partial void. |
409optional | already_voided | The sale was already voided. |
409optional | not_voidable | The gateway has no record of the sale yet, or cannot find it. Refund instead after settlement. |
409optional | terminal_busy | The reader is busy. Retry shortly. |
curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/cancel \ -H "Authorization: Bearer m360_live_xxx"
{
"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
}{
"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
}{
"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."
}curl -X POST https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11/void \ -H "Authorization: Bearer m360_live_xxx"
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
}'{
"payment": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
"amount": 1000,
"channel": "card_present",
"partial": true,
"amount_remaining": 1500
}{
"error": {
"message": "The void amount is more than the sale.",
"code": "amount_exceeds_original"
}
}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
}'{
"object": "refund",
"payment": "tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11",
"amount": 500,
"status": "pending",
"channel": "card_present",
"mode": "live"
}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
- Always send an
Idempotency-KeyonPOST /payments(your order id). If the create times out or the connection drops, resend the identical request with the same key: you get the originaltp_payment back, and no second sale reaches the reader. A different key is a new sale. - Store the
tp_id as soon as the 202 arrives. - Wait for the result by webhook (
payment.succeeded,payment.failed,payment.canceled), by pollingGET /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 stayspendingwithrequires_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. 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.- Treat only
succeededas paid.failedandcanceledmean no charge; you may start a new sale (new idempotency key). Do not start a new sale while the first is stillpending: cancel it first. - 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). - Reconcile daily.
GET /transactions?tender=card_presentlists every reader sale, including sales run on the device itself;payment_idis thetp_id. Upsert byid.
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.
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"
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \ -H "Authorization: Bearer m360_live_xxx"
curl -X GET https://www.merchant360.com/api/m360/v1/payments?channel=card_present&page_size=25 \ -H "Authorization: Bearer m360_live_xxx"
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"
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.
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
}
}'curl -X GET https://www.merchant360.com/api/m360/v1/payments/tp_5b0e2c1a-8f0e-4c4b-9a51-2d7b1f0c9e11 \ -H "Authorization: Bearer m360_live_xxx"
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.
| Field | Type | Description |
|---|---|---|
Standaloneoptional | mode | The clerk runs sales on the reader itself (keypad, history, reports). The reader does not wait for a POS. API sales still arrive. |
Integratedoptional | mode | The 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 Listeneroptional | mode | A 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.
| Field | Type | Description |
|---|---|---|
terminalModeoptional | Standalone | Integrated | POS Listener | Integrated and POS Listener both put the reader in Handpoint cloud mode. |
listenerCancelButtonoptional | boolean | “Cancel button on the terminal”. Default true. Overridden per sale by terminal_options.allow_cancel. |
listenerIdleMessageoptional | text ≤40 | Default "Ready for Payment". POS Listener only. The merchant can also edit it in Merchant360. |
listenerShowIsvLogooptional | boolean | Default 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.
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
}
}'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>| Field | Type | Description |
|---|---|---|
keyrequired | string | The embed key. vtk_test_… keys load directly for development; live vtk_… keys only work inside a registered domain (Referer-checked before any session exists). |
headeroptional | string | 1 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.
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.
| Field | Type | Description |
|---|---|---|
terminal_idoptional | string | Pass as terminal_id. Format <model>/<serial>. |
nameoptional | string | The label the merchant set on the Card readers card, else the serial. |
nickname / locationoptional | string | null | The raw fields behind name. |
model / serialoptional | string | null | For example PAXA920MAX / 2740199794. |
default_terminal_idoptional | string | null | The 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.
curl -X GET https://www.merchant360.com/api/m360/v1/terminal/devices \ -H "Authorization: Bearer m360_live_xxx"
{
"object": "terminal.device_list",
"available": false,
"default_terminal_id": null,
"devices": []
}curl -X GET https://www.merchant360.com/api/m360/v1/terminal/devices \ -H "Authorization: Bearer m360_test_xxx"
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"
}'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.
/egift/balanceBalance inquiry (egift:read)./egift/activateOpening value on a new card. An already-active card gets the value added instead./egift/add-valueReload. A never-activated card is activated instead. Up to $2,000 per call./egift/redeemDeduct from a card. Idempotent./egift/voidUndo a prior redemption: the amount goes back on the card. Idempotent./egift/tip-adjustAdd or change the tip on a prior redemption./egift/store-creditLoad merchandise credit (reported separately from purchased value)./egift/deactivateMake a card inactive; refund: true also zeroes the balance./egift/balance-transferMove value from one existing card to another./egift/reissueMove the whole balance from an old card to a new card./egift/expiration-adjustSet or reset the card's expiration./egift/issueActivate a new card from the merchant's pool and email it to the recipient. No charge. Idempotent.| Field | Type | Description |
|---|---|---|
cardNumberoptional | string | 12 to 19 digits. Or track1 / track2 on the single-card load and adjust routes. |
amountCentsoptional | integer | activate 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). |
orderRefoptional | string | Your reference (activate, redeem, issue; up to 80 characters on issue). |
transactionIdoptional | string | void: the transaction_id of the redemption to undo. |
originalTransactionId / tipCentsoptional | string / integer | tip-adjust: the redemption and the tip in cents (0 to 100000000). |
refundrequired | boolean | deactivate: true zeroes the balance; false keeps it and makes the card inactive. |
fromCardNumber / toCardNumberoptional | string | balance-transfer: two different existing cards (same card is 400). |
oldCardNumber / newCardNumberoptional | string | reissue. |
newExpirationoptional | string | expiration-adjust: YYYYMMDD; omit to reset to the program default. |
recipient / sender / kindoptional | object / object / string | issue: recipient.name and recipient.email required, optional recipient.message; sender defaults to the store; kind PURCHASE (default) or STORE_CREDIT. |
| Field | Type | Description |
|---|---|---|
egift_balanceoptional | object | card_last4, balance_cents. |
egift_loadoptional | object | activate / 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_redemptionoptional | object | transaction_id (keep it for void and tip-adjust), amount_redeemed_cents, balance_after_cents. |
egift_voidoptional | object | transaction_id of the void, voided: true. |
egift_tip_adjustoptional | object | original_transaction_id, tip_cents, balance_after_cents. |
egift_store_creditoptional | object | amount_loaded_cents, balance_after_cents. |
egift_deactivationoptional | object | refund, balance_after_cents. |
egift_transferoptional | object | from_card_last4, to_card_last4, amount_cents (null on a whole-balance transfer: the gateway does not echo it), to_balance_after_cents. |
egift_reissueoptional | object | old_card_last4, new_card_last4, balance_after_cents. |
egift_expirationoptional | object | new_expiration (null after a reset). |
egift_cardoptional | object | issue: gift_card_id, card_last4, balance_cents. The recipient receives the full number by email (branded PDF). |
| Field | Type | Description |
|---|---|---|
400 bad_requestoptional | validation | Card number not 12 to 19 digits, bad track data, amount or points out of range, missing field. |
401 unauthorizedoptional | auth | Missing or invalid key. |
403 insufficient_scopeoptional | auth | The key lacks egift:* / loyalty:*. |
403 billing_tools_offoptional | account | Live 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_enabledoptional | account | The merchant has no eGift program. |
404 not_foundoptional | card | Card not found or not active in the merchant's program. |
409 idempotency_in_progressoptional | idempotency | A duplicate of a request still in flight. |
422 idempotency_mismatchoptional | idempotency | An Idempotency-Key reused on another path. |
429 rate_limitedoptional | limit | See the limits below. |
502 gateway_error / gateway_timeoutoptional | gateway | The gift processor did not answer or failed. |
402 redeem_failed / void_failedoptional | redeem, void | The gift processor refused. decline_code carries its reason when it gives one. |
403 gatedoptional | issue | Gift cards are not active for this merchant. |
404 original_transaction_not_foundoptional | tip-adjust | The redemption id is unknown. |
409 duplicate_loadoptional | activate, add-value | The card was loaded for this amount minutes ago. Check the balance before loading again. |
409 no_pooloptional | issue | No 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.
| Field | Type | Description |
|---|---|---|
Card ending 0000optional | test card | 404 not_found on every route (history returns an empty list). |
Any other cardoptional | test card | Balance 2500 cents. Loads return 2500 + the amount (activate returns the amount). Redeem returns 2500 minus the amount, not below 0. |
transaction_idoptional | test | test_ + 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 effectsoptional | test | No 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.
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"
}'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"
}'{
"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"
}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
}'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
}'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"
}'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"
}'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
}'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
}'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
}'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
}'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
}'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"
}'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"
}'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"
}'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"
}'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"
}'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"
}'{
"error": {
"message": "`cardNumber` must be 12–19 digits (strip spaces and hyphens).",
"code": "bad_request"
}
}Gift card history
The card’s activity, newest first, from Merchant360’s own transaction records. Output never contains the full card number.
"coverage": "merchant360".| Field | Type | Description |
|---|---|---|
cardNumberrequired | string | 12 to 19 digits, or track1 / track2. |
limitoptional | integer | 1 to 100. Default 25. |
from / tooptional | string | YYYY-MM-DD (to covers the whole day) or an ISO datetime. Inclusive. |
starting_afteroptional | string | The next_cursor of the previous page (startingAfter also accepted). Must be one of this merchant’s entries. |
include_balance_inquiriesoptional | boolean | Default false. |
| Field | Type | Description |
|---|---|---|
typeoptional | string | activate, 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). |
statusoptional | string | approved, declined, or error (no answer, or eGift not enabled). |
amount_centsoptional | integer | null | The transaction amount. |
balance_after_centsoptional | integer | null | The balance after an approved transaction; null when not approved and on the sending side of a transfer or reissue. |
counterpart_card_last4optional | string | null | The other card on a transfer or reissue. |
referenceoptional | string | The eGift transaction id (use it for void and tip-adjust). |
decline_codeoptional | string | null | Decline reason when not approved. |
sourceoptional | string | api (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_serialoptional | string | null | Set 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.
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"
}'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 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.
/loyalty/balancePoint balance (loyalty:read)./loyalty/activateOpening balance on a new card (points 0 to 1000000). An active card answers 409 card_already_active: use add-points./loyalty/add-pointspoints 1 to 1000000, optional orderRef./loyalty/redeem-pointsDeduct points. Above the balance: 402 insufficient_points./loyalty/refundReverse a prior points transaction by originalTransactionId; points is required and echoed./loyalty/transferMove the whole point balance to another card./loyalty/voidVoid a prior points transaction by transactionId.| Field | Type | Description |
|---|---|---|
loyalty_balanceoptional | object | card_last4, point_balance. |
loyalty_activationoptional | object | transaction_id, points_loaded, point_balance_after. |
loyalty_pointsoptional | object | transaction_id, points_added, point_balance_after. |
loyalty_redemptionoptional | object | transaction_id, points_redeemed, point_balance_after. |
loyalty_refundoptional | object | original_transaction_id, points_refunded, point_balance_after. |
loyalty_transferoptional | object | from_card_last4, to_card_last4, to_point_balance_after. |
loyalty_voidoptional | object | voided_transaction_id, point_balance_after. |
| Field | Type | Description |
|---|---|---|
400 bad_requestoptional | validation | Card number not 12 to 19 digits, bad track data, amount or points out of range, missing field. |
401 unauthorizedoptional | auth | Missing or invalid key. |
403 insufficient_scopeoptional | auth | The key lacks egift:* / loyalty:*. |
403 billing_tools_offoptional | account | Live 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_enabledoptional | account | The merchant has no eGift program. |
404 not_foundoptional | card | Card not found or not active in the merchant's program. |
409 idempotency_in_progressoptional | idempotency | A duplicate of a request still in flight. |
422 idempotency_mismatchoptional | idempotency | An Idempotency-Key reused on another path. |
429 rate_limitedoptional | limit | See the limits below. |
502 gateway_error / gateway_timeoutoptional | gateway | The gift processor did not answer or failed. |
402 insufficient_pointsoptional | redeem-points | More points than the card holds. |
404 original_transaction_not_foundoptional | refund, void | The transaction id is unknown. |
409 card_already_activeoptional | activate | Use add-points. |
409 already_voidedoptional | void | The 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.
| Field | Type | Description |
|---|---|---|
Card ending 0000optional | test card | 404 not_found on every route. |
Card ending 1111optional | test card | Holds 0 points. |
Any other cardoptional | test card | Holds 1000 points. Redeeming more than the balance returns 402 insufficient_points. |
transaction_idoptional | test | test_ + 8 hex characters derived from the request. |
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"
}'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
}'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"
}'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
}'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
}'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"
}'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"
}'{
"error": {
"message": "Not enough points on the card.",
"code": "insufficient_points"
}
}Loyalty history
Same request and paging as gift history. Entries carry points and point_balance_after in place of cents.
"coverage": "merchant360".| Field | Type | Description |
|---|---|---|
cardNumberrequired | string | 12 to 19 digits, or track1 / track2. |
limitoptional | integer | 1 to 100. Default 25. |
from / tooptional | string | YYYY-MM-DD (to covers the whole day) or an ISO datetime. Inclusive. |
starting_afteroptional | string | The next_cursor of the previous page (startingAfter also accepted). Must be one of this merchant’s entries. |
include_balance_inquiriesoptional | boolean | Default false. |
| Field | Type | Description |
|---|---|---|
typeoptional | string | activate, add_points, redeem_points, refund, void, transfer_out, transfer_in, balance_inquiry (only with include_balance_inquiries). |
statusoptional | string | approved, declined, error. |
points / point_balance_afteroptional | integer | null | Points moved, and the balance after an approved transaction. |
counterpart_card_last4optional | string | null | The other card on a transfer. |
reference / decline_codeoptional | string | null | The transaction id (for refund and void) / decline reason. |
source / terminal_serialoptional | string | api (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). |
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
/customersCreate a customer./customersList customers (?search=)./customers/{id}Retrieve a customer./customers/{id}Update a customer./customers/{id}/autopayAutopay status (card on file that auto-pays future invoices)./customers/{id}/autopayEnroll a saved card (card_token + consent:true) so future invoices auto-charge on their due date./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.
| Field | Type | Description |
|---|---|---|
namerequired | string | Display name (required on create). |
legal_nameoptional | string | Legal/business name. |
emailoptional | string | Email. |
phoneoptional | string | Phone. |
address.line1optional | string | Billing street. |
address.city / state / zipoptional | string | Billing city / state / ZIP. |
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
/invoicesCreate an invoice (add "send": true to email it)./invoicesList invoices (?status=)./invoices/{id}Retrieve an invoice./invoices/{id}/payCharge a card (card_token or keyed card) against the invoice; flips it to partial/paid./invoices/{id}/sendEmail the invoice to the customer./invoices/{id}/textText (SMS) the pay link to the customer – STOP-compliant./invoices/{id}/voidVoid the invoice (cancels pending links and any reservation it funds)./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.
| Field | Type | Description |
|---|---|---|
DRAFToptional | status | Created but not sent; freely editable. |
OPENoptional | status | Sent to the customer; awaiting payment on the hosted page. |
PARTIALoptional | status | Partially paid; paid shows cents collected so far. |
PAIDoptional | status | Paid in full. Fires invoice.paid. |
VOIDoptional | status | Voided. 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.
| Field | Type | Description |
|---|---|---|
customer.namerequired | string | Bill-to name. |
customer.email / phoneoptional | string | Contact (email needed to send). |
customer.address1 / city / state / zipoptional | string | Bill-to address. |
line_items[]required | array | One or more line items (below). |
line_items[].descriptionrequired | string | Line description. |
line_items[].quantityoptional | number | Quantity. Default 1. |
line_items[].unit_pricerequired | integer | Unit price in cents. |
tax_rate_bpsoptional | integer | Invoice-level tax in basis points. |
due_dateoptional | string | Due date, YYYY-MM-DD. |
invoice_dateoptional | string | Issue date, YYYY-MM-DD. Default today. |
notesoptional | string | Customer-facing note. |
sendoptional | boolean | Email the invoice on create. |
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
/estimatesCreate an estimate (identical body to invoices)./estimatesList estimates./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.
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
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.
/credit-memosCreate a credit memo./credit-memosList credit memos./credit-memos/{id}/applyApply the memo to an invoice./credit-memos/{id}/voidVoid (reverse) the memo.| Field | Type | Description |
|---|---|---|
invoice_idrequired | string | The invoice to credit. |
amountrequired | integer | Credit amount in cents (> 0). |
kindoptional | string | writeoff, adjustment (default), or chargeback. |
reasonoptional | string | Internal reason (≤500 chars). |
applyoptional | boolean | Apply immediately (vs. leave as a draft). |
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 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.
| Field | Type | Description |
|---|---|---|
activeoptional | status | Billing normally (includes trials – first charge deferred to trial end). |
past_dueoptional | status | A charge declined; the dunning retry ladder is working the balance. |
pausedoptional | status | No charges until resumed – set manually or by ladder exhaustion. |
cancelledoptional | status | Stopped – manually or by ladder exhaustion. Fires subscription.canceled. |
endedoptional | status | Natural completion: occurrence count, end date, or amount cap reached. |
failedoptional | status | Billing 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.
| Field | Type | Description |
|---|---|---|
plan_namerequired | string | Name of the plan/subscription. |
intervalrequired | string | Billing cadence: weekly, biweekly, monthly, quarterly, semiannual, annual, or custom. |
interval_daysoptional | integer | Days between charges when interval is custom. |
fire_dayoptional | integer | For weekly, day of week to charge (0=Sun … 6=Sat). |
amountoptional | integer | Per-cycle amount in cents. Required for flat pricing; ignored for usage-based. |
start_atoptional | string | First charge date, YYYY-MM-DD. Default today. |
customer_idoptional | string | Attach to a customer. |
| Field | Type | Description |
|---|---|---|
payment_methodoptional | string | card (default) or ach. |
vault_tokenoptional | string | Required for card plans – obtain from a payment with save_card: true (with customer_id or customer). |
| Field | Type | Description |
|---|---|---|
trial_daysoptional | integer | Free-trial length; the first recurring charge is deferred by this many days. |
setup_feeoptional | integer | One-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_offoptional | number | Percent off every cycle (e.g. 10 = 10%). |
discount.amount_offoptional | integer | Flat amount off every cycle, in cents. Use one of percent_off / amount_off. |
| Field | Type | Description |
|---|---|---|
pricing.modeoptional | string | flat (default, uses amount), per_unit, or tiered. |
pricing.unit_priceoptional | integer | Cents per unit (per_unit mode). |
pricing.unit_labeloptional | string | Unit name, e.g. seat. |
pricing.tiers[]optional | array | Tiered pricing (tiered mode): [{ up_to, unit_price }]. Use up_to: null for the final open tier. |
| Field | Type | Description |
|---|---|---|
ends.afteroptional | integer | Stop after N charges. |
ends.onoptional | string | Stop on a date, YYYY-MM-DD. |
ends.at_amountoptional | integer | Stop once this total (cents) has been billed. |
Manage
/subscriptionsList subscriptions./subscriptions/{id}Retrieve a subscription./subscriptions/{id}Update status: { "status": "pause" } – accepts active, pause, cancel./subscriptions/{id}/chargeCharge off-cycle now.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-ratesList tax rates./tax-ratesCreate a tax rate./tax-rates/{id}Update a tax rate./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%).
| Field | Type | Description |
|---|---|---|
namerequired | string | Label (≤40 chars). |
rate_bpsrequired | integer | Rate in basis points (625 = 6.25%), 0–5000. |
stateoptional | string | Two-letter state code this rate applies to. |
defaultoptional | boolean | Make this the default rate. |
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
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.
| Field | Type | Description |
|---|---|---|
namerequired | string | Vendor legal / DBA name. |
entity_typeoptional | string | BUSINESS (default) or INDIVIDUAL – drives SSN vs EIN on the 1099. |
email / phoneoptional | string | Contact info. |
addressoptional | object | line1, line2, city, state, zip – required to file a 1099. |
tax_idoptional | string | 9-digit SSN/EIN (encrypted at rest; last-4 returned). |
is_1099_vendoroptional | boolean | Whether this vendor is 1099-reportable (default true). |
bank_accountoptional | object | routing_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. |
| Field | Type | Description |
|---|---|---|
payee_idrequired | string | The payee to pay. |
amountrequired | integer | Cents (>0). |
memooptional | string | Free-text note (shown on the P&L expense). |
payment_dateoptional | string | YYYY-MM-DD; defaults to today. Determines the 1099 tax year. |
reportable_1099optional | boolean | Counts 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.
/vendor-payments/payeesList payees (with this-year totals)./vendor-payments/payeesCreate a payee./vendor-payments/payees/{id}Get a payee./vendor-payments/payees/{id}Update a payee (partial; blank tax_id keeps the stored TIN, absent bank_account keeps the stored bank)./vendor-payments/payees/{id}Archive a payee./vendor-payments/payees/{id}/w9W-9 PDF – ?type=blank (prefilled) or signed (stored)./vendor-payments/payees/{id}/10991099-NEC recipient copy (PDF). ?year=YYYY./vendor-payments/paymentsRecord a payment (books the P&L expense). Idempotent./vendor-payments/paymentsList payments. ?payee_id= to filter./vendor-payments/payments/{id}Get a payment./vendor-payments/1099s1099 rollup for a tax year (JSON).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
/catalogCreate a catalog item./catalogList items (?include_pos=true to include register-only menu items)./catalog/{id}Retrieve an item./catalog/{id}Update an item./catalog/{id}Archive an item./catalog/{id}/stockRecord a stock movement.| Field | Type | Description |
|---|---|---|
namerequired | string | Product/service name. |
pricerequired | integer | Unit price in cents. |
costoptional | integer | Unit cost in cents. |
skuoptional | string | Stock-keeping unit. |
categoryoptional | string | Category label. |
descriptionoptional | string | Description. |
typeoptional | string | product (default), service, or digital. |
taxableoptional | boolean | Whether tax applies. Default true. |
track_stockoptional | boolean | Track on-hand quantity. Default false. |
quantityoptional | integer | Opening on-hand (when tracking stock). |
pos_itemoptional | boolean | Show in the TableTurn register. Default false (catalog-only). |
| Field | Type | Description |
|---|---|---|
quantityrequired | integer | Non-zero; positive receives, negative removes. |
reasonoptional | string | received, returned, adjustment (default), or sold. |
notesoptional | string | e.g. a PO number. |
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
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).
| Field | Type | Description |
|---|---|---|
customer.namerequired | string | Customer name. email, phone, address1, city, state, zip are optional; email is needed for the deposit link. |
start_at / end_atrequired | date-time | ISO 8601 with offset. Read in timezone (default America/New_York) for the day-based legs. |
unit_id / quantityoptional | string / integer | A 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[]required | array | Required 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_itemrecommended | string | What is reserved: unit, vehicle, equipment, space. Shown to the customer and on receipts. |
deposit.mode / deposit.valueoptional | string / integer | PCT (value in basis points, 2500 = 25%), FLAT (value in cents), FULL, or NONE. Default from Reservation settings. |
balance_days_beforeoptional | integer | Charge the balance this many days before start_at; 0 = the start date. A booking inside the window charges the balance with the deposit. |
hold.amountoptional | integer | Security 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_emailoptional | boolean | Default true: email the customer the deposit link. Ignored when card_vault_token_id is passed. |
card_vault_token_idoptional | string | Charge 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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Cents, at most the live hold amount. |
reasonrequired | string | DAMAGE, CLEANING, MISSING_PROPERTY, UNAUTHORIZED_GUESTS, or OTHER. |
descriptionrequired | string | What 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.
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
/appointmentsBook an appointment./appointmentsList appointments (?from= & ?to=)./appointments/{id}Retrieve an appointment./appointments/{id}Reschedule / update./service-typesList bookable service types (durations, prices, colors, buffers)./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.
| Field | Type | Description |
|---|---|---|
titlerequired | string | What the appointment is. |
start_atrequired | string | ISO datetime. |
end_atrequired | string | ISO datetime, after start. |
service_type_idoptional | string | A service from GET /service-types. |
customer_idoptional | string | Attach to an existing customer. |
customer_name / email / phoneoptional | string | Inline customer details. |
assigned_user_idoptional | string | Staff member assigned. |
assignee_nameoptional | string | Free-text assignee name. |
locationoptional | string | Location/address text. |
priceoptional | integer | Price in cents. |
notesoptional | string | Internal notes. |
forceoptional | boolean | Book even if the assignee is already busy (else 409). |
| Field | Type | Description |
|---|---|---|
statusrequired | string | scheduled, confirmed, in_progress, completed, canceled, no_show. |
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
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.
/classesList classes (?active= filter)./classesCreate a class./classes/{id}Retrieve a class./classes/{id}Update (partial). Deactivate with active: false – classes with booking history are never deleted./class-sessionsList sessions with spots_left (?class_id=, ?from=, ?to=)./class-sessionsSchedule sessions – repeat_weeks creates a weekly series./class-sessions/{id}Retrieve a session./class-sessions/{id}Cancel / reinstate (status: canceled | scheduled)./class-sessions/{id}/bookingsSession roster (all statuses)./class-sessions/{id}/bookingsAdd an attendee (walk-in / phone booking)./class-bookings/{id}Retrieve a booking./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.
| Field | Type | Description |
|---|---|---|
titlerequired | string | Class name (≤120 chars). |
descriptionoptional | string | Shown in the public booking modal. |
instructor_staff_idoptional | string | A staff id from GET /staff. |
capacityoptional | integer | Max people per session. Default 10. |
payment_modeoptional | string | free (RSVP only), full (pay price to book), deposit (pay deposit to book). Default free. |
priceoptional | integer | Cents. Required > 0 for full / deposit. |
depositoptional | integer | Cents due at booking (deposit mode); can't exceed price. |
coloroptional | string | #rrggbb calendar color. |
require_email / require_phoneoptional | boolean | Public bookings must include an email / mobile number. |
activeoptional | boolean | Bookable on the public calendar. Default true. |
| Field | Type | Description |
|---|---|---|
class_idrequired | string | The class to schedule. |
start_atrequired | string | ISO datetime. |
duration_minoptional | integer | Default 60. |
capacity_overrideoptional | integer | Override the class capacity for these sessions. |
repeat_weeksoptional | integer | 1–52; >1 creates a weekly series sharing a series_id. |
| Field | Type | Description |
|---|---|---|
namerequired | string | Attendee name. |
emailoptional | string | Attendee email (used for waitlist promotion emails). |
phoneoptional | string | Attendee mobile number. |
marketing_opt_inoptional | boolean | Adds them to the class marketing list export. |
waitlistoptional | boolean | Join the waitlist instead of failing with 409 sold_out when full. |
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"
}'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.
/shiftsList shifts in a window (?from=, ?to=, ISO; default this week + next). Includes the roster of worker keys./shiftsCreate a draft shift./shifts/{id}Retrieve a shift./shifts/{id}Update (partial): times, break_min, role, note, or reassign via worker_key./shifts/{id}Remove a shift./shifts/publishPublish every draft in { from, to }. Each scheduled worker with a mobile number on file is texted./schedule-requestsStaff time-off, drop, pick-up and handoff requests (?status=pending)./schedule-requests/{id}Decide: { decision: "approve" | "deny", note? }. The worker is texted. A handoff the coworker has not accepted yet returns 409.| Field | Type | Description |
|---|---|---|
worker_keyoptional | string | null | u:<user_id> or c:<cashier_id> from the roster; null = open shift. |
starts_at / ends_atoptional | ISO datetime | End after start, at most 24 hours. |
break_minoptional | integer | Unpaid break minutes, netted out of hours and labor cost. |
roleoptional | string | Position label shown to the worker (Server, Front desk). |
statusoptional | draft | published | Read-only; flip with POST /shifts/publish. |
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"
}'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.
| Field | Type | Description |
|---|---|---|
cardoptional | tender | Card-not-present: keyed, saved card, hosted fields, payment links, checkout, pay widget, eGift purchases paid by card. |
card_presentoptional | tender | A 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. |
achoptional | tender | Bank debits and credits. bank.last4 is the account’s last 4. |
cashoptional | tender | Cash on the Merchant360 Payment App or TableTurn Lite POS, and cash recorded against an invoice. |
checkoptional | tender | A check recorded against an invoice. |
gift_cardoptional | tender | Gift card used as the tender at the POS. |
egiftoptional | tender | eGift 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. |
otheroptional | tender | Another method recorded against an invoice. |
| Field | Type | Description |
|---|---|---|
from / tooptional | date | YYYY-MM-DD, Eastern-time days, inclusive. Default: the last 7 days. At most 92 days per request. |
tenderoptional | string | One tender from the table above. |
typeoptional | string | SALE, REFUND or VOID. |
page / page_sizeoptional | integer | page_size up to 500 (default 25). Newest first. |
| Field | Type | Description |
|---|---|---|
idoptional | string | Stable: <source>_<id>. Upsert on it. |
statusoptional | string | succeeded, failed, pending, processing (ACH in flight), settled (ACH), returned (ACH), voided, refunded, partially_refunded, canceled. raw_status is the source’s own word. |
amountoptional | integer | Cents. amount_base / amount_tip / amount_surcharge itemize it where the source records them; amount_refunded is refunded against the row. |
payment_idoptional | string | For card and card-present rows: the id to use with GET /payments/{id}, void and refund. Null for other tenders. |
truncatedoptional | boolean | true 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.
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"
Reports
| Field | Type | Description |
|---|---|---|
fromoptional | string | ISO datetime start. Default 30 days ago. |
tooptional | string | ISO datetime end. Default now. |
{ "object": "report_summary", "period": { "from": "…", "to": "…" }, "gross_sales": 128400, "transaction_count": 37, "currency": "usd" }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.
| Field | Type | Description |
|---|---|---|
yearrequired | integer | Four-digit year, e.g. 2026. |
monthrequired | integer | 1–12. |
/statementsList available statement periods (period, year, month, pdf_url)./statements/pdfDownload the statement PDF for a period (application/pdf). 404 if none.curl -X GET https://www.merchant360.com/api/m360/v1/statements \ -H "Authorization: Bearer m360_live_xxx"
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).
{ "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
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.
/custom-fieldsList field definitions./custom-fieldsCreate a field./custom-fields/{id}Retrieve a field./custom-fields/{id}Update a field./custom-fields/{id}Remove a field.| Field | Type | Description |
|---|---|---|
labelrequired | string | Shown to the payer (≤60 chars). The stable key is derived from it. |
typeoptional | string | "text" (default) or "select". |
optionsoptional | string[] | Choices for a "select" field (2–25). |
requiredoptional | boolean | Whether the payer must fill it in. |
surfacesoptional | object | Where it renders – payment_links, invoices, estimates, checkout, pay_widget, virtual_terminal (each defaults to true). |
receiptsoptional | object | Where the captured value prints – customer_receipt (default false), merchant_receipt (default true). |
accounting_classoptional | boolean | "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.
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
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).
| Field | Type | Description |
|---|---|---|
namerequired | string | The legal name on the TIN (business or individual). |
tinrequired | string | 9-digit SSN or EIN (dashes optional). |
tin_typeoptional | string | "SSN" / "EIN" / "ITIN". Omit and the IRS resolves which file to check. |
| Field | Type | Description |
|---|---|---|
objectoptional | string | Always "tin_match". |
matchedoptional | boolean | true only on an exact name + TIN match (response codes 0, 6, 7, 8). |
verdictoptional | string | MATCH / MISMATCH / NOT_ISSUED / INVALID_INPUT / DUPLICATE / ERROR. |
response_codeoptional | number | null | The raw IRS numeric code (see the table below). null when the request never reached the IRS. |
descriptionoptional | string | Plain-language explanation of the code, safe to log or surface to staff. |
tin_last4optional | string | null | Last 4 digits of the submitted TIN, for your own reconciliation. The full TIN is never returned. |
sourceoptional | string | LIVE when the IRS was queried, CIRCUIT_OPEN if our rate-limit guard short-circuited the call, DISABLED if matching is turned off. |
checked_atoptional | string | ISO 8601 timestamp of the check. |
| response_code | verdict | matched | Meaning |
|---|---|---|---|
0 | MATCH | true | TIN and Name combination matches IRS records. |
1 | INVALID_INPUT | false | TIN is missing or not 9-digit numeric. |
2 | NOT_ISSUED | false | TIN entered is not currently issued by the IRS. |
3 | MISMATCH | false | TIN and Name combination does not match IRS records. |
4 | INVALID_INPUT | false | Invalid TIN Matching request. |
5 | DUPLICATE | false | Duplicate request – the IRS caps repeats per 24h; we cache to avoid tripping it. |
6 | MATCH | true | Matched on SSN (returned when tin_type is omitted/unknown). |
7 | MATCH | true | Matched on EIN (returned when tin_type is omitted/unknown). |
8 | MATCH | true | Matched 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.
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"
}'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.
| Field | Type | Description |
|---|---|---|
Authorizationrequired | string | Bearer m360_isv_…: the partner’s platform key, on every request. |
M360-On-Behalf-Ofrequired | string | The 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.
| Field | Type | Description |
|---|---|---|
idoptional | string | The merchant id – pass it as M360-On-Behalf-Of. |
dba_nameoptional | string | The merchant's business name. |
linked_viaoptional | string | grant (explicit access) or boarded (boarded under the partner's office). |
realtime_data / volume_30doptional | object | With ?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=…>", ... }| Field | Type | Description |
|---|---|---|
domainsrequired | string[] | Where the terminal may render. *.example.com matches subdomains. Live keys refuse every other host. |
nameoptional | string | A label for the key, shown to the merchant. |
testoptional | boolean | Mint 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:
| Field | Type | Description |
|---|---|---|
Refunds / voidsoptional | guardrail | Blocked when the merchant’s channel disables refunds (a void that only releases an authorization hold is still allowed). |
Reportsoptional | guardrail | A 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"] }'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_…"
}'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.