Payments · ACH · Billing gateway · REST

Merchant360 API

Build payments and billing into your own app. Take card payments, run ACH debits/credits, refund, and drive invoices, estimates, customers, catalog, subscriptions, appointments, and more — with scoped, rate-limited API keys, idempotent writes, signed webhooks, and a live sandbox. Every request runs against the merchant’s live Merchant360 account through the same engine as the app, so behavior matches the product exactly.

Overview

Introduction

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

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

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

▶ See every tool working live on the examples page.

New here? Get a free test key.

Build against the payments, vault, and hosted-fields APIs in test mode — no merchant account needed.

Open the developer sandbox →
Bearer key

Authentication

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

Authorization: Bearer m360_live_xxx

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

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

curl -X GET https://www.merchant360.net/api/m360/v1/me \
  -H "Authorization: Bearer m360_live_xxx"
Per-key permissions

Scopes

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

FieldTypeDescription
payments:read / :writeoptionalscopeCharge cards, refund, read transactions.
cards:read / :writeoptionalscopeCard vault (tokens) + hosted-field sessions.
checkout:read / :writeoptionalscopeHosted Checkout Sessions.
ach:writeoptionalscopeACH debits and credits.
payment_links:read / :writeoptionalscopeHosted payment links.
customers:read / :writeoptionalscopeCustomer CRM.
invoices:read / :writeoptionalscopeInvoices (create, send, void).
estimates:read / :writeoptionalscopeEstimates.
credit_memos:read / :writeoptionalscopeCredit memos.
inventory:read / :writeoptionalscopeCatalog products + stock.
tax_rates:read / :writeoptionalscopeTax table.
recurring:read / :writeoptionalscopeSubscriptions.
scheduling:read / :writeoptionalscopeAppointments + service types.
reports:readoptionalscopeReport summaries.
webhooks:manageoptionalscopeRegister/list/delete webhook endpoints.
Sandbox

Test mode

Keys prefixed m360_test_ run in sandbox: requests are validated and shaped exactly like live — payments, tokens, hosted fields, Checkout Sessions, and ACH all simulate end-to-end — but no money moves and no gateway/bank call is made. Card 4000 0000 0000 0002 simulates a decline, as does any card_token containing the word decline; any other valid-format card succeeds. Switch to a m360_live_ key to move real money.

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) — so a test id can never be confused with live money.

Envelope

Errors

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

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

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

{
  "error": {
    "message": "Your card was declined.",
    "code": "payment_declined"
  }
}
Safe retries

Idempotency

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

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

Pagination

Query
FieldTypeDescription
pageoptionalinteger1-based page number. Default 1.
page_sizeoptionalintegerItems per page, 1–100. Default 25.
{ "data": [ … ], "page": 1, "page_size": 25, "total": 132, "has_more": true }
POST/GET /payments · payments:*

Payments

POST/paymentsCharge a card. Returns 201, or 402 on decline.
GET/paymentsList recent payments.

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.

Present the card ONE of three ways: a keyed card, a saved card_token (a card on file — see Cards), or a card-present card_present capture from a terminal or mobile SDK. Exactly one is required.

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

