APIv1
API Docs

API Docs

Complete reference for the Mocha QuickBill API: every endpoint with its request fields, example bodies, response payloads and status codes.

Overview

Base URL, authentication and the endpoint index.

Every endpoint takes a JSON body and returns JSON. Authenticate with the X-Tenant and API Key headers on every request — see Authentication for the details.

Base URL

Every path on this page is relative to:

Base URL
https://services.ap.mochatechnologies.com/quickbill/api

Endpoint index

EndpointWhat it does
GET /income-accountsList the income accounts a product's revenue_account can point at.
GET /expense-accountsList the expense accounts a product's expense_account can point at, for a given product type.
GET /inventory-accountsList the accounts an inventory product's stock value can sit in.
GET /bank-accountsList the accounts a payment can be deposited into.
POST /productsCreate a product or service that invoice line items can reference.
PUT /products/:idReplace a product with how it should end up.
GET /productsList your products, a page at a time.
GET /products/:idRead a single product back by its id.
POST /customersCreate the customer an invoice is issued to, with its addresses.
PUT /customers/:idReplace a customer with how it should end up.
GET /customersList your customers, a page at a time.
GET /customers/:idRead a single customer back by its id.
GET /invoices/get-invoice-numberTake the next invoice number, before you create the invoice.
POST /invoicesBill a customer for one or more products.
PUT /invoices/:idReplace an invoice, while nothing has been paid on it.
GET /invoicesList your invoices, a page at a time.
GET /invoices/:idRead a single invoice back in full, with its addresses and payments.
GET /invoices/payment-link/:idBuild a link the customer can open to pay the invoice online.
GET /payment-methodsList the ways a payment can be taken — cash, cheque, card.
GET /payments/get-next-payment-numberTake the next payment reference, before you record the payment.
POST /paymentsRecord a payment against one or more of a customer's invoices.
PUT /payments/:idReplace a recorded payment and what it settles.
GET /paymentsList recorded payments, a page at a time.
GET /payments/:idRead one payment back, with what it was applied to.
POST /pricing-componentsCreate a fixed or an adjustment pricing component.
PUT /pricing-components/:idReplace a pricing component with how it should end up.
GET /pricing-componentsList every pricing component at once, with no pagination.
GET /pricing-components/:idRead a single pricing component back by its id.
POST /pricing-plansBuild a pricing plan out of one or more pricing components.
PUT /pricing-plans/:idReplace a pricing plan with how it should end up.
GET /pricing-plansList every pricing plan at once, with no pagination.
GET /pricing-plans/:idRead a single pricing plan back by its id.
POST /products/:id/pricing-plansSet which pricing plans a product is on.
GET /products/:id/pricing-plansRead back which pricing plans a product is on.
POST /subscriptionsPut a customer on a plan, with or without a trial.
GET /subscriptionsList your subscriptions, a page at a time.
POST /subscriptions/updateChange the plan, apply a coupon, or both.
POST /subscriptions/calculate-prorationPreview what a plan change will cost, without saving anything.
POST /subscriptions/cancelEnd a subscription now, or at the end of its term.
POST /subscriptions/cancel/reverseUndo a cancellation and put the subscription back in service.

Every PUT is a replace, not a patch

PUT /products/:id, PUT /customers/:id, PUT /invoices/:id, PUT /payments/:id and the two pricing ones all work the same way: send the whole record as it should end up, not only what changed. Any array you send — lines, paidAmount, tags, components — becomes the entire set, so an entry you leave out is removed. Read the record first, then send it back with your edits. The id travels in the URL; you never put it in the body as well.

Authentication failures are not listed per endpoint

A missing or invalid X-Tenant or API Key is rejected before the request reaches any of the endpoints below, so the status tables on this page do not repeat it. Handle it once in the layer that adds your headers — see Getting Started → Authentication.

More endpoints on the way

This reference is being rebuilt endpoint by endpoint against the live API. Only the endpoints listed above are confirmed — anything else is not documented here yet.

Accounts

The account ids that products and payments ask for.

Accounts are what QuickBill uses to keep your accounting tracked — a sale posts to an income account, a cost to an expense account, stock value sits in an inventory account, money received lands in a bank account. You do not create or manage them here: every tenant starts with a default set, ready to use.

Full accounting is a separate product

These accounts are a feature of Mocha's accounting product. If you purchase it later, you get the whole thing — your own chart of accounts, ledgers and reporting. Until then the defaults are all you need, and these four small read endpoints are exposed for exactly one reason: so you can create a product and record a payment without owning the accounting product.
GET/income-accountsX-Tenant + API Key required
GET/expense-accounts?product_type=X-Tenant + API Key required
GET/inventory-accountsX-Tenant + API Key required
GET/bank-accountsX-Tenant + API Key required

Which one feeds which field

Call the endpoint, show the names to your user, and send back the id they picked.

EndpointFills inOn
GET /income-accountsrevenue_accountPOST /products
GET /expense-accounts?product_type=expense_accountPOST /products
GET /inventory-accountsinventory_accountPOST /products
GET /bank-accountsaccount_idPOST /payments

You only need the inventory list for an inventory product

inventory_account is required when a product's type is inventory, and not used for service or non_inventory. See what each type requires.

Never hard-code an account id

The ids differ from one tenant to the next, so a value that works in your own tenant will be wrong in your customer's. Read the list at runtime.

Only expense-accounts takes a parameter

Income, inventory and bank accounts take none. Expense accounts require product_type — see below. None of the four paginate, so there is no page, no search and no meta block anywhere.

Every entry is just an id and a name

id is what you send, name is what you show in a picker. There is nothing else on an account — no type, no balance, no currency. Only active accounts come back.

Income accounts

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/income-accounts' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"
200 OK
{
  "data": [
    { "id": 412, "name": "Sale of Product Income" },
    { "id": 418, "name": "Service Income" },
    { "id": 423, "name": "Discounts given" },
    { "id": 431, "name": "Other Income" }
  ]
}

Inventory accounts

Same shape, no parameters — swap the path for /inventory-accounts.

200 OK
{
  "data": [
    { "id": 340, "name": "Inventory Asset" }
  ]
}

Bank accounts

Same again, with /bank-accounts.

200 OK
{
  "data": [
    { "id": 301, "name": "Checking" },
    { "id": 305, "name": "Savings" },
    { "id": 312, "name": "Undeposited Funds" }
  ]
}

Expense accounts

This one is different: it needs to know what kind of product the cost belongs to, because that decides which accounts are even eligible.

FieldTypeRequiredDescription
product_typestringRequiredinventory, non_inventory or service — the type of the product you are about to create.

What the product type changes

product_typeWhich accounts come back
inventoryCost of goods sold only.
non_inventoryAny expense account.
serviceAny expense account.

Why inventory is narrower

An inventory product's stock is held until it sells, so its cost belongs in cost of goods sold. A service or a non-inventory good holds nothing, so its cost can go to any expense account.

Example: an inventory product

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/expense-accounts' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'product_type=inventory'
200 OK
{
  "data": [
    { "id": 502, "name": "Supplies & Materials - COGS" }
  ]
}

Example: a service or non-inventory product

Send product_type=service or product_type=non_inventory and the whole list comes back.

200 OK
{
  "data": [
    { "id": 502, "name": "Supplies & Materials - COGS" },
    { "id": 509, "name": "Advertising" },
    { "id": 514, "name": "Rent or Lease" },
    { "id": 521, "name": "Office Expenses" }
  ]
}

Ask with the type you are actually creating

Fetch the list with the same type you will send on POST /products. Fetch it as service, let your user pick Advertising, then create the product as inventory — and you will be sending an account that type is not allowed to use.

Errors

A missing or unrecognised product_type is a 422:

422 Unprocessable
{
  "message": "The product type is required.",
  "errors": {
    "product_type": ["The product type is required."]
  }
}

Status codes

StatusMeaning
200The accounts are returned.
422product_type was missing, or was not one of the three values. Expense accounts only.
500Unexpected error.

Create a Product

Add a product or service your invoices can bill against.

POST/productsX-Tenant + API Key required

Creates a product or service on your account. Once created it can be referenced on any invoice line item. The response returns the full product record, including the fields the server filled in for you.

Body parameters

FieldTypeRequiredDescription
typestringRequiredinventory, non_inventory or service. It decides which of the fields below are required — see What each type requires.
namestringRequiredDisplay name shown on invoices and in your catalog.
skustringOptionalYour own identifier for the product. Optional — omit it and the field comes back null.
descriptionstringOptionalLonger text about the product, for your own reference and on the invoice line.
tagsarrayOptionalLabels you can group and filter products by. Each entry is an object carrying a label and nothing else — send [{ "label": "Earphone" }].
quantityintegerOptionalHow many units you hold. Required when type is inventory, ignored otherwise.
revenue_accountintegerOptionalId of the account that sales of this product post to. Required for inventory, optional for the other two. Take it from GET /income-accounts and send the one your user picked.
inventory_accountintegerOptionalId of the account that holds this product's stock value. Required when type is inventory, and not used otherwise. Take it from GET /inventory-accounts.
expense_accountintegerRequiredId of the account that the cost of this product posts to. Required whichever type you send. Take it from GET /expense-accounts, passing the same type as product_type — the eligible accounts differ by type.

What each type requires

The type you send decides what else has to be in the body.

typequantityinventory_accountexpense_accountrevenue_account
inventoryRequiredRequiredRequiredRequired
non_inventoryNot neededNot usedRequiredOptional
serviceNot neededNot usedRequiredOptional

An inventory product needs all four

Send type: "inventory" and you must also send quantity, inventory_account, expense_account and revenue_account. Miss any of them and the create is refused.

A service or non-inventory product needs far less

No quantity and no inventory_account — there is no stock to track. expense_account is still required; revenue_account is optional.

Never hard-code an account id

All the account ids come from Accounts, and they differ per tenant. Read the lists at runtime and send what your user picked.

Example: a service

JSON body
{
    "type": "service",
    "name": "Wireless Earbuds",
    "sku": "EAR-001",
    "description": "True wireless earbuds with noise isolation.",
    "tags": [
        {
            "label": "Earphone"
        }
    ],
    "revenue_account": 17,
    "expense_account": 9
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/products' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "service",
    "name": "Wireless Earbuds",
    "sku": "EAR-001",
    "description": "True wireless earbuds with noise isolation.",
    "tags": [
      { "label": "Earphone" }
    ],
    "revenue_account": 17,
    "expense_account": 9
  }'

Example: an inventory item

The same product tracked as stock — quantity and inventory_account appear, and revenue_account stops being optional.

JSON body
{
    "type": "inventory",
    "name": "Wireless Earbuds",
    "sku": "EAR-001",
    "description": "True wireless earbuds with noise isolation.",
    "tags": [
        {
            "label": "Earphone"
        }
    ],
    "quantity": 50,
    "revenue_account": 412,
    "expense_account": 502,
    "inventory_account": 340
}

Example response

The response echoes what you sent, plus two fields the server adds: the generated id — which is what you reference on invoice line items — and is_active, which starts as true. The account ids you sent are not echoed back.

200 OK
{
  "id": 3210,
  "name": "Wireless Earbuds",
  "sku": "EAR-001",
  "type": "service",
  "description": "True wireless earbuds with noise isolation.",
  "tags": [{ "label": "Earphone", "value": "Earphone" }],
  "is_active": true
}

Store the id

This is the only place the new product's id is handed to you. Save it against your own record — you need it for every invoice line that bills this product.

Tags come back with an extra key

You send { "label": "Earphone" }; the response returns { "label": "Earphone", "value": "Earphone" }. The server fills value in from the label — do not send it yourself, and do not be surprised when the response does not match your request field for field.

Status codes

StatusMeaning
200Product created. The record is returned.
422Validation failed — a required field is missing or a value is not acceptable.
403You do not have permission for this product.
500Unexpected error.

Errors

Every error on the product endpoints comes back in the same shape — a single message. Show it, log it, and branch on the status code rather than on the text.

404 Not Found
{
  "message": "Product not found"
}

Validation errors add one key. message holds the first problem, and errors maps each rejected field to a list of messages — which is what you want if you are highlighting fields in a form:

422 Unprocessable
{
  "message": "The name field is required.",
  "errors": {
    "name": ["The name field is required."]
  }
}

Read errors, not message, when a form is involved

message is only the first failure. If two fields are invalid, the second one appears in errors and nowhere else — so a client that shows only message will have the user fix one field, resubmit, and hit the next error one at a time. Also note each value in errors is an array, since a single field can fail more than one rule.

Retry 500, never retry 4xx

A 500 may be temporary — your request could be fine, so retrying after a short backoff is reasonable. 422, 403 and 404 will fail identically every time; retrying them just wastes calls.

Authentication failures are handled once, not per endpoint

A bad or missing X-Tenant or API Key is rejected before the request reaches the product — so it is not listed above. Handle it centrally, as covered in Getting Started → Authentication.

Update a Product

Replace a product with how it should end up.

PUT/products/:idX-Tenant + API Key required

Replaces a product. Send the whole product as it should end up, not only the fields that changed.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe product's id, as returned by POST /products. The example updates product 90.

Body parameters

FieldTypeRequiredDescription
namestringRequiredDisplay name shown on invoices and in your catalog. Max 255.
revenue_accountintegerRequiredId of the account that sales of this product post to — from GET /income-accounts.
expense_accountintegerRequiredId of the account the cost posts to — from GET /expense-accounts.
inventory_accountintegerRequiredId of the stock account — from GET /inventory-accounts. Required here whatever the product's type — see the warning below.
skustringOptionalYour own identifier for the product. Max 100.
descriptionstringOptionalLonger text about the product.
tagsarrayOptionalLabels you can group and filter by, each an object with a label.

All four are required, even on a service

