Expi API Documentation

Welcome to the Expi API documentation. All endpoints are accessed via the central query handler.

The API is built for quick integration: send JSON requests, receive JSON responses, and keep all payment workflows in one place. Use it to build custom checkout experiences, manage customers, issue invoices, and automate recurring billing with minimal overhead.

If you are new to the platform, start with Authentication, then explore the endpoint sections for the resources you need. Each section highlights core fields and common workflows.

Base URL: https://your-domain.com/query

Authentication

Every request must be authenticated. You can do this via Basic Auth or by including credentials in the JSON body. Pick one method and keep it consistent across your integration.

Use your merchant username as x_login and your secret key as x_tran_key for protected merchant endpoints. The login token flow on /query/auth/* is public and uses user credentials instead.

Quick Checklist
  • Credentials are present on every request.
  • Only one auth method is used per request.
  • Secrets never appear in client-side code.
Auth Endpoints
  • POST /query/auth/mfatoken — Validates username/password and returns an mfa_token.
  • POST /query/auth/token — If payload contains mfa_token and code, verifies MFA and issues an access token.
  • POST /query/auth/mfaresend — Starts a replacement challenge for the method in mfa_token and returns a replacement token.
  • POST /query/auth/mfarecovery — Requires an SMS mfa_token plus password re-entry and starts controlled email recovery.
  • POST /query/auth/token — If payload contains username (or email) and password, it also requires trusted-device credentials to issue an access token.
  • GET /query/me — Returns the current authenticated user, merchant, and organization context (requires authentication).
Example: Login Step 1 (POST /query/auth/mfatoken)
{
  "username": "user@example.com",
  "password": "your_password"
}
MFA Challenge Response (HTTP 200)
{
  "result": "success",
  "mfa_token": "dummy_mfa_token",
  "expires_in": 300,
  "mfa_method": "sms"
}
Explicit email recovery (POST /query/auth/mfarecovery)
{
  "mfa_token": "sms_mfa_token",
  "password": "your_password"
}

Email recovery never disables SMS and is limited to one successful login per rolling 24 hours.

Example: Login Step 2 (POST /query/auth/token)
{
  "mfa_token": "dummy_mfa_token",
  "code": "123456",
  "remember_device": 1
}
Example: Trusted Login (POST /query/auth/token)
{
  "username": "user@example.com",
  "password": "your_password",
  "trusted_device_id": "32_hex_device_id",
  "trusted_device_token": "device_token"
}
MFA Success (remembered device)
{
  "result": "success",
  "user_id": 12,
  "merchant": "MERCHANTCODE",
  "access_token": "dummy_access_token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "trusted_device_id": "32_hex_device_id",
  "trusted_device_token": "device_token",
  "trusted_device_expires_in": 2592000
}
Example: Me (GET /query/me)
Authorization: Bearer <access_token>
Option 1: Basic Auth (Header)
Authorization: Basic <base64(username:secret_key)>

Base64 must be generated from the exact string username:secret_key (one colon, no extra spaces).

Option 2: Body Parameters
{
  "x_login": "your_username",
  "x_tran_key": "your_secret_key"
}
Common Pitfalls
  • Wrong key: Make sure you use the secret key, not a public identifier.
  • Mixed methods: Do not send both Basic Auth and body credentials in the same request.
  • Invalid JSON: Trailing commas or comments will cause auth to fail.
  • MFA challenge shape: An MFA-required login returns result: "success" with mfa_token and expires_in.
  • Remember device default: If remember_device is omitted during MFA verification, the device is remembered by default.
  • Trusted login requirements: The username/password token flow requires both trusted_device_id and trusted_device_token; otherwise it fails with Unknown Device.
Security Tips
  • Store secrets in server-side environment variables.
  • Rotate keys regularly and after staff changes.
  • Never log raw credentials in application logs.

REST API

Streamline Transactions with Expitrans API


Our REST API allows developers to integrate online payment functionalities into their applications. By making API requests, you can process transactions, manage customers, handle subscriptions, and generate invoices programmatically.

How It Works

The API uses JSON format for requests and responses, ensuring seamless communication between your application and our payment gateway. Authentication is required for secure access, and each request must include the necessary credentials.

Our REST API provides several key functionalities:

  • Charges and Voids - Create charges, capture auths, and void eligible transactions.
  • Refunds - Issue credits against existing charges.
  • Customers – Create profiles, store billing details, and manage customer data.
  • Invoices – Generate invoices, track status, and collect payments.
  • Products – Define items and pricing used across invoices and recurring.
  • Card Issuing – Issue cards, fund balances, and fetch card activity.
  • Recurring – Automate scheduled billing with flexible intervals.
Optional Pagination Query Parameters

List endpoints support optional pagination query parameters:

  • page (optional): Page number to return (1-based).
  • pageSize (optional): Number of records per page. Defaults to 50 when not provided.

Pagination is only applied when page is provided. If page is omitted, the full list is returned.

These endpoints allow businesses to automate payment workflows and enhance their integration capabilities. In the next sections, we’ll provide detailed instructions on how to use each API.

Tokens

Create short-lived card tokens for use with the Charges API.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

POST /tokens Create Card Token
Body Parameters
Field Type Required Description
numberstringYes16-digit card number.
monthstringYes2-digit month 0112.
yearstringYes2-digit year YY.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
Create Token Body
{
  "number": "4242424242424242",
  "month": "12",
    "year": "30"
}
Sample Response
{
  "result": "success",
  "token": "tok_..."
}

Charges

Charge operations backed by Transactions. A charge id corresponds to the Transaction presentation_id within the authenticated merchant scope.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

Idempotency (optional):

Send an Idempotency-Key header (or idempotency_key body field, max 128 characters) on POST /charges and POST /charges/{id}/capture to safely retry requests without charging the card twice. If a request with the same key already completed, the original response is replayed with an Idempotency-Replayed: true header. Reusing a key with a different payload returns 422; a duplicate sent while the original is still processing returns 409 with Retry-After. Keys are kept for 7 days and are scoped to your merchant account and endpoint. Requests without a key behave exactly as before.

GET /charges List Charges
POST /charges Create a Charge
Create Charge Body Parameters
Field Type Required Description
tokenstringConditionalToken from /tokens. Required unless charging with payment_method_id or wallet.
payment_method_idnumberConditionalExisting saved payment method id (CustomerDetails). Required unless charging with token or wallet. Aliases: x_payment_id, paymentmethodid.
walletobjectConditionalDigital wallet charge. Required unless charging with token or payment_method_id. Shape: {"type": "apple_pay", "payment_data": {...}}, where payment_data is the PKPaymentToken.paymentData object from Apple Pay. Single-use; cannot be combined with save_payment_method.
cvcstringConditionalRequired for token-based charges, and accepted for payment_method_id charges. Provide at charge time (do not store). You can also pass billing.cvc. Not applicable to wallet charges.
amountnumberYesCharge amount.
transtypestringNoDefaults to AUTH_CAPTURE. Use AUTH_ONLY for auth-then-capture flows.
billingobjectNoBilling/contact fields (first_name, last_name, address, city, state, zip, country, phone, email, description).
customer_idnumberNoOptional customer id. When charging with payment_method_id, if provided it must match the payment method’s customer.
save_payment_methodbooleanNoIf true, creates a customer/payment method (if needed) and charges it. Requires token-based charge (raw card data is needed). Not allowed for wallet charges.
use_customer_profilebooleanNoIf true and customer_id is set, instructs the gateway to use the stored customer profile where supported. Auto-enabled for payment_method_id charges.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
GET /charges/{id} Retrieve a Charge
PUT /charges/{id} Update a Charge
POST /charges/{id}/capture Capture a Charge
GET /charges/search?q={query} Search Charges
Create Charge (Token) Body
{
    "token": "tok_...",
    "cvc": "123",
    "amount": 12.34,
    "transtype": "AUTH_CAPTURE",
    "billing": {
        "first_name": "Jane",
        "last_name": "Smith",
        "address": "123 Main St",
        "city": "New York",
        "state": "NY",
        "zip": "10001",
        "country": "USA",
        "email": "jane@example.com",
        "phone": "5550123",
        "description": "Online Purchase"
    }
}
Create Charge (Payment Method) Body
{
  "payment_method_id": 12345,
  "customer_id": 67890,
  "amount": 12.34,
  "cvc": "123",
  "transtype": "AUTH_CAPTURE"
}
Create Charge (Apple Pay) Body
{
  "wallet": {
    "type": "apple_pay",
    "payment_data": {
      "version": "EC_v1",
      "data": "...",
      "signature": "...",
      "header": {
        "ephemeralPublicKey": "...",
        "publicKeyHash": "...",
        "transactionId": "..."
      }
    }
  },
  "amount": 12.34,
  "billing": {
    "first_name": "Jane",
    "last_name": "Smith",
    "email": "jane@example.com"
  }
}
Create Charge + Save Payment Method
{
  "token": "tok_...",
    "cvc": "123",
  "amount": 12.34,
  "save_payment_method": true,
  "billing": {
    "first_name": "Jane",
    "last_name": "Smith",
    "email": "jane@example.com",
    "company": "Acme Inc",
    "address": "123 Main St",
    "city": "New York",
    "state": "NY",
    "zip": "10001",
    "country": "USA",
    "phone": "5550123"
  }
}
Create Charge (Sample Response)
{
  "result": "success",
  "charge": {
    "id": 123456,
    "transaction_id": 98765,
    "amount": 12.34,
    "currency": "USD",
    "status": 1,
    "status_text": "succeeded",
    "type": 1,
    "payment_method": "card",
    "created": "...",
    "description": "Online Purchase",
    "notes": null,
    "reference_id": 0,
    "last4": "4242"
  },
    "customer_id": 67890,
    "payment_method_id": 12345,
  "transaction": {
    "status": 1,
    "status_text": "Success",
    "reason_text": "...",
    "transaction_id": "123456"
    }
}
Capture Charge Body Parameters
Field Type Required Description
amountnumberNoOptional capture amount (omit for full capture where supported).
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
Capture Charge Body
{
  "amount": 12.34
}
HTTP Status Codes
Code Meaning When it is returned
200OKSuccessful list, retrieve, search, update, and approved create/capture requests. Idempotent replays return the original response with this or the originally stored code.
400Bad RequestInvalid JSON body; missing amount or token/payment_method_id/wallet; invalid or expired token; invalid card number, expiration, or CVC; missing cvc on a token-based charge; save_payment_method without a token or with a wallet charge; unsupported wallet.type or missing wallet.payment_data; update with no supported fields; invalid Idempotency-Key.
401UnauthorizedMissing or invalid credentials (Bearer token, Basic auth, or x_login/x_tran_key).
403ForbiddenThe authenticated merchant account is disabled.
404Not FoundCharge, merchant, customer, or payment method not found (or not owned by the authenticated merchant); unrecognized route.
405Method Not AllowedHTTP method not supported on the route (e.g. DELETE /charges, GET /charges/{id}/capture).
409ConflictA request with the same Idempotency-Key is still processing. Includes a Retry-After header.
422Unprocessable EntityThe gateway declined the charge or capture (see transaction.reason_text), or an Idempotency-Key was reused with a different payload.
429Too Many RequestsRate limit exceeded. Includes a Retry-After header and rate limit headers.
500Internal Server ErrorServer-side configuration or processing error (e.g. token key not configured).
502Bad GatewayThe upstream payment gateway could not be reached.

Transactions

Transaction records are backed by the transactions table.

ID behavior: for GET /query/transaction/{id}, the endpoint first resolves {id} against merchant-scoped presentation_id, then falls back to internal transaction_id. In responses, uniqueID reflects merchant-facing presentation_id.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

GET /query/transaction List Transactions

Optional query parameters for GET /query/transaction:

  • page: page number (1-based). When provided, results are paginated.
  • pageSize: records per page. Optional; defaults to 50.
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for list results.
pageSizeintegerNoOptional page size for list results.
filtersstringNoOptional filter expression (same behavior as other Expi list endpoints).
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Transactions Example
GET /query/transaction?page=1&pageSize=25&sort=-uniqueID
GET /query/transaction/{id} Retrieve a Transaction
POST /query/transaction Create a Transaction Record
Create Transaction Body Parameters
Field Type Required Description
amount_total / amountnumberYesTransaction amount. amount_total matches the response structure.
amount_subtotalnumberNoAmount before tax, surcharge, and tip. If omitted, inferred as amount_total - amount_surcharge - amount_tax - amount_tip.
amount_taxnumberNoOptional tax amount.
amount_discountnumberNoOptional discount amount.
amount_shippingnumberNoOptional shipping amount.
amount_surchargenumberNoOptional surcharge amount.
amount_tipnumberNoOptional tip amount.
currencystringNoOptional currency code (for example USD).
descriptionstringNoOptional description.
notesstringNoOptional notes.
coupon_idnumberNoOptional coupon id association.
recurring_idnumberNoOptional recurring/subscription id association.
transtypestring|numberNoDefaults to AuthCapture. Accepts string or numeric code.
statusstring|numberNoNot accepted on create. Always stored as Unprocessed.
statestring|numberNoDefaults to Unknown. Accepts string or numeric code.
sourcestring|numberNoDefaults to Endpoint. Accepts string or numeric code; response returns the human-readable label (for example, Endpoint).
created / transdatestringNoIgnored on create. The transaction timestamp is generated from the database clock in YYYY-MM-DD HH:MM:SS format. created matches the response structure.
invoicing_id / invoice_idnumberNoOptional link to an invoice.
invoiceobjectNoIf provided and invoice id is omitted (invoicing_id / invoice_id), an invoice is created and linked.
items / line_itemsarrayNoOptional line items. Stored on the linked invoice as invoice details. If no invoice context is supplied, an invoice will be created (requires customer_id or enough customer/billing data to create one).
customer_idnumber|nullNoOptional customer association. Omit or set to null to create an unassociated transaction; on update, null detaches the current customer. A customer is created only when customer or populated billing/shipping data is provided.
customerobjectNoOptional customer payload used to create a customer when customer_id is omitted.
payment_information.methodnumber|stringNoNon-sensitive payment method indicator (ex: CC, echeck, or numeric code).
payment_information.last4stringNoNon-sensitive last 4 digits (stored as provided digits only).
billingobjectNoOptional billing/contact fields (first_name, last_name, address, city, state, zip, country, phone1, phone2, email). phone is also accepted as an alias for phone1.
shippingobjectNoOptional shipping/contact fields (first_name, last_name, address, city, state, zip, country, phone1, phone2, email). phone is also accepted as an alias for phone1.
PUT /query/transaction/{id} Update a Transaction
DELETE /query/transaction/{id} Delete a Transaction
Create Transaction Body
{
    "amount_total": 12.34,
    "amount_subtotal": 10.09,
    "amount_tax": 0.50,
    "amount_discount": 1.00,
    "amount_shipping": 2.50,
    "amount_surcharge": 0.25,
    "amount_tip": 1.50,
    "currency": "USD",
    "description": "Order #1001",
    "notes": "Recorded from mobile tap-to-pay",
    "transtype": "AuthCapture",
    "state": "Settled",
    "created": "2026-01-29 12:34:56",
    "payment_information": {
        "method": "CC",
        "last4": "4242"
    },
    "billing": {
        "first_name": "Jane",
        "last_name": "Smith",
        "email": "jane@example.com",
        "phone1": "555-555-5555"
    },
    "items": [
        {"title": "T-Shirt", "quantity": 1, "unit_price": 12.34}
    ]
}
Create Transaction Without a Customer
{
    "amount_total": 12.34,
    "amount_subtotal": 12.34,
    "description": "Guest checkout record",
    "customer_id": null
}
Detach a Customer from a Transaction
PUT /query/transaction/{id}
{
    "customer_id": null
}
Transaction (Sample Response)
{
  "result": "success",
  "transaction": {
    "uniqueID": 123456,
    "customer_id": 555,
    "coupon_id": 0,
    "recurring_id": 0,
    "amount_total": 12.34,
    "amount_subtotal": 12.34,
    "amount_tax": 0,
    "amount_discount": 12.34,
    "amount_shipping": 0,
    "amount_surcharge": 0,
    "amount_tip": 0,
    "currency": "USD",
    "created": "2026-01-29 12:34:56",
    "description": "Order #1001",
    "notes": "Created by integration",
    "billing": {
      "first_name": "Jane",
      "last_name": "Smith",
      "email": "jane@example.com",
      "phone1": "555-555-5555",
      "phone2": "",
      "address": "",
      "address2": "",
      "city": "",
      "state": "",
      "zip": "",
      "country": ""
    },
    "shipping": {
      "first_name": "",
      "last_name": "",
      "email": "",
      "phone1": "",
      "phone2": "",
      "address": "",
      "address2": "",
      "city": "",
      "state": "",
      "zip": "",
      "country": ""
    },
    "payment_information": {
      "method": 16,
      "last4": "4242"
    },
    "invoicing_id": null,
    "reference_id": null,
    "status": "Unprocessed",
    "state": "Settled",
    "transtype": "AuthCapture",
    "source": "Endpoint"
  }
}

Apple Pay

Apple Pay lets customers authorize a card payment from a supported Apple device. You can offer it through a hosted checkout page or submit an Apple Pay payment token through POST /charges from your own checkout.

Choose an integration
Integration What you need to do
Hosted checkout Request Apple Pay enablement. The Apple Pay button is displayed automatically to eligible customers on supported invoice and hosted payment pages. No Apple certificates or JavaScript integration are required from you.
Your website Request enablement and provide every domain or subdomain where the Apple Pay button will appear. Complete domain verification as described below, then use Apple Pay JS to collect a payment token and submit it to POST /charges.
Your app Request enablement, configure the Apple Pay capability in your app, and send the resulting PKPaymentToken.paymentData object to POST /charges.
Custom website setup
  1. Send support your merchant account ID, checkout display name, and the fully qualified production and test domains that will show Apple Pay.
  2. Support will provide the domain-association file for your integration. Host that exact file at https://<your-domain>/.well-known/apple-developer-merchantid-domain-association. It must be publicly available over HTTPS without authentication or a redirect.
  3. Wait for support to confirm that the domain is verified and Apple Pay is enabled before testing a payment.
Merchant validation on the web

Hosted checkout pages handle merchant validation automatically. When Apple Pay JS fires onvalidatemerchant, the page sends the event's validationURL with a short-lived validation token issued by the gateway. The merchant account ID is derived from that signed token and must not be supplied by browser code.

POST /applepay/validate-merchant Validate an Apple Pay merchant session
Merchant Validation
session.onvalidatemerchant = async (event) => {
  const response = await fetch('/applepay/validate-merchant', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      validationURL: event.validationURL,
      validationToken: checkout.applePayValidationToken
    })
  });

  if (!response.ok) {
    session.abort();
    return;
  }

  const merchantSession = await response.json();
  session.completeMerchantValidation(merchantSession);
};

Always use the validationURL supplied by the Apple Pay event. Never expose gateway credentials or construct a validation token in browser code. A merchant session is short-lived and single-use; do not cache or reuse it.

Create a charge

After the customer authorizes the payment, send event.payment.token.paymentData unchanged as wallet.payment_data. The charge amount must match the amount shown in the Apple Pay payment sheet.

POST /charges Create an Apple Pay charge
Apple Pay Charge Body
{
  "wallet": {
    "type": "apple_pay",
    "payment_data": {
      "version": "EC_v1",
      "data": "...",
      "signature": "...",
      "header": {
        "ephemeralPublicKey": "...",
        "publicKeyHash": "...",
        "transactionId": "..."
      }
    }
  },
  "amount": 12.34,
  "billing": {
    "first_name": "Jane",
    "last_name": "Smith",
    "email": "jane@example.com"
  }
}

See the Charges documentation for authentication, idempotency, the complete request schema, and response fields.

Requirements and limitations
  • Show the Apple Pay button only when the Apple Pay APIs are available and ApplePaySession.canMakePayments() returns true.
  • Serve custom checkout pages over HTTPS and follow Apple's current Apple Pay on the Web requirements and button guidelines.
  • Treat paymentData as sensitive, single-use payment data. Send it only to the gateway over HTTPS; do not log, alter, decrypt, cache, or reuse it.
  • Apple Pay cannot be used with save_payment_method and cannot be initiated from a virtual terminal.
  • Refunds, voids, and captures use the standard endpoints and the resulting transaction ID. No Apple Pay token is needed for these follow-up operations.

Refunds

Refund operations backed by Transactions. Refunds are processed as gateway CREDIT transactions against an existing charge (Transaction presentation_id) within the authenticated merchant scope.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

Idempotency (optional):

Send an Idempotency-Key header (or idempotency_key body field, max 128 characters) on POST /refunds to safely retry a refund without crediting the card twice. If a request with the same key already completed, the original response is replayed with an Idempotency-Replayed: true header. Reusing a key with a different payload returns 422; a duplicate sent while the original is still processing returns 409 with Retry-After. Keys are kept for 7 days and are scoped to your merchant account and endpoint. Requests without a key behave exactly as before.

POST /refunds Create a Refund
Create Refund Body Parameters
Field Type Required Description
charge_idnumberYesOriginal charge Transaction presentation_id. Alias: transaction_id.
amountnumberYesRefund amount. Must be greater than 0 and not exceed the original charge amount.
notesstringNoOptional notes sent to the gateway.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
Create Refund (Body)
{
  "charge_id": 123456,
  "amount": 12.34,
  "notes": "Customer requested refund"
}
Create Refund (Sample Response)
{
  "result": "success",
  "refund": {
    "id": 222222,
    "transaction_id": 98765,
    "amount": 12.34,
    "currency": "USD",
    "status": 1,
    "status_text": "succeeded",
    "type": 4,
    "created": "...",
    "description": null,
    "notes": null,
    "reference_id": 0,
    "last4": "4242"
  },
  "original_charge": {
    "id": 123456,
    "transaction_id": 12345,
    "amount": 12.34,
    "currency": "USD",
    "status": 1,
    "status_text": "succeeded",
    "type": 1,
    "created": "...",
    "description": "Online Purchase",
    "notes": null,
    "reference_id": 0,
    "last4": "4242"
  },
  "transaction": {
    "status": 1,
    "status_text": "Success",
    "reason_text": "...",
    "transaction_id": "222222"
  }
}
HTTP Status Codes
Code Meaning When it is returned
200OKThe refund (gateway CREDIT) was approved. Idempotent replays return the original response with the originally stored code.
400Bad RequestMissing charge_id/transaction_id; missing or non-numeric amount; amount not greater than 0; amount exceeds the original charge amount; invalid Idempotency-Key.
401UnauthorizedMissing or invalid credentials (Bearer token, Basic auth, or x_login/x_tran_key).
404Not FoundThe referenced charge does not exist within the authenticated merchant scope; unrecognized route.
405Method Not AllowedAny HTTP method other than POST.
409ConflictA request with the same Idempotency-Key is still processing. Includes a Retry-After header.
422Unprocessable EntityThe gateway declined the refund (see transaction.reason_text), or an Idempotency-Key was reused with a different payload.
429Too Many RequestsRate limit exceeded. Includes a Retry-After header and rate limit headers.
500Internal Server ErrorServer-side initialization or processing error.
502Bad GatewayThe upstream payment gateway could not be reached.

Disputes

Chargebacks and retrievals (disputes) filed against your account. You can list disputes, retrieve a single dispute, and respond to an open dispute by accepting it or challenging it with evidence. Disputes are pulled from the chargeback processing system, so only merchants with a MID configured on their account can use this endpoint. Data and behavior match the Disputes page of the dashboard.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

GET /query/disputes List Disputes
List Disputes Query Parameters
Field Type Required Description
pagenumberNoPage number. Defaults to 1.
pageSizenumberNoResults per page, between 1 and 100. Defaults to 25.
sortstringNoSort field, prefixed with - for descending. Supported fields: due_date, posted_date, case_amount, case_status, reason_code, id. Defaults to -due_date.
filtersstringNoStandard filter expression (e.g. filters=case_status~=needs). Applied to the current page only, since disputes are paginated by the upstream system.
modifiersstringNoComma-separated list of fields to include in each record (uniqueID is always included).
List Disputes (Sample Response)
{
  "result": "success",
  "disputes": [
    {
      "uniqueID": 448821,
      "case_number": "7211930051",
      "case_type": "Chargeback",
      "case_status": "Needs Response",
      "case_amount": 74.95,
      "currency": "USD",
      "reason_code": "10.4",
      "reason_description": "Other Fraud - Card Absent Environment",
      "cardholder_account_number": "************4242",
      "posted_date": "2026-07-02",
      "due_date": "2026-07-18",
      "item_type": "Open"
    }
  ],
  "pageCount": 4,
  "totalCount": 92
}

The list returns a summary of each dispute. Fields only available on the full record (e.g. card_brand, card_name, auth_code, arn, order_id, transaction_date, chargeback_date, notes) are omitted from list results — retrieve the dispute by id to get them.

GET /query/disputes/{id} Retrieve a Dispute

Returns the full dispute record by its uniqueID. Disputes belonging to another merchant return 404.

Retrieve Dispute (Sample Response)
{
  "result": "success",
  "dispute": {
    "uniqueID": 448821,
    "case_number": "7211930051",
    "case_type": "Chargeback",
    "case_status": "Needs Response",
    "case_amount": 74.95,
    "currency": "USD",
    "reason_code": "10.4",
    "reason_description": "Other Fraud - Card Absent Environment",
    "card_brand": "Visa",
    "card_name": "JOHN SMITH",
    "cardholder_account_number": "************4242",
    "auth_code": "081522",
    "arn": "74537506123456789012345",
    "order_id": "ORD-10592",
    "transaction_date": "2026-06-02",
    "chargeback_date": "2026-07-01",
    "posted_date": "2026-07-02",
    "due_date": "2026-07-18",
    "item_type": "Open",
    "notes": ""
  }
}
POST /query/disputes/{id}/accept Accept a Dispute

Accepts liability for an open dispute. Only disputes that are open for responses (e.g. status Needs Response) can be accepted; otherwise 409 is returned.

Accept Dispute Body Parameters
Field Type Required Description
commentstringNoOptional comment recorded with the response.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
POST /query/disputes/{id}/challenge Challenge a Dispute

Challenges an open dispute with supporting evidence. Send the request as multipart/form-data with the evidence document in a file field. The file is required unless documents have already been uploaded to the dispute (e.g. through the dashboard). Allowed file types: JPG, PNG, GIF, PDF, TXT (max 32 MB).

Challenge Dispute Body Parameters (multipart/form-data)
Field Type Required Description
filefileYes*Evidence document (JPG, PNG, GIF, PDF, or TXT). *Optional only when the dispute already has uploaded documents.
commentstringNoOptional comment recorded with the challenge.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
Challenge Dispute (Sample Request)
curl -X POST https://api.example.com/query/disputes/448821/challenge \
  -H "Authorization: Bearer <access_token>" \
  -F "file=@signed_receipt.pdf" \
  -F "comment=Customer signed for delivery on 2026-06-04"
Respond to Dispute (Sample Response)
{
  "result": "success",
  "message": "Challenge submitted",
  "dispute": {
    "uniqueID": 448821,
    "case_status": "Under Review",
    "item_type": "Resolved",
    "...": "..."
  }
}
Dispute Object Fields

Fields are included only when the upstream system provides them: list results contain the summary fields shown in the list sample above, while retrieving a dispute by id returns the full set below.

Field Type Description
uniqueIDnumberDispute identifier. Use with GET /query/disputes/{id}.
case_numberstringCase number assigned by the processor.
case_typestringType of case (e.g. Chargeback, Retrieval, Pre-Arbitration).
case_statusstringCurrent status of the case (e.g. Needs Response, Under Review, Closed).
case_amountnumberDisputed amount.
currencystringCurrency of the disputed amount.
reason_codestringCard-network reason code (e.g. 10.4, 4837).
reason_descriptionstringHuman-readable description of the reason code.
card_brandstringCard network (Visa, Mastercard, etc.).
card_namestringCardholder name, when provided by the network.
cardholder_account_numberstringMasked card number.
auth_codestringAuthorization code of the original transaction.
arnstringAcquirer Reference Number of the original transaction.
order_idstringMerchant order reference, when available.
transaction_datestringDate of the original transaction.
chargeback_datestringDate the chargeback was initiated.
posted_datestringDate the case was posted to your account.
due_datestringDeadline to respond to the case.
item_typestringWorkflow state of the item: Open or Resolved.
notesstringCase notes, when available.
HTTP Status Codes
Code Meaning When it is returned
200OKThe dispute list or dispute was returned, or the response (accept/challenge) was submitted successfully.
400Bad RequestInvalid page/pageSize; unsupported sort field; missing, oversized, or invalid evidence file on a challenge.
401UnauthorizedMissing or invalid credentials (Bearer token, Basic auth, or x_login/x_tran_key).
404Not FoundThe dispute does not exist or does not belong to the authenticated merchant.
405Method Not AllowedAny method/route combination other than the documented GET and POST routes.
409ConflictThe dispute is not open for responses (accept/challenge on a closed or already-answered case).
422Unprocessable EntityThe merchant account does not have a MID configured.
502Bad GatewayThe chargeback processing system could not be reached or returned an error.
503Service UnavailableThe dispute service is not configured for this account.

Voids

Void operations backed by Transactions. Voids are processed as gateway VOID transactions against an existing charge (Transaction presentation_id) within the authenticated merchant scope.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

POST /voids Create a Void
POST /voids/{id} Create a Void (Path Alias)
Create Void Body Parameters
Field Type Required Description
charge_idnumberConditionalOriginal charge Transaction presentation_id. Required unless id is supplied in the URL path. Alias: transaction_id.
notesstringNoOptional notes sent to the gateway.
x_loginstringNoMerchant login (only if not using Authorization header).
x_tran_keystringNoMerchant tran key/secret (only if not using Authorization header).
Create Void (Body)
{
  "charge_id": 123456,
  "notes": "Customer requested cancellation"
}
Create Void (Sample Response)
{
  "result": "success",
  "void": {
    "id": 333333,
    "transaction_id": 99999,
    "amount": 12.34,
    "currency": "USD",
    "status": 1,
    "status_text": "succeeded",
    "type": 8,
    "created": "...",
    "description": null,
    "notes": null,
    "reference_id": 0,
    "last4": "4242"
  },
  "original_charge": {
    "id": 123456,
    "transaction_id": 12345,
    "amount": 12.34,
    "currency": "USD",
    "status": 1,
    "status_text": "succeeded",
    "type": 1,
    "created": "...",
    "description": "Online Purchase",
    "notes": null,
    "reference_id": 0,
    "last4": "4242"
  },
  "transaction": {
    "status": 1,
    "status_text": "Success",
    "reason_text": "...",
    "transaction_id": "333333"
  }
}

Tap to Pay

Process Apple Tap to Pay on iPhone transactions through Query. The mobile client sends encrypted ttp_… envelopes; Expitrans decrypts them only to forward the required provider fields. Never decrypt, alter, or log those envelopes in your application.

Authentication: Authorization: Bearer <access_token>, Basic merchant credentials, or x_login and x_tran_key in the request body.

magensaResponse is Magensa's response object and only contains fields supplied by the processor. The client model exposes transactionOutput.authorizedAmount as a string; Magensa can serialize it as either a JSON string or number.

POST /query/taptopay/paymentcardreader Create a reader token

Exchange the identifier returned by Apple's reader APIs for the token used to configure the payment card reader.

Reader token request parameters
FieldTypeRequiredDescription
paymentCardReaderIdentifierstringYesApple payment-card reader identifier, at least six characters.
Reader Token Request
{ "paymentCardReaderIdentifier": "reader_01HZXT8C3MGK" }
Reader Token Response
{
  "result": "success",
  "paymentCardReader": {
    "traceID": "provider-trace-id",
    "customerTransactionID": "reader-token-request-id",
    "transactionUTCTimeStamp": "2026-07-28T16:30:00Z",
    "paymentCardReaderToken": "<reader-token>"
  }
}
POST /query/taptopay/emvsale Submit an EMV sale

customerTransactionID is the sale's stable idempotency key. Reusing it with the same amount after the first attempt returns 409; reusing it with different transaction data returns 422. Treat either result as a reconciliation signal, not a reason to issue a new sale.

EMV sale request parameters
FieldTypeRequiredDescription
customerTransactionIDstringYesUnique client-generated sale ID, 1–128 characters.
transactionInput.transactionTypestringYesMust be SALE.
transactionInput.amountnumberYesPositive amount as a JSON number.
transactionContext.customer_idintegerYesExisting Expi customer ID for local transaction history.
transactionContext.descriptionstringNoLocal transaction description.
transactionContext.amount_subtotalnumberNoSubtotal saved in local history. If omitted, it is inferred from the authorized total minus tax, surcharge, and tip.
transactionContext.amount_taxnumberNoTax amount saved in local history.
transactionContext.amount_surchargenumberNoSurcharge amount saved in local history.
dataInput.encryptedData.dataTypestringYesMust be AppleTapToPay.
dataInput.encryptedData.datastringYesEncrypted Apple payment data in a ttp_… envelope.
dataInput.tlvListstringYesEncrypted EMV TLV data in a ttp_… envelope.
dataInput.paymentModestringYesMust be EMV.
dataInput.paymentTypestringYesMust be CREDIT.
deviceInfoobjectNoWhen present, all four fields below are required.
deviceInfo.serialNumber, make, model, nickNamestringConditionalReader metadata forwarded to the provider when deviceInfo is supplied.
EMV Sale Request
{
  "customerTransactionID": "ttp-sale-01HZXT8C3MGK",
  "transactionInput": { "transactionType": "SALE", "amount": 12.34 },
  "transactionContext": { "customer_id": 12345, "description": "Coffee and pastry" },
  "dataInput": {
    "encryptedData": { "dataType": "AppleTapToPay", "data": "ttp_<encrypted-apple-payment-data>" },
    "tlvList": "ttp_<encrypted-emv-tlv-data>",
    "paymentMode": "EMV",
    "paymentType": "CREDIT"
  },
  "deviceInfo": { "serialNumber": "D123456", "make": "Apple", "model": "iPhone", "nickName": "Front counter" }
}
EMV Sale Response
{
  "result": "success",
  "magensaResponse": {
    "traceID": "provider-trace-id",
    "magTranID": "mag-transaction-id",
    "customerTransactionID": "ttp-sale-01HZXT8C3MGK",
    "transactionUTCTimeStamp": "2026-07-28T16:30:00Z",
    "transactionOutput": { "isTransactionApproved": true, "authorizedAmount": "12.34", "authCode": "A1B2C3", "transactionID": "processor-transaction-id", "transactionStatus": "APPROVED", "transactionMessage": "Approved" },
    "dataOutput": { "panLast4": "4242" }
  },
  "transaction": { "uniqueID": 98765, "presentation_id": 98765 }
}

Keep magTranID for post-sale operations and traceID for provider support. A successful HTTP response is not itself an approval—always check transactionOutput.isTransactionApproved.

Other optional provider fields include additionalResponseData, dataOutput.additionalOutputData, dataOutput.cardID, and the converted or normalized processor responses. Treat card IDs, tokens, issuer data, scripts, and receipts as sensitive.

POST /query/taptopay/tipadjust Adjust a tip

Use the original sale's magTranID. The reference must identify an approved Tap to Pay sale recorded for the authenticated merchant. transactionInputDetails must contain the Magensa fields required for the original payment; this API validates only that it is an object and forwards it unchanged.

Tip adjustment request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from the original Tap to Pay sale.
tipAmountnumberYesNew tip amount; zero is valid.
transactionInputDetailsobjectYesProvider-required original transaction detail fields.
Tip Adjust Request
{
  "referenceMagTranID": "mag-transaction-id",
  "tipAmount": 2.50,
  "transactionInputDetails": { "amount": 12.34 }
}
Tip Adjust Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-tip-adjustment-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98765, "tip": 2.50 }
}
POST /query/taptopay/void Void a transaction

Use the original sale's magTranID. This operation is idempotent by referenceMagTranID: retrying the same reference returns 409, and changing its amount returns 422. Include a positive amount only for a partial void; omit it to use the original amount.

Void request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from the original approved Tap to Pay sale.
amountnumberNoPositive partial or full operation amount.
Void Request
{
  "referenceMagTranID": "mag-transaction-id",
  "amount": 12.34
}
Void Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-void-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98766, "reference_id": 98765 }
}
POST /query/taptopay/refund Refund a transaction

Use the original sale's magTranID. This operation is idempotent by referenceMagTranID: retrying the same reference returns 409, and changing its amount returns 422. Include a positive amount only for a partial refund; omit it to use the original amount.

Refund request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from the original approved Tap to Pay sale.
amountnumberNoPositive partial or full refund amount.
Refund Request
{
  "referenceMagTranID": "mag-transaction-id",
  "amount": 12.34
}
Refund Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-refund-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "authorizedAmount": "12.34", "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98767, "reference_id": 98765 }
}
Errors and response handling

Invalid envelopes, missing fields, and unsupported values return the standard failed response. Provider failures include a structured providerError when Magensa returns one.

Provider Failure Response
{
  "result": "failed",
  "message": "Provider declined the transaction",
  "providerError": { "code": "DECLINED", "message": "Provider declined the transaction", "traceID": "provider-trace-id" }
}

External Reader

Process encrypted MagTek physical-card-reader transactions through Query. These routes use the merchant's server-side Magensa credentials; send the encrypted reader output only. They do not accept Apple Tap to Pay ttp_… envelopes.

Authentication: Authorization: Bearer <access_token>, Basic merchant credentials, or x_login and x_tran_key in the request body.

magensaResponse is Magensa's response object and only contains fields supplied by the processor. The client model exposes transactionOutput.authorizedAmount as a string; Magensa can serialize it as either a JSON string or number.

POST /query/externalreader/verify Verify Magensa credentials

Checks the configured Magensa physical-reader credentials. There are no operation-specific request fields; when using body credentials, include the authentication fields described above.

Verify Request
{}
Verify Response
{
  "result": "success",
  "magensaResponse": {
    "code": "404",
    "message": "Transaction not found"
  }
}

Magensa documents the provider's 404 for its credential-check transaction (custTranID=0) as a successful authorization check, so it is returned in the success envelope above.

POST /query/externalreader/emvsale Submit an EMV ARQC sale

customerTransactionID is the sale idempotency key. Do not reuse it for another sale. If the network result is unknown, call recall before attempting any recovery. The reader ARQC must be an even-length hexadecimal string; it is normalized to uppercase before forwarding.

EMV sale request parameters
FieldTypeRequiredDescription
customerTransactionIDstringYesClient-generated sale ID, 1–128 characters. Reuse only when referring to this exact sale.
transactionInput.transactionTypestringYesMust be SALE.
transactionInput.amountnumberYesPositive sale amount. Send a JSON number, not a quoted value.
transactionContext.customer_idintegerNoExisting Expi customer ID for local transaction history. Omit it, send null, or send a non-positive integer for a walk-in sale; Expitrans uses or creates this merchant's active Walk-In Customer.
transactionContext.descriptionstringNoLocal transaction description.
transactionContext.amount_subtotalnumberNoSubtotal saved in local history. If omitted, it is inferred from the authorized total minus tax, surcharge, and tip.
transactionContext.amount_taxnumberNoTax amount saved in local history.
transactionContext.amount_surchargenumberNoSurcharge amount saved in local history.
dataInput.encryptedData.dataTypestringYesMust be ARQC.
dataInput.encryptedData.datastringYesEven-length hexadecimal encrypted reader output. Never decrypt or log it.
dataInput.paymentTypestringYesMust be Credit.
EMV Sale Request
{
  "customerTransactionID": "external-sale-01HZXT8C3MGK",
  "transactionInput": { "transactionType": "SALE", "amount": 12.34 },
  "transactionContext": { "description": "Coffee and pastry" },
  "dataInput": {
    "encryptedData": { "dataType": "ARQC", "data": "A1B2C3D4E5F6" },
    "paymentType": "Credit"
  }
}
EMV Sale Response
{
  "result": "success",
  "magensaResponse": {
    "traceID": "provider-trace-id",
    "magTranID": "mag-transaction-id",
    "customerTransactionID": "external-sale-01HZXT8C3MGK",
    "transactionUTCTimeStamp": "2026-07-28T16:30:00Z",
    "transactionOutput": {
      "isTransactionApproved": true,
      "authorizedAmount": "12.34",
      "authCode": "A1B2C3",
      "transactionID": "processor-transaction-id",
      "transactionStatus": "APPROVED",
      "transactionMessage": "Approved",
      "issuerAuthenticationData": "<return-to-reader-when-present>"
    },
    "dataOutput": { "panLast4": "4242" }
  },
  "transaction": { "uniqueID": 98765, "presentation_id": 98765 }
}

For chip flows, return issuerAuthenticationData (and either issuer script template when supplied) to the reader according to the reader integration. Retain magTranID for all post-sale operations and traceID for provider support.

Other optional provider fields include additionalResponseData, dataOutput.additionalOutputData, dataOutput.cardID, and the converted or normalized processor responses. Treat card IDs, tokens, issuer data, scripts, and receipts as sensitive.

POST /query/externalreader/recall Reconcile an ambiguous sale

Use this after a timeout or disconnected client to retrieve the provider outcome for the original customerTransactionID. It does not submit another charge.

Recall request parameters
FieldTypeRequiredDescription
customerTransactionIDstringYesThe original EMV sale's customer transaction ID.
Recall Request
{ "customerTransactionID": "external-sale-01HZXT8C3MGK" }
Recall Response
{
  "result": "success",
  "magensaResponse": {
    "traceID": "provider-trace-id",
    "magTranID": "mag-transaction-id",
    "customerTransactionID": "external-sale-01HZXT8C3MGK",
    "transactionUTCTimeStamp": "2026-07-28T16:30:00Z",
    "transactionOutput": { "isTransactionApproved": true, "authorizedAmount": "12.34", "transactionStatus": "APPROVED", "transactionMessage": "Approved" }
  }
}
POST /query/externalreader/tipadjust Adjust a tip

Use the sale response's magTranID as referenceMagTranID. transactionInputDetails must contain the Magensa fields required for the original payment; this API validates only that it is an object and forwards it unchanged.

Tip adjustment request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from an approved External Reader sale owned by this merchant.
tipAmountnumberYesNew tip amount; zero is valid.
transactionInputDetailsobjectYesProvider-required transaction detail fields for the original payment.
Tip Adjust Request
{
  "referenceMagTranID": "mag-transaction-id",
  "tipAmount": 2.50,
  "transactionInputDetails": { "amount": 12.34 }
}
Tip Adjust Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-tip-adjustment-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98765, "tip": 2.50 }
}
POST /query/externalreader/void Void a sale

Use the original sale's magTranID. Only an approved, not-yet-voided External Reader sale can be voided. A declined, unapproved, or already-voided sale returns 422 without sending a request to Magensa. If the gateway cannot verify the sale's local void history, it returns 503 and does not submit the void. Include a positive amount for a partial void; omit it when Magensa should use the original transaction amount. A concurrent repeat may return 409; a repeat after a successful void returns 422.

Void request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from an approved External Reader sale owned by this merchant.
amountnumberNoPositive partial or full operation amount.
Void Request
{
  "referenceMagTranID": "mag-transaction-id",
  "amount": 12.34
}
Void Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-void-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98766, "reference_id": 98765 }
}
POST /query/externalreader/refund Refund a sale

Use the original sale's magTranID and a new customerTransactionID for this refund. The refund ID is its idempotency key; reuse it only to retry this exact refund. Include a positive amount for a partial refund, or omit it to use the original transaction amount.

Refund request parameters
FieldTypeRequiredDescription
referenceMagTranIDstringYesmagTranID from an approved External Reader sale owned by this merchant.
customerTransactionIDstringYesNew 1–128 character refund ID. Reuse only to retry this exact refund.
amountnumberNoPositive partial or full refund amount.
Refund Request
{
  "referenceMagTranID": "mag-transaction-id",
  "customerTransactionID": "external-refund-01HZXT8C3MGK",
  "amount": 12.34
}
Refund Response
{
  "result": "success",
  "magensaResponse": { "traceID": "provider-trace-id", "magTranID": "mag-refund-id", "transactionUTCTimeStamp": "2026-07-28T16:30:00Z", "transactionOutput": { "isTransactionApproved": true, "authorizedAmount": "12.34", "transactionStatus": "APPROVED", "transactionMessage": "Approved" } },
  "transaction": { "uniqueID": 98767, "reference_id": 98765 }
}
Errors and response handling

A provider rejection or transport error uses the standard failure envelope. The provider's HTTP status is preserved where possible.

Provider Failure Response
{
  "result": "failed",
  "message": "Provider declined the transaction",
  "providerError": { "code": "DECLINED", "message": "Provider declined the transaction", "traceID": "provider-trace-id" }
}

Webhooks

Webhooks let a merchant register HTTPS endpoints that receive signed event payloads when payment activity happens. The API is available through /query/webhook, /query/webhooks, or /query/expiwebhook.

Authentication:

- Authorization: Bearer <access_token>

- or Authorization: Basic using merchant x_login / x_tran_key

- or include x_login and x_tran_key in the JSON body

POST /query/webhook Create Webhook Endpoint
GET /query/webhook List Webhook Endpoints
GET /query/webhook/{id} Retrieve Webhook Endpoint
PATCH /query/webhook/{id} Update Webhook Endpoint
DELETE /query/webhook/{id} Delete Webhook Endpoint
Endpoint Body Parameters
Field Type Required Description
urlstringYesHTTPS receiver URL. HTTP is only allowed when WEBHOOK_ALLOW_HTTP=true.
eventsarray/stringNoEvent types to deliver. Use ["*"] for all events.
descriptionstringNoInternal label for the endpoint.
enabledbooleanNoDefaults to true. Disabled endpoints do not receive deliveries.
Create Webhook Endpoint
{
  "url": "https://example.com/webhooks/expitrans",
  "description": "Production payment events",
  "events": [
    "charge.created",
    "charge.captured",
    "charge.failed",
    "recurring.payment_succeeded",
    "recurring.payment_failed",
    "refund.created"
  ],
  "enabled": true
}
Create Response
{
  "result": "success",
  "webhook": {
    "id": 101,
    "object": "webhook_endpoint",
    "url": "https://example.com/webhooks/expitrans",
    "description": "Production payment events",
    "enabled": true,
    "events": ["charge.created", "charge.captured", "charge.failed", "recurring.payment_succeeded", "recurring.payment_failed", "refund.created"],
    "api_version": "2026-06-29",
    "created_at": "2026-06-29 12:00:00-06",
    "updated_at": "2026-06-29 12:00:00-06",
    "secret": "whsec_..."
  }
}
Supported Events
Event When it fires
charge.createdA charge succeeds through the Charges API.
charge.updatedA charge description or notes field is updated.
charge.capturedAn authorized charge is captured.
charge.failedA charge or capture attempt returns a gateway failure and a transaction-backed charge exists.
refund.createdA refund succeeds through the Refunds API.
recurring.payment_succeededA recurring payment run succeeds.
recurring.payment_failedA recurring payment run fails or has a gateway transport error.
recurring.completedA recurring schedule completes after a successful final payment.
recurring.pausedA recurring schedule is automatically paused after 3 consecutive failed payment attempts.
webhook.testA test event sent from the webhook API.
Delivery Format

Receivers get a JSON event object. Delivery includes Expi-Event-Id and Expi-Signature headers. The signature format is t={timestamp},v1={hmac}, where hmac is HMAC-SHA256(timestamp + "." + raw_body, endpoint_secret).

Failed deliveries are retried by the webhook delivery cron (lib/cron/webhooks.php) when their next_attempt_at time is due, up to the configured maximum attempt count. Manual resend marks a delivery due immediately so the same worker can process it.

Event Payload
{
  "id": "evt_...",
  "object": "event",
  "type": "charge.created",
  "created": 1782765600,
  "data": {
    "object": {
      "id": 123456,
      "transaction_id": 98765,
      "amount": 12.34,
      "currency": "USD",
      "status_text": "succeeded"
    }
  }
}
POST /query/webhook/{id}/test Send Test Event
GET /query/webhook/events?limit=25 List Events
GET /query/webhook/deliveries?limit=25 List Delivery Attempts
POST /query/webhook/resend/{delivery_id} Resend Delivery

Payouts

Deposits (payouts) made to your account. Payouts are pulled from the same backend as the Payouts page of the dashboard, so only merchants with a MID configured on their account can use this endpoint. List-only: there is no single-payout lookup by id, and payouts have no accept/challenge-style response workflow.

Authentication:

- Authorization: Bearer <access_token> (recommended if you use the login/JWT flow)

- or Authorization: Basic (merchant x_login / x_tran_key)

- or include x_login and x_tran_key in the JSON body

GET /query/payouts List Payouts
List Payouts Query Parameters
Field Type Required Description
pagenumberNoPage number. Defaults to 1.
pageSizenumberNoResults per page, between 1 and 100. Defaults to 25.
sortstringNoSort field, prefixed with - for descending. Supported fields: deposit_date, amount. Defaults to -deposit_date.
filtersstringNoStandard filter expression (e.g. filters=amount>=100). Applied to the current page only, since payouts are paginated by the upstream system.
modifiersstringNoComma-separated list of fields to include in each record (uniqueID is always included).
List Payouts (Sample Response)
{
  "result": "success",
  "payouts": [
    {
      "uniqueID": 91234,
      "deposit_date": "2026-08-01",
      "amount": 1542.30,
      "routing_number": "****6789"
    }
  ],
  "pageCount": 3,
  "totalCount": 58
}
Payout Object Fields

Fields are included only when the upstream system provides them.

Field Type Description
uniqueIDnumberPayout identifier.
deposit_datestringDate the deposit was made.
amountnumberDeposit amount.
routing_numberstringMasked to the last 4 digits, matching what the dashboard's Payouts page shows.
HTTP Status Codes
Code Meaning When it is returned
200OKThe payout list was returned successfully.
400Bad RequestInvalid page/pageSize; unsupported sort field; or a request for a single payout by id (not supported).
401UnauthorizedMissing or invalid credentials (Bearer token, Basic auth, or x_login/x_tran_key).
404Not FoundThe merchant does not exist.
405Method Not AllowedAny method other than the documented GET route.
422Unprocessable EntityThe merchant account does not have a MID configured.
502Bad GatewayThe payout backend could not be reached or returned an error.
503Service UnavailableThe payout service is not configured for this account.

Customer

Manage customer profiles with a single endpoint. Create customers and store contact, billing, and custom fields for downstream billing workflows.

GET /query/customer Get All Customers
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for customer list results.
pageSizeintegerNoOptional page size for customer list results.
filtersstringNoOptional filter expression.
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Customers Example
GET /query/customer?page=1&pageSize=25&sort=-uniqueID
GET /query/customer/{id} Get Single Customer
Sample Response
{
  "result": "success",
  "customer": {
    "uniqueID": 1234,
    "customer_information": {
      "firstname": "Alex",
      "lastname": "Rivera",
      "address1": "123 Market Street",
      "address2": "",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94105",
      "country": "USA",
      "phone1": "4155550134",
      "phone2": "",
      "email": "alex.rivera@example.com"
    },
    "billing_information": {
      "firstname": "Alex",
      "lastname": "Rivera",
      "address1": "123 Market Street",
      "address2": "",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94105",
      "country": "USA",
      "phone": "4155550134",
      "email": "alex.rivera@example.com"
    },
    "custom": {
      "custom1": "A-1001",
      "custom2": "Gold",
      "custom3": "West",
      "custom4": "",
      "custom5": "",
      "custom6": "",
      "custom7": "",
      "custom8": "",
      "custom9": "",
      "custom10": ""
    },
    "defaultPaymentID": 2002,
    "customer_payments": [
      {
        "paymentID": 2001,
        "paymenttype": "Credit Card",
        "lastfour": "4242"
      },
      {
        "paymentID": 2002,
        "paymenttype": "Checking",
        "lastfour": "6789"
      }
    ]
  }
}
POST /query/customer Create Customer
Body Parameters
{
  "x_login": "...",
  "x_tran_key": "...",
  "customer": {
    "customer_information": {
      "firstname": "Jane",
      "lastname": "Smith",
      "email": "jane@example.com",
      "phone1": "555-0123",
      "address1": "123 Main St",
      "city": "New York",
      "state": "NY",
      "zip": "10001",
      "country": "US"
    },
    "billing_information": {
      "firstname": "Jane",
      "lastname": "Smith",
      "address1": "123 Main St",
      "city": "New York",
      "state": "NY",
      "zip": "10001",
      "country": "US"
    },
    "custom": {
      "custom1": "VIP Client"
    }
  }
}
PUT /query/customer/{id} Update Customer
Update Customer Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "_method": "PUT",
  "customer": {
    "customer_information": {
      "firstname": "Jane",
      "lastname": "Smith",
      "email": "jane@example.com",
      "phone1": "555-0123"
    },
    "billing_information": {
      "address1": "456 Elm St",
      "city": "New York",
      "state": "NY",
      "zip": "10001",
      "country": "US"
    },
    "custom": {
      "custom1": "VIP Client"
    },
    "customer_payments": [
      {
        "paymentID": 2002
      }
    ]
  }
}
DELETE /query/customer/{id} Delete Customer

Recurring

Create and manage recurring billing schedules through the central query handler.

Create Modes: recurring creation supports single-product mode (product_id) and multi-product mode (products).

Required (Create): customer_id, payment_id, status, surcharge, run_transaction, and exactly one mode input: product_id OR products.

GET /query/recurring Get All Recurring
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for recurring list results.
pageSizeintegerNoOptional page size for recurring list results.
filtersstringNoOptional filter expression.
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Recurring Example
GET /query/recurring?page=1&pageSize=25&sort=-uniqueID
GET /query/recurring/{id} Get Single Recurring
Sample Response (Get Single Recurring)
{
  "result": "success",
  "data": {
    "uniqueID": 12345,
    "status": 1,
    "interval": 3,
    "interval_number": 1,
    "run_until": 2,
    "run_limit": 12,
    "end_date": null,
    "run_next": "2026-11-19 00:00:00",
    "run_last": "2026-10-19 00:00:00",
    "run_count": 2,
    "run_total": 400.00,
    "amount": 203.50,
    "surcharge": 3.50,
    "currency": "USD",
    "customer_id": 6531,
    "payment_id": 3370,
    "idempotency_key": "recurring-single-20260919215931",
    "items": [
      {
        "product_id": 823,
        "product_name": "Monthly Service",
        "description": "Monthly recurring service fee",
        "qty": 1,
        "price": 200
      },
      {
        "product_id": null,
        "product_name": "Custom Support Add-on",
        "description": "Optional support fee",
        "qty": 1,
        "price": 0
      }
    ]
  }
}
POST /query/recurring Create Recurring
Parameters
Field Required Notes
customer_idYesCustomer for the schedule.
payment_idYesStored payment method ID that belongs to the provided customer.
statusYesRecurring status value. Allowed: 1=Active, 2=Completed, 4=Paused, 5=Terminated.
surchargeYesNumeric surcharge added to computed recurring amount.
run_transactionYesWhen true, executes Recurring::runRecurring() after save.
idempotency_keyConditionalClient-generated unique string, up to 128 characters. Required when run_transaction is true. Reuse the same key when retrying the same create request; if a recurring already exists for that merchant and key, the endpoint returns the existing recurring instead of creating another schedule.
product_idModeSingle-product mode. Must not be sent together with root products.
productsModeMulti-product mode. Array/object of items (normalized internally). Must not be sent with root product_id.
recurring_rule_productMulti ModeOptional schedule anchor. If provided, it must belong to merchant, be recurring-enabled, and be present in products.
intervalNoOptional override: 1=Day, 2=Week, 3=Month, 4=Year.
interval_numberNoOptional override; must be >= 1.
run_untilNoOptional override: 0=Until terminated, 1=Specific date, 2=Fixed count.
run_limitConditionalRequired when run_until is 2 (count).
end_dateConditionalRequired when run_until is 1. Must be a future date based on the merchant-local date; date/time value is accepted as provided.
start_dateNoDate/time value is accepted as provided. Defaults to current date/time when omitted.
run_lastNoDate/time value is accepted as provided.
run_nextNoMust be a future date based on the merchant-local date when provided; date/time value is accepted as provided. Defaults to merchant-local today + 1 day when omitted.
run_totalNoOptional decimal, defaults to 0.
run_countNoOptional whole number, defaults to 0.

Validation Notes (Create):

  • Date/time values are passed through by the endpoint and handled by the existing recurring model/database conversion.
  • run_next and date-based end_date must be future dates based on the merchant-local date; today's merchant-local date is not accepted.
  • idempotency_key is created by the client and should be unique per create request for the merchant. Reuse it only when retrying the same request after an unclear response.
  • All referenced products must belong to the same merchant as x_login.
  • In multi-product mode, each item may use product_id (or alias productID). For custom items without a product ID, name and price are required; qty defaults to 1.
  • Schedule resolution in multi-product mode:
    • If recurring_rule_product is provided, its recurring rule is used.
    • If omitted and exactly one recurring-enabled product exists in products, that product's recurring rule is used.
    • If omitted and multiple recurring-enabled products exist, provide recurring_rule_product or explicit schedule fields.
    • If omitted and no recurring-enabled products exist, explicit schedule fields are required.
  • Amount calculation:
    • Single-product mode: amount = product price + surcharge
    • Multi-product mode: amount = sum(qty * unit_price) + surcharge
Sample Body (Single-Product Mode)
{
  "x_login": "...",
  "x_tran_key": "...",
  "recurring": {
    "customer_id": 123,
    "payment_id": 456,
    "product_id": 111,
    "status": 1,
    "surcharge": 3.50,
    "run_transaction": true,
    "idempotency_key": "recurring-single-20260919215931",
    "run_next": "2026-10-19",
    "interval": 2,
    "interval_number": 1,
    "run_until": 2,
    "run_limit": 12
  }
}
Sample Body (Multi-Product Mode)
{
  "x_login": "...",
  "x_tran_key": "...",
  "recurring": {
    "customer_id": 123,
    "payment_id": 456,
    "recurring_rule_product": 111,
    "products": [
      {
        "product_id": 111
      },
      {
        "productID": 222,
        "qty": 2
      },
      {
        "name": "Custom Item",
        "description": "Manual line item",
        "qty": 1,
        "price": 12.50
      }
    ],
    "status": 1,
    "surcharge": 3.50,
    "run_transaction": false,
    "idempotency_key": "recurring-multi-20260919215931",
    "interval": 2,
    "interval_number": 1,
    "run_until": 1,
    "end_date": "2027-03-19"
  }
}
Sample Body (Multi-Product Without Recurring Product)
{
  "x_login": "...",
  "x_tran_key": "...",
  "recurring": {
    "customer_id": 123,
    "payment_id": 456,
    "products": [
      {
        "name": "Custom Item A",
        "qty": 1,
        "price": 15.00
      },
      {
        "name": "Custom Item B",
        "qty": 2,
        "price": 20.00
      }
    ],
    "status": 1,
    "surcharge": 3.50,
    "run_transaction": false,
    "interval": 3,
    "interval_number": 1,
    "run_until": 2,
    "run_limit": 6
  }
}
PUT /query/recurring/{id} Update Recurring
Parameters
Field Required Description
uniqueIDNoOptional when calling /query/recurring/{id}; otherwise required.
statusNo1=Active, 2=Completed, 4=Paused, 5=Terminated.
intervalNo1=Day, 2=Week, 3=Month, 4=Year.
interval_numberNoNumber of intervals between runs.
run_untilNo0=Until terminated, 1=Specific date, 2=Fixed count.
run_limitNoRequired when run_until is 2 (count).
end_dateNoRequired when run_until is 1; must be a future date based on the merchant-local date when provided.
run_nextNoNext run date/time; must be a future date based on the merchant-local date when provided.
surchargeNoSurcharge amount added to product price.
payment_idNoStored payment method ID.

Note: Recurring line items cannot be updated via the update endpoint.

Sample Update Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "_method": "PUT",
  "recurring": {
    "status": 1,
    "interval": 2,
    "interval_number": 1,
    "run_until": 2,
    "run_limit": 12,
    "run_next": "2026-10-26"
  }
}
DELETE /query/recurring/{id} Delete Recurring
Failed Payment Behavior

When a scheduled recurring payment run fails (card declined or gateway transport error), the schedule's billing date is not changed:

  • run_next, run_count, and run_total are not advanced. The schedule only advances after a successful payment.
  • The failed run is retried automatically on the next daily billing cycle, once per day, until the payment succeeds or the retry limit below is reached.
  • Retry limit: after 3 consecutive failed attempts for the same billing date, the schedule is automatically set to 4 (Paused) and stops retrying. A manual run via the recurring detail page also counts toward this limit.
  • A successful payment resets the consecutive-failure count back to 0.
  • To resume a schedule that was auto-paused, update its status back to 1 (Active) via the update endpoint (after fixing the underlying issue, for example updating payment_id to a valid stored payment method). Reactivating resets the failure count, so the schedule gets a fresh 3 attempts.
  • A recurring.payment_failed webhook event is emitted for each failed attempt. When a retry later succeeds, recurring.payment_succeeded is emitted and the schedule advances normally. When the retry limit is reached, a recurring.paused event is emitted in addition to that attempt's recurring.payment_failed event. See the Webhooks documentation for event details.

Invoice

Create and manage invoices.

GET /query/invoice Get All Invoices
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for invoice list results.
pageSizeintegerNoOptional page size for invoice list results.
filtersstringNoOptional filter expression.
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Invoices Example
GET /query/invoice?page=1&pageSize=25&sort=-uniqueID

Supports optional invoice filters:

Filter Description
customer_idLimit to a specific customer
statusInvoice status (e.g., Pending, Sent, Paid)
due_beforeInvoices due before date (YYYY-MM-DD)
due_afterInvoices due after date (YYYY-MM-DD)
GET /query/invoice/{id} Get Single Invoice

Returns a single invoice by unique ID. If not found, returns 404.

POST /query/invoice Create Invoice

Note: Provide customer_id to associate with an existing customer. If customer_id is omitted, include a customer object to create a new customer.

Item Fields
Field Type Required Description
titlestringYesLine item name.
descriptionstringNoLine item description.
quantitynumberNoDefaults to 1.
unit_pricenumberNoPrice per unit.
booking_idstringNoID of an appointment booking (from the Booking Module) to link this line item to. See the note below.
Create Invoice Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "invoice": {
    "invoice_number": "INV-2024-001",
    "due_date": "2024-12-31",
    "notes": "Thank you for your business",
    "discount": 0,
    "tax": 10.50,
    "shipping": 5.00,
    "amount": 635.50,
    "customer_id": 123,
    "items": [
      {
        "title": "Web Design",
        "description": "Homepage design",
        "quantity": 1,
        "unit_price": 500.00
      },
      {
        "title": "Hosting",
        "quantity": 12,
        "unit_price": 10.00
      },
      {
        "title": "Haircut Appointment",
        "quantity": 1,
        "unit_price": 30.00,
        "booking_id": "uuid"
      }
    ]
  }
}
PUT /query/invoice/{id} Update Invoice
Update Invoice Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "_method": "PUT",
  "invoice": {
    "uniqueID": 456,
    "notes": "Updated notes",
    "status": "Sent"
  }
}
DELETE /query/invoice/{id} Delete Invoice
Delete Example
DELETE /query/invoice/456

Products

Manage products and services.

GET /query/product Get All Products

Returns a list of products. If none exist, returns an empty list: { "products": [] } with status 200.

Optional query parameters for GET /query/product:

  • page: page number (1-based). When provided, results are paginated.
  • pageSize: records per page. Optional; defaults to 50.
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for product list results.
pageSizeintegerNoOptional page size for product list results.
filtersstringNoOptional filter expression.
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Products Example
GET /query/product?page=1&pageSize=25&sort=-uniqueID
GET /query/product/{id} Get Single Product
POST /query/product Create Product
Body Parameters
Field Type Required Description
namestringYesProduct name.
descriptionstringNoProduct description.
pricenumberNoProduct price.
isBookablebooleanNoMarks product as bookable.
durationintegerNoDuration in minutes (for bookable products).
is_recurringbooleanNoEnable recurring schedule; provide recurring_rule when true.
recurring_rule object Conditional Recurring schedule config; required when is_recurring is true.

Format: {"interval":"1","interval_number":"1","run_limit":"7"}.

interval: 1=Day, 2=Week, 3=Month, 4=Year.

interval_number: how many intervals between charges (e.g., 2 with interval=Week means every 2 weeks).

run_limit: number of times to run; omit or use 0 for unlimited.
Create Product Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "product": {
    "name": "Premium Service",
    "description": "One hour consultation",
    "price": 99.99,
    "isBookable": true,
    "duration": 60,
    "is_recurring": true,
    "recurring_rule": {
      "interval": "1",
      "interval_number": "1",
      "run_limit": "7"
    }
  }
}
PUT /query/product/{id} Update Product
Body Parameters
Field Type Required Description
uniqueIDintegerNoOptional when calling /query/product/{id}; otherwise required.
namestringNoProduct name.
descriptionstringNoProduct description.
pricenumberNoProduct price.
isBookablebooleanNoMarks product as bookable.
durationintegerNoDuration in minutes (for bookable products).
is_recurringbooleanNoEnable recurring schedule; provide recurring_rule when true.
recurring_rule object Conditional Recurring schedule config; required when is_recurring is true.

Format: {"interval":"1","interval_number":"1","run_limit":"7"}.

interval: 1=Day, 2=Week, 3=Month, 4=Year.

interval_number: how many intervals between charges (e.g., 2 with interval=Week means every 2 weeks).

run_limit: number of times to run; omit or use 0 for unlimited.

Notes:

- Omit fields you do not want to change.

- When setting is_recurring to false, the recurring schedule is cleared.

Update Product Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "_method": "PUT",
  "product": {
    "name": "Updated Service Name",
    "price": 149.99,
    "isBookable": true,
    "duration": 60,
    "is_recurring": false,
    "recurring_rule": {
      "interval": "1",
      "interval_number": "1",
      "run_limit": "7"
    }
  }
}

Coupons

Manage discount coupons.

GET /query/coupon Get All Coupons

Returns a list of coupons. If none exist, returns an empty list: { "coupons": [] } with status 200.

Optional query parameters for GET /query/coupon:

  • page: page number (1-based). When provided, results are paginated.
  • pageSize: records per page. Optional; defaults to 50.
List Query Parameters
Field Type Required Description
pageintegerNoOptional page number for coupon list results.
pageSizeintegerNoOptional page size for coupon list results.
filtersstringNoOptional filter expression.
sortstringNoOptional sort key (for example -uniqueID).
modifiersstringNoOptional comma-separated response field projection.
List Coupons Example
GET /query/coupon?page=1&pageSize=25&sort=-uniqueID
GET /query/coupon/{id} Get Single Coupon
GET /query/coupon?code={code} Get Coupon By Code
POST /query/coupon Create Coupon

Create accepts either a nested coupon object or a flat top-level payload. name is required and cannot be blank.

Body Parameters
Field Type Required Description
namestringYesCoupon name.
descriptionstringNoCoupon description.
codestringNoCoupon code customers enter.
percent_offnumberNoPercent discount (e.g., 10 for 10% off).
duration_typestringNoDiscount duration (e.g., once).
first_purchase_onlybooleanNoOnly apply to the first purchase.
max_redemptionsintegerNoMaximum number of redemptions allowed.
enabledbooleanNoEnable/disable coupon.
Create Coupon Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "coupon": {
    "name": "New Customer Discount",
    "description": "10% off first purchase",
    "code": "WELCOME10",
    "percent_off": 10,
    "duration_type": "once",
    "first_purchase_only": true,
    "max_redemptions": 100,
    "enabled": true
  }
}
Create Coupon Body (Flat Payload)
{
  "x_login": "...",
  "x_tran_key": "...",
  "name": "New Customer Discount",
  "code": "WELCOME10",
  "percent_off": 10,
  "duration_type": "once",
  "first_purchase_only": true,
  "max_redemptions": 100,
  "enabled": true
}
PUT /query/coupon/{id} Update Coupon

Update accepts either a nested coupon object or a flat top-level payload. uniqueID is optional when calling /query/coupon/{id}, and required for non-path updates.

Body Parameters
Field Type Required Description
uniqueIDintegerNoOptional when calling /query/coupon/{id}; otherwise required.
namestringNoCoupon name.
descriptionstringNoCoupon description.
codestringNoCoupon code customers enter.
percent_offnumberNoPercent discount.
duration_typestringNoDiscount duration.
first_purchase_onlybooleanNoOnly apply to the first purchase.
max_redemptionsintegerNoMaximum number of redemptions allowed.
enabledbooleanNoEnable/disable coupon.
Update Coupon Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "_method": "PUT",
  "coupon": {
    "name": "Updated Coupon Name",
    "percent_off": 15,
    "enabled": true
  }
}
DELETE /query/coupon/{id} Delete Coupon
Delete Coupon Example
DELETE /query/coupon/123

Booking Module

Appointment Scheduling via the Payment Gateway


The Booking Module lets merchants manage appointment-based services, staff, schedules, and customer bookings — all through the payment gateway's standard /query/booking endpoint.

Base URL

All booking calls go through: /query/booking/{path}

Only the exact path-and-verb combinations documented below are routes. Anything else — an unrecognised path segment, an extra trailing segment, or an unsupported verb on a documented path — returns 404 with "Route not found". The one exception is the manage token flows and the two payment endpoints, which answer 405 for a wrong verb on an otherwise valid path.

Request & Response Format

All responses follow the ExpiEndpoint envelope — the payload is returned under a named resource key alongside "result":

{ "result": "success", "booking": { ... } }
{ "result": "success", "bookings": [ ... ] }
{ "result": "success", "service": { ... } }
{ "result": "success", "staff": [ ... ] }

Request bodies must be wrapped under the resource name. Flat JSON (fields at the top level) is also accepted:

// wrapped (documented format)
{ "booking": { "scheduledAt": "2027-06-01T09:00Z", "timeZone": "UTC", ... } }

// flat (also accepted)
{ "scheduledAt": "2027-06-01T09:00Z", "timeZone": "UTC", ... }
Authentication

Every endpoint except the manage token flows requires the caller to authenticate. The gateway resolves identity in order:

  1. Session — active browser/cookie session for the gateway user.
  2. Bearer token — a gateway-issued JWT passed as Authorization: Bearer <token> with a sub or user_id claim.
  3. Basic AuthAuthorization: Basic base64(merchanttext:secret), the merchant's existing API credential (the same one used for x_login/x_tran_key elsewhere in the gateway).

The resolved user's account type determines the booking permission forwarded to the backend:

Only merchant accounts can currently log in and call this API — the payment gateway does not yet have a staff login flow.

Basic Auth alone (no session/Bearer user) also resolves to merchant permission by default. A handful of flows scope down to public permission when they explicitly opt in — the anonymous booking-create flow, and top-level reads of staff, services, and merchant settings — see the next section.

Public-Scoped Endpoints (still require Basic Auth)

These flows are intended for the unauthenticated customer-facing widget. They still require the merchant's Basic Auth credential — there is no bare merchant_id fallback — but they run with a narrower public permission instead of full merchant permission:

  • GET /query/booking/slots — available time slots
  • POST /query/booking with is_public_booking: true in the body — create a booking (public/customer-facing). Omitting the flag (e.g. internal/migration callers using the same merchant credential) resolves to full merchant permission instead.
  • GET /query/booking/staff, GET /query/booking/staff/{staffId}, GET /query/booking/services, and GET /query/booking/merchant with ?is_public=true — top-level reads only (no sub-resource path segment). Omitting the flag resolves to full merchant permission instead. Each endpoint's response is narrowed for public callers — see that endpoint's section below for the exact fields returned.
  • GET /query/booking/services/{serviceId}/options and POST /query/booking/services/{serviceId}/price-preview with ?is_public=true — the two option endpoints an anonymous checkout needs in order to render the option form and show a running total. These are the only service sub-resources a public caller can reach; PUT .../options and every other service sub-resource stay merchant-scoped.
  • POST /query/booking/holds and DELETE /query/booking/holds/{holdId} with is_public_booking: true — the customer-facing checkout hold flow. Omitting the flag keeps the caller's own identity, which is what a merchant-initiated hold wants.

The /query/booking/manage/{token}/... flows remain fully unauthenticated — they are gated solely by the signed manage token in the URL.


Automated Email Notifications

Creating, updating, or cancelling a booking through any endpoint below — merchant/staff calls, the public booking-create flow, or the manage-token flows — automatically queues transactional emails through the gateway's own email system. No separate call is needed to trigger them. Up to three recipients are considered per event:

  • Customer — sent only when the booking has a non-empty customerEmail snapshot (bookings taken without one, e.g. phone-only when emailRequired is off, are never emailed). Always sent regardless of merchant/staff preferences.
  • Merchant — sent to the merchant's configured admin recipients, gated by that merchant's notifyOnBookingCreated / notifyOnBookingUpdated / notifyOnBookingCancelled setting (see Upsert Merchant Settings below). Defaults to true when unset.
  • Staff — sent to the assigned staff member's email, gated by that staff member's own notifyOnBooking* flags (see Staff below). Only a merchant can set these for a staff member — staff cannot set their own.

These preference flags are stored on the booking backend but enforced entirely by the gateway — the booking backend itself does not send or suppress any notification. Customer emails that include a manage link point to the gateway's own hosted self-service page at /managebooking?token={manageToken}, which lets a customer view, reschedule, or cancel their booking with no gateway login — it consumes the same manage endpoints documented below.


Invoice Integration

A merchant can invoice a booking instead of charging a card, using the gateway's own Invoice API — set booking_id on an invoice line item to link it to a booking here. This keeps the booking's payment state in sync automatically; no separate call against this API is needed or possible for this sync (it isn't exposed as its own endpoint — the gateway calls the booking backend directly, server-side, whenever a linked invoice is created, cancelled, or paid):

  • Creating an invoice with a linked item (or editing one to add the link) sets the booking's paymentMethod to invoice. A booking that is already paid, or already linked to a different outstanding invoice, cannot be linked to another one.
  • Cancelling that invoice before it's paid reverts the booking's paymentMethod to what it was before, freeing it to be invoiced again.
  • Paying that invoice confirms payment on the booking the same way PATCH /{bookingId}/confirm-payment does (see below), reporting paymentMethod: "invoice".

Use hasInvoice=false on List Bookings below to find bookings that are eligible to be invoiced (see that endpoint's query parameters).


Availability Slots

GET /query/booking/slots Get Available Slots

Returns available start times for a service on a given calendar date. Requires the merchant's Basic Auth credential (runs with public permission); there is no bare merchant_id fallback.

Query Parameters
FieldTypeRequiredDescription
serviceIdUUIDYesService to evaluate.
staffIdUUIDConditionalRequired when the service uses providerMode = atCreation; must be omitted for later.
datestring YYYY-MM-DDYesLocal calendar date to query.
displayTzstringNoOptional. Return slot times converted into this timezone instead of the merchant's own. Defaults to the merchant's timezone when omitted.
quantityintegerNoNumber of seats. Defaults to 1.
optionValueIdsstringNoComma-separated option value ids (a JSON array on POST /query/booking/holds). Duration effects only. Pass whatever the customer has already selected: without it, someone who picks a duration-lengthening option is shown slots their booking will not fit and gets a 409 at creation.
Get Slots Example
GET /query/booking/slots?serviceId=uuid&staffId=uuid&date=2027-06-01&displayTz=America%2FLos_Angeles
Authorization: Basic base64(merchanttext:secret)
Response
{
  "result": "success",
  "slots": [
    { "time": "09:00" },
    { "time": "10:00" },
    { "time": "14:00" }
  ]
}

Bookings

POST /query/booking Create Booking

Creates a booking. Requires the merchant's Basic Auth credential (or an authenticated merchant session). Pass is_public_booking: true in the body for the customer-facing widget flow — this scopes the request to public permission instead of full merchant permission. Omit it for internal/migration callers that need full merchant permission. Set paymentMethod to online when the customer will pay via the payment gateway; the booking is created in a pending state and confirmed later via confirm-payment.

Body Parameters
FieldTypeRequiredDescription
is_public_bookingbooleanNoSet true for customer-widget bookings to scope to public permission. Defaults to false (full merchant permission).
serviceIdUUIDYesService being booked.
staffIdUUIDConditionalRequired for atCreation services; must be omitted for later.
scheduledAtISO 8601 datetimeYesBooking start time (minute precision).
timeZonestringYesTimezone identifier.
paymentMethodinPerson | onlineYesPayment method chosen by the customer.
customerExternalIdintegerYesGateway customer/user ID.
customerFnamestringYesCustomer first name (1–25 chars).
customerLnamestringYesCustomer last name (1–25 chars).
customerEmailstringConditionalRequired when emailRequired is set to true on the merchant.
customerPhonestringYesE.164 phone if provided.
taxRatenumberNoTax percentage (non-negative).
surchargeTypeflat | percentageConditionalRequired together with surchargeAmount.
surchargeAmountnumberConditionalRequired together with surchargeType.
couponCodestringNoA coupon code belonging to this merchant. There is no separate discountAmount input — if present, this endpoint validates the coupon itself (must exist, be enabled, under its redemption limit, and percent-off) and derives the discount from the coupon's percentage applied to the real subtotal; the caller cannot supply or influence the dollar amount directly. Omit entirely for no discount.
quantityintegerNoSeats/units. Defaults to 1. For a service with a definesUnits option group this is derived from the selected classes — sending a value that contradicts them is a 400.
optionSelectionsarrayNoUp to 50 of { valueId, quantity? }. Ids and counts only — the server derives every label and amount, so a caller can never assert what an option costs. See Service Options. When present, a couponCode discount is computed against the option-adjusted subtotal rather than the bare service price.
holdIdUUIDConditionalConverts a checkout hold into the booking. Required for a public caller when the service has holdsRequired. Must match the hold's serviceId, scheduledAt, staffId and quantity exactly — any mismatch is a 409. See Checkout Holds.
notesstringNoInternal notes.

New response fields when options are in play: snapshotCurrency, snapshotUnitsFromOptions, and an optionSelections[] array carrying an immutable snapshot of each selection (group and value key, label, effect definition, resolved amount) that survives the merchant later editing or deleting the option configuration.

Errors added: 400 for any invalid option selection, and 400 "holdId is required for this service" when holdsRequired is true and a public caller omits it. A 409 means the slot is gone — the hold expired, was already consumed, or another caller won the race — and should be presented to the customer as "this slot is no longer available", not as a generic failure.

Create Booking Body
{
  "is_public_booking": true,
  "booking": {
    "serviceId":          "uuid",
    "staffId":            "uuid",
    "scheduledAt":        "2027-06-01T09:00Z",
    "timeZone":           "UTC",
    "paymentMethod":      "online",
    "customerExternalId": 5001,
    "customerFname":      "Jane",
    "customerLname":      "Doe",
    "customerEmail":      "jane@example.com",
    "customerPhone":      "+15551234567",
    "taxRate":            8,
    "couponCode":         "SUMMER10",
    "quantity":           1,
    "holdId":             "uuid",
    "optionSelections": [
      { "valueId": "uuid" }
    ]
  }
}
Response (201)
{
  "result": "success",
  "booking": {
    "id":                      "uuid",
    "serviceId":               "uuid",
    "staffId":                 "uuid",
    "scheduledAt":             "2027-06-01T09:00:00.000Z",
    "endsAt":                  "2027-06-01T10:00:00.000Z",
    "paymentMethod":           "online",
    "paymentStatus":           "pending",
    "status":                  "pending",
    "snapshotPrice":           "30.00",
    "snapshotDurationMinutes": 60,
    "snapshotDiscountAmount":  "5.00",
    "snapshotCouponCode":      "SUMMER10",
    "totalPrice":              "27.00",
    "manageToken":             "jwt"
  }
}

GET /query/booking List Bookings

Returns bookings for the authenticated merchant.

Response
{
  "result": "success",
  "bookings": [
    {
      "id":            "uuid",
      "serviceId":     "uuid",
      "staffId":       "uuid",
      "scheduledAt":   "2027-06-01T09:00:00.000Z",
      "endsAt":        "2027-06-01T10:00:00.000Z",
      "status":        "pending",
      "paymentMethod": "online",
      "paymentStatus": "pending",
      "invoiceId":     null,
      "totalPrice":    "32.40"
    }
  ]
}
Query Parameters
FieldTypeRequiredDescription
datestring YYYY-MM-DDNoFilter by date; must be paired with tz.
tzstringNoTimezone; must be paired with date.
statusenumNopending, confirmed, cancelled, completed, noShow
staffIdUUIDNoFilter by assigned staff member.
serviceIdUUIDNoFilter by service.
customerExternalIdintegerNoFilter by customer.
paymentMethodenumNoinPerson, online, or invoice
paymentStatusenumNopending, paid, failed, refunded
hasInvoice"true" | "false"NoFilter by whether a booking currently has an invoice attached (see Invoice Integration above). Combine with paymentStatus=pending&hasInvoice=false to find bookings eligible to be invoiced.

GET /query/booking/{bookingId} Get Booking

Returns a single booking.

Response
{
  "result": "success",
  "booking": {
    "id":                      "uuid",
    "serviceId":               "uuid",
    "staffId":                 "uuid",
    "scheduledAt":             "2027-06-01T09:00:00.000Z",
    "endsAt":                  "2027-06-01T10:00:00.000Z",
    "status":                  "pending",
    "paymentMethod":           "online",
    "paymentStatus":           "pending",
    "invoiceId":               null,
    "snapshotName":            "Haircut",
    "snapshotPrice":           "30.00",
    "snapshotDurationMinutes": 60,
    "snapshotDiscountAmount":  "0.00",
    "snapshotCouponCode":      null,
    "totalPrice":              "32.40"
  }
}

PATCH /query/booking/{bookingId} Update Booking

Updates a booking. Merchant only.

Body Parameters (all optional)
FieldTypeDescription
statusenumconfirmed, completed, noShow, cancelled
paymentStatusenumOnly paid is accepted; only for inPerson bookings.
notesstringFree-text notes.
staffIdUUIDMust reference an active staff member linked to the service, else 404. See Override Behavior below for what an explicit value does on a later-mode service.
scheduledAtISO 8601 datetimeMust be paired with timeZone.
timeZonestringMust be paired with scheduledAt.
quantityintegerPositive integer.
Update Booking Body
{
  "booking": {
    "status":      "confirmed",
    "notes":       "Arrived 5 min early",
    "scheduledAt": "2027-06-01T10:00Z",
    "timeZone":    "America/New_York",
    "staffId":     "uuid",
    "quantity":    2
  }
}
Response
{
  "result": "success",
  "booking": {
    "id":          "uuid",
    "status":      "confirmed",
    "notes":       "Arrived 5 min early",
    "scheduledAt": "2027-06-01T09:00:00.000Z"
  }
}
Responses
StatusCause
200Booking updated.
400paymentStatus: 'paid' requested for a booking whose paymentMethod isn't inPerson ("Online payments must be confirmed through the payment gateway."), or no recognized field changed at all ("No valid fields provided.").
401Missing or invalid token.
403The caller's permission isn't merchant, or the authenticated user doesn't belong to this merchant account.
404Merchant, staff record, or booking not found; or an explicit staffId doesn't reference an active staff member linked to the booking's service.
409Booking is already cancelled; paymentStatus: 'paid' requested but payment was already processed; or the resulting time slot isn't available (see Override Behavior — this single message covers several distinct causes).
422Body failed schema validation (see the validation-errors note near the bottom of this page).
Override Behavior
  • For atCreation services, when this endpoint changes scheduledAt/quantity and triggers a slot-availability recheck, the assigned staff member's durationMinutesOverride is used for that check (and to recompute endsAt) instead of the service's base durationMinutes — but only when the merchant has allowStaffPricingOverrides = true (a boolean flag on the merchant's own account, not a separate settings resource) and an override is actually set for that staff/service pairing. Otherwise the service's base duration is used.
  • For providerMode = later bookings: if the request changes scheduledAt or quantity and does not include a staffId key at all, fill-first assignment re-runs against the new slot/quantity and overwrites staffId. The outcome is one of three things — a specific staff member is assigned when one clearly has the most remaining capacity; staffId is set back to null when there's remaining capacity but multiple staff are tied for it (no clear winner, left for a merchant to assign manually); or, if no staff member has any remaining capacity at all, the request is rejected with 409 rather than silently clearing the assignment.
  • For providerMode = later bookings: if the request includes an explicit, non-null staffId — whether or not scheduledAt/quantity also changed — that specific staff member's own schedule, breaks, exceptions, existing bookings, and capacity are checked at the resulting time (the new scheduledAt if provided, otherwise the booking's current one). This is a completely different, narrower check than the aggregate "is anyone free" check used elsewhere — being merely linked to the service isn't enough, they have to actually be free themselves. 409 if they aren't. There is no short-circuit for resubmitting the staff member who's already assigned: the same specific-availability check runs every time staffId is present, even when the value is unchanged.
  • All slot-unavailability 409s from this endpoint — the aggregate reschedule-target check, fill-first finding no staff with capacity, and the per-staff pin check above — return the exact same message string. There is no way to distinguish which of the three occurred from the response alone.
  • Reassigning staff (when the new staff member has a priceOverride) or changing quantity recomputes totalPrice from the current snapshotPrice, quantity, tax rate, and surcharge — and, if the booking has a stored snapshotDiscountAmount from a coupon applied at creation, that discount is reapplied against the new subtotal too (before surcharge/tax, same as at creation). This recalculation only happens while the booking is still pending. Once paymentStatus is paid, totalPrice — discount included — is frozen permanently; no later edit through this endpoint changes it again.

PATCH /query/booking/{bookingId}/confirm-payment Confirm Payment

Marks an online booking as paid after the payment gateway confirms the transaction. Merchant only. Idempotent when called with the same transaction ID.

Body Parameters
FieldTypeRequiredDescription
transactionExternalIdstringYesGateway transaction (or invoice, when paymentMethod is invoice) identifier.
paymentMethod"invoice"NoSet when the payment being confirmed came from a paid invoice rather than a card charge (see Invoice Integration above). Sent automatically by the gateway when a linked invoice is paid — omit for the normal card-checkout flow.
invoiceIdstringConditionalRequired together with paymentMethod: "invoice". Sent on every call, not only the first, so the invoice/booking link is re-established even if an earlier attach failed.
Confirm Payment Body
{
  "booking": {
    "transactionExternalId": "txn_abc123"
  }
}
Response
{
  "result": "success",
  "booking": {
    "id":                    "uuid",
    "status":                "confirmed",
    "paymentMethod":         "online",
    "paymentStatus":         "paid",
    "transactionExternalId": "txn_abc123"
  }
}

PATCH /query/booking/{bookingId}/cancel-payment Cancel Payment

Marks an online payment as failed and cancels the booking. Merchant only. Idempotent when the booking is already in a failed state.

Response
{
  "result": "success",
  "booking": {
    "id":            "uuid",
    "status":        "cancelled",
    "paymentStatus": "failed"
  }
}

Manage Link (Customer Self-Service)

These endpoints are authenticated solely by the signed manage token returned at booking creation — no gateway session or credentials are needed.

GET /query/booking/manage/{token} Get Booking via Manage Token

Returns the booking addressed by the manage token, together with the complete nested merchant and service records — everything a manage/cancel/reschedule UI needs in one call, with no separate merchant or service lookup required. Also flags whether self-service cancel and reschedule are still permitted.

Response
{
  "result": "success",
  "booking": {
    "id":                      "uuid",
    "serviceId":               "uuid",
    "staffId":                 "uuid",
    "customerFname":           "Jane",
    "customerLname":           "Doe",
    "customerEmail":           "jane@example.com",
    "customerPhone":           "+15551234567",
    "scheduledAt":             "2027-06-01T09:00:00.000Z",
    "endsAt":                  "2027-06-01T10:00:00.000Z",
    "timeZone":                "America/Los_Angeles",
    "quantity":                1,
    "status":                  "pending",
    "paymentMethod":           "online",
    "paymentStatus":           "pending",
    "snapshotName":            "Haircut",
    "snapshotPrice":           "30.00",
    "snapshotDurationMinutes": 60,
    "snapshotDiscountAmount":  "0.00",
    "snapshotCouponCode":      null,
    "totalPrice":              "30.00",
    "notes":                   "",
    "merchant": {
      "id":         "uuid",
      "externalId": 1042,
      "...":        "the full merchant settings record — see Get Merchant Settings below"
    },
    "service": {
      "id":              "uuid",
      "name":            "Haircut",
      "price":           "30.00",
      "durationMinutes": 60,
      "...":             "the full service record — see Services below"
    },
    "canCancel":     true,
    "canReschedule": true
  }
}

POST /query/booking/manage/{token}/cancel Cancel via Manage Token

Cancels the booking. Returns 409 if the booking is already cancelled, in a non-cancellable state, or outside the cancellation window configured by the merchant.

Response
{
  "result": "success",
  "booking": {
    "id":     "uuid",
    "status": "cancelled"
  }
}

PATCH /query/booking/manage/{token}/reschedule Reschedule via Manage Token

Reschedules the booking to a new slot. A new manageToken is returned — the old one is invalidated.

Body Parameters
FieldTypeRequiredDescription
scheduledAtISO 8601 datetimeYesNew start time.
timeZonestringYesTimezone identifier.
Reschedule Body
{
  "booking": {
    "scheduledAt": "2027-06-02T10:00Z",
    "timeZone":    "UTC"
  }
}
Response
{
  "result": "success",
  "booking": {
    "id":          "uuid",
    "scheduledAt": "2027-06-02T10:00:00.000Z",
    "status":      "pending",
    "manageToken": "jwt"
  }
}

Merchant Settings

Merchant-only endpoints. Manage booking settings, weekly opening hours, and one-off date exceptions.

GET /query/booking/merchant Get Merchant Settings

Returns the merchant's booking configuration together with the full weekly schedule and all date exceptions. Accessible by merchant tokens. Also accessible with public permission via ?is_public=true (see Authentication above) — public callers receive a narrowed response with just serviceLabel, slotFormat, emailRequired, timeBeforeBooking, acceptsInPersonPayment, and acceptsOnlinePayment; no schedule, exceptions, or identifying fields.


PUT /query/booking/merchant Upsert Merchant Settings

Registers or updates the merchant's booking record. Safe to call on every login — creates on first call, updates on subsequent calls. All fields are optional.

Body Parameters (all optional)
FieldTypeDefaultDescription
acceptsInPersonPaymentbooleanfalseAccept in-person payments (e.g. cash).
acceptsOnlinePaymentbooleanfalseAccept online payments (e.g. card).
bookingServicebooleanfalseEnable service booking module.
bookingEventbooleanfalseEnable event booking module.
serviceLabelstring""Custom UI label for services.
eventLabelstring""Custom UI label for events.
staffLabelstring""Custom UI label for staff.
timeStepMinutesinteger15Slot granularity in minutes.
timeBeforeBookinginteger1Minimum days in advance a booking can be made.
leadTimeMinutesinteger60Minimum minutes of notice before a booking.
cancelWindowHoursinteger24Hours before scheduledAt when self-cancel/reschedule stop being allowed.
allowStaffPricingOverridesbooleanfalseEnable per-staff price and duration overrides on services.
slotFormattwentyFourHour | twelveHour"twelveHour"Frontend-only display preference for how time slots are rendered (e.g. `14:00` vs `2:00 PM`). Not used by the backend for any calculation
emailRequiredbooleanfalseWhen true, POST /api/v1/bookings requires both customerEmail and customerPhone. When false, only customerPhone is required and customerEmail is optional
notifyOnBookingCreatedbooleantrueWhether the merchant is emailed when a new booking is created. See Automated Email Notifications above — enforced by the gateway, not the booking backend.
notifyOnBookingUpdatedbooleantrueWhether the merchant is emailed when a booking is updated (including reschedules).
notifyOnBookingCancelledbooleantrueWhether the merchant is emailed when a booking is cancelled.
Upsert Merchant Body
{
  "merchant": {
    "acceptsInPersonPayment":     true,
    "acceptsOnlinePayment":       true,
    "bookingService":             true,
    "serviceLabel":               "Appointment",
    "timeStepMinutes":            30,
    "leadTimeMinutes":            120,
    "cancelWindowHours":          24,
    "allowStaffPricingOverrides": false,
    "notifyOnBookingCreated":     true,
    "notifyOnBookingUpdated":     true,
    "notifyOnBookingCancelled":   true
  }
}

GET /query/booking/merchant/schedule Get Merchant Schedule

Returns the merchant's weekly opening hours as an array of schedule entries. Returns an empty array if no schedule has been set yet. Merchant only.

Response
{
  "result": "success",
  "schedule": [
    { "id": "uuid", "dayOfWeek": 0, "isOpen": false, "openTime": null, "closeTime": null },
    { "id": "uuid", "dayOfWeek": 1, "isOpen": true,  "openTime": "09:00", "closeTime": "18:00" }
  ]
}

PUT /query/booking/merchant/schedule Set Merchant Schedule

Replaces the merchant's full weekly opening hours. Must send all 7 days (one entry per day of week, 0 = Sunday through 6 = Saturday). Each day's record is upserted.

Body — Array of 7 day entries
FieldTypeRequiredDescription
dayOfWeekinteger 0–6Yes0 = Sunday, 6 = Saturday.
isOpenbooleanYesWhether the merchant is open that day.
openTimestring HH:MMConditionalRequired when isOpen is true.
closeTimestring HH:MMConditionalRequired when isOpen is true.
Set Schedule Body
{
  "schedule": [
    { "dayOfWeek": 0, "isOpen": false },
    { "dayOfWeek": 1, "isOpen": true,  "openTime": "09:00", "closeTime": "18:00" },
    { "dayOfWeek": 2, "isOpen": true,  "openTime": "09:00", "closeTime": "18:00" },
    { "dayOfWeek": 3, "isOpen": true,  "openTime": "09:00", "closeTime": "18:00" },
    { "dayOfWeek": 4, "isOpen": true,  "openTime": "09:00", "closeTime": "18:00" },
    { "dayOfWeek": 5, "isOpen": true,  "openTime": "09:00", "closeTime": "14:00" },
    { "dayOfWeek": 6, "isOpen": false }
  ]
}

GET /query/booking/merchant/exceptions List Schedule Exceptions

Returns all date exceptions for the merchant. Returns an empty array if none have been created. Merchant only.

Response
{
  "result": "success",
  "exceptions": [
    { "id": "uuid", "date": "2026-12-25", "isOpen": false, "openTime": null, "closeTime": null }
  ]
}

GET /query/booking/merchant/exceptions/{exceptionId} Get Schedule Exception

Returns a single date exception by ID. Merchant only.


PUT /query/booking/merchant/exceptions Upsert Schedule Exception

Adds or updates a one-off date override (e.g. a holiday or special hours). Idempotent — calling again with the same date updates the existing record.

Body Parameters
FieldTypeRequiredDescription
datestring YYYY-MM-DDYesThe date to override.
isOpenbooleanYesfalse = closed all day. true = open with special hours.
openTimestring HH:MMConditionalRequired when isOpen is true.
closeTimestring HH:MMConditionalRequired when isOpen is true.

DELETE /query/booking/merchant/exceptions/{exceptionId} Delete Schedule Exception

Removes a date exception. Only the owning merchant can delete it.


Services

Services are the bookable items in the catalog. Merchant only.

POST /query/booking/services Create Service

Creates a service record. Merchant only.

Body Parameters
FieldTypeRequiredDescription
namestringYesService/product name.
pricenumberYesService price (positive).
descriptionstringNoService description.
durationMinutesintegerYesHow long the service takes (positive integer).
capacityintegerNoMax simultaneous bookings per slot. Omit for unlimited.
capacityModeperStaff | perServiceNoDefaults to perStaff. Whether capacity is enforced per staff member or shared across all staff for the slot. Only meaningful when capacity is set.
mondaysundaybooleanNoDays of the week when the service is offered. All default to false.
schedulingModeblocks | startTimeNoDefaults to blocks. Use startTime for a fixed daily start time.
fixedStartTimestring HH:MMConditionalRequired when schedulingMode is startTime.
providerModeatCreation | laterNoDefaults to atCreation. later defers staff assignment after booking.
Create Service Body
{
  "service": {
    "name":            "Swedish Massage",
    "price":           75.00,
    "description":     "Relaxing 60-minute massage",
    "durationMinutes": 60,
    "capacity":        5,
    "capacityMode":    "perStaff",
    "monday":          true,
    "tuesday":         true,
    "wednesday":       true,
    "thursday":        true,
    "friday":          true,
    "schedulingMode":  "blocks",
    "providerMode":    "atCreation"
  }
}

GET /query/booking/services List Services

Returns all services for the merchant. Also accessible with public permission via ?is_public=true — public callers receive all merchant services, with each service's staff array reduced to { id, firstName, lastName, color } plus effectivePrice/effectiveDurationMinutes (the override value when one applies, otherwise the service's base price/durationMinutes) in place of the raw priceOverride/durationMinutesOverride fields.


GET /query/booking/services/{serviceId} Get Service

Returns a single service with its assigned staff. Merchant only.


PATCH /query/booking/services/{serviceId} Update Service

Updates a service. Merchant only.

Body Parameters (all optional)
FieldTypeDescription
namestringService name.
pricenumberService price (positive).
descriptionstringService description.
durationMinutesintegerDuration in minutes (positive integer).
capacityintegerPositive integer. Send empty string to revert to unlimited.
capacityModeperStaff | perServiceWhether capacity is enforced per staff member or shared across all staff for the slot. Only meaningful when capacity is set.
mondaysundaybooleanDays the service is offered.
schedulingModeblocks | startTime
fixedStartTimestring HH:MMRequired when switching to startTime mode. Cleared automatically when switching back to blocks.
providerModeatCreation | later
Update Service Body
{
  "service": {
    "name":             "Deep Tissue Massage",
    "price":            75.00,
    "description":      "60-minute deep tissue session.",
    "durationMinutes":  60,
    "capacity":         1,
    "capacityMode":     "perService",
    "monday":           true,
    "tuesday":          true,
    "wednesday":        true,
    "thursday":         true,
    "friday":           true,
    "saturday":         false,
    "sunday":           false,
    "schedulingMode":   "blocks",
    "providerMode":     "atCreation"
  }
}

DELETE /query/booking/services/{serviceId} Delete Service

Permanently deletes a service. Merchant only.


PUT /query/booking/services/{serviceId}/staff Set Service Staff Assignments

Replaces the full list of staff assigned to a service. Sending an empty array removes all staff. Merchant only.

Body Parameters
FieldTypeRequiredDescription
staffIdsUUID[]YesArray of staff UUIDs to assign. All must belong to the merchant.
Set Service Staff Body
{
  "staffService": {
    "staffIds": ["uuid-1", "uuid-2"]
  }
}
Response
{
  "result": "success",
  "staffServices": [
    { "staffId": "uuid-1", "serviceId": "uuid", "priceOverride": null, "durationMinutesOverride": null },
    { "staffId": "uuid-2", "serviceId": "uuid", "priceOverride": null, "durationMinutesOverride": null }
  ]
}

GET /query/booking/services/{serviceId}/staff/{staffId} Get Staff Override for Service

Returns the assignment row for one staff member on one service, including current price and duration overrides. Merchant only. For the reverse lookup — all services a given staff member is assigned to — see GET /query/booking/staff/{staffId}/services in the Staff section.

Response
{
  "result": "success",
  "staffService": {
    "staffId":                 "uuid",
    "serviceId":               "uuid",
    "priceOverride":           "49.99",
    "durationMinutesOverride": 30
  }
}

PATCH /query/booking/services/{serviceId}/staff/{staffId} Update Staff Override for Service

Sets or clears per-staff price and duration overrides for a service assignment. Merchant only. Requires merchant allowStaffPricingOverrides = true. Send null to clear an override.

Body Parameters (all optional)
FieldTypeDescription
priceOverridenumber | nullOverride price for this staff member. null clears it.
durationMinutesOverrideinteger | nullOverride duration in minutes. null clears it.
Update Staff Override Body
{
  "staffService": {
    "priceOverride":            49.99,
    "durationMinutesOverride":  45
  }
}

Service Options

Options let a service carry customer-selectable add-ons that change its price, its duration, or both — Long hair +$20, Add gift wrap +$3, or adult/child fare classes that also determine how many units the booking covers. Options are grouped; a group controls how many of its values a customer may pick.

Only ids and counts are ever sent to these endpoints. Every label and amount is derived on the server from the merchant's own configuration, so a caller can never assert what an option costs.

GET /query/booking/services/{serviceId}/options List Service Options

Returns the service's option groups, each with its values nested inside, ordered by sortOrder. Also accessible with public permission via ?is_public=true — public callers receive active groups and active values only, because a customer must not be offered something they cannot select. Merchant and staff callers see inactive entries too.

Response
{
  "result": "success",
  "options": [
    {
      "id":            "uuid",
      "serviceId":     "uuid",
      "key":           "hair_length",
      "label":         "Hair length",
      "description":   null,
      "selectionMode": "single",
      "minSelections": 0,
      "maxSelections": 1,
      "definesUnits":  false,
      "sortOrder":     0,
      "isActive":      true,
      "values": [
        {
          "id":                 "uuid",
          "groupId":            "uuid",
          "key":                "long",
          "label":              "Long",
          "description":        null,
          "isDefault":          false,
          "isActive":           true,
          "sortOrder":          0,
          "priceEffectType":    "fixed",
          "priceEffectValue":   "20",
          "priceEffectScope":   "perUnit",
          "durationEffectType": "delta",
          "durationMinutes":    15
        }
      ]
    }
  ]
}
Group Fields
FieldTypeDescription
selectionModesingle | multipleHow many values a customer may pick. A single group must have maxSelections: 1.
minSelectionsintegerMinimum a customer must pick. This is the only option rule that fires on absence, so it is the only one that can reject a caller that has not changed — see the deployment note below.
maxSelectionsintegerMaximum a customer may pick.
definesUnitsbooleanWhen true, this group supplies the booking's quantity — adult/child fare classes, for example. At most one active definesUnits group per service, and it requires selectionMode: multiple, minSelections >= 1, all-perUnit price scopes, no duration effects and no defaults.
descriptionstringOptional, and optional means absent, not null. The schema is z.string().optional(), so an explicit null is rejected with 422 "Invalid input: expected string, received null" — omit the key instead. Note that GET returns "description": null when unset, so a GET response cannot be fed straight back into the PUT. Omitting the key still clears a previously-set description, because the PUT recreates every group. Same rule for a value's description.
Value Fields
FieldTypeDescription
priceEffectTypefixed | percentage | nullHow priceEffectValue is applied. A percentage effect must use perUnit scope, and is measured against the service price — never a running subtotal. See How a percentage is measured below.
priceEffectValuenumber in, string outThe amount or percentage. Send it as a JSON number (20) — a quoted string is rejected with "Invalid input: expected number, received string". Responses serialise it back as a string ("20").
priceEffectScopeperUnit | perLeg | nullperUnit scales with quantity; perLeg is charged once per booking.
durationEffectTypedelta | override | nulldelta adds durationMinutes to the service duration; override replaces it.
durationMinutesinteger | nullThe duration effect's value.
isDefaultbooleanPre-selected in the widget. At most one active default per single-select group.

A price effect and a duration effect must each be wholly present or wholly absent: sending priceEffectType without priceEffectValue and priceEffectScope is rejected. For a value with no effects, omit the effect keys rather than sending them as null (as the short value above does).

Validation failures come back as 422 with a field path per problem, indexing into the arrays — e.g. groups[0].values[1].priceEffectValue.

How a percentage is measured

A percentage effect is applied to the service price × quantity, fixed before any option is applied. It is not applied to a running subtotal, so percentages never compound with each other and are never affected by the fixed effects selected alongside them. Selection order is therefore irrelevant — that is the point of the design.

Worked example, on a $50 service at quantity 1 with three options selected:

SelectedEffectContributionRunning total
service price × quantity50 × 150.00
Long hairfixed 20, perUnit20 × 170.00
Premium finishpercentage 10, perUnit50 × 10 / 100 = 5.0075.00
Gift wrapfixed 3, perLeg3, once78.00

The 10% is $5.00, not $7.00 — the +$20 is not part of the basis. Two 10% options on a $100 service add $20, not $21.

Because the basis includes quantity, a perUnit percentage scales with it: the same 10% on the $50 service at quantity 2 contributes 100 × 10 / 100 = $10, while a perLeg effect stays flat.

One exception. For a service whose quantity comes from a definesUnits group, the basis is the sum of the selected unit-class totals rather than price × quantity. Each class total is (service price + that class's own effect) × its count, so the class-level effects are inside the basis and an ordinary percentage option does include them.

On a $30 service whose fare group offers Adult (no effect) and Child (fixed −10, perUnit), picking 2 adults and 1 child gives 30 × 2 + 20 × 1 = a basis of $80, so a separate 10% option contributes $8 and the subtotal is $88.


PUT /query/booking/services/{serviceId}/options Set Service Options

Atomic full replace, not a merge — every call deletes the service's existing option groups and recreates the submitted list, the same way PUT /query/booking/services/{serviceId}/staff behaves. { "groups": [] } clears everything. Merchant only; staff callers additionally need canEditService.

To clear a service's options, send this PUT with { "groups": [] }. There is deliberately no DELETE on this path — DELETE /query/booking/services/{serviceId}/options returns 404, as does any other verb.

Structural rules are validated as a whole, so violations come back as 422 with the field path that failed.

Set Service Options Body
{
  "options": {
    "groups": [
      {
        "key":           "hair_length",
        "label":         "Hair length",
        "selectionMode": "single",
        "minSelections": 0,
        "maxSelections": 1,
        "definesUnits":  false,
        "sortOrder":     0,
        "isActive":      true,
        "values": [
          {
            "key":                "short",
            "label":              "Short",
            "isDefault":          false,
            "isActive":           true,
            "sortOrder":          0
          },
          {
            "key":                "long",
            "label":              "Long",
            "isDefault":          false,
            "isActive":           true,
            "sortOrder":          1,
            "priceEffectType":    "fixed",
            "priceEffectValue":   20,
            "priceEffectScope":   "perUnit",
            "durationEffectType": "delta",
            "durationMinutes":    15
          }
        ]
      }
    ]
  }
}

POST /query/booking/services/{serviceId}/price-preview Preview Option-Adjusted Price

Prices a set of option selections without creating anything — stateless and side-effect free. Drives the running total in the widget, and is the same source the gateway uses to resolve the trusted subtotal a percentage coupon is applied to. Also accessible with public permission via ?is_public=true.

This endpoint accepts exactly three fields and rejects anything else with 422. It deliberately returns no tax, surcharge, coupon or grand total: the gateway owns everything layered above the base price, and that split is the reason the endpoint is this narrow. Do not send taxRate, surchargeAmount, discountAmount or couponCode.

This is the only correct source for a displayed total. Reimplementing the arithmetic client-side drifts from what is actually charged — percentage effects in particular are measured against a fixed basis rather than a running subtotal, so they add instead of compounding. See How a percentage is measured under GET /options above.

Body Parameters
FieldTypeRequiredDescription
staffIdUUIDNoPrices against that staff member's priceOverride / durationMinutesOverride when the merchant has allowStaffPricingOverrides. Rejected with 400 for a later-mode service; 404 when the staff member is not assigned to the service.
quantityintegerNoPositive integer, defaults to 1. Omit for a definesUnits service — quantity is derived from the selected classes there, and a value that disagrees is a 400.
optionSelectionsarrayNoUp to 50 of { valueId, quantity? }. The inner quantity is only meaningful for a definesUnits class.
Price Preview Body
{
  "pricePreview": {
    "staffId":  "uuid",
    "quantity": 2,
    "optionSelections": [
      { "valueId": "uuid" },
      { "valueId": "uuid", "quantity": 1 }
    ]
  }
}
Response
{
  "result": "success",
  "pricePreview": {
    "currency":        "USD",
    "basePrice":       "45",
    "unitPrice":       "55",
    "perBookingTotal": "3",
    "subtotal":        "113",
    "quantity":        2,
    "durationMinutes": 75,
    "selections": [
      {
        "optionGroupId": "uuid",
        "optionValueId": "uuid",
        "groupKey":      "hair_length",
        "groupLabel":    "Hair length",
        "valueKey":      "long",
        "valueLabel":    "Long",
        "quantity":      1,
        "amount":        "20"
      }
    ]
  }
}
Response Fields
FieldDescription
basePriceThe service price before any option effect.
unitPricePer-unit price after perUnit effects. An average when a definesUnits group mixes classes at different prices.
perBookingTotalThe sum of perLeg effects, charged once per booking rather than per unit.
subtotalThe amount to price against. Already equals unitPrice x quantity + perBookingTotal — do not multiply it by quantity again.
durationMinutesThe option-adjusted duration. Pass the same selections to /slots so the customer is only offered slots the booking will actually fit.

Errors: 400 invalid selection (unknown, inactive or foreign valueId; group min/max violated; duplicate value; quantity on a non-unit option; quantity contradicting the price classes; competing duration overrides; a negative resulting price) · 404 service or staff not found · 422 malformed UUID or an unrecognised field.


Checkout Holds

A hold reserves capacity for one slot while a customer completes checkout, so a contended slot cannot be taken from under them between picking a time and paying. GET /query/booking/services exposes holdsRequired; when it is true, a public booking submitted without a holdId is rejected with 400. merchant and staff callers bypass that check — a trusted operator is not racing an anonymous customer.

POST /query/booking/holds Create Hold

Reserves the slot and returns an expiresAt to drive a countdown in the widget. Creating a hold releases every other active hold for the same checkout, so a customer who changes their slot or their options just creates another hold rather than needing to release the old one first. Pass is_public_booking: true for the customer-facing widget flow; omit it for a merchant-initiated hold, which keeps the caller's own identity.

Body Parameters
FieldTypeRequiredDescription
is_public_bookingbooleanNoSet true for customer-widget holds to scope to public permission.
serviceIdUUIDYesService being held.
staffIdUUIDConditionalRequired for atCreation services; must be omitted for later — the same rule as booking creation.
scheduledAtISO 8601 datetimeYesSlot start, minute precision (no seconds) — and it must carry an offset, e.g. 2027-06-01T09:00Z. The schema is z.iso.datetime({ precision: -1 }), which rejects a bare local datetime with a 422. Send the UTC instant here and the customer's zone in timeZone, exactly as POST /query/booking does — the hold must resolve to the same instant as the booking or redeeming it is a 409.
quantityintegerNoPositive integer, defaults to 1.
timeZonestringYesTimezone identifier.
optionValueIdsUUID[]NoUp to 50 option value ids, as a JSON array (unlike the comma-separated string /slots takes). Duration effects only, so the hold reserves the window the booking will actually occupy. Group min/max are not enforced here — a partial selection mid-checkout is expected.
ttlSecondsintegerNo30–900. Omit this. The merchant's holdTtlSeconds is both the default and the ceiling, and a larger request is clamped down silently rather than rejected — so a hardcoded value produces confusing behaviour across merchants.
sessionRefDo not send. Generated by the gateway; any client value is discarded. See the note above.
Create Hold Body
{
  "is_public_booking": true,
  "hold": {
    "serviceId":      "uuid",
    "staffId":        "uuid",
    "scheduledAt":    "2027-06-01T09:00Z",
    "timeZone":       "UTC",
    "quantity":       1,
    "optionValueIds": ["uuid"]
  }
}
Response (201)
{
  "result": "success",
  "hold": {
    "id":          "uuid",
    "merchantId":  "uuid",
    "serviceId":   "uuid",
    "staffId":     "uuid",
    "scheduledAt": "2027-06-01T09:00:00.000Z",
    "endsAt":      "2027-06-01T10:30:00.000Z",
    "timeZone":    "UTC",
    "quantity":    1,
    "expiresAt":   "2027-05-20T12:05:00.000Z",
    "consumedAt":  null,
    "releasedAt":  null,
    "bookingId":   null,
    "createdAt":   "2027-05-20T12:00:00.000Z",
    "updatedAt":   "2027-05-20T12:00:00.000Z"
  }
}

endsAt is option-adjusted, so a hold whose window was lengthened by a duration option reserves the longer window. Drive the customer-facing countdown from expiresAt.

Errors: 400 staffId rule violation or bad optionValueIds · 403 booking service disabled for the merchant · 404 merchant, service or staff not found · 409 slot unavailable · 422 validation.


DELETE /query/booking/holds/{holdId} Release Hold

Releases the reservation when the customer abandons checkout or navigates back. Idempotent by design — releasing an already-released or already-expired hold is a 200 no-op, because the caller's intent is already satisfied, so it is safe to call blind without tracking whether the hold is still live.

Errors: 404 unknown hold, or one belonging to another merchant · 409 the hold has already been consumed into a booking · 422 malformed UUID.


Checkout Sequence
  1. Load the service. Branch on holdsRequired from GET /query/booking/services.
  2. Show available times. GET /query/booking/slots, passing optionValueIds if duration-affecting options are already chosen.
  3. Create the hold as soon as the slot is chosen — before collecting payment details, not after. Show a countdown from expiresAt.
  4. If the customer changes their mind, just create another hold. The previous one is released automatically.
  5. Complete checkout. POST /query/booking with holdId plus the full optionSelections. The hold must describe the same serviceId, scheduledAt, staffId and quantity — any mismatch is a 409.
  6. On abandon, DELETE /query/booking/holds/{holdId}.
  7. On a 409 at booking creation, present it as "this slot is no longer available" and re-fetch slots. It means the hold expired, was already consumed, or another caller won the race — not a generic failure.

Hold consumption is an atomic compare-and-swap inside the booking transaction, so two concurrent requests redeeming the same holdId produce exactly one booking and the loser gets a clean 409. A double-submitted checkout that carries a holdId therefore cannot create a duplicate booking.


Staff

Manage staff members under the merchant, their schedules, recurring breaks, and one-off date exceptions.

POST /query/booking/staff Create Staff Member

Creates a new staff member. Merchant only.

Body Parameters
FieldTypeRequiredDescription
firstNamestringYes1–100 characters.
lastNamestringYes1–100 characters.
emailstringNoValid email address.
userExternalIdintegerNoGateway user ID, for reference. Staff login is not yet supported by the payment gateway.
colorstringNoHex color for UI display. Defaults to #6083b4.
isActivebooleanNoWhether the staff member is active. Defaults to true. Inactive staff cannot be assigned to new bookings.
notifyOnBookingCreatedbooleanNoWhether this staff member is emailed when a booking assigned to them is created. Defaults to true. See Automated Email Notifications above.
notifyOnBookingUpdatedbooleanNoWhether this staff member is emailed when a booking assigned to them is updated (including reschedules). Defaults to true.
notifyOnBookingCancelledbooleanNoWhether this staff member is emailed when a booking assigned to them is cancelled. Defaults to true.
Create Staff Body
{
  "staff": {
    "firstName":          "Jane",
    "lastName":           "Smith",
    "email":              "jane@example.com",
    "userExternalId":     101,
    "color":              "#FF5733",
    "notifyOnBookingCreated":   true,
    "notifyOnBookingUpdated":   true,
    "notifyOnBookingCancelled": true
  }
}

GET /query/booking/staff List Staff

Returns all staff under the merchant. Merchant tokens receive full records. Also accessible with public permission via ?is_public=true — public callers receive each entry reduced to { id, firstName, lastName, color } (no email).


GET /query/booking/staff/{staffId} Get Staff Member

Returns a staff member. Merchant tokens get the full record. Also accessible with public permission via ?is_public=true — public callers receive { id, firstName, lastName, color } only (no email, no sub-resources).


PATCH /query/booking/staff/{staffId} Update Staff Member

Updates a staff record. Merchant only.

Body Parameters (all optional)
FieldTypeDescription
firstNamestring1–100 characters.
lastNamestring1–100 characters.
emailstringValid email address.
userExternalIdintegerPositive integer.
colorstringValid hex color.
isActivebooleanSet to false to deactivate the staff member. Inactive staff cannot be assigned to new bookings.
notifyOnBookingCreatedbooleanSee Automated Email Notifications above.
notifyOnBookingUpdatedboolean
notifyOnBookingCancelledboolean
Update Staff Body
{
  "staff": {
    "firstName":  "Jane",
    "lastName":   "Doe",
    "email":      "jane@example.com",
    "color":      "#FF5733",
    "isActive":   true,
    "notifyOnBookingCreated":   true,
    "notifyOnBookingUpdated":   true,
    "notifyOnBookingCancelled": true
  }
}

DELETE /query/booking/staff/{staffId} Delete Staff Member

Permanently deletes a staff member and all their sub-resources (schedules, breaks, exceptions). Merchant only.


GET /query/booking/staff/{staffId}/services Get Staff Assigned Services

Returns the services this staff member is assigned to, via the staff/service join. Each entry is a full service record plus that staff member's priceOverride and durationMinutesOverride (both null if unset). Returns an empty array if the staff member has no assignments. This is the reverse lookup of GET /query/booking/services/{serviceId}/staff/{staffId}. Merchant only.

Response
{
  "result": "success",
  "services": [
    {
      "id":                      "uuid",
      "merchantId":              "uuid",
      "name":                    "Haircut",
      "price":                   "50",
      "durationMinutes":         30,
      "priceOverride":           null,
      "durationMinutesOverride": null
    }
  ]
}

GET /query/booking/staff/{staffId}/schedule Get Staff Schedule

Returns the staff member's weekly work schedule. Returns an empty array if no schedule has been set yet. Merchant only.

Response
{
  "result": "success",
  "schedule": [
    { "id": "uuid", "staffId": "uuid", "dayOfWeek": 0, "isActive": false, "startTime": "", "endTime": "" },
    { "id": "uuid", "staffId": "uuid", "dayOfWeek": 1, "isActive": true, "startTime": "09:00", "endTime": "17:00" }
  ]
}

PUT /query/booking/staff/{staffId}/schedule Set Staff Schedule

Replaces the staff member's full weekly work schedule. Must send all 7 days. Merchant only.

Body — Array of 7 day entries
FieldTypeRequiredDescription
dayOfWeekinteger 0–6Yes0 = Sunday, 6 = Saturday.
isActivebooleanYesWhether the staff member works that day.
startTimestring HH:MMConditionalRequired when isActive is true.
endTimestring HH:MMConditionalRequired when isActive is true. Must be after startTime.
Set Staff Schedule Body
{
  "schedule": [
    { "dayOfWeek": 0, "isActive": false },
    { "dayOfWeek": 1, "isActive": true, "startTime": "09:00", "endTime": "17:00" },
    { "dayOfWeek": 2, "isActive": true, "startTime": "09:00", "endTime": "17:00" },
    { "dayOfWeek": 3, "isActive": true, "startTime": "09:00", "endTime": "17:00" },
    { "dayOfWeek": 4, "isActive": true, "startTime": "09:00", "endTime": "17:00" },
    { "dayOfWeek": 5, "isActive": true, "startTime": "09:00", "endTime": "13:00" },
    { "dayOfWeek": 6, "isActive": false }
  ]
}

GET /query/booking/staff/{staffId}/breaks List Staff Breaks

Returns all recurring breaks for the staff member. Returns an empty array if none have been created. Merchant only.

Response
{
  "result": "success",
  "breaks": [
    { "id": "uuid", "staffId": "uuid", "dayOfWeek": 1, "startTime": "12:00", "endTime": "13:00", "isActive": true }
  ]
}

GET /query/booking/staff/{staffId}/breaks/{breakId} Get Staff Break

Returns a single break by ID. The break must belong to the specified staff member. Merchant only.


POST /query/booking/staff/{staffId}/breaks Add Staff Break

Adds a recurring break window to a specific day of the week. Merchant only.

Body Parameters
FieldTypeRequiredDescription
dayOfWeekinteger 0–6YesDay the break applies to.
startTimestring HH:MMYesBreak start. Must be before endTime.
endTimestring HH:MMYesBreak end. Must be after startTime.
isActivebooleanNoDefaults to true.

PATCH /query/booking/staff/{staffId}/breaks/{breakId} Update Staff Break

Updates one or more fields on an existing break. Merchant only. All fields optional. When only one of startTime or endTime is sent, the other is read from the database to validate ordering.

Body Parameters (all optional)
FieldTypeDescription
dayOfWeekinteger 0–6Day the break applies to.
startTimestring HH:MMBreak start time.
endTimestring HH:MMBreak end time. Must be after startTime.
isActivebooleanEnable or disable this break without deleting it.
Update Staff Break Body
{
  "break": {
    "dayOfWeek": 1,
    "startTime": "12:00",
    "endTime":   "13:00",
    "isActive":  true
  }
}

DELETE /query/booking/staff/{staffId}/breaks/{breakId} Delete Staff Break

Removes a recurring break from a staff member. Merchant only.


GET /query/booking/staff/{staffId}/exceptions List Staff Date Exceptions

Returns all date exceptions for the staff member. Returns an empty array if none have been created. Merchant only.

Response
{
  "result": "success",
  "exceptions": [
    { "id": "uuid", "staffId": "uuid", "date": "2026-12-25", "isAvailable": false, "startTime": null, "endTime": null, "breaks": [] }
  ]
}

GET /query/booking/staff/{staffId}/exceptions/{exceptionId} Get Staff Date Exception

Returns a single date exception by ID. The exception must belong to the specified staff member. Merchant only.


PUT /query/booking/staff/{staffId}/exceptions Upsert Staff Date Exception

Adds or updates a one-off availability override for a staff member (day off or special hours). Merchant only. Idempotent on the same date — calling again with the same date updates the existing record, including replacing its breaks (see below).

An exception may also carry breaks: non-repeating break windows scoped to that single date. Exception breaks are additive to the staff member's recurring breaks for that day of week — creating or updating an exception to change hours/availability does not drop the recurring breaks that would otherwise apply. Use breaks when a specific date needs an extra break on top of the usual ones (e.g. a one-off appointment). breaks is only allowed when isAvailable is true, and each PUT fully replaces the exception's existing breaks — omit the field to clear them.

Body Parameters
FieldTypeRequiredDescription
datestring YYYY-MM-DDYesThe date to override.
isAvailablebooleanYesfalse = day off. true = working special hours.
startTimestring HH:MMConditionalRequired when isAvailable is true.
endTimestring HH:MMConditionalRequired when isAvailable is true. Must be after startTime.
breaksarrayNoOnly allowed when isAvailable is true. Fully replaces the exception's existing breaks.
breaks[].startTimestring HH:MMYes (per entry)Must be before endTime.
breaks[].endTimestring HH:MMYes (per entry)Must be after startTime.
breaks[].isActivebooleanNoDefaults to true.
Staff Exception Examples
// Day off
{ "exceptions": { "date": "2026-08-15", "isAvailable": false } }

// Special hours
{ "exceptions": { "date": "2026-08-20", "isAvailable": true, "startTime": "10:00", "endTime": "14:00" } }

// Special hours with an extra one-off break
{
  "exceptions": {
    "date": "2026-08-22",
    "isAvailable": true,
    "startTime": "09:00",
    "endTime": "17:00",
    "breaks": [
      { "startTime": "12:00", "endTime": "13:00" }
    ]
  }
}

DELETE /query/booking/staff/{staffId}/exceptions/{exceptionId} Delete Staff Date Exception

Removes a one-off date exception for a staff member. Merchant only.


Evidence

Transaction Supporting Documents & Dispute Evidence


The Evidence API stores photo/file evidence tied to a transaction, and exposes chargeback/retrieval (dispute) data alongside the ability to accept or challenge an open dispute with that evidence. Both areas are served by the gateway's /query/evidence endpoint, which resolves the caller's identity, mints a module JWT, and forwards the request to the evidence locker backend.

Base URL

All evidence calls go through: /query/evidence/{path}

Request & Response Format

All responses follow the ExpiEndpoint envelope — the payload is returned under a named resource key alongside "result". The resource key varies by endpoint (media, documents, dispute, disputes):

{ "result": "success", "media": { ... } }
{ "result": "success", "dispute": { ... } }
{ "result": "success", "disputes": [ ... ], "current_page": 1, "per_page": 10, "last_page": 1, "total": 1 }

Upload/response endpoints (document upload, dispute response) are multipart/form-data — every other request/response body is JSON.

Authentication

Every endpoint requires the caller to authenticate. The gateway resolves identity in order:

  1. Session — active browser/cookie session for the gateway user.
  2. Bearer token — a gateway-issued JWT passed as Authorization: Bearer <token> with a sub or user_id claim.
  3. Basic AuthAuthorization: Basic base64(merchanttext:secret), the merchant's existing API credential (the same one used for x_login/x_tran_key elsewhere in the gateway).

The resolved user's account type determines the permission forwarded to the backend:

  • sysadsysAdmin
  • merchantmerchant
  • enhancedenhanced
  • teammember (staff) → staff

merchant and enhanced callers are additionally checked against the merchant in the URL — the request is rejected with 403 if that user doesn't have access to that specific merchant account. sysad and staff callers aren't scoped to a single merchant.

Basic Auth alone (no session/Bearer user resolved) also succeeds and runs with merchant permission by default — the same fallback the Booking Module uses. Only a request with neither a resolvable user nor valid x_login/x_tran_key credentials is rejected with 401.


Transaction Supporting Documents

Every transaction can have supporting photo evidence (receipts, signed slips, ID, etc.) attached under a "group" identified by the transaction's own ID. A group has a storage cap — maxPhotos images and maxBytes total — enforced by the backend and reported back in groupSummary.

GET /query/evidence/group/{groupId} List Group Documents

groupId is the transaction's ID. Returns every document uploaded to that transaction's group, plus a groupSummary describing how much of the group's storage is used.

Response
{
  "result": "success",
  "media": [
    {
      "id":        "uuid",
      "fileName":  "receipt.jpg",
      "signedUrl": "https://.../receipt.jpg?signature=..."
    }
  ],
  "groupSummary": {
    "photoCount":   1,
    "maxPhotos":    20,
    "totalBytes":   482113,
    "maxBytes":     9437184,
    "isSubmitted":  false
  }
}

POST /query/evidence/group Upload Document(s)

Uploads one or more files to a transaction's group. Request body is multipart/form-data.

Form Fields
FieldTypeRequiredDescription
groupIdstringYesThe transaction ID to attach documents to.
photos[]file(s)YesOne or more files. See limits below.

Accepted types: JPEG, PNG, WebP, HEIC, HEIF, PDF. Per the dashboard uploader's own limits: up to 5 files per request, 3 MB per file, 9 MB total per group (subject to the group's remaining maxPhotos/maxBytes as reported by groupSummary).

Response (201)
{
  "result": "success",
  "media": {
    "id":       "uuid",
    "fileName": "receipt.jpg"
  }
}

GET /query/evidence/{mediaId} Get Document

Returns a single document's metadata.


GET /query/evidence/{mediaId}/export Get Document Download Link

Returns a temporary download link for a single document — used for viewing/downloading rather than embedding a persistent URL.

Response
{
  "result": "success",
  "media": {
    "url":      "https://.../receipt.jpg?signature=...",
    "fileName": "receipt.jpg",
    "fileType": "image/jpeg"
  }
}

DELETE /query/evidence/{mediaId} Delete Document

Permanently removes a document from its transaction's group.


Disputes

Chargebacks and retrievals (disputes) filed against a merchant's account. List disputes, retrieve a single dispute, view/download its attached documents, and respond to an open dispute by accepting it or challenging it with evidence. Only merchants with a MID configured on their account can use these endpoints.

GET /query/evidence/disputes/merchant/{mid} List Disputes

Returns a paginated list of disputes for the merchant identified by {mid}.

Query Parameters
FieldTypeRequiredDescription
pageintegerNoDefaults to 1.
per_pageintegerNo10, 25, or 50. Defaults to 10.
Response
{
  "result":       "success",
  "disputes":     [ { "...": "see Get Dispute below for the full shape of each entry" } ],
  "current_page": 1,
  "per_page":     10,
  "last_page":    1,
  "total":        1
}

GET /query/evidence/disputes/{disputeId} Get Dispute

Returns a single dispute.

Response Fields
FieldDescription
idDispute ID.
case_numberCase number.
case_status / statusCurrent status (e.g. "Needs Response", "Open", "Won", "Lost", "Pending", "Closed").
item_type / case_typeChargeback or retrieval.
reason_code / reason_descriptionNetwork reason code and its description.
case_amount / currencyDisputed amount.
created_date / due_dateWhen the case was opened, and the merchant's response deadline.
transaction_idAssociated gateway transaction ID, if matched.
cardholder_account_number / card_nameMasked card number and card brand.
transaction_date / posted_dateOriginal transaction and posting dates.
auth_code / order_id / arnAuthorization code, order ID, and acquirer reference number, when available.
mid / dba_name / legal_name / mcc / bank / associationMerchant/processing details as recorded on the case.
merchant_commentsAction requested of the merchant, when present.
notesArray of { type, note, created_at } case notes.

GET /query/evidence/disputes/documents/{documentId}/export Download Dispute Document

Downloads a single dispute document's raw content.

Response
{
  "result": "success",
  "dispute": {
    "content":      "<base64-encoded file bytes>",
    "content_type": "image/jpeg"
  }
}

POST /query/evidence/disputes/response Respond to a Dispute

Accepts or challenges an open dispute. Request body is multipart/form-data.

Form Fields
FieldTypeRequiredDescription
disputeIdstringYesThe dispute to respond to.
disputeActionaccept | challengeYesWhether to accept the chargeback or challenge it with evidence.
commentstringNoAdditional context for the response.
photos[]file(s)ConditionalEvidence files. Required when disputeAction is challenge and the dispute has no documents already on file. Ignored for accept.

Accepted file types, count, and size limits match the Transaction Supporting Documents group above: JPEG, PNG, WebP, HEIC, HEIF, PDF — up to 5 files, 3 MB per file, 9 MB total.

Response
{
  "result":  "success",
  "dispute": { "...": "the updated dispute — see Get Dispute above" }
}
Responses
StatusCause
200Response recorded.
401Missing or invalid token.
403Caller doesn't belong to the merchant that owns this dispute.
404Merchant or dispute not found.
422disputeAction missing or not one of accept/challenge ("action must be 'challenge' or 'accept'." — the error message itself still refers to it as "action" since that's the field name the evidence locker backend sees after translation), or the merchant account does not have a MID configured.

Organizations

Manage organizations and look up the merchants that belong to one.

Authentication is different from other endpoints: organizations are scoped by user (the owner and any linked team members), not by merchant. Every call below still authenticates the same way as any other endpoint — either a Bearer token or x_login/x_tran_key — but the gateway resolves it to a user:

  • Bearer tokenAuthorization: Bearer <token> (or token field). The user is read directly from the token's sub claim.
  • x_login / x_tran_key — validated as merchant credentials exactly like every other endpoint, then resolved to that merchant's attached user account. That resolved user is who the organization actions run as.

A user can only read or manage organizations they own or belong to. There is no unauthenticated/public route on this endpoint.

Organization Object
{
  "uniqueID": 4,
  "name": "Acme Salons",
  "orgKey": "acme-salons",
  "ownerUserId": 12,
  "notes": "Rolled up under the Acme parent account",
  "enabled": true
}

This shape is returned under organization for create/read/update, and under organizations (as a list) for GET /query/organization.

GET /query/organization Get All Organizations

Returns the organizations the authenticated user owns or belongs to. If none exist, returns an empty list: { "organizations": [] } with status 200.

Optional query parameters for GET /query/organization:

  • page: page number (1-based). When provided, results are paginated.
  • pageSize: records per page. Optional; defaults to 50.
GET /query/organization/{id} Get Single Organization

Returns 404 if the organization doesn't exist, is disabled, or the authenticated user doesn't have access to it.

POST /query/organization Create Organization

The authenticated user becomes the organization's owner. System administrator accounts cannot create organizations.

Body Parameters
Field Type Required Description
namestringYesOrganization name (minimum 2 characters).
org_keystringNoPublic slug used to look up the organization (e.g. via GET /query/organization/merchants). 2-64 letters, numbers, dashes, or underscores; must be unique.
notesstringNoFree-form notes.
Create Organization Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "organization": {
    "name": "Acme Salons",
    "org_key": "acme-salons",
    "notes": "Rolled up under the Acme parent account"
  }
}
PATCH /query/organization/{id} Update Organization

Only the organization's owner or an admin member may update it. Omit fields you don't want to change.

Body Parameters
Field Type Required Description
namestringNoOrganization name (minimum 2 characters).
org_keystringNoPublic slug; must remain unique. Send an empty string to clear it.
notesstringNoFree-form notes.
Update Organization Body
{
  "x_login": "...",
  "x_tran_key": "...",
  "organization": {
    "name": "Acme Salons & Spa",
    "notes": "Updated after the spa line launch"
  }
}
DELETE /query/organization/{id} Delete Organization

Soft-disables the organization (enabled = false) rather than removing the row, and unlinks every merchant that belonged to it (so they're free to join another organization). Once disabled, it stops appearing in list/lookup results for every user, including the owner. Only the organization's owner or an admin member may delete it.


GET /query/organization/merchants List Merchants in an Organization

Looks up an organization by its public org_key and returns the merchant IDs linked to it. Requires the same authentication as every other action above — there is no public/unauthenticated version of this route.

Query Parameters
Field Type Required Description
org_keystringYesThe organization's public slug.
List Merchants Example
GET /query/organization/merchants?org_key=acme-salons
List Merchants Response
{
  "result": "success",
  "merchants": [
    { "merchant_id": 1024 },
    { "merchant_id": 1031 }
  ]
}

Card Issuing

Issue and manage cards via the central query handler. Primary-account endpoints require merchant authentication; the cardholder list-cards endpoint uses cardholder credentials in the body.

Two request sections:

  • Primary Account: Operates on subaccounts using Primary Account API credentials.
  • User: Cardholder actions using Primary Account API credentials, plus cardholder username/password in body.
Primary Account Endpoints
GET /query/expicard List Cards (Primary account)

Optional filters: search, type (virtual|physical), status.

List Cards Example
GET /query/expicard
GET /query/expicard?uniqueID={CARD_ID} Get Card Details (Primary account)

Returns full details (including PAN/CVC) for authorized merchant/enhanced users. The response includes card.id (the requested card ID) and card.token (partner token).

Get Card Details Example
GET /query/expicard?uniqueID=CARD-12345
GET /query/expicard?subaction=summary&uniqueID={CARD_ID} Get Card Summary (Primary account)

Lightweight, cached card summary without sensitive details; falls back to full read if unavailable. Includes card.id (requested card ID) and card.token (partner token).

Get Card Summary Example
GET /query/expicard?subaction=summary&uniqueID=CARD-12345
POST /query Issue Card (Primary account)

Body fields: first_name, last_name, email, amount, address_line1, city, state, zip, type (virtual|physical), cardholder_type (team_member|vendor|contractor), optional external_id.

Issue Card Body
{
  "action": "expicard",
  "subaction": "create",
  "first_name": "Jane",
  "last_name": "Doe",
  "email": "jane@example.com",
  "password": "TempPassword123",
  "amount": 200.00,
  "address_line1": "123 Main St",
  "city": "Austin",
  "state": "TX",
  "zip": "78701",
  "type": "virtual",
  "cardholder_type": "team_member",
  "external_id": "CARD-EXT-001"
}
POST /query Fund Card (Primary account)
Fund Card Body
{
  "action": "expicard",
  "subaction": "fund",
  "uniqueID": "CARD-67890",
  "amount": 50.00
}
GET /query/expicard?subaction=transactions&uniqueID={CARD_ID} Get Transactions (Primary account)

Optional filters supported: from, to, status.

Transactions Example
GET /query/expicard?subaction=transactions&uniqueID=CARD-12345&from=2025-10-01&to=2025-10-31&status=approved
POST /query Create Funding Bank (Subaccount)

Creates a funding bank (bank account) for a specific subaccount.

Body fields: subaccountId, bankAccountNumber, bankRoutingNumber. Additional fields may be included per partner requirements.

Note: The query handler lowercases top-level JSON keys. To avoid casing issues, you can send snake_case keys (bank_account_number, bank_routing_number) or nest fields under a bank object.

Create Funding Bank Body
{
  "action": "expicard",
  "subaction": "subaccount_bank_create",
  "subaccountId": "SUB-123456",
  "bank_account_number": "1234567890",
  "bank_routing_number": "021000021"
}

Note: Sensitive card detail fields are only returned to authorized merchant/enhanced users; summaries are optimized for portal views.

User Endpoints

These endpoints do not require merchant authentication. They use merchant API credentials for partner access internally, but require cardholder username and password in the request body.

GET /query/expicard?role=user&subaction=user_cards List Cards (User)

Response: Returns full card details for the authenticated cardholder, including sensitive fields such as full card number (PAN), CVC, and expiration. Handle this response securely.

Authentication: You can provide the cardholder username/password either in the body or via HTTP Basic Auth (recommended). When using Basic Auth, the body credentials are optional.

User Auth Header
Authorization: Basic <base64(cardholder_username:cardholder_password)>
User Body
{
  "action": "expicard",
  "role": "user",
  "subaction": "user_cards",
  "username": "cardholder@example.com",
  "password": "cardholderPassword"
}

Note: User endpoints restrict access to cards linked to the authenticated cardholder; sensitive fields are not returned.