Body
FieldTypeDescription
amountrequiredintegerAmount to charge, in cents (> 0).
currencyoptionalstringOnly usd (default).
card_tokenoptionalstringCharge a saved card on file. Use INSTEAD of card. From save_card or POST /tokens.
card_present.entry_modeoptionalstringCard-present read type: chip, contactless, emv_fallback, swipe, or track1. Preview.
card_present.emv_dataoptionalstringDE 55 ICC TLV payload (hex) from the EMV kernel. Required for chip / contactless / emv_fallback.
card_present.track_2optionalstringTrack-2 data (raw swipe, or the chip's track-2 image on an EMV read).
card_present.track_1optionalstringTrack-1 data (track1 entry mode).
card_present.ksnoptionalstringDUKPT Key Serial Number when the capture device encrypts (P2PE).
card.numberoptionalstringPAN, digits (spaces ok). Required unless card_token or card_present is given.
card.exp_monthoptionalstringTwo digits, 0112. Required with card.
card.exp_yearoptionalstringTwo or four digits (27 or 2027). Required with card.
card.cvvoptionalstringSecurity code.
cardholder.nameoptionalstringFull name; split into first/last.
cardholder.emailoptionalstringReceipt/AVS.
cardholder.phoneoptionalstringContact.
cardholder.zipoptionalstringBilling ZIP (AVS).
cardholder.addressoptionalstringBilling street (AVS).
sales_taxoptionalintegerTax portion in cents (Level II qualification).
descriptionoptionalstringReference shown on the transaction (≤120 chars).
save_cardoptionalbooleanVault the keyed card and return a reusable card_token for future charges/subscriptions.

The response returns the saved card as card_token; vault_token is kept as a back-compat alias for the same value.

GET /payments — query
FieldTypeDescription
page / page_sizeoptionalintegerPagination.
curl -X POST https://www.merchant360.net/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
}'
Two-step · payments:write

Authorize & capture

POST/payments/authorizePlace a hold (no money moves).
POST/payments/{id}/captureSettle up to the authorized amount (partial ok): { "amount": 4200 }.
POST/payments/{id}/voidRelease the hold (no body).

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

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

Every transition has a webhook: payment.authorized when the hold lands, payment.captured on capture, payment.voided on release — so a back office can track holds it didn’t place itself.

POST /payments/authorize — body
FieldTypeDescription
amountrequiredintegerAmount to authorize, in cents (> 0).
currencyoptionalstringOnly usd (default).
card.numberrequiredstringPAN (keyed). Saved-card pre-auth is a planned follow-up.
card.exp_monthrequiredstringTwo digits, 0112.
card.exp_yearrequiredstringTwo or four digits.
card.cvvoptionalstringSecurity code.
cardholder.*optionalobjectSame shape as POST /payments (name/email/phone/zip/address, for AVS).
descriptionoptionalstringReference on the transaction (≤120 chars).
POST /payments/{id}/capture — body
FieldTypeDescription
amountoptionalintegerCents to capture. Omit for the full authorized amount; cannot exceed it.

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.net/api/m360/v1/payments/authorize \
  -H "Authorization: Bearer m360_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
  "amount": 5000,
  "card": {
    "number": "4242424242424242",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123"
  },
  "description": "Room hold"
}'
POST /payments/{id}/refund · /void · payments:write

Refunds & voids

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

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

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

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

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

Cards (vault)

A card token is a reusable handle for a card on file. Mint one two ways: set save_card on a payment (vaults the card you just charged), or POST /tokens to save a card without charging it. Charge a saved card by passing card_token to POST /payments, or reference one from a subscription. The raw PAN is never returned — only brand + last4 + expiry.

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

POST /tokens — body (cards:write)
FieldTypeDescription
card.numberrequiredstringPAN to vault, digits (spaces ok).
card.exp_monthrequiredstringTwo digits, 0112.
card.exp_yearrequiredstringTwo or four digits (27 or 2027).
cardholder.nameoptionalstringLabel for the saved card.
cardholder.zipoptionalstringBilling ZIP stored for AVS on future charges.
cardholder.addressoptionalstringBilling street stored for AVS.
set_defaultoptionalbooleanMake this the merchant's default card on file.

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

POST/tokensTokenize a card without charging it (cards:write).
GET/tokensList saved cards on file (cards:read).
GET/tokens/{id}Retrieve one saved card (cards:read).
DELETE/tokens/{id}Remove a saved card (cards:write).

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.net/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"
  },
  "set_default": true
}'
Browser tokenization · keep the PAN off your servers

Hosted fields

Hosted fields render the card inputs in a Merchant360-hosted iframe embedded on your page, so the PAN is entered on our origin: it never touches your DOM or your server. The browser exchanges the card for a reusable card_token; you then charge that token server-side with your secret key. This keeps your website and servers out of PCI scope (equivalent to SAQ A) — you handle a token, never card data.

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

Two calls make it work:

POST/hosted/sessionsYour server mints a short-lived client_token (cards:write). Never expose your secret key to the browser — this token is what the browser uses.
POST/hosted/tokenizeCalled by the iframe (not you) to turn the card into a card_token.
POST /hosted/sessions — body
FieldTypeDescription
expires_inoptionalintegerClient-token lifetime in seconds. Default 900 (15 min); clamped to 60–3600.
merchant360.js
FieldTypeDescription
Merchant360(clientToken)optionalfnCreate an instance from the client token minted in step 1.
.hostedFields({ style })optionalfnCreate the fields. style.accent + style.fontFamily theme them.
.mount(selector)optionalfnInsert the iframe into your page.
.on('change', cb)optionalfnLive field validity → { complete, brand }.
.tokenize()optionalfnPromise → { card_token, last4, brand }, or rejects with an error.

The client token authorizes tokenization only — it can never charge — and it is stateless: nothing is stored server-side, so there is nothing to clean up. 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 simply mint a new session and re-initialize — expired tokens are never renewed in place. Live tokenization is temporarily unavailable and returns 503 until the tokenization service is certified; use a test-modekey (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.net/api/m360/v1/hosted/sessions  →  { "client_token": "hfs_…", "expires_at": "…" }

// 2) On YOUR PAGE, drop in the fields:
<script src="https://www.merchant360.net/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.net/api/m360/v1/payments  { "amount": 2500, "card_token": r.card_token }
      })
      .catch(function (err) { alert(err.message); });
  });
</script>
POST /ach/debits · /ach/credits · GET /ach/{id} · ach:write

ACH debits & credits

POST/ach/debitsPull from a customer bank account.
POST/ach/creditsPush to a customer bank account (same body).
GET/ach/{id}Retrieve an entry — poll its status, or use the webhooks below.

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

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

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

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

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

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

GET /ach/{id} — response
FieldTypeDescription
idoptionalstringEntry id (ach_…).
objectoptionalstringach_payment.
kindoptionalstringdebit or credit.
statusoptionalstringqueued · processing · settled · returned · canceled · failed.
amount_centsoptionalintegerAmount in cents.
currencyoptionalstringusd.
sec_codeoptionalstringPPD / WEB / CCD — resolved server-side (above).
run_dateoptionalstringBanking day the entry submits (or submitted) to the bank.
createdoptionalstringISO timestamp.
settled_atoptionalstringISO timestamp once settled; null before.
returnoptionalobject{ code, description } once returned; null otherwise.
resubmitted_asoptionalstringId of the automatic re-presentment when an R01/R09 was retried.
ACH errors
FieldTypeDescription
400 no_authoptionalerrorDebits require a stored customer authorization.
403 not_enabled / not_allowedoptionalerrorACH is an EPI-approved capability, not on by default — the merchant isn't enabled for ACH, or not for this direction (debit vs credit).
422 over_limitoptionalerrorPer-transaction or monthly volume cap exceeded.
422 not_validatedoptionalerrorThe bank account hasn't completed verification — a prenote takes 1–2 banking days.
502 queue_failedoptionalerrorThe entry could not be queued.
Body
FieldTypeDescription
amountrequiredintegerCents (> 0).
bank_account.routingrequiredstring9-digit ABA routing number.
bank_account.accountrequiredstringAccount number.
bank_account.holder_namerequiredstringName on the account.
bank_account.typeoptionalstringchecking (default) or savings.
bank_account.holder_typeoptionalstringconsumer (PPD/WEB, default) or business (CCD).
authorization.frequencyoptionalstringone_time (default) or recurring.
descriptionoptionalstringNACHA entry description (≤10 chars).
curl -X POST https://www.merchant360.net/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"
}'
/customers · customers:*

Customers

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

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

Body (create / update)
FieldTypeDescription
namerequiredstringDisplay name (required on create).
legal_nameoptionalstringLegal/business name.
emailoptionalstringEmail.
phoneoptionalstringPhone.
address.line1optionalstringBilling street.
address.city / state / zipoptionalstringBilling city / state / ZIP.
curl -X POST https://www.merchant360.net/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"
  }
}'
/catalog · inventory:*