name, revenue_account, expense_account and inventory_account must all be in the body. This is stricter than create, where inventory_account is only wanted for an inventory product and revenue_account is optional for the other two.

Fields that are not accepted

These are dropped if sent:

  • type
  • quantity

You cannot change a product's type

type is fixed at creation. Sending a different one here does not fail — it is simply ignored, and the response comes back with the type the product already had. The same goes for quantity.

Example request

JSON body
{
  "name": "On-Site Repair Service",
  "sku": "SERV-003",
  "description": "Engineer visit and on-site repair.",
  "revenue_account": 17,
  "expense_account": 9,
  "inventory_account": 10,
  "tags": [{ "label": "Repair" }]
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/products/90' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "On-Site Repair Service",
    "sku": "SERV-003",
    "description": "Engineer visit and on-site repair.",
    "revenue_account": 17,
    "expense_account": 9,
    "inventory_account": 10,
    "tags": [{ "label": "Repair" }]
  }'

Example response

200 OK
{
  "id": 90,
  "name": "On-Site Repair Service",
  "sku": "SERV-003",
  "type": "service",
  "description": "Engineer visit and on-site repair.",
  "revenue_account": 17,
  "expense_account": 9,
  "inventory_account": null,
  "tags": [{ "label": "Repair", "value": "Repair" }],
  "is_active": true
}

An account that is not set comes back null

In the example inventory_account was sent as 10 but comes back null — the product is a service, so there is no stock for it to hold. Read the response rather than assuming what you sent was kept.

Unlike create, this response does echo the accounts

revenue_account, expense_account and inventory_account are all in the body above. POST /products does not return them, and neither do the two reads — so this is the one place the API tells you what a product's accounts actually are.

Status codes

StatusMeaning
200The product is updated and returned.
404No product with that id.
422A required field is missing, or the name or SKU already belongs to another product.

Errors

404 Not Found
{
  "message": "Product not found"
}

name and sku are both unique within a tenant, so a clash is a 422:

422 Unprocessable
{
  "message": "A product with this name already exists.",
  "errors": { "name": ["A product with this name already exists."] }
}

A 422 is as likely to be a clash as a missing field

The message names the field either way, so branch on errors rather than assuming the user left something blank. A missing field carries the same shape — see create.

List Products

Read your products back, a page at a time.

GET/productsX-Tenant + API Key required

Returns your products in pages, newest first. Use this to populate a product picker in your own UI, or to find the id you need for an invoice line item.

Query parameters

FieldTypeRequiredDescription
pageintegerRequiredWhich page to return, starting at 1.
page_lengthintegerRequiredHow many products per page. Comes back as per_page in the response.
searchstringRequiredA JSON object, sent as a string, holding your filters. Send {} for no filter — that is what the example does.

search filters are not documented yet

Only the empty object {} has been confirmed here. List Contacts takes the same search parameter and does accept a key inside it, so this one probably accepts keys too — but which ones has not been supplied. Filter on your side for now.

Example request

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/products' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'page=1' \
  --data-urlencode 'page_length=10' \
  --data-urlencode 'search={}'

Example response

Two keys: the products in data, and the paging in meta. Each entry is the same seven-field record that Create a Product and Get a Product return, so one parser covers all three.

meta

FieldWhat it is
totalHow many products exist in total, across every page — 6 in the example.
current_pageThe page you are on, echoing the page you asked for.
last_pageThe highest page number available. Stop paging when current_page reaches it.
per_pagePage size in effect, echoing page_length.
from / toPosition of the first and last item on this page within the full set — 1 and 6 here.

This envelope is not the one the other lists use

Products put their paging inside meta. The contact, invoice and payment lists put the same values at the top level next to data, and add links and URL fields that are not here. So response.meta.last_page on this endpoint is response.last_page on the others — write the paging helper to take the envelope it is given rather than assuming one shape.

No paging URLs to worry about

Unlike the other lists, this response contains no next_page_url, links or path — so none of the internal-host and malformed-query problems those carry apply here. Page with current_page against last_page.

Fields worth knowing

FieldWhat it tells you
typeinventory, non_inventory or service — whichever you created it as.
description / tagsWhat you sent when you created the product. tags is an empty array when you did not send any.
is_activeWhether the product is still in use. Every product on the example page is active.
skuYour own identifier, or null if you did not send one.

The account ids are not in the list

revenue_account and expense_account are required when you create a product, but they do not come back on any read — not here and not on GET /products/:id. Keep your own copy if you need them.

One thing to confirm

Whether inactive products are included in the list or filtered out of it. Every product in the example is active, so there is nothing to tell from it.
200 OK (data trimmed to one of six products)
{
  "data": [
    {
      "id": 6,
      "name": "Premium Monthly",
      "sku": "PRECNBRHWH",
      "type": "service",
      "description": "A Premium Monthly",
      "tags": [],
      "is_active": true
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "last_page": 1,
    "total": 6,
    "from": 1,
    "to": 6
  }
}

Status codes

StatusMeaning
200The page is returned, even when data is empty.
403You do not have permission for this product.
500Unexpected error.

Error shape

Errors carry a single message, the same as everywhere else on the product endpoints — see Errors under Create a Product. An empty page is a 200 with an empty data array, not an error.

Get a Product

Read a single product back by its id.

GET/products/:idX-Tenant + API Key required

Returns one product. Use it to refresh a product you already hold the id for — after creating it, or after picking it out of List Products.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe product's id, as returned by POST /products or found in the list. The example reads product 3210 — the one created above.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/products/3210' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

The product on its own — no wrapper, no paging fields — and the same seven fields POST /products returns.

200 OK
{
  "id": 3210,
  "name": "Wireless Earbuds",
  "sku": "EAR-001",
  "type": "service",
  "description": "True wireless earbuds with noise isolation.",
  "tags": [{ "label": "Earphone", "value": "Earphone" }],
  "is_active": true
}

No envelope on this one

The product sits at the top level of the response rather than under a data or product key. Read the fields straight off the response body.

Same shape everywhere

These seven fields are exactly what you get from POST /products and from each entry in GET /products. One product model in your code covers all three calls.

The account ids are not returned

The account ids you sent when creating the product are not on this response. If you need them, keep your own copy.

Status codes

StatusMeaning
200The product is returned.
404No product with that id.
403The product exists but you do not have permission to see it.
500Unexpected error.

Errors

404 Not Found
{
  "message": "Product not found"
}

404 and 403 mean different things — handle both

404 is “no such product”; 403 is “it exists, but not for you”. Treating both as “missing” will quietly hide a permissions problem from whoever is trying to use your integration. The full error shapes are under Errors on Create a Product.

Create a Customer

Add the person or business your invoices are issued to.

POST/customersX-Tenant + API Key required

Creates a customer along with its billing and shipping addresses in the same call. The body has two parts: contact_infos for the person or business, and addresses for where they are billed and shipped to.

What is actually required

email always, plus a name — first_name in contact_infos, or company_name in additional_infos. Send company_name and first_name stops being required. Everything else, including the whole addresses array, is optional.

The two choices you are giving your user

Whatever form you build on top of this endpoint has two independent decisions in it, and every combination is valid:

ChoiceOption AOption B
Company nameLeft out — a person. first_name is then required.Sent in additional_infos — a business. first_name becomes optional, and if you do send it, it is the contact person at that business.
AddressTyped by hand — is_google_address: false, and you send only the plain fields.Picked from Google Places — is_google_address: true, and you pass the Places fields through as well.

There is no flag saying which one it is

Nothing in the body declares a person or a business. What makes a record a business is simply the presence of company_name — so an accidental empty string there turns a person into a nameless business. Omit the key rather than sending "".

There is no type field to send

The create body does not take one, and the create response does not return one. You will see "type": "customer" on the list and read-by-id responses further down this page — the server sets it there. Leave it out of anything you send.

contact_infos

FieldTypeRequiredDescription
emailstringRequiredWhere invoices are emailed. Required for both a person and a company.
first_namestringRequiredGiven name of the person. Required only when you are not sending company_name — on a business it is optional, and names the contact person rather than the business itself.
last_namestringOptionalFamily name of the person.
titlestringOptionalSalutation such as Mr or Ms.
phone_numberstringOptionalContact number including country code, digits only — for example 919685745259.

company_name is what makes it a company

There is no type flag to set. Send company_name and you get a company; leave it out and you get a person. So an accidental empty company_name on a person record is a real risk — omit the key rather than sending "".

additional_infos

A separate object, and the only place the business name goes. Leave the whole object out when you are creating a person.

FieldTypeRequiredDescription
company_namestringOptionalThe business name. Sending it makes the record a business and releases you from sending first_name. It is also what display_name is derived from.

It is additional_infos going in, add_infos coming back

You send the object as additional_infos; every read response returns it as add_infos, and as an array rather than an object. Do not reuse one field name for both directions.

Only company_name is confirmed here

Read responses show add_infos also carrying gst_treatment, term_id, is_tax_exempt, customer_type, website and more. Whether this endpoint accepts them on creation has not been supplied, so only company_name is documented as settable. The rest come back with defaults.

addresses

An array. Send one entry per address, each tagged with its type. Billing and shipping can be the same address — repeat the same values under both types, as in the first example below.

FieldTypeRequiredDescription
typestringRequiredbilling or shipping.
formatted_addressstringRequiredThe whole address as a single line, exactly as it should appear on the invoice.
countrystringRequiredTwo-letter ISO country code — for example IN.
administrative_area_level_1stringOptionalState or province code — for example MH, HR.
localitystringOptionalCity or town.
postal_codestringOptionalPostal or PIN code.
is_google_addressbooleanRequiredtrue if the address came from Google Places, false if it was typed in by hand. This decides which of the fields below apply.
is_primarybooleanOptionalMarks this as the default address for its type.

Addresses from Google Places

Both examples below type the address in by hand, so they set is_google_address: false and stop at the fields above. When the address came out of a Google Places lookup instead, set it to true and add these — pass them through from the Places result unchanged:

FieldTypeRequiredDescription
google_place_idstringOptionalThe Places identifier for the selected address.
routestringOptionalStreet name component.
street_numberstringOptionalBuilding or house number. Send an empty string when Google did not return one.
administrative_area_level_2stringOptionalDistrict or division — for example Pune Division.
latitudenumberOptionalLatitude of the place.
longitudenumberOptionalLongitude of the place.
One address, Google-sourced
{
  "type": "billing",
  "formatted_address": "A-5, Block A, Sector 26A, Gurugram, Haryana 122002, India",
  "country": "IN",
  "administrative_area_level_1": "HR",
  "administrative_area_level_2": "Gurgaon Division",
  "locality": "Gurugram",
  "postal_code": "122002",
  "route": "A-5",
  "street_number": "",
  "latitude": 28.4728561,
  "longitude": 77.0995667,
  "google_place_id": "EjlBLTUsIEJsb2NrIEEsIFNlY3RvciAyNkEsIEd1cnVncmFtLCBIYXJ5YW5hIDEyMjAwMiwgSW5kaWEiLi4...",
  "is_google_address": true,
  "is_primary": true
}

null or empty string on the unfilled Google fields?

Google does not always return a street number or a postal code. Two captures disagree on what to send when it does not: one used "" for street_number, the other used null for street_number, route and postal_code. Both appear to be accepted. Pick one and use it everywhere rather than mixing, and expect either to read back as null.

Example: a person, address typed by hand

No additional_infos, so this is a person and first_name is required. Billing and shipping are the same address, so the same values appear twice with different type values.

Person, manual address
{
  "contact_infos": {
    "title": "Ms",
    "first_name": "Priya",
    "last_name": "Sharma",
    "email": "priya.sharma@example.com",
    "phone_number": "919685745259"
  },
  "addresses": [
    {
      "type": "billing",
      "formatted_address": "21 MG Road",
      "country": "IN",
      "administrative_area_level_1": "MH",
      "locality": "Mumbai",
      "postal_code": "425360",
      "is_google_address": false,
      "is_primary": true
    },
    {
      "type": "shipping",
      "formatted_address": "21 MG Road",
      "country": "IN",
      "administrative_area_level_1": "MH",
      "locality": "Mumbai",
      "postal_code": "425360",
      "is_google_address": false,
      "is_primary": true
    }
  ]
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/customers' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "contact_infos": {
      "title": "Ms",
      "first_name": "Priya",
      "last_name": "Sharma",
      "email": "priya.sharma@example.com",
      "phone_number": "919685745259"
    },
    "addresses": [
      {
        "type": "billing",
        "formatted_address": "21 MG Road",
        "country": "IN",
        "administrative_area_level_1": "MH",
        "locality": "Mumbai",
        "postal_code": "425360",
        "is_google_address": false,
        "is_primary": true
      },
      {
        "type": "shipping",
        "formatted_address": "21 MG Road",
        "country": "IN",
        "administrative_area_level_1": "MH",
        "locality": "Mumbai",
        "postal_code": "425360",
        "is_google_address": false,
        "is_primary": true
      }
    ]
  }'

Example: a business

company_name sits in additional_infos, which is what makes this a business — so first_name is no longer required. It is sent anyway here, because it names the person to deal with at that business.

This example carries no addresses, only to show that the array is optional. You can send addresses on a business exactly as the person example does — add the same addresses array, typed by hand or picked from Google Places. Nothing about a business changes how addresses work.

Business
{
  "contact_infos": {
    "first_name": "Rohan",
    "last_name": "Verma",
    "title": "Mr",
    "email": "rohan.verma@example.com",
    "phone_number": "919685745259"
  },
  "additional_infos": {
    "company_name": "Verma Enterprises"
  }
}

Example response

The created customer. Store the id — it is what you pass as customer_id when you raise an invoice.

