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.
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/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.net/api/m360/v1/openapi.json.
▶ See every tool working live on the examples page.
Build against the payments, vault, and hosted-fields APIs in test mode — no merchant account needed.
Open the developer sandbox →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.
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"
Scopes
Grant a key only what it needs. :read permits GET, :write permits create/update/delete.
| Field | Type | Description |
|---|---|---|
payments:read / :writeoptional | scope | Charge cards, refund, read transactions. |
cards:read / :writeoptional | scope | Card vault (tokens) + hosted-field sessions. |
checkout:read / :writeoptional | scope | Hosted Checkout Sessions. |
ach:writeoptional | scope | ACH debits and credits. |
payment_links:read / :writeoptional | scope | Hosted payment links. |
customers:read / :writeoptional | scope | Customer CRM. |
invoices:read / :writeoptional | scope | Invoices (create, send, void). |
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. |
scheduling:read / :writeoptional | scope | Appointments + service types. |
reports:readoptional | scope | Report summaries. |
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. 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.
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 — 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-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 }Payments
/paymentsCharge a card. Returns 201, or 402 on decline./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.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to charge, in cents (> 0). |
currencyoptional | string | Only usd (default). |
card_tokenoptional | string | Charge a saved card on file. Use INSTEAD of card. From save_card or POST /tokens. |
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_taxoptional | integer | Tax portion in cents (Level II qualification). |
descriptionoptional | string | Reference shown on the transaction (≤120 chars). |
save_cardoptional | boolean | Vault 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.
| Field | Type | Description |
|---|---|---|
page / page_sizeoptional | integer | Pagination. |
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
}'Authorize & capture
/payments/authorizePlace a hold (no money moves)./payments/{id}/captureSettle up to the authorized amount (partial ok): { "amount": 4200 }./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.
| 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 — so a back office can track holds it didn’t place itself.
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Amount to authorize, in cents (> 0). |
currencyoptional | string | Only usd (default). |
card.numberrequired | string | PAN (keyed). Saved-card pre-auth is a planned follow-up. |
card.exp_monthrequired | string | Two digits, 01–12. |
card.exp_yearrequired | string | 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. |
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"
}'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.net/api/m360/v1/payments/{id}/refund \
-H "Authorization: Bearer m360_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount": 2500
}'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.
| 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. |
▶ 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)./tokens/{id}Retrieve one saved card (cards:read)./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
}'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:
/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./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. |
| 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 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>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 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. |
| Field | Type | Description |
|---|---|---|
amountrequired | integer | Cents (> 0). |
bank_account.routingrequired | string | 9-digit ABA routing number. |
bank_account.accountrequired | string | Account number. |
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). |
authorization.frequencyoptional | string | one_time (default) or recurring. |
descriptionoptional | string | NACHA 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
/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.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
/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.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-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.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
/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.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.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
/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.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
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.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
}'Payment links
A payment link is a fixed-amount hosted pay page at merchant360.net/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.net/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 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.
| 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>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.
/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. |
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). |
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 callback — onComplete 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 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. |
| Field | Type | Description |
|---|---|---|
trial_daysoptional | integer | Free-trial length; the first charge is deferred by this many days. |
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.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
/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.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
}'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" }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
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.
/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). |
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
/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. |
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). |
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"
]
}'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.
| Field | Type | Description |
|---|---|---|
M360-On-Behalf-Ofrequired | string | The 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.
| 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). |
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.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_…"
}'