Catalog & inventory

POST/catalogCreate a catalog item.
GET/catalogList items (?include_pos=true to include register-only menu items).
GET/catalog/{id}Retrieve an item.
PATCH/catalog/{id}Update an item.
DELETE/catalog/{id}Archive an item.
POST/catalog/{id}/stockRecord a stock movement.
Body (create / update)
FieldTypeDescription
namerequiredstringProduct/service name.
pricerequiredintegerUnit price in cents.
costoptionalintegerUnit cost in cents.
skuoptionalstringStock-keeping unit.
categoryoptionalstringCategory label.
descriptionoptionalstringDescription.
typeoptionalstringproduct (default), service, or digital.
taxableoptionalbooleanWhether tax applies. Default true.
track_stockoptionalbooleanTrack on-hand quantity. Default false.
quantityoptionalintegerOpening on-hand (when tracking stock).
pos_itemoptionalbooleanShow in the TableTurn register. Default false (catalog-only).
POST /catalog/{id}/stock — body
FieldTypeDescription
quantityrequiredintegerNon-zero; positive receives, negative removes.
reasonoptionalstringreceived, returned, adjustment (default), or sold.
notesoptionalstringe.g. a PO number.
curl -X POST https://www.merchant360.net/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
}'
/tax-rates · tax_rates:*

Tax rates

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

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

Body (create / update)
FieldTypeDescription
namerequiredstringLabel (≤40 chars).
rate_bpsrequiredintegerRate in basis points (625 = 6.25%), 0–5000.
stateoptionalstringTwo-letter state code this rate applies to.
defaultoptionalbooleanMake this the default rate.
curl -X POST https://www.merchant360.net/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
}'
/invoices · invoices:*

Invoices

POST/invoicesCreate an invoice (add "send": true to email it).
GET/invoicesList invoices (?status=).
GET/invoices/{id}Retrieve an invoice.
POST/invoices/{id}/payCharge a card (card_token or keyed card) against the invoice; flips it to partial/paid.
POST/invoices/{id}/sendEmail the invoice to the customer.
POST/invoices/{id}/textText (SMS) the pay link to the customer — STOP-compliant.
POST/invoices/{id}/voidVoid the invoice.

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

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

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

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

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

Estimates

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

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

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

Credit memos

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

POST/credit-memosCreate a credit memo.
GET/credit-memosList credit memos.
POST/credit-memos/{id}/applyApply the memo to an invoice.
POST/credit-memos/{id}/voidVoid (reverse) the memo.
Body
FieldTypeDescription
invoice_idrequiredstringThe invoice to credit.
amountrequiredintegerCredit amount in cents (> 0).
kindoptionalstringwriteoff, adjustment (default), or chargeback.
reasonoptionalstringInternal reason (≤500 chars).
applyoptionalbooleanApply immediately (vs. leave as a draft).
curl -X POST https://www.merchant360.net/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
}'
Embed · pay-widget.js · POST /api/cygma/v1/charge

Pay Widget

The Pay Widget is a drop-in, embeddable checkout: paste the two-line snippet into any website and it renders a complete, branded payment form in an iframe served from cygma.cloud/{merchantId}. Unlike payment links (single-use URLs) or Checkout Sessions (server-created, cart-shaped), the widget is a permanent fixtureon 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.

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

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

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

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

<!-- 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>
/checkout/sessions · checkout:*

Checkout Sessions

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

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

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

POST/checkout/sessionsCreate a session; returns the hosted url to redirect to.
GET/checkout/sessionsList sessions.
GET/checkout/sessions/{id}Retrieve one — poll for status/payment (or use the webhook).
Body
FieldTypeDescription
amountoptionalintegerTotal to collect, in cents. Required unless line_items is given.
line_itemsoptionalobject[][{ name, amount (cents), quantity }]. Their sum is the total.
success_urlrequiredstringRedirect after payment. {CHECKOUT_SESSION_ID} is substituted; otherwise session_id is appended.
cancel_urloptionalstringWhere the customer returns if they cancel.
descriptionoptionalstringShown on the hosted page (≤300 chars).
customer.name / emailoptionalstringPre-fill the payer's details.
client_reference_idoptionalstringYour own id, echoed back on the session + webhook.
metadataoptionalobjectArbitrary key/values echoed back.
expires_in_minutesoptionalintegerAuto-expire the session (default 1440 = 24h).