200 OK — person
{
  "id": 16908,
  "title": null,
  "first_name": "Priya",
  "last_name": "Sharma",
  "display_name": "Priya Sharma",
  "email": "priya.sharma@example.com",
  "phone_number": "919685745259",
  "addresses": [
    {
      "id": "14893",
      "type": "billing",
      "formatted_address": "A-5, Block A, Sector 26A, Gurugram, Haryana 122002, India",
      "administrative_area_level_1": "HR",
      "administrative_area_level_2": "Gurgaon Division",
      "country": "IN",
      "locality": "Gurugram",
      "postal_code": "122002",
      "route": "A-5",
      "street_number": null,
      "google_place_id": "EjlBLTUsIEJsb2NrIEEsIFNlY3RvciAyNkE...",
      "latitude": 28.4728561,
      "longitude": 77.0995667,
      "is_google_address": true,
      "address_line_1": null,
      "address_line_2": null,
      "is_primary": true
    }
  ],
  "open_balance": 0,
  "over_due": 0,
  "is_active": true
}

What the server adds

FieldYou sentComes back as
idNothingThe customer id. This is the only place you get it.
display_nameNothing — it is not a request fieldDerived. Priya Sharma from the first and last name; on a business it is derived from company_name instead.
open_balance / over_dueNothing0 on a new customer. They move as invoices and payments are recorded.
is_activeNothingtrue.

What happens to the addresses you sent

FieldBehaviour
idEach address is assigned one — and it is a string, "14893", not a number.
address_line_1 / address_line_2Added to every address as null. They are not request fields.
street_numberComes back null when Google did not supply one, whether you sent null or an empty string.
Everything elseReturned as you sent it — formatted_address, locality, postal_code, the Places fields, is_google_address and is_primary all pass through.

One address in, one address out

The array is returned with the same entries you sent, each keeping its type. Send billing and shipping and you get both back; send one and you get one. Nothing is invented for you.

A business returns one extra key

Everything above is identical. The only difference is an add_infos array, placed after addresses, holding the company name you sent:

Extra key on a business
  "add_infos": [
    { "id": 618, "company_name": "Verma Enterprises" }
  ],

company_name changes name and nesting on the way back

You send it as additional_infos.company_name — an object. It returns as add_infos[0].company_name — an array, under a shortened key. Three things to get right at once, so read it from add_infos[0] rather than reusing the request path.

add_infos is absent, not empty, on a person

The person response above has no add_infos key at all — it is not an empty array. Check the key exists before indexing into it, or a person record will throw.

Where the rest of the business details live

The other business fields — website, gst_treatment, payment terms, tax exemption — live on the same add_infos record, but the create response returns only id and company_name. How to set or read the rest has not been supplied.

Status codes

StatusMeaning
200Customer created. The record is returned.
422A required field is missing or an address entry is invalid.

Update a Customer

Replace a customer with how it should end up.

PUT/customers/:idX-Tenant + API Key required

Replaces a customer. Send the whole customer as it should end up, not only the fields that changed.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe customer's id, as returned by POST /customers. The example updates customer 16908.

Body parameters

The same payload as Create a Customer — same fields, same rules, same address shapes. Nothing extra and nothing removed; the id travels in the URL, not in the body.

Example request

JSON body
{
  "contact_infos": {
    "title": "Ms",
    "first_name": "Priya",
    "last_name": "Sharma",
    "email": "priya.sharma@example.com",
    "phone_number": "919685745259"
  },
  "addresses": [
    {
      "type": "billing",
      "formatted_address": "21 MG Road",
      "country": "IN",
      "administrative_area_level_1": "MH",
      "locality": "Mumbai",
      "postal_code": "425360",
      "is_google_address": false,
      "is_primary": true
    },
    {
      "type": "shipping",
      "formatted_address": "21 MG Road",
      "country": "IN",
      "administrative_area_level_1": "MH",
      "locality": "Mumbai",
      "postal_code": "425360",
      "is_google_address": false,
      "is_primary": true
    }
  ]
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/customers/16908' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "contact_infos": {
      "title": "Ms",
      "first_name": "Priya",
      "last_name": "Sharma",
      "email": "priya.sharma@example.com",
      "phone_number": "919685745259"
    },
    "addresses": [
      {
        "type": "billing",
        "formatted_address": "21 MG Road",
        "country": "IN",
        "administrative_area_level_1": "MH",
        "locality": "Mumbai",
        "postal_code": "425360",
        "is_google_address": false,
        "is_primary": true
      },
      {
        "type": "shipping",
        "formatted_address": "21 MG Road",
        "country": "IN",
        "administrative_area_level_1": "MH",
        "locality": "Mumbai",
        "postal_code": "425360",
        "is_google_address": false,
        "is_primary": true
      }
    ]
  }'

Example response

The same record you get back from create, so both can share one model on your side.

200 OK
{
  "id": 16908,
  "title": null,
  "first_name": "Priya",
  "last_name": "Sharma",
  "display_name": "Priya Sharma",
  "email": "priya.sharma@example.com",
  "phone_number": "919685745259",
  "addresses": [
    {
      "id": "14893",
      "type": "billing",
      "formatted_address": "A-5, Block A, Sector 26A, Gurugram, Haryana 122002, India",
      "administrative_area_level_1": "HR",
      "administrative_area_level_2": "Gurgaon Division",
      "country": "IN",
      "locality": "Gurugram",
      "postal_code": "122002",
      "route": "A-5",
      "street_number": null,
      "google_place_id": "EjlBLTUsIEJsb2NrIEEsIFNlY3RvciAyNkE...",
      "latitude": 28.4728561,
      "longitude": 77.0995667,
      "is_google_address": true,
      "address_line_1": null,
      "address_line_2": null,
      "is_primary": true
    }
  ],
  "open_balance": 0,
  "over_due": 0,
  "is_active": true
}

Errors

404 Not Found
{
  "message": "Customer not found"
}

Status codes

StatusMeaning
200Customer updated. The record is returned.
404No customer with that id.
422A required field is missing or an address entry is invalid.

List Customers

Read your customers back, a page at a time.

GET/customersX-Tenant + API Key required

Returns your customers in pages. Use the id of the one you want when you raise an invoice. Each entry carries the customer's addresses and outstanding balance, so a customer list in your own UI does not need a second call per row.

Query parameters

FieldTypeRequiredDescription
pageintegerRequiredWhich page to return, starting at 1.
page_lengthintegerRequiredHow many customers per page. See the warning below — the example did not get back the size it asked for.
typestringRequiredWhich kind of contact to return — customer in the capture. Probably redundant now that the path itself says customers; see the note below.
sortstringRequiredA JSON object, sent as a string, with sort_by and sort_order. Both empty strings in the example, which gives you the default order.
searchstringRequiredA JSON object, sent as a string, holding your filters. The example sends {"bothActiveInactive":2}.

Both JSON parameters must be URL-encoded

sort and search carry braces and quotes, so encode them before putting them in the query string — --data-urlencode in cURL, or your HTTP client's own parameter handling. Pasting the raw JSON into a URL will not work.

search and sort values are only partly known

  • bothActiveInactive with the value 2 is the one filter confirmed to work. Judging by the name it controls whether inactive customers are included, but what 1 and 0 do has not been supplied — so this is the only value you can rely on today.
  • Which other keys search accepts — by name, by email, by balance — is not documented.
  • Which column names sort_by takes, and whether sort_order wants asc/desc, has not been supplied. Send both empty for the default order.

type is probably no longer needed

The capture below was taken when this endpoint was /contacts — a shared endpoint for every kind of contact, where type=customer was what narrowed it to customers. Now that the path is /customers, that filter has nothing left to do. Confirm whether it can be dropped.

Example request

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/customers' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'page=1' \
  --data-urlencode 'page_length=10' \
  --data-urlencode 'type=customer' \
  --data-urlencode 'sort={"sort_by":"","sort_order":""}' \
  --data-urlencode 'search={"bothActiveInactive":2}'

Example response

The customers are in data, with the paging fields around it. Build your paging from current_page, last_page and total, not from the URLs in the response.

This capture predates the current shape

It was taken from /contacts, before GET /products and GET /invoices moved to a data plus meta envelope with slimmer entries. Expect this endpoint to have changed the same way — read the response below for the field names, not as the current shape.

page_length did not take effect

The example asked for page_length=10 against 6 total customers, which should be a single page. What came back was per_page: 1 and last_page: 6 — one customer per page. Either page_length is ignored on this endpoint or it is read from somewhere else. Do not assume the page size you ask for is the page size you get: read per_page and last_page off the response and page until current_page reaches last_page.

Fields worth knowing

FieldWhat it tells you
display_nameWhat to show in your UI. It is the company name for a business and the person's own name for an individual, so you never have to assemble it from the name parts.
open_balance / over_dueWhat the customer owes in total, and how much of that is past its due date. Both 600 in the example, meaning the whole balance is overdue.
addressesThe billing and shipping addresses, each tagged with its type — the same shape you sent when you created the customer.
add_infosThe extra customer record — payment term, GST treatment, delivery method, classification. Always an array, with one entry per contact.
typeThe contact kind, echoing the type you filtered on.
transactionsPresent on the list but empty in the example. What populates it has not been confirmed.

Ignore the fields that are not about billing

add_infos carries pets, resident_access and occupants, and the contact carries renter_insurance and tds_config. These belong to other products built on the same contact record and mean nothing for invoicing — leave them alone.
200 OK
{
    "current_page": 1,
    "data": [
        {
            "id": 5,
            "shopify_id": null,
            "uuid": "107cacec-53d3-407b-8bb2-cca2f9bb14ce",
            "title": null,
            "first_name": "David",
            "middle_name": "Kwan",
            "last_name": "Chen",
            "display_name": "David Kwan Chen",
            "name_on_checks": null,
            "email": "david.chen@example.com",
            "phone_number": "+918746145263",
            "mobile_number": null,
            "type": "customer",
            "created_at": "2026-03-09T13:15:34.000000Z",
            "updated_at": "2026-03-09T13:15:34.000000Z",
            "deleted_at": null,
            "is_active": 1,
            "open_balance": 600,
            "over_due": 600,
            "transactions": [],
            "notes": [],
            "attachments": [],
            "addresses": [
                {
                    "id": "5",
                    "administrative_area_level_1": "Bengkulu",
                    "administrative_area_level_2": "Bengkulu City",
                    "country": "ID",
                    "formatted_address": "Bengkulu",
                    "google_place_id": "ChIJeZLjNx6wNi4R6qaQ53a1eaA",
                    "locality": "Bengkulu",
                    "postal_code": null,
                    "route": null,
                    "street_number": null,
                    "latitude": -3.7928451,
                    "longitude": 102.2607641,
                    "type": "shipping",
                    "is_google_address": true,
                    "address_line_1": null,
                    "address_line_2": null,
                    "is_primary": false
                },
                {
                    "id": "5",
                    "administrative_area_level_1": "Bengkulu",
                    "administrative_area_level_2": "Bengkulu City",
                    "country": "ID",
                    "formatted_address": "Bengkulu",
                    "google_place_id": "ChIJeZLjNx6wNi4R6qaQ53a1eaA",
                    "locality": "Bengkulu",
                    "postal_code": null,
                    "route": null,
                    "street_number": null,
                    "latitude": -3.7928451,
                    "longitude": 102.2607641,
                    "type": "billing",
                    "is_google_address": true,
                    "address_line_1": null,
                    "address_line_2": null,
                    "is_primary": false
                }
            ],
            "tax_rates": [],
            "add_infos": [
                {
                    "id": 7,
                    "parent_id": null,
                    "customer_type": 1,
                    "company_name": null,
                    "suffix": null,
                    "fax": null,
                    "website": null,
                    "other": null,
                    "exemption_id": null,
                    "exemption_details": null,
                    "opening_balance": null,
                    "as_of_balance": null,
                    "payment_method_id": null,
                    "delivery_method": "none",
                    "term_id": 4,
                    "is_tax_exempt": false,
                    "tax_number": null,
                    "gst_treatment": "Unregistered Business",
                    "customer_classification": "regular",
                    "sez_supply_mode": null,
                    "lut_reference": null,
                    "pets": false,
                    "resident_access": false,
                    "occupants": null,
                    "user_id": null
                }
            ],
            "tds_config": null,
            "renter_insurance": null
        }
    ],
    "first_page_url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=1",
    "from": 1,
    "last_page": 6,
    "last_page_url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=6",
    "links": [
        {
            "url": null,
            "label": "« Previous",
            "active": false
        },
        {
            "url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=1",
            "label": "1",
            "active": true
        },
        {
            "url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=2",
            "label": "2",
            "active": false
        },
        {
            "url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=2",
            "label": "Next »",
            "active": false
        }
    ],
    "next_page_url": "https://services.ap.mochatechnologies.com/quickbill/api/customers?page=2",
    "path": "https://services.ap.mochatechnologies.com/quickbill/api/customers",
    "per_page": 1,
    "prev_page_url": null,
    "to": 1,
    "total": 6
}

Status codes

StatusMeaning
200The page is returned, even when data is empty.

Get a Customer

Read a single customer back by its id.

GET/customers/:idX-Tenant + API Key required

Returns one customer, with no paging envelope around it. This is the call to make before raising an invoice: it gives you the billing and shipping addresses to pass through, and the term_id that decides the due date.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe customer's id, as returned by POST /customers or found in the list. The example reads customer 5.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/customers/5' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

Mostly the same record you get inside data when you list customers — but not identically. Three differences matter if you write one piece of code to read both:

FieldIn the listHere
is_active1 — an integertrue — a boolean
The uuiduuiduser_id — same value, different field name
Timestampscreated_at, updated_at, deleted_atNot returned

Do not share one parser between the two

Because of the differences above, code that reads is_active as a boolean will misread the list, and code that reads uuid will find nothing here. Normalise both shapes into your own model as soon as you receive them.

Fields only this endpoint returns