Test-mode keys create is_test sessions: card 4242 4242 4242 4242 approves, 4000 0000 0000 0002 declines — 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.

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 callbackonComplete can be spoofed by a hostile browser; checkout.session.completed cannot.

curl -X POST https://www.merchant360.net/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"
}'
/subscriptions · recurring:*

Subscriptions

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

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

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

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

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

Manage

GET/subscriptionsList subscriptions.
GET/subscriptions/{id}Retrieve a subscription.
PATCH/subscriptions/{id}Update status: { "status": "pause" } — accepts active, pause, cancel.
POST/subscriptions/{id}/chargeCharge off-cycle now.
curl -X POST https://www.merchant360.net/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,
  "discount": {
    "percent_off": 10
  },
  "ends": {
    "after": 12
  }
}'
/appointments · scheduling:*

Appointments

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

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

Body
FieldTypeDescription
titlerequiredstringWhat the appointment is.
start_atrequiredstringISO datetime.
end_atrequiredstringISO datetime, after start.
service_type_idoptionalstringA service from GET /service-types.
customer_idoptionalstringAttach to an existing customer.
customer_name / email / phoneoptionalstringInline customer details.
assigned_user_idoptionalstringStaff member assigned.
assignee_nameoptionalstringFree-text assignee name.
locationoptionalstringLocation/address text.
priceoptionalintegerPrice in cents.
notesoptionalstringInternal notes.
forceoptionalbooleanBook even if the assignee is already busy (else 409).
PATCH /appointments/{id} — body
FieldTypeDescription
statusrequiredstringscheduled, confirmed, in_progress, completed, canceled, no_show.
curl -X POST https://www.merchant360.net/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
}'
GET /reports/summary · reports:read

Reports

Query
FieldTypeDescription
fromoptionalstringISO datetime start. Default 30 days ago.
tooptionalstringISO datetime end. Default now.
{ "object": "report_summary", "period": { "from": "…", "to": "…" }, "gross_sales": 128400, "transaction_count": 37, "currency": "usd" }
GET /settings

Settings

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

{ "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 },
  "installments": { "enabled": true, "min_cents": 5000, "max_payments": 4, "interval_days": 30 } }
/custom-fields · payments:*

Custom fields

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

GET/custom-fieldsList field definitions.
POST/custom-fieldsCreate a field.
GET/custom-fields/{id}Retrieve a field.
PATCH/custom-fields/{id}Update a field.
DELETE/custom-fields/{id}Remove a field.
Body
FieldTypeDescription
labelrequiredstringShown to the payer (≤60 chars). The stable key is derived from it.
typeoptionalstring"text" (default) or "select".
optionsoptionalstring[]Choices for a "select" field (2–25).
requiredoptionalbooleanWhether the payer must fill it in.
surfacesoptionalobjectWhere it renders — payment_links, invoices, estimates, checkout, pay_widget, virtual_terminal (each defaults to true).
curl -X POST https://www.merchant360.net/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
  }
}'
/webhooks · webhooks:manage

Webhooks

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

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

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

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

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

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

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.net/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"
  ]
}'
One key, many merchants · for technology partners

Platform keys (ISV)

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

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

Naming the merchant per request

Every merchant-scoped call (payments, tokens, invoices, …) takes an M360-On-Behalf-Of header naming the target merchant. The key can only reach a merchant it’s connected to — otherwise 403 no_grant. Everything else about the request is identical to a normal merchant key.

Header
FieldTypeDescription
M360-On-Behalf-OfrequiredstringThe target merchant’s id (from GET /accounts). Required on every merchant-scoped call with a platform key.

Connected accounts

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

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

Channel guardrails

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

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

Partner webhooks

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

curl -X POST https://www.merchant360.net/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.net/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_…"
}'