FieldWhat it is
contact_personsAdditional people to deal with at the customer. Empty in the example.
custom_fieldsYour own fields on the contact. Empty in the example.
home_no, business_no, other_contactExtra phone numbers beyond phone_number and mobile_number.
tax_profileTax profile attached to the contact. Null in the example.

Both addresses come back with the same id

The shipping and billing entries are two different addresses, but both carry "id": "5" — the same value as the contact's own id, and a string rather than a number. Whatever that field is, it does not identify the address, so key your UI on type instead. This needs checking on the API side.
200 OK
{
    "id": 5,
    "user_id": "107cacec-53d3-407b-8bb2-cca2f9bb14ce",
    "title": null,
    "first_name": "David",
    "middle_name": "Kwan",
    "last_name": "Chen",
    "display_name": "David Kwan Chen",
    "name_on_checks": null,
    "email": "david.chen@example.com",
    "phone_number": "+918746145263",
    "mobile_number": null,
    "type": "customer",
    "shopify_id": null,
    "tax_number": null,
    "tax_profile": null,
    "notes": [],
    "attachments": [],
    "addresses": [
        {
            "id": "5",
            "administrative_area_level_1": "Bengkulu",
            "administrative_area_level_2": "Bengkulu City",
            "country": "ID",
            "formatted_address": "Bengkulu",
            "google_place_id": "ChIJeZLjNx6wNi4R6qaQ53a1eaA",
            "locality": "Bengkulu",
            "postal_code": null,
            "route": null,
            "street_number": null,
            "latitude": -3.7928451,
            "longitude": 102.2607641,
            "type": "shipping",
            "is_google_address": true,
            "address_line_1": null,
            "address_line_2": null,
            "is_primary": false
        },
        {
            "id": "5",
            "administrative_area_level_1": "Bengkulu",
            "administrative_area_level_2": "Bengkulu City",
            "country": "ID",
            "formatted_address": "Bengkulu",
            "google_place_id": "ChIJeZLjNx6wNi4R6qaQ53a1eaA",
            "locality": "Bengkulu",
            "postal_code": null,
            "route": null,
            "street_number": null,
            "latitude": -3.7928451,
            "longitude": 102.2607641,
            "type": "billing",
            "is_google_address": true,
            "address_line_1": null,
            "address_line_2": null,
            "is_primary": false
        }
    ],
    "tax_rates": [],
    "add_infos": [
        {
            "id": 7,
            "parent_id": null,
            "customer_type": 1,
            "company_name": null,
            "suffix": null,
            "fax": null,
            "website": null,
            "other": null,
            "exemption_id": null,
            "exemption_details": null,
            "opening_balance": null,
            "as_of_balance": null,
            "payment_method_id": null,
            "delivery_method": "none",
            "term_id": 4,
            "is_tax_exempt": false,
            "tax_number": null,
            "gst_treatment": "Unregistered Business",
            "customer_classification": "regular",
            "sez_supply_mode": null,
            "lut_reference": null,
            "pets": false,
            "resident_access": false,
            "occupants": null,
            "user_id": null
        }
    ],
    "open_balance": 600,
    "over_due": 600,
    "is_active": true,
    "custom_fields": [],
    "contact_persons": [],
    "home_no": null,
    "business_no": null,
    "other_contact": null,
    "tds_config": null,
    "association_due": 0
}

Status codes

StatusMeaning
200The contact is returned.
404No contact with that id on your account. Not verified — the response for an unknown id has not been supplied.

Get an Invoice Number

Take the next number in your sequence.

GET/invoices/get-invoice-numberX-Tenant + API Key required

Returns the next invoice number for your account. Call this before creating an invoice and use what it gives you — the numbering is the server's to keep, not yours to generate.

Parameters

None. No query string, no body — just the two authentication headers.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/invoices/get-invoice-number' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

One field, and it is the whole point of the call:

200 OK
{
  "invoice_number": "INV-2025-001"
}

Where the value goes

Into invoice_no on the create body, and nowhere else. The server copies it into unique_no, reference_no and tracking_no for you — you will see all four come back on the response.

Never parse or predict the format

Three different formats have been seen from this API — INV-2025-001 here, INV-00016 in the create example, and INVOICE-387 on another account. The prefix, the padding and whether a year appears all vary. Treat the value as an opaque string: do not split it, do not increment it, and do not build the next one from the last one you saw.

Two things to confirm

  • Whether calling this reserves the number or only previews it. If it is a preview, two requests in parallel can both be handed the same number and the second invoice will collide — so until this is confirmed, take the number and create the invoice straight away rather than holding it.
  • What happens if you create an invoice with a number you did not get from here, or reuse one. Whether the API rejects the duplicate or accepts it has not been supplied.

Status codes

StatusMeaning
200The next number is returned.

Create an Invoice

Bill a customer for one or more products.

POST/invoicesX-Tenant + API Key required

Creates an invoice against an existing customer. Every line points at a product, so the customer and the products have to exist first. The server works out the totals — you do not send them.

Four things are required, and that is all

customer_id, invoice_date, invoice_no and at least one entry in lines. Everything else — due date, shipping date, the message, the whole addresses array — is optional.

Body parameters

FieldTypeRequiredDescription
customer_idintegerRequiredThe id returned when you created the customer via POST /customers.
invoice_datestringRequiredDate the invoice is raised, as YYYY-MM-DD.
invoice_nostringRequiredThe invoice number, up to 100 characters. Take it from GET /invoices/get-invoice-number rather than generating your own.
linesarrayRequiredThe products being billed. At least one entry. See the table below.
due_datestringOptionalDate payment is due, as YYYY-MM-DD.
shipping_datestringOptionalDate the goods ship, as YYYY-MM-DD.
message_on_invoicestring | nullOptionalA note to the customer, shown on the invoice. Send null or leave it out for none.
addressesarrayOptionalBilling and shipping addresses. Optional as a whole — see the table below.

Do not send the totals

amount, balance and the per-line amount are not request fields — the server calculates them. Same for unique_no, reference_no and tracking_no, which are all filled in from invoice_no. Send only what is in the table above.

lines

One entry per product being billed.

FieldTypeRequiredDescription
product_idintegerRequiredThe id returned when you created the product via POST /products.
ratenumberRequiredPrice per unit, as a plain number, zero or more. Overrides the product's own price.
quantitynumberRequiredHow many units. Must be a whole number greater than zero — fractional quantities are rejected.

Plain numbers, not strings

rate and quantity are numbers — 120 and 1, not "120.00" or "1.0000000000". The response returns them as numbers too.

addresses

Optional. When you do send it, each entry is tagged billing or shipping — those are the only two values accepted. An entry comes in one of two shapes depending on where the address came from.

Typed by hand

FieldTypeRequiredDescription
typestringRequiredbilling or shipping.
addressstringOptionalStreet line — for example Plot 23, MG Road.
citystringOptionalCity name.
statestringOptionalState, spelled out — Maharashtra, not MH, in the example.
zip_codestringOptionalPostal or PIN code, as a string.
countrystringOptionalCountry, spelled out — India, not IN, in the example.

From Google Places

FieldTypeRequiredDescription
is_google_addressintegerRequired1 on a Google-sourced address. Note this is the integer 1, not true.
google_place_idstringOptionalThe Places identifier for the selected address.
addressstringOptionalThe address as Google returned it.
latitudestringOptionalLatitude, as a string — "19.0760", not a number.
longitudestringOptionalLongitude, as a string.

These are not the field names the customer endpoint uses

An invoice address takes address, city, state, zip_code and country. The same address on POST /customers takes formatted_address, locality, administrative_area_level_1, postal_code and a two-letter country. You cannot read an address off a customer and post it straight onto an invoice — map the fields across, and note the invoice wants full names (Maharashtra, India) where the customer wants codes (MH, IN).

is_google_address flips type between the two endpoints too

Here it is the integer 1. On POST /customers it is the boolean true. Latitude and longitude are strings here and numbers there. Convert rather than copying.

What the server works out for you

FieldHow it is derived
unique_no, reference_no, tracking_noAll three copied from invoice_no.
lines[].amountrate × quantity. In the example: 120 × 1 = 120, and 500 × 3 = 1500.
amountThe sum of every line amount — 120 + 1500 = 1620.
balanceEqual to amount, since nothing has been paid on a new invoice.
lines[].idEach line is assigned its own id.

Validation

FieldRuleMessage
customer_idrequired, integerThe customer field is required.
invoice_daterequired, dateThe invoice date field is required.
invoice_norequired, max 100 charactersThe invoice number field is required.
linesrequired, at least one entryThe line items field is required.
lines[].product_idrequired, integerThe product field is required.
lines[].raterequired, numeric, zero or moreThe rate field is required.
lines[].quantityrequired, numeric, greater than zero, whole numberThe quantity must be a whole number.
message_on_invoiceoptional, string or null
addresses[].typebilling or shipping, when an address is sent

Quantity has to be whole

Fractional quantities are rejected. If you bill in halves or hours, put the fraction into rate and keep quantity at a whole number — 1 × 750 rather than 1.5 × 500.

The messages name the field differently to the payload

The validation text says “the customer field” for customer_id, “the line items field” for lines, “the product field” for product_id. These are written for people, not for your code — match on the field key you sent, not on the message text.

Example request

JSON body
{
  "customer_id": 16909,
  "invoice_date": "2026-08-13",
  "invoice_no": "INV-00016",
  "due_date": "2026-08-28",
  "shipping_date": "2026-08-20",
  "message_on_invoice": "Thanks for your business",
  "lines": [
    { "product_id": 3204, "rate": 120, "quantity": 1 },
    { "product_id": 3205, "rate": 500, "quantity": 3 }
  ],
  "addresses": [
    {
      "type": "billing",
      "address": "Plot 23, MG Road",
      "city": "Mumbai",
      "state": "Maharashtra",
      "zip_code": "400001",
      "country": "India"
    },
    {
      "type": "shipping",
      "is_google_address": 1,
      "google_place_id": "ChIJ...",
      "address": "...",
      "latitude": "19.0760",
      "longitude": "72.8777"
    }
  ]
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/invoices' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_id": 16909,
    "invoice_date": "2026-08-13",
    "invoice_no": "INV-00016",
    "due_date": "2026-08-28",
    "shipping_date": "2026-08-20",
    "message_on_invoice": "Thanks for your business",
    "lines": [
      { "product_id": 3204, "rate": 120, "quantity": 1 },
      { "product_id": 3205, "rate": 500, "quantity": 3 }
    ],
    "addresses": [
      {
        "type": "billing",
        "address": "Plot 23, MG Road",
        "city": "Mumbai",
        "state": "Maharashtra",
        "zip_code": "400001",
        "country": "India"
      },
      {
        "type": "shipping",
        "is_google_address": 1,
        "google_place_id": "ChIJ...",
        "address": "...",
        "latitude": "19.0760",
        "longitude": "72.8777"
      }
    ]
  }'

Example: the minimum

Four fields, one line, no addresses. Everything else is filled in by the server:

Minimum body
{
  "customer_id": 16909,
  "invoice_date": "2026-08-13",
  "invoice_no": "INV-00016",
  "lines": [
    { "product_id": 3204, "rate": 120, "quantity": 1 }
  ]
}

Example response

The created invoice, with the customer expanded inline and the totals calculated. Store the id — it is what you pass in paidAmount when you record a payment.

200 OK
{
  "id": 1185,
  "customer_id": 16909,
  "customer": {
    "id": 16909,
    "title": "Mr",
    "first_name": "Aarav",
    "last_name": "Mehta",
    "display_name": "Aarav Mehta",
    "email": "aarav.mehta@example.com",
    "phone_number": "+91-9876543210",
    "addresses": [ { "...": "as stored" } ],
    "add_infos": [ { "id": 88, "company_name": "Mehta Traders" } ],
    "open_balance": 1620,
    "over_due": 0,
    "is_active": true
  },
  "invoice_date": "2026-08-13",
  "due_date": "2026-08-28",
  "shipping_date": "2026-08-20",
  "unique_no": "INV-00016",
  "reference_no": "INV-00016",
  "tracking_no": "INV-00016",
  "message_on_invoice": "Thanks for your business",
  "lines": [
    { "id": 1492, "product_id": 3204, "quantity": 1, "rate": 120, "amount": 120 },
    { "id": 1493, "product_id": 3205, "quantity": 3, "rate": 500, "amount": 1500 }
  ],
  "addresses": [ { "...": "as stored" } ],
  "amount": 1620,
  "balance": 1620
}

Reading the response

FieldWhat it is
idThe invoice id — 1185 here. Use it to read the invoice back, and to apply payments to it.
customerThe customer expanded inline, with its addresses and add_infos, so an invoice screen needs no second call.
customer.open_balanceAlready includes this invoice — 1620 in the example, matching its balance.
amount / balanceBoth 1620 on a fresh invoice. balance drops as payments are applied.
linesYour lines with an id and the calculated amount added. rate and quantity come back as you sent them.
addressesThe addresses as stored.

The embedded customer balance is live here

customer.open_balance on this response already reflects the invoice you just created. That is worth knowing because the same field comes back null inside GET /invoices — so trust it here, not there.

Status codes

StatusMeaning
200Invoice created. The record is returned.
404The customer or one of the products on this invoice was not found.
422Validation failed, or the invoice could not be created with the details provided.

404 covers two different mistakes

A bad customer_id and a bad product_id both return “The customer or one of the products on this invoice was not found”, and the message does not say which. With several lines you will not be told which product is the problem either — check the ids yourself before blaming the invoice.

Update an Invoice

Replace an invoice — only while nothing has been paid on it.

PUT/invoices/:idX-Tenant + API Key required

Replaces an invoice. Send the whole invoice as it should end up, not only the fields that changed.

A paid invoice cannot be edited at all

An invoice is editable only while nothing has been received against it. The moment its balance drops below its amount — by any amount at all — this endpoint refuses. See the table below.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe invoice's id, as returned by POST /invoices. It travels into the body for you — you do not send it twice.

Body parameters

FieldTypeRequiredDescription
customer_idintegerRequiredThe customer the invoice is issued to.
invoice_datedateRequiredThe date on the invoice.
invoice_nostringRequiredThe customer-facing number. Max 100.
message_on_invoicestringOptionalFree text printed on the invoice.
linesarrayRequiredAt least one line. See the table below.
addressesarrayOptionalEach entry needs a type billing, location, shipping or ship_via.

lines[]

FieldTypeRequiredDescription
product_idintegerRequiredThe product this line bills for.
ratenumberRequiredPrice per unit. Cannot be negative.
quantityintegerRequiredA whole number, greater than zero.

What the server fills in

FieldHow it is worked out
unique_no, tracking_no, reference_noAll three from invoice_no, if you did not send them.
lines[].amountrate × quantity, per line.
amountThe total of every line.

The lines you send become the whole invoice

lines is a full replacement, not an append. A line you leave out is removed from the invoice — so read the invoice first, and send back every line you want to keep.

When an edit is allowed

amountbalanceEditable?
500500Yes — nothing has been received.
500499.99No
5000No
422 Unprocessable
{
  "message": "This invoice cannot be edited because a payment has already been received against it."
}

Check before you offer an edit

Read the invoice with GET /invoices/:id and compare balance against amount. Equal means editable; anything less means grey the button out rather than letting your user fill in a form that will be refused.

Example request

JSON body
{
  "customer_id": 1125,
  "invoice_date": "2026-09-08",
  "invoice_no": "INVOICE-400",
  "message_on_invoice": "Thanks for your business.",
  "lines": [
    { "product_id": 90, "rate": 500, "quantity": 2 }
  ],
  "addresses": [
    { "type": "billing", "locality": "Mumbai" }
  ]
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/invoices/1185' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_id": 1125,
    "invoice_date": "2026-09-08",
    "invoice_no": "INVOICE-400",
    "message_on_invoice": "Thanks for your business.",
    "lines": [
      { "product_id": 90, "rate": 500, "quantity": 2 }
    ],
    "addresses": [
      { "type": "billing", "locality": "Mumbai" }
    ]
  }'

Example response

The whole invoice, in the same shape create returns.

200 OK
{
  "id": 1185,
  "customer_id": 16909,
  "customer": {
    "id": 16909,
    "title": "Mr",
    "first_name": "Aarav",
    "last_name": "Mehta",
    "display_name": "Aarav Mehta",
    "email": "aarav.mehta@example.com",
    "phone_number": "+91-9876543210",
    "addresses": [ { "...": "as stored" } ],
    "add_infos": [ { "id": 88, "company_name": "Mehta Traders" } ],
    "open_balance": 1620,
    "over_due": 0,
    "is_active": true
  },
  "invoice_date": "2026-08-13",
  "due_date": "2026-08-28",
  "shipping_date": "2026-08-20",
  "unique_no": "INV-00016",
  "reference_no": "INV-00016",
  "tracking_no": "INV-00016",
  "message_on_invoice": "Thanks for your business",
  "lines": [
    { "id": 1492, "product_id": 3204, "quantity": 1, "rate": 120, "amount": 120 },
    { "id": 1493, "product_id": 3205, "quantity": 3, "rate": 500, "amount": 1500 }
  ],
  "addresses": [ { "...": "as stored" } ],
  "amount": 1620,
  "balance": 1620
}

Status codes

StatusMeaning
200The invoice is updated and returned.
404The invoice, the customer, or one of the products was not found.
422Validation failed, or a payment has already been received against the invoice.

404 covers three different things

The invoice, the customer you named, or any product on a line. The message does not say which, so validate the ids you are about to send rather than working backwards from the status.

List Invoices

Read your invoices back, a page at a time.

GET/invoicesX-Tenant + API Key required

Returns your invoices in pages. Each entry is the full invoice record — the customer expanded inline, the lines with their amounts, and the totals — so an invoice list screen needs no extra call per row.

Query parameters

FieldTypeRequiredDescription
pageintegerRequiredWhich page to return, starting at 1.
page_lengthintegerRequiredHow many invoices per page. Comes back as meta.per_page.
searchstringRequiredA JSON object, sent as a string, holding your filters. Send {} for no filter, and URL-encode it. Which keys it accepts has not been supplied.

Example request

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/invoices' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'page=1' \
  --data-urlencode 'page_length=1' \
  --data-urlencode 'search={}'

Example response

Two keys: the invoices in data, and the paging in meta — the same envelope GET /products uses. Each entry is the same record POST /invoices and GET /invoices/:id return, so one invoice model in your code covers all three.

meta

FieldWhat it is
totalHow many invoices exist in total, across every page — 8 in the example.
current_pageThe page you are on, echoing the page you asked for.
last_pageThe highest page number available. Stop paging when current_page reaches it.
per_pagePage size in effect, echoing page_length.
from / toPosition of the first and last item on this page within the full set.

No paging URLs to worry about

Nothing in this response points anywhere — no next_page_url, links or path. Page with current_page against last_page.

Fields worth knowing

FieldWhat it tells you
amount / balanceWhat the invoice is for, and what is still owed. Equal on an invoice nothing has been paid against.
customerThe customer expanded inline, so a list screen needs no extra call per row.
linesThe lines in full, each with its id and calculated amount.
unique_no, reference_no, tracking_noAll three carry the invoice number.

Whether anything has been paid

There is no status field to read. Compare balance against amount — equal means nothing paid, zero means settled, anything between is a part payment.
200 OK (data trimmed to one of eight invoices)
{
  "data": [
    {
      "id": 1185,
      "customer_id": 16909,
      "customer": { "...": "as stored" },
      "invoice_date": "2026-08-13",
      "due_date": "2026-08-28",
      "shipping_date": "2026-08-20",
      "unique_no": "INV-00016",
      "reference_no": "INV-00016",
      "tracking_no": "INV-00016",
      "message_on_invoice": "Thanks for your business",
      "lines": [
        { "id": 1492, "product_id": 3204, "quantity": 1, "rate": 120, "amount": 120 },
        { "id": 1493, "product_id": 3205, "quantity": 3, "rate": 500, "amount": 1500 }
      ],
      "addresses": [ { "...": "as stored" } ],
      "amount": 1620,
      "balance": 1620
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "last_page": 1,
    "total": 8,
    "from": 1,
    "to": 8
  }
}

Status codes

StatusMeaning
200The page is returned, even when data is empty.

Get an Invoice

Read one invoice back in full.

GET/invoices/:idX-Tenant + API Key required

Returns one invoice — the same record POST /invoices hands back when it creates one, with the customer expanded inline, the lines with their calculated amounts, and the addresses as stored. This is the call for an invoice detail screen, and the way to check a balance after a payment.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe invoice's id, as returned by POST /invoices or found in the list. The example reads invoice 1185.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/invoices/7' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

Identical in shape to the create response, so one invoice model in your code covers both calls:

200 OK
{
  "id": 1185,
  "customer_id": 16909,
  "customer": {
    "id": 16909,
    "title": "Mr",
    "first_name": "Aarav",
    "last_name": "Mehta",
    "display_name": "Aarav Mehta",
    "email": "aarav.mehta@example.com",
    "phone_number": "+91-9876543210",
    "addresses": [ { "...": "as stored" } ],
    "add_infos": [ { "id": 88, "company_name": "Mehta Traders" } ],
    "open_balance": 1620,
    "over_due": 0,
    "is_active": true
  },
  "invoice_date": "2026-08-13",
  "due_date": "2026-08-28",
  "shipping_date": "2026-08-20",
  "unique_no": "INV-00016",
  "reference_no": "INV-00016",
  "tracking_no": "INV-00016",
  "message_on_invoice": "Thanks for your business",
  "lines": [
    { "id": 1492, "product_id": 3204, "quantity": 1, "rate": 120, "amount": 120 },
    { "id": 1493, "product_id": 3205, "quantity": 3, "rate": 500, "amount": 1500 }
  ],
  "addresses": [ { "...": "as stored" } ],
  "amount": 1620,
  "balance": 1620
}

Reading the response

FieldWhat it is
balanceWhat is still owed. This is the field to read after recording a payment — it is the only place the new figure appears.
amountThe invoice total. Compare it against balance to tell whether anything has been paid.
customerThe customer expanded inline, with its addresses, add_infos and open_balance — no second call needed.
linesEach line with its own id, the product_id, quantity, rate and the calculated amount.
addressesThe addresses as stored on the invoice. The list endpoint returns this empty, so this is where to read them.
unique_no, reference_no, tracking_noAll three carry the invoice number. There is no separate invoice_no on the response.

This is how you check whether an invoice is settled

Recording a payment does not return the new balance, so read the invoice back with this call afterwards and compare balance against amount. Equal means nothing paid; zero means settled; anything between is a part payment.

message_on_invoice can contain HTML

The example shows plain text, but the field holds whatever was written into it — invoices created through the web app come back with markup (<p> tags and the like). If you render it, sanitise it first and never inject it straight into the DOM; if you only need text, strip the tags rather than escaping the whole string.

Status codes

StatusMeaning
200The invoice is returned.
404No invoice with that id. Not verified — the response body for an unknown id has not been supplied.

Payment Methods

How the money was paid — the ids a payment can use.

GET/payment-methodsX-Tenant + API Key required

Returns the ways a payment can be taken. Call it to populate a “how did they pay” picker, then send the chosen id as payment_method on POST /payments.

Query parameters

None. Every method comes back in one call — no page, no search, and no meta block.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/payment-methods' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

200 OK
{
  "data": [
    { "id": 1, "name": "Cash" },
    { "id": 2, "name": "Cheque" },
    { "id": 3, "name": "Credit Card" }
  ]
}

An id and a name, nothing more

id is what you send, name is what you show. There is no flag telling you which method needs extra details — a cheque number, a card reference — so collect that on your side if you need it.

Do not hard-code the ids

The list is per tenant, so 1 meaning Cash in your own tenant is no guarantee it means Cash in your customer's. Read it at runtime.

Status codes

StatusMeaning
200The methods are returned.
500Unexpected error.

Get a Payment Number

Take the next reference in your payment sequence.

GET/payments/get-next-payment-numberX-Tenant + API Key required

Returns the next payment reference for your account. Call this before recording a payment and send what it gives you as reference_no.

Parameters

None. No query string, no body — just the two authentication headers.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/payments/get-next-payment-number' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

The reference is nested one level down, under data:

200 OK
{
  "success": true,
  "data": {
    "reference_no": "PMT-00417"
  }
}

Not the same envelope as the invoice number endpoint

The two number endpoints do not match, in three ways at once:
  • The invoice one returns the value at the top level; this one nests it under data.
  • The invoice one has no success flag; this one does.
  • The field is called invoice_number there and reference_no here.
So response.invoice_number and response.data.reference_no — do not write one helper for both.

Never parse or predict the format

Treat the value as an opaque string. Do not split the PMT- prefix off, do not increment the digits, and do not build the next reference from the last one you saw — the format varies between accounts, exactly as invoice numbers do.

Two things to confirm

  • Whether calling this reserves the reference or only previews it. If it is a preview, two requests in parallel can both be handed PMT-00417 — so take the reference and record the payment straight away rather than holding it.
  • What success: false looks like, and when it happens. Only the success case has been supplied, so check the flag rather than assuming data is always there.

Status codes

StatusMeaning
200The next reference is returned.

Receive a Payment

Record a payment against a customer's open invoices.

POST/paymentsX-Tenant + API Key required

Records money received from a customer and applies it to the invoices you name. One call can settle several invoices at once — list each of them in paidAmount with the amount going to it. The invoice has to exist first, so create it with POST /invoices before you call this.

Body parameters

All six are required.

FieldTypeRequiredDescription
customer_idintegerRequiredThe customer the money came from — the id from POST /customers. Every invoice in paidAmount must belong to this customer.
account_idintegerRequiredId of the account the money is deposited into. Take it from GET /bank-accounts and send the one your user picked.
payment_methodintegerRequiredHow the money was paid. Take the id from GET /payment-methods and send the one your user picked.
payment_datestringRequiredDate the money was received, as YYYY-MM-DD.
reference_nostringRequiredThe payment's own reference — PMT-00004 in the example. Take it from GET /payments/get-next-payment-number rather than generating it yourself.
paidAmountarrayRequiredWhich invoices the money is applied to, and how much goes to each. See the table below.

paidAmount is camelCase

Every other field on this endpoint is snake_case, but this one is paidAmount. Sending paid_amount will not work.

payment_method is an integer

Send 1, not "1". The ids and what each one means come from GET /payment-methods.

Two lookups before you record a payment

account_id comes from GET /bank-accounts and payment_method from GET /payment-methods. Both sets of ids differ per tenant, so read them at runtime rather than hard-coding a value that happens to work in your own.

paidAmount

One entry per invoice the payment is applied to.

FieldTypeRequiredDescription
idintegerRequiredThe invoice's id, as returned by POST /invoices. Invoice 8 in the example is the one that comes back as INV-00008.
paymentstringRequiredAmount applied to that invoice, as a string with two decimal places. Must be greater than zero.

Two decimal places, as a string, on the way in

"1500.00" — a string here, even though every money value in a response comes back as a plain number. Send it as shown.

Partial payments work

Send less than the outstanding balance and the rest stays owed. Read the invoice back with GET /invoices/:id afterwards and its balance will show the remainder. Record the rest later against the same invoice id.

Example request

JSON body
{
  "customer_id": 5,
  "account_id": 21,
  "payment_method": 1,
  "payment_date": "2026-08-10",
  "reference_no": "PMT-00004",
  "paidAmount": [
    { "id": 8, "payment": "1500.00" }
  ]
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/payments' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_id": 5,
    "account_id": 21,
    "payment_method": 1,
    "payment_date": "2026-08-10",
    "reference_no": "PMT-00004",
    "paidAmount": [
      { "id": 8, "payment": "1500.00" }
    ]
  }'

Example response

Unlike the other endpoints on this page, this one does not return the record it created — just two fields:

FieldWhat it is
payment_idId of the payment record that was created. Store it against your own transaction — this is the only place you get it, and it is what you pass to GET /payments/:id.
invoice_numbersThe customer-facing numbers of the invoices the payment was applied to — INV-00008 here.
201 Created
{
  "invoice_numbers": "INV-00008",
  "payment_id": 6
}

201, not 200

This is the only endpoint on the page that answers with 201. Creating a product, a customer or an invoice all return 200. If your client treats anything other than 200 as a failure, a payment that was recorded perfectly well will look like an error.

There is no success flag to check

The body carries no success field, so the status code is all you have. Treat 201 as recorded and anything else as not recorded.

Re-fetch the invoice to see the new balance

The response does not include the amount applied or the remaining balance. If you need to know whether the invoice is now settled, read it back with GET /invoices/:id after the call and check its balance.

invoice_numbers is a single string

The field is plural, but the example only ever paid one invoice, so it came back as one number. How several are joined when paidAmount has more than one entry has not been confirmed — do not parse it until it has.

Validation errors

A 422 carries a message and an errors object keyed by field:

422 Unprocessable
{
  "message": "The invoice field is required.",
  "errors": {
    "paidAmount.0.id": ["The invoice field is required."]
  }
}

The error keys are dotted paths into the array

A problem inside paidAmount is reported as paidAmount.0.id — the array index is part of the key. To highlight the right row in a form, split the key on dots rather than looking for a plain field name. And as elsewhere, the message text names the field for people (“the invoice field”), not by its key.

Status codes

StatusMeaning
201Payment recorded.
422Validation failed — a required field is missing, or a value is not acceptable.
500Unexpected error.

Update a Payment

Replace a recorded payment and what it settles.

PUT/payments/:idX-Tenant + API Key required

Replaces a payment. Send the whole payment as it should end up, not only the fields that changed.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe payment_id that POST /payments returned. It travels into the body for you — you do not send it twice.

Body parameters

FieldTypeRequiredDescription
customer_idintegerRequiredThe customer the money came from.
account_idintegerRequiredThe account the money is deposited into — from GET /bank-accounts.
payment_methodintegerRequiredHow the money was paid — from GET /payment-methods.
payment_datedateRequiredThe date the money was received.
reference_nostringRequiredThe payment's own reference. Max 100.
paidAmountarrayRequiredAt least one entry. See the table below.

paidAmount[]

FieldTypeRequiredDescription
idintegerRequiredThe invoice's id.
paymentnumberRequiredThe amount applied to it. Greater than zero.

Fields that pass straight through

memo, paidCredit, send_email and attachments are forwarded exactly as you send them.

What the server fills in

FieldWhat happens to it
payment_methodThe id you send is turned into its text form.
paidAmount[].typeSet to "invoice" on every row if you did not send it.

The rows you send become everything this payment settles

paidAmount is a full replacement. Leave a row out and that invoice is no longer settled by this payment — its balance goes back up. Read the payment first and send back every row you want to keep.

Example request

JSON body
{
  "customer_id": 1125,
  "account_id": 24,
  "payment_method": 2,
  "payment_date": "2026-09-08",
  "reference_no": "PMT-00467",
  "memo": "",
  "paidAmount": [
    { "id": 1048, "payment": "80.00" }
  ],
  "paidCredit": [],
  "send_email": false,
  "attachments": []
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/payments/6' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_id": 1125,
    "account_id": 24,
    "payment_method": 2,
    "payment_date": "2026-09-08",
    "reference_no": "PMT-00467",
    "paidAmount": [
      { "id": 1048, "payment": "80.00" }
    ]
  }'

Example response

200 OK
{
  "invoice_numbers": "INVOICE-400"
}

No payment_id here, unlike create

The response carries only invoice_numbers — the invoices this payment now settles. POST /payments returns payment_id because you do not have it yet; here you already do, it is in the URL.

Errors

422 Unprocessable
{
  "message": "The payment date field is required.",
  "errors": { "payment_date": ["The payment date field is required."] }
}
404 Not Found
{
  "message": "Payment not found"
}

Status codes

StatusMeaning
200The payment is updated.
404No payment with that id.
422Validation failed.

List Payments

Read recorded payments back, a page at a time.

GET/paymentsX-Tenant + API Key required

Returns the payments recorded on your account, in pages. Each entry is a summary row — enough for a payments list screen, with the customer flattened onto it so no extra call is needed per row.

Query parameters

All optional. Send none of them and you get the first ten payments, unfiltered.

ParameterTypeDefault
pageinteger1
page_lengthinteger10
searchstring (JSON){}

URL-encode search when you do send it

The value is a JSON object sent as a string, so its braces and quotes have to be encoded — --data-urlencode in cURL. Which keys it accepts has not been supplied.

Example request

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/payments' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'page=1' \
  --data-urlencode 'page_length=10' \
  --data-urlencode 'search={"type":"payment"}'

Example response

The payments in data, the paging in meta — the same envelope the product and invoice lists use.

200 OK
{
  "data": [
    {
      "id": 6,
      "type": "payment",
      "amount": -1500,
      "date": "2026-08-10",
      "no": "PMT-00004",
      "customer_id": 5,
      "customer_name": "David Kwan Chen",
      "email": "david.chen@example.com",
      "due_date": null,
      "balance": null
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "last_page": 1,
    "total": 1,
    "from": null,
    "to": null
  }
}

Fields on a payment row

FieldWhat it is
idThe payment id. Use it to read the payment back by id.
typeAlways "payment" on this endpoint.
amountWhat was received — negative, because a payment reduces what the customer owes. See the warning below.
dateThe date the money was received.
noThe payment reference — PMT-00004. Note the field is called no here, not reference_no.
customer_id, customer_name, emailWho the money came from, flattened onto the row.
due_date / balanceAlways null. They do not apply to a payment — the row shape is shared with other kinds of sales transaction.

amount is negative

A payment of 1500 comes back as -1500, because in the sales ledger it reduces the receivable. Take the absolute value before you show it, or your users will see a minus sign against money they received.

A plain number, not a string

The amount is returned as -1500, not "-1500.0000000000". You do not have to parse a decimal string.

meta.from and meta.to are always null

The paging block keeps the same five-plus-two shape as the product and customer lists so one paging helper works everywhere, but from and to are never filled in here. Use current_page, last_page and total; do not compute a “showing 1–10 of 50” label from from and to on this endpoint.

You cannot tell which invoice a payment settled

There is no invoice_id on the row, and a single payment can cover several invoices anyway. Go the other way round: read the invoice with GET /invoices/:id and check its balance, or read the payment by id.

No payments yet

An empty account is a 200 with an empty data array — not an error, and not a 404:

200 OK — empty
{
  "data": [],
  "meta": {
    "current_page": 1,
    "per_page": 10,
    "last_page": 1,
    "total": 0,
    "from": null,
    "to": null
  }
}

Errors

A single message, whatever went wrong:

Error
{
  "message": "Failed to fetch payments"
}

Status codes

StatusMeaning
200The page is returned, even when data is empty.
500Unexpected error.

Get a Payment

Read one payment back, with what it was applied to.

GET/payments/:idX-Tenant + API Key required

Returns one payment. The response is not a single record — it is three objects at the top level, each answering a different question about the same payment.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe payment_id that POST /payments returned. The example reads payment 520. This is not the transaction id — in the example the transaction is 1738, and that value will not work here.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/payments/520' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

The three objects

KeyWhat it holdsUse it for
paymentThe payment record — amount, date, method, deposit account, reference, and one allocation row per invoice.Everything about the payment itself.
receivePayment.invoicesThe invoices this payment settled, each in full.Showing what was paid, and each invoice's remaining balance.
transactionThe ledger entry the payment produced.Tying the payment to your books.

The invoices are the same shape as everywhere else

Each entry in receivePayment.invoices is exactly what GET /invoices/:id returns — same fields, same lines, same addresses. Your existing invoice model reads them without changes. In the example the invoice comes back with balance: 0, so this payment settled it in full.

payment

FieldWhat it is
payment_amountThe total received — a positive number, unlike the negative amount the list endpoint returns.
payment_method / account_idThe two values you sent when recording it.
reference_noThe PMT- reference. Repeated on every allocation row.
invoice_receive_paymentOne row per invoice the payment was applied to — invoice_id and the amount that went to it.

Read the allocations from payment, not from the invoices

payment.invoice_receive_payment is what tells you how much of this payment went to which invoice. The invoices under receivePayment show their own totals and balances, which is not the same thing — an invoice with a 29.99 balance cleared could have been settled by two payments.

transaction

FieldWhat it is
idThe ledger transaction's own id — 1738 here. Not the payment id.
transaction_type_idThe payment id — 520 here. Despite the name, this is the link back to the payment.
transaction_refThe PMT- reference again.
totalThe payment amount, positive.
contact_idThe customer. Note it is contact_id here and customer_id on the payment object.

Three ids in one response, and the names do not help

payment.id is 520, transaction.id is 1738, and transaction.transaction_type_id is 520 again — the payment id under a name that reads like a type. Only payment.id works in this endpoint's URL.

Money comes back as numbers

Every amount here is a plain number — 29.99, not "29.9900000000". The underlying store keeps ten-decimal strings and this endpoint converts them, so you do not have to parse anything.

invoice_receive_payment is spelled correctly here

The field is invoice_receive_payment. The underlying service misspells it (paymnet); this API corrects it before returning. Use the correct spelling — and if you have code written against the misspelling from an earlier version, it will read undefined now.
200 OK
{
  "receivePayment": {
    "invoices": [
      {
        "id": 995,
        "customer_id": 1108,
        "customer": { "...": "customer object" },
        "invoice_date": "2026-07-01",
        "due_date": "2026-07-16",
        "shipping_date": null,
        "unique_no": "INV-00995",
        "reference_no": "INV-00995",
        "tracking_no": "INV-00995",
        "message_on_invoice": null,
        "lines": [
          { "id": 3001, "product_id": 44, "quantity": 1, "rate": 29.99, "amount": 29.99 }
        ],
        "addresses": [ { "...": "as stored" } ],
        "amount": 29.99,
        "balance": 0
      }
    ]
  },
  "transaction": {
    "id": 1738,
    "contact_id": 1108,
    "date": "2026-07-09",
    "transaction_type": "payment",
    "balance": 0,
    "due_date": null,
    "transaction_type_id": 520,
    "transaction_ref": "PMT-00462",
    "payee": null,
    "total": 29.99
  },
  "payment": {
    "id": 520,
    "customer_id": 1108,
    "payment_amount": 29.99,
    "payment_date": "2026-07-09",
    "payment_method": 4,
    "account_id": 29,
    "reference_no": "PMT-00462",
    "invoice_receive_payment": [
      {
        "id": 520,
        "payment_id": 520,
        "payment": 29.99,
        "invoice_id": 995,
        "payment_date": "2026-07-09",
        "payment_method_id": 4,
        "account_id": 29,
        "reference_no": "PMT-00462"
      }
    ]
  }
}

Errors

404 Not Found
{
  "message": "Payment not found"
}

Status codes

StatusMeaning
200The payment is returned.
404No payment with that id.
500Unexpected error.

Errors look the same on all three payment endpoints

Every failure returns a single message, with errors added on a 422. Only the text differs — Payment not found here, Failed to fetch payments on the list. Branch on the status code, not the wording.

Create a Pricing Component

Add a fixed or an adjustment pricing component.

POST/pricing-componentsX-Tenant + API Key required

Creates a pricing component.

Body parameters

FieldTypeRequiredDescription
component_namestringRequiredmax 255
component_codestringRequiredmax 50, must be unique
base_amountnumberRequired0 or more
adjustmentobjectOptionalSend this instead of leaning on base_amount when the component adds to or discounts a price. See below.

You do not send calculation_type

Whether a component is fixed or an adjustment is worked out from what you send — a base_amount on its own, or an adjustment object. It comes back on every response, so read it there rather than tracking it yourself.

adjustment

FieldTypeValues
adjustment_typestringadd or discount
typestringpercent or flat_amount
valuenumber0 or more

Example request — fixed

JSON body
{
  "component_name": "Setup Fee",
  "component_code": "SETUP",
  "base_amount": 2500
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "component_name": "Setup Fee",
    "component_code": "SETUP",
    "base_amount": 2500
  }'

Example request — adjustment

JSON body
{
  "component_name": "Service Charge",
  "component_code": "SVCCHG",
  "base_amount": 0,
  "adjustment": {
    "adjustment_type": "add",
    "type": "percent",
    "value": 4
  }
}

Example response

A component is priced one way or the other. A fixed component carries base_amount and has no adjustment key; an adjustment component carries adjustment and has no base_amount.

201 Created — fixed
{
  "id": 10,
  "component_code": "SETUP",
  "component_name": "Setup Fee",
  "calculation_type": "fixed",
  "is_active": false,
  "pricing_plans": [],
  "base_amount": 2500
}
201 Created — adjustment
{
  "id": 12,
  "component_code": "SVCCHG",
  "component_name": "Service Charge",
  "calculation_type": "adjustment",
  "is_active": false,
  "pricing_plans": [],
  "adjustment": {
    "adjustment_type": "add",
    "type": "percent",
    "value": 4
  }
}

pricing_plans

Every component response carries pricing_plans — the plans this component is part of. It is a response-only field; you never send it. Each entry is slim, three fields:

FieldWhat it is
idThe plan's id.
nameThe plan's name.
codeThe plan's code.
pricing_plans, populated
"pricing_plans": [
  { "id": 5, "name": "Standard Monthly Plan", "code": "STDMON" },
  { "id": 6, "name": "Yearly Plan", "code": "YRLY" }
]

Empty on a component you just created

A new component is not on any plan yet, so pricing_plans comes back as []. It fills in once the component is put on a plan with POST /pricing-plans. Read it back with GET /pricing-components/:id to see the plans it ended up on.

It is not there inside a plan

A component reported inside a pricing plan response has no pricing_plans key at all — it would point back at the plan you are already reading. Do not expect the field there.

A component has no dates of its own

There is no start_date or end_date on this endpoint, and none on the other component endpoints either. A component on its own is not something that starts or stops.

The dates belong to the component on a plan

You set them when you put the component on a plan, in POST /pricing-plans components[].start_date and components[].end_date. They are that component's lifetime on that plan, so the same component can run for different dates on two different plans. That is also why they come back inside a plan response and never on a component response.

Status codes

StatusMeaning
201The component is created.
422Validation failed, or the component code is already in use.
500Unexpected error.

The bodies for each of those are in Pricing Component Errors.

Update a Pricing Component

Replace a pricing component with how it should end up.

PUT/pricing-components/:idX-Tenant + API Key required

Updates a pricing component. Send the whole component as it should end up, not only the fields that changed. The payload is the same as create; the id goes in the path.

Example request

JSON body
{
  "component_name": "Service Charge",
  "component_code": "SVCCHG",
  "base_amount": 0,
  "adjustment": {
    "adjustment_type": "add",
    "type": "percent",
    "value": 4
  }
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components/12' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "component_name": "Service Charge",
    "component_code": "SVCCHG",
    "base_amount": 0,
    "adjustment": {
      "adjustment_type": "add",
      "type": "percent",
      "value": 4
    }
  }'

Example response

Same shape as the create response.

Status codes

StatusMeaning
200The component is updated.
404No pricing component with that id.
422Validation failed, or the component code is already in use.
500Unexpected error.

List Pricing Components

Every component at once, with no pagination.

GET/pricing-componentsX-Tenant + API Key required

Returns all pricing components in one call — there is no pagination on this endpoint.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

The components come back inside data. Each item is the same shape as the create and update responses — all four endpoints pass through the same mapper.

200 OK
{
  "data": [
    {
      "id": 10,
      "component_code": "SETUP",
      "component_name": "Setup Fee",
      "calculation_type": "fixed",
      "is_active": true,
      "pricing_plans": [
        { "id": 5, "name": "Standard Monthly Plan", "code": "STDMON" },
        { "id": 6, "name": "Yearly Plan", "code": "YRLY" }
      ],
      "base_amount": 2500
    },
    {
      "id": 12,
      "component_code": "SVCCHG",
      "component_name": "Service Charge",
      "calculation_type": "adjustment",
      "is_active": true,
      "pricing_plans": [],
      "adjustment": { "adjustment_type": "add", "type": "percent", "value": 4 }
    },
    {
      "id": 14,
      "component_code": "LOYALTY",
      "component_name": "Loyalty Discount",
      "calculation_type": "adjustment",
      "is_active": true,
      "pricing_plans": [],
      "adjustment": { "adjustment_type": "discount", "type": "flat_amount", "value": 100 }
    }
  ]
}

Status codes

StatusMeaning
200The components are returned.
500Unexpected error.

Get a Pricing Component

Read one component back by its id.

GET/pricing-components/:idX-Tenant + API Key required

Returns one pricing component as a plain object — no wrapper. The list endpoint puts its items inside data; this one does not.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe component's id, as returned by POST /pricing-components. The example reads component 14.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components/14' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

200 OK
{
  "id": 14,
  "component_code": "LOYALTY",
  "component_name": "Loyalty Discount",
  "calculation_type": "adjustment",
  "is_active": true,
  "pricing_plans": [],
  "adjustment": { "adjustment_type": "discount", "type": "flat_amount", "value": 100 }
}

Errors

404 Not Found
{
  "message": "Pricing component not found"
}

Status codes

StatusMeaning
200The component is returned.
404No pricing component with that id.
500Unexpected error.

Pricing Component Errors

What comes back when a create or an update is refused.

Errors

StatusWhenResponse
422validation failed
422 Unprocessable
{
  "message": "...",
  "errors": {
    "component_code": ["The component code field is required."]
  }
}
422code already in use
422 Unprocessable
{
  "message": "The component code has already been taken."
}
404component not found (update, get by id)
404 Not Found
{
  "message": "Pricing component not found"
}

Validation messages

WhenMessage
bad adjustment_typeThe adjustment type must be either add or discount.
bad typeThe adjustment must be either a percent or a flat_amount.
negative valueThe adjustment value field must be at least 0.

Fields that are not accepted

These are dropped if sent:

  • cost_amount
  • min_amount
  • max_amount
  • quantity
  • display_order
  • item_type_id
  • is_taxable

Create a Pricing Plan

Build a plan out of pricing components.

POST/pricing-plansX-Tenant + API Key required

A plan is built from one or more pricing components.

At least one component must be fixed

A plan needs at least one fixed component — one that carries a base_amount. A plan made only of adjustments has nothing to adjust, so it is rejected.

You do not send the price

price is worked out from the components the plan holds, and it comes back on every response. It is never sent in.

Dates

Every date is sent and returned as YYYY-MM-DD — no time, no timezone.

A timestamp is rejected

2027-03-30T18:30:00.000Z will not be accepted. It has to be rejected, because that value is 30 March in UTC and 31 March in India — the day it means depends on where it is read. 2027-03-31 means the same day everywhere.

Body parameters

FieldTypeRequiredDescription
namestringRequiredmax 255
codestringRequiredmax 50, must be unique
plan_typestringRequiredstandard
effective_datedateRequiredYYYY-MM-DD, the day the plan starts.
descriptionstringOptionalFree text.
expiration_datedateOptionalYYYY-MM-DD, must be after effective_date.
componentsarrayRequiredAt least one, and at least one of them must be a fixed component.

components[]

FieldTypeRequiredDescription
idintegerRequiredId of the pricing component.
start_datedateRequiredYYYY-MM-DD, when it starts being charged. Must fall within the plan's dates.
end_datedateOptionalYYYY-MM-DD, must be after start_date and within the plan's dates.

Component dates have to sit inside the plan's dates

A component's start_date and end_date must both fall between the plan's effective_date and expiration_date. A component cannot start before the plan does or run on after it ends.

Leaving a date out

  • No expiration_date — the plan runs on with no end.
  • No end_date on a component — the component stays for as long as the plan does.

Example request

JSON body
{
  "name": "Yearly Plan",
  "code": "YRLY",
  "plan_type": "standard",
  "description": "Covers the yearly subscription fee.",
  "effective_date": "2026-08-31",
  "expiration_date": "2027-03-31",
  "components": [
    {
      "id": 10,
      "start_date": "2026-08-31",
      "end_date": "2026-09-30"
    }
  ]
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Yearly Plan",
    "code": "YRLY",
    "plan_type": "standard",
    "description": "Covers the yearly subscription fee.",
    "effective_date": "2026-08-31",
    "expiration_date": "2027-03-31",
    "components": [
      { "id": 10, "start_date": "2026-08-31", "end_date": "2026-09-30" }
    ]
  }'

Example response

The whole plan, with each component reported in full and the two dates it runs for added to it. price is worked out from those components.

201 Created
{
  "id": 6,
  "name": "Yearly Plan",
  "code": "YRLY",
  "description": "Covers the yearly subscription fee.",
  "plan_type": "standard",
  "effective_date": "2026-08-31",
  "expiration_date": "2027-03-31",
  "price": 2500,
  "is_active": false,
  "components": [
    {
      "id": 10,
      "component_code": "SETUP",
      "component_name": "Setup Fee",
      "calculation_type": "fixed",
      "is_active": true,
      "base_amount": 2500,
      "start_date": "2026-08-31",
      "end_date": "2026-09-30"
    }
  ]
}

Components keep their own shape inside a plan

A component inside a plan carries the same fields it does on its own endpoints — a fixed component carries base_amount and an adjustment component carries adjustment. See Pricing Components for that shape.

Two differences from the component endpoints

start_date and end_date are added — those are the dates the component runs for on this plan. And pricing_plans is not there: on its own endpoints a component lists the plans it is on, but inside a plan that would point back at the plan you are already reading.

Status codes

StatusMeaning
201The plan is created.
422Validation failed, the code is already in use, or the plan has no fixed component.
404A component in the plan does not exist.
500Unexpected error.

The bodies for each of those are in Pricing Plan Errors.

Update a Pricing Plan

Replace a plan with how it should end up.

PUT/pricing-plans/:idX-Tenant + API Key required

Updates a plan. Send the whole plan as it should end up, not only the fields that changed. The payload is the same as create; the id goes in the path.

The same date rules apply

YYYY-MM-DD only — a timestamp is rejected here too. expiration_date must be after effective_date, and every component's dates must sit inside the plan's. They are spelled out under Create a Pricing Plan → Dates.

Example request

JSON body
{
  "name": "Yearly Plan",
  "code": "YRLY",
  "plan_type": "standard",
  "description": "Covers the yearly subscription fee.",
  "effective_date": "2026-08-31",
  "expiration_date": "2027-03-31",
  "components": [
    {
      "id": 10,
      "start_date": "2026-08-31",
      "end_date": "2026-09-30"
    }
  ]
}
cURL
curl -X PUT \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans/6' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Yearly Plan",
    "code": "YRLY",
    "plan_type": "standard",
    "description": "Covers the yearly subscription fee.",
    "effective_date": "2026-08-31",
    "expiration_date": "2027-03-31",
    "components": [
      { "id": 10, "start_date": "2026-08-31", "end_date": "2026-09-30" }
    ]
  }'

Example response

Same shape as the create response.

Status codes

StatusMeaning
200The plan is updated.
404No pricing plan with that id.
422Validation failed, the code is already in use, or the plan has no fixed component.
500Unexpected error.

List Pricing Plans

Every plan at once, with no pagination.

GET/pricing-plansX-Tenant + API Key required

Returns every plan in one go. There is no pagination on this endpoint.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

The plans come back inside data, each with its components expanded.

200 OK
{
  "data": [
    {
      "id": 5,
      "name": "Standard Monthly Plan",
      "code": "STDMON",
      "description": null,
      "plan_type": "standard",
      "effective_date": "2026-08-31",
      "expiration_date": null,
      "price": 2500,
      "is_active": true,
      "components": [
        {
          "id": 10,
          "component_code": "SETUP",
          "component_name": "Setup Fee",
          "calculation_type": "fixed",
          "is_active": true,
          "base_amount": 2500,
          "start_date": "2026-08-31",
          "end_date": null
        }
      ]
    }
  ]
}

Status codes

StatusMeaning
200The plans are returned.
500Unexpected error.

Get a Pricing Plan

Read one plan back by its id.

GET/pricing-plans/:idX-Tenant + API Key required

Returns one plan — the same shape as a list item, without the data wrapper.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredThe plan's id, as returned by POST /pricing-plans. The example reads plan 5.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans/5' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

200 OK
{
  "id": 5,
  "name": "Standard Monthly Plan",
  "code": "STDMON",
  "description": null,
  "plan_type": "standard",
  "effective_date": "2026-08-31",
  "expiration_date": null,
  "price": 2500,
  "is_active": true,
  "components": [
    {
      "id": 10,
      "component_code": "SETUP",
      "component_name": "Setup Fee",
      "calculation_type": "fixed",
      "is_active": true,
      "base_amount": 2500,
      "start_date": "2026-08-31",
      "end_date": null
    }
  ]
}

Errors

404 Not Found
{
  "message": "Pricing plan not found"
}

Status codes

StatusMeaning
200The plan is returned.
404No pricing plan with that id.
500Unexpected error.

Pricing Plan Errors

What comes back when a plan is refused.

Errors

StatusWhenResponse
422validation failed
422 Unprocessable
{
  "message": "...",
  "errors": {
    "code": ["The plan code field is required."]
  }
}
422code already in use
422 Unprocessable
{
  "message": "The code has already been taken."
}
422no fixed component on the plan
422 Unprocessable
{
  "message": "At least one component with a fixed calculation type is required."
}
404plan or component not found
404 Not Found
{
  "message": "Pricing plan not found"
}
500unexpected error
500 Server Error
{
  "message": "Failed to create pricing plan"
}

Validation messages

WhenMessage
bad plan_typeThe plan type must be standard.
timestamp instead of a dateThe effective date field must match the format Y-m-d.
expiration_date before effective_dateThe expiration date must come after the effective date.
component end_date before its start_dateA component end date must come after its start date.
no componentsThe components field is required.

Attach Plans to a Product

Set which pricing plans a product is on.

POST/products/:id/pricing-plansX-Tenant + API Key required

Sets which pricing plans a product is on.

This replaces, it does not add

Send the full list the product should end up with. Any plan already on the product but left out of the list is removed, and an empty list takes them all off.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredId of the product — the id from POST /products.

Body parameters

FieldTypeRequiredDescription
pricing_plan_idsarray of integersRequiredThe plans the product should end up on — ids from POST /pricing-plans. May be empty.

Example request

JSON body
{
  "pricing_plan_ids": [6]
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/products/3210/pricing-plans' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "pricing_plan_ids": [6]
  }'

Several plans

JSON body
{
  "pricing_plan_ids": [5, 6]
}

Take every plan off the product

JSON body
{
  "pricing_plan_ids": []
}

An empty list has to be sent on purpose

Leaving the field out altogether is a 422, so a forgotten field can never unlink everything by accident.

Example response

200 OK
{
  "message": "Pricing plans linked successfully"
}

Status codes

StatusMeaning
200The plans are linked.
422The field is missing or the wrong type, a plan id does not exist, or the product does not exist.
404Product or plan not found.
500Unexpected error.

The bodies for each of those are in Attach Plan Errors.

Read the plans back to confirm

The response carries a message and nothing else, so to see what the product ended up on, call GET /products/:id/pricing-plans afterwards.

Get a Product's Pricing Plans

Which plans a product is on.

GET/products/:id/pricing-plansX-Tenant + API Key required

Returns the pricing plans a product is on. This is how you check what POST /products/:id/pricing-plans left the product with.

Path parameters

FieldTypeRequiredDescription
idintegerRequiredId of the product — the id from POST /products.

Example request

cURL
curl -X GET \
  'https://services.ap.mochatechnologies.com/quickbill/api/products/3210/pricing-plans' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY"

Example response

The plans come back inside data, each with its components expanded — the same shape GET /pricing-plans returns, just narrowed to this one product.

200 OK
{
  "data": [
    {
      "id": 5,
      "name": "Standard Monthly Plan",
      "code": "STDMON",
      "description": null,
      "plan_type": "standard",
      "effective_date": "2026-08-31",
      "expiration_date": null,
      "price": 2500,
      "is_active": true,
      "components": [
        {
          "id": 10,
          "component_code": "SETUP",
          "component_name": "Setup Fee",
          "calculation_type": "fixed",
          "is_active": true,
          "base_amount": 2500,
          "start_date": "2026-08-31",
          "end_date": null
        }
      ]
    }
  ]
}

Components here have no pricing_plans either

As with every plan response, the components inside carry start_date and end_date but no pricing_plans key.

Status codes

StatusMeaning
200The plans are returned. A product on no plans is an empty data array, not an error.
404No product with that id.
500Unexpected error.

Attach Plan Errors

What comes back when a link is refused.

Errors

StatusWhenResponse
422field missing or wrong typeSee Request validation below.
422a plan id does not existSee A plan id that does not exist below.
422the product does not existSee A product id that does not exist below.
404product or plan not found
404 Not Found
{
  "message": "The product or one of the pricing plans was not found"
}
500unexpected error
500 Server Error
{
  "message": "Failed to link the pricing plans"
}

Request validation

WhenResponse
pricing_plan_ids left out
422 Unprocessable
{
  "message": "...",
  "errors": {
    "pricing_plan_ids": ["The pricing plans field must be present."]
  }
}
not an array
422 Unprocessable
{
  "message": "...",
  "errors": {
    "pricing_plan_ids": ["The pricing plans field must be an array."]
  }
}
an entry is not an integer
422 Unprocessable
{
  "message": "...",
  "errors": {
    "pricing_plan_ids.0": ["The pricing plan field must be an integer."]
  }
}

A plan id that does not exist

422 Unprocessable
{
  "message": "The selected pricing plan (574) is invalid.",
  "errors": {
    "pricing_plan_ids.0": ["The selected pricing plan (574) is invalid."]
  }
}

A product id that does not exist

422 Unprocessable
{
  "message": "The product does not exist."
}

There is no errors here because the product id is not a field you send — it is in the path. Naming it would point you at something you never wrote.

More than one thing wrong

The messages are run together, and errors names each field it can.

422 Unprocessable
{
  "message": "The selected pricing plan (574) is invalid. The product does not exist.",
  "errors": {
    "pricing_plan_ids.0": ["The selected pricing plan (574) is invalid."]
  }
}

Subscriptions

How the subscription endpoints behave, before you call any of them.

These endpoints take a compact payload: you send ids, and the service resolves everything else — the customer, the plan price, the current plan, the product name and whether a change is an upgrade or a downgrade.

Two things you never send

FieldWhy not
billing_alignment_modeWhether a change applies now or at the next renewal. The service decides it.
business_entity_idDerived from the subscription.

subscription_code

Every endpoint except create identifies the subscription by its subscription_code, not by a numeric id:

Format
SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001

Send it whole, and treat it as opaque

It is a long string, not a short reference like INV-00016 or PMT-00004. Store it as you received it and send it back unchanged — do not shorten it, and do not build one yourself from the trailing counter.

Upgrade and downgrade

TermWhat it means
upgradeThe new plan costs the same as, or more than, the current one.
downgradeThe new plan costs less.

Comparison is on gross price

Always the price before any discount. A discounted expensive plan is still an upgrade over a cheaper one.

Five behaviours worth knowing up front

  • prorate defaults to false — an invoice is only raised when you send true.
  • Whether a change lands now or at the next renewal is decided by the service, never sent by you.
  • Nothing is charged during a trial or before the start date — in update and in preview.
  • A downgrade never applies immediately outside a trial.
  • No payment record is ever created by these endpoints.

Invoices yes, payments no

These endpoints create invoices but never record a payment. Collecting the money happens outside this API — there is no receive-payment row to find afterwards.

Create a Subscription

Put a customer on a plan, with or without a trial.

POST/subscriptionsX-Tenant + API Key required

Body parameters

FieldTypeRequiredDefaultNotes
customer_idintegerRequiredMust exist for the tenant.
product_idintegerRequiredMust exist for the tenant.
pricing_plan_idintegerRequiredMust be attached to product_id.
billing_cyclestringRequiredmonthly or yearly.
start_datedateOptionaltodayYYYY-MM-DD
trial_daysintegerOptionalnoneMinimum 1. trial_end = start_date + trial_days.

The four cases

What gets created depends on two things only — whether the start date is today or in the future, and whether there is a trial.

Start dateTrialInvoice todaytrial_endTerm end and recurring anchorstatus
todaynoYes, full amountstart_date + 1 cycleactive
todayyesYes, zero amount (100% discount)start_date + trial_daystrial_endtrialing
futurenoNostart_dateactive
futureyesNostart_date + trial_daystrial_endtrialing

Always created, in every case

The subscription row, its items, and a recurring template anchored as the table shows.

Example request

JSON body
{
  "customer_id": 16908,
  "product_id": 3210,
  "pricing_plan_id": 5,
  "billing_cycle": "monthly",
  "start_date": "2026-09-07",
  "trial_days": 14
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "customer_id": 16908,
    "product_id": 3210,
    "pricing_plan_id": 5,
    "billing_cycle": "monthly",
    "start_date": "2026-09-07",
    "trial_days": 14
  }'

The minimum

Leave the optional two out and the subscription starts today, with no trial.

Minimum body
{
  "customer_id": 16908,
  "product_id": 3210,
  "pricing_plan_id": 5,
  "billing_cycle": "monthly"
}

Errors

ConditionStatusMessage
Plan not attached to the product422The selected pricing plan is not attached to this product.
Unknown customer, product or plan422Validation errors on the id fields.
billing_cycle is not monthly or yearly422Validation error.

List Subscriptions

Read your subscriptions back, a page at a time.

GET/subscriptionsX-Tenant + API Key required

Returns your subscriptions in pages. It takes the same three query parameters as every other list endpoint on this page — the same products, invoices and payments lists use, so one paging helper works everywhere.

Query parameters

FieldTypeRequiredDescription
pageintegerRequiredWhich page to return, starting at 1.
page_lengthintegerRequiredHow many subscriptions per page.
searchstringRequiredA JSON object, sent as a string, holding your filters. Send {} for no filter, and URL-encode it.

Example request

cURL
curl -G \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  --data-urlencode 'page=1' \
  --data-urlencode 'page_length=10' \
  --data-urlencode 'search={}'

Status codes

StatusMeaning
200The page is returned, even when it is empty.
500Unexpected error.

Update a Subscription

Move a subscription onto a different plan.

POST/subscriptions/updateX-Tenant + API Key required

Moves a subscription onto a different plan of the same product.

Body parameters

FieldTypeRequiredDefaultNotes
subscription_codestringRequiredMust exist for the tenant.
product_idintegerRequiredMust be the product the subscription is already on.
pricing_plan_idintegerRequiredThe plan to move onto.
proratebooleanOptionalfalsetrue to raise a proration invoice.

prorate defaults to false

A missing flag never charges a customer by accident. Send prorate: true explicitly when you want the mid-term difference charged.

How the mode is decided

You do not send it. The service works it out:

SituationModeWhat it means
Subscription is in trialimmediateApplies now, even for a downgrade.
UpgradeimmediateApplies now.
Downgrade, not in trialdelayedNothing changes today; it applies at the next renewal.

A delayed change is stored, not applied

The whole request is kept and applied when the next renewal invoice is generated. Nothing is written to the subscription until then — reading it back straight after the call will still show the old plan.

Every case

PlanIn trialModeProration invoiceResult
upgradenoimmediateif proratePlan changes now.
sameno422
downgradenodelayedNoneApplies at next renewal.
upgrade or downgradeyesimmediateNonePlan changes now, nothing charged.

Proration

Charged only when all three hold: the mode is immediate, the subscription is not in trial, and its start date has passed.

Formula
proration = (new gross amount - old gross amount)
              x remaining days
              / total days in the term
  • The invoice line is described as <plan name> - Proration Adjustment.
  • The current term end and next billing date do not move.

Example request

JSON body
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
  "product_id": 3210,
  "pricing_plan_id": 6,
  "prorate": true
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions/update' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
    "product_id": 3210,
    "pricing_plan_id": 6,
    "prorate": true
  }'

Errors

ConditionStatusMessage
The plan sent is the plan it is already on422Nothing to change on this subscription.
Plan not attached to the product422The selected pricing plan is not attached to this product.
Plan belongs to a different product422A subscription can only move between plans of the same product.
Subscription has no plan item422This subscription has no plan to update.
Unknown subscription, product or plan422Validation errors on the id fields.

Preview Proration

What a plan change will cost, before you commit to it.

POST/subscriptions/calculate-prorationX-Tenant + API Key required

Read-only. Nothing is saved and no invoice is created. Call this before update to show the customer what a plan change will cost.

Body parameters

FieldTypeRequiredDescription
subscription_codestringRequiredThe subscription being previewed.
product_idintegerRequiredThe product the subscription is on.
pricing_plan_idintegerRequiredThe plan being previewed.

Do not send prorate

It does not affect the number.

What comes back when

The preview runs the same rules as update, so it always matches what update will actually do.

Situationproration_amount
Upgrade, not in trial, already startedThe calculated amount.
Downgrade, not in trial0 — the change lands at the next renewal.
In trial0 — nothing has been billed yet.
Start date is in the future0 — nothing has been billed yet.

Example request

JSON body
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
  "product_id": 3210,
  "pricing_plan_id": 6
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions/calculate-proration' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
    "product_id": 3210,
    "pricing_plan_id": 6
  }'

Example response

200 OK
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000002",
  "business_entity_id": "...",
  "effective_at": "2026-09-06T14:45:59Z",
  "total_proration_amount": 1350,
  "items": [
    {
      "item_type": "plan",
      "reference_id": 6,
      "new_reference_id": 3,
      "old_amount": "4000.0000000000",
      "new_amount": 2000,
      "quantity": 1,
      "unit_price": 2000,
      "proration_amount": 0,
      "change_type": "downgrade"
    }
  ]
}

change_type is reported even when the amount is zero

It is upgrade or downgrade. Use it to tell the customer what kind of change they are making, and total_proration_amount to tell them what it costs — a zero amount does not mean nothing is changing.

Cancel a Subscription

End it now, or let it run to the end of the term.

POST/subscriptions/cancelX-Tenant + API Key required

Body parameters

FieldTypeRequiredDefaultNotes
subscription_codestringRequired
cancel_typestringRequiredimmediate or period_end.
cancel_atdateOptionalsee belowOverrides the default cancel date.
reasonstringOptionalMax 255 characters.

The two cancel types

immediateperiod_end
statuscancelled right awayUnchanged — the customer keeps access.
cancel_atnowThe current term end.
cancel_at_period_endfalsetrue
Renewal invoicesStoppedStopped
Auto-payDisabledDisabled

The recurring template is paused either way

No further renewal invoices are generated in either case. A period_end cancellation is finalised on its cancel date by a scheduled job, which flips the status to cancelled.

No refund, no credit note

Neither cancel type produces one. If money needs to go back, that happens outside these endpoints.

Example: cancel at the end of the term

JSON body
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
  "cancel_type": "period_end",
  "reason": "Customer request"
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions/cancel' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
    "cancel_type": "period_end",
    "reason": "Customer request"
  }'

Example: cancel immediately

JSON body
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000002",
  "cancel_type": "immediate",
  "reason": "Test"
}

Example response

The response to the immediate cancellation above.

200 OK
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000002",
  "business_entity_id": "...",
  "status": "cancelled",
  "cancel_type": "immediate",
  "cancel_at": "2026-09-06 15:15:04",
  "cancel_at_period_end": false
}

Resume a Cancellation

Undo a cancellation and put the subscription back in service.

POST/subscriptions/cancel/reverseX-Tenant + API Key required

Undoes a cancellation and puts the subscription back into service.

Body parameters

FieldTypeRequiredDescription
subscription_codestringRequiredThe cancelled subscription.
reasonstringOptionalMax 255 characters.

What changes

  • status becomes active.
  • cancel_at is cleared and cancel_at_period_end becomes false.
  • The recurring template resumes and its next run date is recalculated.
  • Auto-pay is re-enabled.

A term that expired while cancelled is not replayed

If the term end has already passed, the recurring schedule restarts from today rather than generating the invoices that were missed.

Example request

JSON body
{
  "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
  "reason": "Customer changed their mind"
}
cURL
curl -X POST \
  'https://services.ap.mochatechnologies.com/quickbill/api/subscriptions/cancel/reverse' \
  -H "X-Tenant: $MOCHA_TENANT" \
  -H "API Key: $MOCHA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
    "reason": "Customer changed their mind"
  }'