Complete reference for the Mocha QuickBill API: every endpoint with its request fields, example bodies, response payloads and status codes.
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.
Every path on this page is relative to:
https://services.ap.mochatechnologies.com/quickbill/api| Endpoint | What it does |
|---|---|
| GET /income-accounts | List the income accounts a product's revenue_account can point at. |
| GET /expense-accounts | List the expense accounts a product's expense_account can point at, for a given product type. |
| GET /inventory-accounts | List the accounts an inventory product's stock value can sit in. |
| GET /bank-accounts | List the accounts a payment can be deposited into. |
| POST /products | Create a product or service that invoice line items can reference. |
| PUT /products/:id | Replace a product with how it should end up. |
| GET /products | List your products, a page at a time. |
| GET /products/:id | Read a single product back by its id. |
| GET /products/latest-inventory-number | Get the next reference number to use on an inventory adjustment. |
| GET /inventory-adjustment-accounts | List every account an inventory adjustment can be posted to. |
| GET /products/inventory-adjustment-reasons | List the reasons an inventory adjustment can carry. |
| POST /products/inventory-adjustment | Change the stock held for one or more products, and record why. |
| GET /products/inventory-adjustment | List your inventory adjustments, a page at a time. |
| GET /products/inventory-adjustment/:id | Read a single inventory adjustment back by its id. |
| GET /products/:id/inventory | Read a product's current stock and the movements that got it there. |
| POST /customers | Create the customer an invoice is issued to, with its addresses. |
| PUT /customers/:id | Replace a customer with how it should end up. |
| GET /customers | List your customers, a page at a time. |
| GET /customers/:id | Read a single customer back by its id. |
| GET /invoices/get-invoice-number | Take the next invoice number, before you create the invoice. |
| POST /invoices | Bill a customer for one or more products. |
| PUT /invoices/:id | Replace an invoice, while nothing has been paid on it. |
| GET /invoices | List your invoices, a page at a time. |
| GET /invoices/:id | Read a single invoice back in full, with its addresses and payments. |
| GET /invoices/payment-link/:id | Build a link the customer can open to pay the invoice online. |
| GET /payment-methods | List the ways a payment can be taken — cash, cheque, card. |
| GET /payments/get-next-payment-number | Take the next payment reference, before you record the payment. |
| POST /payments | Record a payment against one or more of a customer's invoices. |
| PUT /payments/:id | Replace a recorded payment and what it settles. |
| GET /payments | List recorded payments, a page at a time. |
| GET /payments/:id | Read one payment back, with what it was applied to. |
| POST /pricing-components | Create a fixed or an adjustment pricing component. |
| PUT /pricing-components/:id | Replace a pricing component with how it should end up. |
| GET /pricing-components | List every pricing component at once, with no pagination. |
| GET /pricing-components/:id | Read a single pricing component back by its id. |
| DELETE /pricing-components/:id | Delete a pricing component. |
| PATCH /pricing-components/:id/status | Activate or deactivate a pricing component. |
| POST /pricing-plans | Build a pricing plan out of one or more pricing components. |
| PUT /pricing-plans/:id | Replace a pricing plan with how it should end up. |
| GET /pricing-plans | List every pricing plan at once, with no pagination. |
| GET /pricing-plans/:id | Read a single pricing plan back by its id. |
| DELETE /pricing-plans/:id | Delete a pricing plan. |
| PATCH /pricing-plans/:id/status | Activate or deactivate a pricing plan. |
| POST /products/:id/pricing-plans | Set which pricing plans a product is on. |
| GET /products/:id/pricing-plans | Read back which pricing plans a product is on. |
| POST /coupons | Create a coupon. |
| PUT /coupons/:id | Replace a coupon. |
| GET /coupons | List every coupon at once, with no pagination. |
| GET /coupons/:id | Fetch one coupon. |
| POST /coupons/validate | Preview what a code takes off a plan. |
| DELETE /coupons/:id | Delete a coupon. |
| PATCH /coupons/:id/status | Activate or deactivate a coupon. |
| POST /subscriptions | Put a customer on a plan, with or without a trial. |
| GET /subscriptions | List your subscriptions, a page at a time. |
| POST /subscriptions/update | Change the plan, apply a coupon, or both. |
| POST /subscriptions/calculate-proration | Preview what a plan change will cost, without saving anything. |
| POST /subscriptions/cancel | End a subscription now, or at the end of its term. |
| POST /subscriptions/cancel/reverse | Undo a cancellation and put the subscription back in service. |
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.Every error response has a message. Some errors also include one of these keys:
| Key | What it means |
|---|---|
errors | Validation failed. Keyed by field: { "field": ["..."] } |
plans | Live pricing plans are blocking the change: { id, name, code } |
used_on | Invoices, leases or subscriptions using the plan are blocking the change: { type, id, no } |
items | Items attached to the plan are blocking the change: { type, id, name } |
Different errors can share a status code. Check which key is present to tell them apart.
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.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.
Call the endpoint, show the names to your user, and send back the id they picked.
| Endpoint | Fills in | On |
|---|---|---|
GET /income-accounts | revenue_account | POST /products |
GET /expense-accounts?product_type= | expense_account | POST /products |
GET /inventory-accounts | inventory_account | POST /products |
GET /bank-accounts | account_id | POST /payments |
inventory_account is required when a product's type is inventory, and not used for service or non_inventory. See what each type requires.product_type — see below. None of the four paginate, so there is no page, no search and no meta block anywhere.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.curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/income-accounts' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": [
{ "id": 412, "name": "Sale of Product Income" },
{ "id": 418, "name": "Service Income" },
{ "id": 423, "name": "Discounts given" },
{ "id": 431, "name": "Other Income" }
]
}Same shape, no parameters — swap the path for /inventory-accounts.
{
"data": [
{ "id": 340, "name": "Inventory Asset" }
]
}Same again, with /bank-accounts.
{
"data": [
{ "id": 301, "name": "Checking" },
{ "id": 305, "name": "Savings" },
{ "id": 312, "name": "Undeposited Funds" }
]
}This one is different: it needs to know what kind of product the cost belongs to, because that decides which accounts are even eligible.
| Field | Type | Required | Description |
|---|---|---|---|
product_type | string | Required | inventory, non_inventory or service — the type of the product you are about to create. |
| product_type | Which accounts come back |
|---|---|
inventory | Cost of goods sold only. |
non_inventory | Any expense account. |
service | Any expense account. |
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'{
"data": [
{ "id": 502, "name": "Supplies & Materials - COGS" }
]
}Send product_type=service or product_type=non_inventory and the whole list comes back.
{
"data": [
{ "id": 502, "name": "Supplies & Materials - COGS" },
{ "id": 509, "name": "Advertising" },
{ "id": 514, "name": "Rent or Lease" },
{ "id": 521, "name": "Office Expenses" }
]
}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.A missing or unrecognised product_type is a 422:
{
"message": "The product type is required.",
"errors": {
"product_type": ["The product type is required."]
}
}| Status | Meaning |
|---|---|
| 200 | The accounts are returned. |
| 422 | product_type was missing, or was not one of the three values. Expense accounts only. |
| 500 | Unexpected error. |
Add a product or service your invoices can bill against.
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.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Required | inventory, non_inventory or service. It decides which of the fields below are required — see What each type requires. |
name | string | Required | Display name shown on invoices and in your catalog. |
sku | string | Optional | Your own identifier for the product. Optional — omit it and the field comes back null. |
description | string | Optional | Longer text about the product, for your own reference and on the invoice line. |
tags | array | Optional | Labels you can group and filter products by. Each entry is an object carrying a label and nothing else — send [{ "label": "Earphone" }]. |
quantity | integer | Optional | How many units you hold. Required when type is inventory, ignored otherwise. |
revenue_account | integer | Optional | Id 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_account | integer | Optional | Id 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_account | integer | Required | Id 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. |
The type you send decides what else has to be in the body.
| type | quantity | inventory_account | expense_account | revenue_account |
|---|---|---|---|---|
inventory | Required | Required | Required | Required |
non_inventory | Not needed | Not used | Required | Optional |
service | Not needed | Not used | Required | Optional |
type: "inventory" and you must also send quantity, inventory_account, expense_account and revenue_account. Miss any of them and the create is refused.quantity and no inventory_account — there is no stock to track. expense_account is still required; revenue_account is optional.{
"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 -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
}'The same product tracked as stock — quantity and inventory_account appear, and revenue_account stops being optional.
{
"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
}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.
{
"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
}id is handed to you. Save it against your own record — you need it for every invoice line that bills this product.{ "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 | Meaning |
|---|---|
| 200 | Product created. The record is returned. |
| 422 | Validation failed — a required field is missing or a value is not acceptable. |
| 403 | You do not have permission for this product. |
| 500 | Unexpected error. |
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.
{
"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:
{
"message": "The name field is required.",
"errors": {
"name": ["The name field is required."]
}
}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.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.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.Replace a product with how it should end up.
Replaces a product. Send the whole product as it should end up, not only the fields that changed.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The product's id, as returned by POST /products. The example updates product 90. |
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Required | Display name shown on invoices and in your catalog. Max 255. |
revenue_account | integer | Required | Id of the account that sales of this product post to — from GET /income-accounts. |
expense_account | integer | Required | Id of the account the cost posts to — from GET /expense-accounts. |
inventory_account | integer | Required | Id of the stock account — from GET /inventory-accounts. Required here whatever the product's type — see the warning below. |
sku | string | Optional | Your own identifier for the product. Max 100. |
description | string | Optional | Longer text about the product. |
tags | array | Optional | Labels you can group and filter by, each an object with a label. |
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.These are dropped if sent:
typequantitytype 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.quantity is dropped here, this is not the endpoint for moving stock. Stock has to stay tracked, so a change in quantity goes through POST /products/inventory-adjustment, which records the reason and the account the movement posts to — see Inventory Adjustments.{
"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 -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" }]
}'{
"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
}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.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 | Meaning |
|---|---|
| 200 | The product is updated and returned. |
| 404 | No product with that id. |
| 422 | A required field is missing, or the name or SKU already belongs to another product. |
{
"message": "Product not found"
}name and sku are both unique within a tenant, so a clash is a 422:
{
"message": "A product with this name already exists.",
"errors": { "name": ["A product with this name already exists."] }
}errors rather than assuming the user left something blank. A missing field carries the same shape — see create.Read your products back, a page at a time.
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.
| Field | Type | Required | Description |
|---|---|---|---|
page | integer | Required | Which page to return, starting at 1. |
page_length | integer | Required | How many products per page. Comes back as per_page in the response. |
search | string | Required | A JSON object, sent as a string, holding your filters. Send {} for no filter — that is what the example does. |
{} 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.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={}'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.
| Field | What it is |
|---|---|
total | How many products exist in total, across every page — 6 in the example. |
current_page | The page you are on, echoing the page you asked for. |
last_page | The highest page number available. Stop paging when current_page reaches it. |
per_page | Page size in effect, echoing page_length. |
from / to | Position of the first and last item on this page within the full set — 1 and 6 here. |
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.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.| Field | What it tells you |
|---|---|
type | inventory, non_inventory or service — whichever you created it as. |
description / tags | What you sent when you created the product. tags is an empty array when you did not send any. |
is_active | Whether the product is still in use. Every product on the example page is active. |
sku | Your own identifier, or null if you did not send one. |
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.{
"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 | Meaning |
|---|---|
| 200 | The page is returned, even when data is empty. |
| 403 | You do not have permission for this product. |
| 500 | Unexpected error. |
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.Read a single product back by its id.
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.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The product's id, as returned by POST /products or found in the list. The example reads product 3210 — the one created above. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/3210' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"The product on its own — no wrapper, no paging fields — and the same seven fields POST /products returns.
{
"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
}data or product key. Read the fields straight off the response body.POST /products and from each entry in GET /products. One product model in your code covers all three calls.| Status | Meaning |
|---|---|
| 200 | The product is returned. |
| 404 | No product with that id. |
| 403 | The product exists but you do not have permission to see it. |
| 500 | Unexpected error. |
{
"message": "Product not found"
}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.Change the stock held for a product, and record why.
An inventory adjustment changes the stock held for one or more products, and records why it changed. It is the only way to move a product's quantity through the API.
PUT /products/:id drops quantity if you send it — see Update a Product. Stock has to stay tracked, so every movement goes through POST /products/inventory-adjustment instead, which records the reason and the account the movement posts to and leaves a history you can read back from GET /products/:id/inventory.Fetch a reference number
ref_no to put on the adjustment.Fetch the accounts it can post to
id your user picked as adjustment_account.Fetch the list of reasons
value, show the label.Post the adjustment
The three reads first — the reference number, the accounts and the reasons:
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/latest-inventory-number' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/inventory-adjustment-accounts' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment-reasons' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"Then the adjustment itself. The product holds 50; adjusted_quantity of 20 moves it by 20, not to 20:
curl -X POST \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"ref_no": "00088",
"adjustment_date": "2026-09-17",
"adjustment_account": 20,
"reason": "other",
"items": [
{ "product_id": 85, "adjusted_quantity": 20 }
]
}'The response carries the stored adjustment, and product.available_stock shows the product now holds 70:
{
"id": 106,
"ref_no": "00088",
"adjustment_date": "2026-09-17",
"adjustment_account": 20,
"reason": "other",
"items": [
{
"id": 134,
"inventory_adjustment_id": 106,
"product_id": 85,
"adjusted_quantity": 20,
"product": {
"name": "Retail Lite Test",
"available_stock": 70
}
}
]
}X-Tenant and API Key headers as the rest of the API, and a bad or missing one is rejected before the request reaches the adjustment — so it is not listed on each endpoint. See Getting Started → Authentication.The next reference number to use on an adjustment.
Returns the next reference number to use on an adjustment. Call it before you post one and send what comes back as ref_no.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/latest-inventory-number' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": {
"ref_no": "00002"
}
}| Field | Type | What it is |
|---|---|---|
data.ref_no | string | The next reference number. |
"00002", not 2. Keep it a string on your side and send it back as one.| Status | Meaning |
|---|---|
| 200 | The reference number is returned. |
| 500 | Unexpected error. |
Every active account an adjustment can be posted to.
Returns every active account an adjustment can be posted to. This is a wider list than the inventory accounts — an adjustment can land almost anywhere in the chart: COGS, Expenses, Income, Equity, Assets, Bank and Liabilities.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/inventory-adjustment-accounts' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": [
{
"id": 3321,
"name": "Cost of Goods Sold"
}
]
}| Field | Type | What it is |
|---|---|---|
data[].id | integer | The account id. This is what you send as adjustment_account. |
data[].name | string | The account name, for showing in your picker. |
data, so there is no meta and no page parameter.| Status | Meaning |
|---|---|
| 200 | The accounts are returned. |
| 500 | Unexpected error. |
The seven reasons an adjustment can carry.
Returns the reasons an adjustment can carry. The list is fixed and does not vary by tenant.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment-reasons' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": [
{ "label": "Stock on Fire", "value": "stock_on_fire" },
{ "label": "Stolen Goods", "value": "stolen_goods" },
{ "label": "Damaged Goods", "value": "damaged_goods" },
{ "label": "Stock Written Off", "value": "stock_written_off" },
{ "label": "Stocktaking Results", "value": "stocktaking_results" },
{ "label": "Inventory Revaluation", "value": "inventory_revaluation" },
{ "label": "Other", "value": "other" }
]
}| Field | Type | What it is |
|---|---|---|
data[].label | string | The reason as it should be shown to a user. |
data[].value | string | The reason as it is sent — this is what goes in the reason field of an adjustment. |
data, so there is no meta.| Status | Meaning |
|---|---|
| 200 | The reasons are returned. |
| 500 | Unexpected error. |
Move the stock held for one or more products.
Creates an adjustment. Each entry in items moves one product's stock, and the whole adjustment is stored against the account and reason you send.
| Field | Type | Required | Description |
|---|---|---|---|
ref_no | string | Required | The reference number, max 50 characters. Take it from GET /products/latest-inventory-number. |
adjustment_date | string | Required | The date of the adjustment, in YYYY-MM-DD format exactly. |
adjustment_account | integer | Required | Id of the account the adjustment posts to — from GET /inventory-adjustment-accounts. |
reason | string | Required | Why the stock changed. One of the seven values from GET /products/inventory-adjustment-reasons. |
items | array | Required | The products to adjust. At least one entry. |
items[].product_id | integer | Required | Id of the product to adjust. A product cannot appear twice in one request. |
items[].adjusted_quantity | numeric | Required | How far to move the stock. Cannot be 0. |
{
"ref_no": "00002",
"adjustment_date": "2026-09-17",
"adjustment_account": 3321,
"reason": "other",
"items": [
{
"product_id": 3180,
"adjusted_quantity": 20
}
]
}curl -X POST \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"ref_no": "00002",
"adjustment_date": "2026-09-17",
"adjustment_account": 3321,
"reason": "other",
"items": [
{ "product_id": 3180, "adjusted_quantity": 20 }
]
}'{
"id": 106,
"ref_no": "00088",
"adjustment_date": "2026-09-17",
"adjustment_account": 20,
"reason": "other",
"items": [
{
"id": 134,
"inventory_adjustment_id": 106,
"product_id": 85,
"adjusted_quantity": 20,
"product": {
"name": "Retail Lite Test",
"available_stock": 70
}
}
]
}| Field | Type | What it is |
|---|---|---|
id | integer | Id of the stored adjustment. |
ref_no | string | The reference number it was stored under. |
adjustment_date | string | The date the adjustment was made for. |
adjustment_account | integer | Id of the account it posted to. |
reason | string | The reason value sent. |
items[].id | integer | Id of the adjustment line. |
items[].inventory_adjustment_id | integer | Id of the adjustment the line belongs to. |
items[].product_id | integer | Id of the product that was adjusted. |
items[].adjusted_quantity | number | How far the stock was moved. |
items[].product.name | string | Name of the product that was adjusted. |
items[].product.available_stock | number | What the product holds after the adjustment. |
| Status | Meaning |
|---|---|
| 201 | The adjustment is stored and returned. |
| 422 | Validation failed — see the table below. |
| 500 | Unexpected error. |
| Cause | Message |
|---|---|
| Product is a service or non-inventory product | Product 3180 is not an inventory product, so its stock cannot be adjusted. |
| Product carries no stock quantity | Product 3180 does not hold a stock quantity, so it cannot be adjusted. |
| Reduction exceeds stock held | Product 3180 holds 50 in stock, so it cannot be reduced by 60. |
| Reason not in the list | The reason must be one of those listed by /products/inventory-adjustment-reasons. |
| Quantity is zero | The adjusted quantity cannot be zero. |
| Same product twice | The same product cannot be adjusted twice in one adjustment. |
| Bad date format | The adjustment date must be in YYYY-MM-DD format. |
{
"message": "Product 3180 holds 50 in stock, so it cannot be reduced by 60.",
"errors": {
"items.0.adjusted_quantity": [
"Product 3180 holds 50 in stock, so it cannot be reduced by 60."
]
}
}422 leaves every product exactly as it was.Read your adjustments back, a page at a time.
Returns your adjustments in pages. Each entry is the same adjustment object that POST /products/inventory-adjustment returns.
| Field | Type | Required | Description |
|---|---|---|---|
page | integer | Optional | Which page to return. Defaults to 1. |
page_length | integer | Optional | How many adjustments per page. Defaults to 10, and comes back as per_page. |
search | string | Optional | A JSON object, sent as a string, holding your filters. Send {} for no filter. |
curl -G \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
--data-urlencode 'page=1' \
--data-urlencode 'page_length=10' \
--data-urlencode 'search={}'{
"data": [
{
"id": 106,
"ref_no": "00088",
"adjustment_date": "2026-09-17",
"adjustment_account": 20,
"reason": "other",
"items": [
{
"id": 134,
"inventory_adjustment_id": 106,
"product_id": 85,
"adjusted_quantity": 20,
"product": {
"name": "Retail Lite Test",
"available_stock": 70
}
}
]
}
],
"meta": {
"current_page": 1,
"per_page": 10,
"last_page": 1,
"total": 1,
"from": 1,
"to": 1
}
}| Field | What it is |
|---|---|
current_page | The page you are on, echoing the page you asked for. |
per_page | Page size in effect, echoing page_length. |
last_page | The highest page number available. Stop paging when current_page reaches it. |
total | How many adjustments exist in total, across every page. |
from / to | Position of the first and last adjustment on this page within the full set. |
| Status | Meaning |
|---|---|
| 200 | The page is returned, even when data is empty. |
| 500 | Unexpected error. |
Read a single adjustment back by its id.
Returns one adjustment — the same object POST /products/inventory-adjustment returns, on its own with no data wrapper.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The adjustment's id, as returned when it was created. The example reads adjustment 106. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/inventory-adjustment/106' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"id": 106,
"ref_no": "00088",
"adjustment_date": "2026-09-17",
"adjustment_account": 20,
"reason": "other",
"items": [
{
"id": 134,
"inventory_adjustment_id": 106,
"product_id": 85,
"adjusted_quantity": 20,
"product": {
"name": "Retail Lite Test",
"available_stock": 70
}
}
]
}| Status | Meaning |
|---|---|
| 200 | The adjustment is returned. |
| 404 | No adjustment with that id. |
| 500 | Unexpected error. |
{
"message": "Inventory adjustment not found"
}A product and the stock movements that got it to where it stands.
Returns a product together with the stock movements that got it to where it stands.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The product's id. The example reads product 3520. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/products/3520/inventory' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"id": 3520,
"code": "GEN-000001",
"name": "Cards",
"sku": "GEN-000001",
"type": "inventory",
"is_active": true,
"available_stock": 20,
"revenue_account": 17,
"expense_account": 9,
"inventory_account": 10,
"stock_list": [
{
"entry_from": "Product",
"detail": {
"id": 3520,
"name": "Cards"
},
"quantity": 20,
"as_of_qty": 20
}
]
}| Field | Type | What it is |
|---|---|---|
id | integer | The product id. |
code | string | The product code. |
name | string | The product name. |
sku | string | Your own identifier. |
type | string | inventory, non_inventory or service. |
is_active | boolean | Whether the product is still in use. |
available_stock | number | What the product holds right now. |
revenue_account / expense_account / inventory_account | integer | The product's account ids. |
stock_list | array | The movement history — see below. |
Each entry is one movement, and maps to a row of a stock history table like this:
| Column | Field |
|---|---|
| Transaction Type | entry_from + detail.name |
| Qty In | quantity |
| Qty on Hand | as_of_qty |
| Field | Type | What it is |
|---|---|---|
entry_from | string | What caused the movement — "Product" for opening stock, for example. |
detail | object | The record that caused the movement, carrying its id and name. |
quantity | number | How far this movement moved the stock. |
as_of_qty | number | What the product held after this movement. |
| Status | Meaning |
|---|---|
| 200 | The product and its movements are returned. |
| 404 | No product with that id. |
| 500 | Unexpected error. |
| Convention | What it means |
|---|---|
| Quantities | Numbers, not strings. A whole value carries no trailing .0. |
| Ids | Integers, or null when unset. |
| Single resources | Returned bare, with no data wrapper — the one exception is GET /products/latest-inventory-number. |
| Collections | Wrapped in data, with meta only when the endpoint is paginated. |
Add the person or business your invoices are issued to.
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.
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.Whatever form you build on top of this endpoint has two independent decisions in it, and every combination is valid:
| Choice | Option A | Option B |
|---|---|---|
| Company name | Left 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. |
| Address | Typed 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. |
company_name — so an accidental empty string there turns a person into a nameless business. Omit the key rather than sending ""."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.| Field | Type | Required | Description |
|---|---|---|---|
email | string | Required | Where invoices are emailed. Required for both a person and a company. |
first_name | string | Required | Given 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_name | string | Optional | Family name of the person. |
title | string | Optional | Salutation such as Mr or Ms. |
phone_number | string | Optional | Contact number including country code, digits only — for example 919685745259. |
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 "".A separate object, and the only place the business name goes. Leave the whole object out when you are creating a person.
| Field | Type | Required | Description |
|---|---|---|---|
company_name | string | Optional | The 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. |
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.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.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.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Required | billing or shipping. |
formatted_address | string | Required | The whole address as a single line, exactly as it should appear on the invoice. |
country | string | Required | Two-letter ISO country code — for example IN. |
administrative_area_level_1 | string | Optional | State or province code — for example MH, HR. |
locality | string | Optional | City or town. |
postal_code | string | Optional | Postal or PIN code. |
is_google_address | boolean | Required | true if the address came from Google Places, false if it was typed in by hand. This decides which of the fields below apply. |
is_primary | boolean | Optional | Marks this as the default address for its type. |
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:
| Field | Type | Required | Description |
|---|---|---|---|
google_place_id | string | Optional | The Places identifier for the selected address. |
route | string | Optional | Street name component. |
street_number | string | Optional | Building or house number. Send an empty string when Google did not return one. |
administrative_area_level_2 | string | Optional | District or division — for example Pune Division. |
latitude | number | Optional | Latitude of the place. |
longitude | number | Optional | Longitude of the place. |
{
"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
}"" 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.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.
{
"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 -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
}
]
}'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.
{
"contact_infos": {
"first_name": "Rohan",
"last_name": "Verma",
"title": "Mr",
"email": "rohan.verma@example.com",
"phone_number": "919685745259"
},
"additional_infos": {
"company_name": "Verma Enterprises"
}
}The created customer. Store the id — it is what you pass as customer_id when you raise an invoice.
{
"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
}| Field | You sent | Comes back as |
|---|---|---|
id | Nothing | The customer id. This is the only place you get it. |
display_name | Nothing — it is not a request field | Derived. Priya Sharma from the first and last name; on a business it is derived from company_name instead. |
open_balance / over_due | Nothing | 0 on a new customer. They move as invoices and payments are recorded. |
is_active | Nothing | true. |
| Field | Behaviour |
|---|---|
id | Each address is assigned one — and it is a string, "14893", not a number. |
address_line_1 / address_line_2 | Added to every address as null. They are not request fields. |
street_number | Comes back null when Google did not supply one, whether you sent null or an empty string. |
Everything else | Returned as you sent it — formatted_address, locality, postal_code, the Places fields, is_google_address and is_primary all pass through. |
type. Send billing and shipping and you get both back; send one and you get one. Nothing is invented for you.Everything above is identical. The only difference is an add_infos array, placed after addresses, holding the company name you sent:
"add_infos": [
{ "id": 618, "company_name": "Verma Enterprises" }
],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 key at all — it is not an empty array. Check the key exists before indexing into it, or a person record will throw.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 | Meaning |
|---|---|
| 200 | Customer created. The record is returned. |
| 422 | A required field is missing or an address entry is invalid. |
Replace a customer with how it should end up.
Replaces a customer. Send the whole customer as it should end up, not only the fields that changed.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The customer's id, as returned by POST /customers. The example updates customer 16908. |
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.
{
"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 -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
}
]
}'The same record you get back from create, so both can share one model on your side.
{
"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
}{
"message": "Customer not found"
}| Status | Meaning |
|---|---|
| 200 | Customer updated. The record is returned. |
| 404 | No customer with that id. |
| 422 | A required field is missing or an address entry is invalid. |
Read your customers back, a page at a time.
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.
| Field | Type | Required | Description |
|---|---|---|---|
page | integer | Required | Which page to return, starting at 1. |
page_length | integer | Required | How many customers per page. See the warning below — the example did not get back the size it asked for. |
type | string | Required | Which kind of contact to return — customer in the capture. Probably redundant now that the path itself says customers; see the note below. |
sort | string | Required | A JSON object, sent as a string, with sort_by and sort_order. Both empty strings in the example, which gives you the default order. |
search | string | Required | A JSON object, sent as a string, holding your filters. The example sends {"bothActiveInactive":2}. |
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.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.search accepts — by name, by email, by balance — is not documented.sort_by takes, and whether sort_order wants asc/desc, has not been supplied. Send both empty for the default order./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.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}'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.
/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=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.| Field | What it tells you |
|---|---|
display_name | What 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_due | What 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. |
addresses | The billing and shipping addresses, each tagged with its type — the same shape you sent when you created the customer. |
add_infos | The extra customer record — payment term, GST treatment, delivery method, classification. Always an array, with one entry per contact. |
type | The contact kind, echoing the type you filtered on. |
transactions | Present on the list but empty in the example. What populates it has not been confirmed. |
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.{
"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 | Meaning |
|---|---|
| 200 | The page is returned, even when data is empty. |
Read a single customer back by its id.
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.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The customer's id, as returned by POST /customers or found in the list. The example reads customer 5. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/customers/5' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"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:
| Field | In the list | Here |
|---|---|---|
is_active | 1 — an integer | true — a boolean |
| The uuid | uuid | user_id — same value, different field name |
| Timestamps | created_at, updated_at, deleted_at | Not returned |
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.| Field | What it is |
|---|---|
contact_persons | Additional people to deal with at the customer. Empty in the example. |
custom_fields | Your own fields on the contact. Empty in the example. |
home_no, business_no, other_contact | Extra phone numbers beyond phone_number and mobile_number. |
tax_profile | Tax profile attached to the contact. Null in the example. |
"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.{
"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 | Meaning |
|---|---|
| 200 | The contact is returned. |
| 404 | No contact with that id on your account. Not verified — the response for an unknown id has not been supplied. |
Take the next number in your sequence.
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.
None. No query string, no body — just the two authentication headers.
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"One field, and it is the whole point of the call:
{
"invoice_number": "INV-2025-001"
}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.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.| Status | Meaning |
|---|---|
| 200 | The next number is returned. |
Bill a customer for one or more products.
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.
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.| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | Required | The id returned when you created the customer via POST /customers. |
invoice_date | string | Required | Date the invoice is raised, as YYYY-MM-DD. |
invoice_no | string | Required | The invoice number, up to 100 characters. Take it from GET /invoices/get-invoice-number rather than generating your own. |
lines | array | Required | The products being billed. At least one entry. See the table below. |
due_date | string | Optional | Date payment is due, as YYYY-MM-DD. |
shipping_date | string | Optional | Date the goods ship, as YYYY-MM-DD. |
message_on_invoice | string | null | Optional | A note to the customer, shown on the invoice. Send null or leave it out for none. |
addresses | array | Optional | Billing and shipping addresses. Optional as a whole — see the table below. |
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.One entry per product being billed.
| Field | Type | Required | Description |
|---|---|---|---|
product_id | integer | Required | The id returned when you created the product via POST /products. |
rate | number | Optional | Price per unit, as a plain number, zero or more. Overrides the product's own price. Required when the line has no pricing_plan_id; ignored when it has one. |
quantity | number | Required | How many units. Must be a whole number greater than zero — fractional quantities are rejected. |
pricing_plan_id | integer | Optional | Bill this line on a pricing plan. The plan's price becomes the line's rate. Must be an invoice plan (has_billing_period: false). |
pricing_plan_id on a line and the plan's price becomes its rate. A rate sent on the same line is ignored.has_billing_period: false) works. A subscription plan, or a plan that cannot be found, returns 422 on that line.pricing_plan_id and pricing_plan on every line, the plan in its current shape. A plan since removed from the product still shows; a plan that has been deleted comes back as pricing_plan: null.rate and quantity are numbers — 120 and 1, not "120.00" or "1.0000000000". The response returns them as numbers too.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.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Required | billing or shipping. |
address | string | Optional | Street line — for example Plot 23, MG Road. |
city | string | Optional | City name. |
state | string | Optional | State, spelled out — Maharashtra, not MH, in the example. |
zip_code | string | Optional | Postal or PIN code, as a string. |
country | string | Optional | Country, spelled out — India, not IN, in the example. |
| Field | Type | Required | Description |
|---|---|---|---|
is_google_address | integer | Required | 1 on a Google-sourced address. Note this is the integer 1, not true. |
google_place_id | string | Optional | The Places identifier for the selected address. |
address | string | Optional | The address as Google returned it. |
latitude | string | Optional | Latitude, as a string — "19.0760", not a number. |
longitude | string | Optional | Longitude, as a string. |
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).1. On POST /customers it is the boolean true. Latitude and longitude are strings here and numbers there. Convert rather than copying.| Field | How it is derived |
|---|---|
unique_no, reference_no, tracking_no | All three copied from invoice_no. |
lines[].amount | rate × quantity. In the example: 120 × 1 = 120, and 500 × 3 = 1500. |
amount | The sum of every line amount — 120 + 1500 = 1620. |
balance | Equal to amount, since nothing has been paid on a new invoice. |
lines[].id | Each line is assigned its own id. |
| Field | Rule | Message |
|---|---|---|
customer_id | required, integer | The customer field is required. |
invoice_date | required, date | The invoice date field is required. |
invoice_no | required, max 100 characters | The invoice number field is required. |
lines | required, at least one entry | The line items field is required. |
lines[].product_id | required, integer | The product field is required. |
lines[].rate | required when the line has no pricing_plan_id, numeric, zero or more | The rate field is required. |
lines[].pricing_plan_id | optional, an invoice plan that exists | — |
lines[].quantity | required, numeric, greater than zero, whole number | The quantity must be a whole number. |
message_on_invoice | optional, string or null | — |
addresses[].type | billing or shipping, when an address is sent | — |
rate and keep quantity at a whole number — 1 × 750 rather than 1.5 × 500.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.{
"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 -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"
}
]
}'Four fields, one line, no addresses. Everything else is filled in by the server:
{
"customer_id": 16909,
"invoice_date": "2026-08-13",
"invoice_no": "INV-00016",
"lines": [
{ "product_id": 3204, "rate": 120, "quantity": 1 }
]
}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.
{
"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
}| Field | What it is |
|---|---|
id | The invoice id — 1185 here. Use it to read the invoice back, and to apply payments to it. |
customer | The customer expanded inline, with its addresses and add_infos, so an invoice screen needs no second call. |
customer.open_balance | Already includes this invoice — 1620 in the example, matching its balance. |
amount / balance | Both 1620 on a fresh invoice. balance drops as payments are applied. |
lines | Your lines with an id and the calculated amount added. rate and quantity come back as you sent them, except a line on a plan, whose rate is the plan's price. |
lines[].pricing_plan_id / lines[].pricing_plan | The line's plan, in the same shape as Get a Pricing Plan. pricing_plan is null if the plan has been deleted. |
addresses | The addresses as stored. |
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 | Meaning |
|---|---|
| 200 | Invoice created. The record is returned. |
| 404 | The customer or one of the products on this invoice was not found. |
| 422 | Validation failed, a line's plan is a subscription plan or cannot be found, or the invoice could not be created with the details provided. |
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.Replace an invoice — only while nothing has been paid on it.
Replaces an invoice. Send the whole invoice as it should end up, not only the fields that changed.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The invoice's id, as returned by POST /invoices. It travels into the body for you — you do not send it twice. |
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | Required | The customer the invoice is issued to. |
invoice_date | date | Required | The date on the invoice. |
invoice_no | string | Required | The customer-facing number. Max 100. |
message_on_invoice | string | Optional | Free text printed on the invoice. |
lines | array | Required | At least one line. See the table below. |
addresses | array | Optional | Each entry needs a type — billing, location, shipping or ship_via. |
| Field | Type | Required | Description |
|---|---|---|---|
product_id | integer | Required | The product this line bills for. |
rate | number | Optional | Price per unit. Cannot be negative. Kept as sent unless use_plan_price is true. |
quantity | integer | Required | A whole number, greater than zero. |
pricing_plan_id | integer | Optional | An invoice plan (has_billing_period: false). A subscription plan, or a plan that cannot be found, returns 422 on that line. |
use_plan_price | boolean | Optional | true sets the line's rate to the plan's current price. Leave it out and the rate you send is kept. |
| Field | How it is worked out |
|---|---|
unique_no, tracking_no, reference_no | All three from invoice_no, if you did not send them. |
lines[].amount | rate × quantity, per line. |
amount | The total of every line. |
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.| amount | balance | Editable? |
|---|---|---|
| 500 | 500 | Yes — nothing has been received. |
| 500 | 499.99 | No |
| 500 | 0 | No |
{
"message": "This invoice cannot be edited because a payment has already been received against it."
}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.{
"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 -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" }
]
}'The whole invoice, in the same shape create returns.
{
"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 | Meaning |
|---|---|
| 200 | The invoice is updated and returned. |
| 404 | The invoice, the customer, or one of the products was not found. |
| 422 | Validation failed, a line's plan is a subscription plan or cannot be found, or a payment has already been received against the invoice. |
Read your invoices back, a page at a time.
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.
| Field | Type | Required | Description |
|---|---|---|---|
page | integer | Required | Which page to return, starting at 1. |
page_length | integer | Required | How many invoices per page. Comes back as meta.per_page. |
search | string | Required | A 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. |
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={}'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.
| Field | What it is |
|---|---|
total | How many invoices exist in total, across every page — 8 in the example. |
current_page | The page you are on, echoing the page you asked for. |
last_page | The highest page number available. Stop paging when current_page reaches it. |
per_page | Page size in effect, echoing page_length. |
from / to | Position of the first and last item on this page within the full set. |
next_page_url, links or path. Page with current_page against last_page.| Field | What it tells you |
|---|---|
amount / balance | What the invoice is for, and what is still owed. Equal on an invoice nothing has been paid against. |
customer | The customer expanded inline, so a list screen needs no extra call per row. |
lines | The lines in full, each with its id and calculated amount. |
lines[].pricing_plan_id | The line's plan id only. The plan itself is not included in the list. |
unique_no, reference_no, tracking_no | All three carry the invoice number. |
balance against amount — equal means nothing paid, zero means settled, anything between is a part payment.{
"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 | Meaning |
|---|---|
| 200 | The page is returned, even when data is empty. |
Read one invoice back in full.
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.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The invoice's id, as returned by POST /invoices or found in the list. The example reads invoice 1185. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/invoices/7' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"Identical in shape to the create response, so one invoice model in your code covers both calls:
{
"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
}| Field | What it is |
|---|---|
balance | What is still owed. This is the field to read after recording a payment — it is the only place the new figure appears. |
amount | The invoice total. Compare it against balance to tell whether anything has been paid. |
customer | The customer expanded inline, with its addresses, add_infos and open_balance — no second call needed. |
lines | Each line with its own id, the product_id, quantity, rate and the calculated amount. |
addresses | The addresses as stored on the invoice. The list endpoint returns this empty, so this is where to read them. |
unique_no, reference_no, tracking_no | All three carry the invoice number. There is no separate invoice_no on the response. |
balance against amount. Equal means nothing paid; zero means settled; anything between is a part payment.<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 | Meaning |
|---|---|
| 200 | The invoice is returned. |
| 404 | No invoice with that id. Not verified — the response body for an unknown id has not been supplied. |
A link the customer can open and pay the invoice online.
Builds a payment link you can send to the customer. Only the invoice id is needed — nothing else.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The invoice's id. The example uses 1048. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/invoices/payment-link/1048' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"payment_url": "https://payments.cashfree.com/links/x9f2k1"
}{
"message": "There is no payment gateway set up to take this payment."
}| Status | Meaning |
|---|---|
| 200 | The payment link is returned. |
| 422 | No payment gateway is active on the tenant, so there is nothing to build a link on. |
| 404 | No invoice with that id. |
How the money was paid — the ids a payment can use.
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.
None. Every method comes back in one call — no page, no search, and no meta block.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/payment-methods' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": [
{ "id": 1, "name": "Cash" },
{ "id": 2, "name": "Cheque" },
{ "id": 3, "name": "Credit Card" }
]
}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.1 meaning Cash in your own tenant is no guarantee it means Cash in your customer's. Read it at runtime.| Status | Meaning |
|---|---|
| 200 | The methods are returned. |
| 500 | Unexpected error. |
Take the next reference in your payment sequence.
Returns the next payment reference for your account. Call this before recording a payment and send what it gives you as reference_no.
None. No query string, no body — just the two authentication headers.
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"The reference is nested one level down, under data:
{
"success": true,
"data": {
"reference_no": "PMT-00417"
}
}data.success flag; this one does.invoice_number there and reference_no here.response.invoice_number and response.data.reference_no — do not write one helper for both.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.PMT-00417 — so take the reference and record the payment straight away rather than holding it.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 | Meaning |
|---|---|
| 200 | The next reference is returned. |
Record a payment against a customer's open invoices.
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.
All six are required.
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | Required | The customer the money came from — the id from POST /customers. Every invoice in paidAmount must belong to this customer. |
account_id | integer | Required | Id of the account the money is deposited into. Take it from GET /bank-accounts and send the one your user picked. |
payment_method | integer | Required | How the money was paid. Take the id from GET /payment-methods and send the one your user picked. |
payment_date | string | Required | Date the money was received, as YYYY-MM-DD. |
reference_no | string | Required | The payment's own reference — PMT-00004 in the example. Take it from GET /payments/get-next-payment-number rather than generating it yourself. |
paidAmount | array | Required | Which invoices the money is applied to, and how much goes to each. See the table below. |
paidAmount. Sending paid_amount will not work.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.One entry per invoice the payment is applied to.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The invoice's id, as returned by POST /invoices. Invoice 8 in the example is the one that comes back as INV-00008. |
payment | string | Required | Amount applied to that invoice, as a string with two decimal places. Must be greater than zero. |
"1500.00" — a string here, even though every money value in a response comes back as a plain number. Send it as shown.balance will show the remainder. Record the rest later against the same invoice id.{
"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 -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" }
]
}'Unlike the other endpoints on this page, this one does not return the record it created — just two fields:
| Field | What it is |
|---|---|
payment_id | Id 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_numbers | The customer-facing numbers of the invoices the payment was applied to — INV-00008 here. |
{
"invoice_numbers": "INV-00008",
"payment_id": 6
}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.success field, so the status code is all you have. Treat 201 as recorded and anything else as not recorded.balance.paidAmount has more than one entry has not been confirmed — do not parse it until it has.A 422 carries a message and an errors object keyed by field:
{
"message": "The invoice field is required.",
"errors": {
"paidAmount.0.id": ["The invoice field is required."]
}
}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 | Meaning |
|---|---|
| 201 | Payment recorded. |
| 422 | Validation failed — a required field is missing, or a value is not acceptable. |
| 500 | Unexpected error. |
Replace a recorded payment and what it settles.
Replaces a payment. Send the whole payment as it should end up, not only the fields that changed.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The payment_id that POST /payments returned. It travels into the body for you — you do not send it twice. |
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | Required | The customer the money came from. |
account_id | integer | Required | The account the money is deposited into — from GET /bank-accounts. |
payment_method | integer | Required | How the money was paid — from GET /payment-methods. |
payment_date | date | Required | The date the money was received. |
reference_no | string | Required | The payment's own reference. Max 100. |
paidAmount | array | Required | At least one entry. See the table below. |
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The invoice's id. |
payment | number | Required | The amount applied to it. Greater than zero. |
memo, paidCredit, send_email and attachments are forwarded exactly as you send them.
| Field | What happens to it |
|---|---|
payment_method | The id you send is turned into its text form. |
paidAmount[].type | Set to "invoice" on every row if you did not send it. |
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.{
"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 -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" }
]
}'{
"invoice_numbers": "INVOICE-400"
}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.{
"message": "The payment date field is required.",
"errors": { "payment_date": ["The payment date field is required."] }
}{
"message": "Payment not found"
}| Status | Meaning |
|---|---|
| 200 | The payment is updated. |
| 404 | No payment with that id. |
| 422 | Validation failed. |
Read recorded payments back, a page at a time.
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.
All optional. Send none of them and you get the first ten payments, unfiltered.
| Parameter | Type | Default |
|---|---|---|
page | integer | 1 |
page_length | integer | 10 |
search | string (JSON) | {} |
--data-urlencode in cURL. Which keys it accepts has not been supplied.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"}'The payments in data, the paging in meta — the same envelope the product and invoice lists use.
{
"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
}
}| Field | What it is |
|---|---|
id | The payment id. Use it to read the payment back by id. |
type | Always "payment" on this endpoint. |
amount | What was received — negative, because a payment reduces what the customer owes. See the warning below. |
date | The date the money was received. |
no | The payment reference — PMT-00004. Note the field is called no here, not reference_no. |
customer_id, customer_name, email | Who the money came from, flattened onto the row. |
due_date / balance | Always null. They do not apply to a payment — the row shape is shared with other kinds of sales transaction. |
-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.-1500, not "-1500.0000000000". You do not have to parse a decimal string.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.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.An empty account is a 200 with an empty data array — not an error, and not a 404:
{
"data": [],
"meta": {
"current_page": 1,
"per_page": 10,
"last_page": 1,
"total": 0,
"from": null,
"to": null
}
}A single message, whatever went wrong:
{
"message": "Failed to fetch payments"
}| Status | Meaning |
|---|---|
| 200 | The page is returned, even when data is empty. |
| 500 | Unexpected error. |
Read one payment back, with what it was applied to.
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.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The 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. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/payments/520' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"| Key | What it holds | Use it for |
|---|---|---|
payment | The payment record — amount, date, method, deposit account, reference, and one allocation row per invoice. | Everything about the payment itself. |
receivePayment.invoices | The invoices this payment settled, each in full. | Showing what was paid, and each invoice's remaining balance. |
transaction | The ledger entry the payment produced. | Tying the payment to your books. |
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.| Field | What it is |
|---|---|
payment_amount | The total received — a positive number, unlike the negative amount the list endpoint returns. |
payment_method / account_id | The two values you sent when recording it. |
reference_no | The PMT- reference. Repeated on every allocation row. |
invoice_receive_payment | One row per invoice the payment was applied to — invoice_id and the amount that went to it. |
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.| Field | What it is |
|---|---|
id | The ledger transaction's own id — 1738 here. Not the payment id. |
transaction_type_id | The payment id — 520 here. Despite the name, this is the link back to the payment. |
transaction_ref | The PMT- reference again. |
total | The payment amount, positive. |
contact_id | The customer. Note it is contact_id here and customer_id on the payment object. |
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.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. 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.{
"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"
}
]
}
}{
"message": "Payment not found"
}| Status | Meaning |
|---|---|
| 200 | The payment is returned. |
| 404 | No payment with that id. |
| 500 | Unexpected error. |
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.Add a fixed or an adjustment pricing component.
Creates a pricing component.
| Field | Type | Required | Description |
|---|---|---|---|
component_name | string | Required | max 255 |
component_code | string | Required | max 50, must be unique |
calculation_type | string | Optional | fixed or adjustment. Defaults to fixed. |
has_billing_period | boolean | Optional | Defaults to false. See below. |
base_amount | number | Optional | Required when has_billing_period is false. min 0. |
billing_amounts | array | Optional | Required when has_billing_period is true. One monthly row and one yearly row. |
adjustment | object | Optional | Required when calculation_type is adjustment. See below. |
| has_billing_period | Price sent in |
|---|---|
false (the default) | One price, in base_amount. |
true | Separate monthly and yearly prices, in billing_amounts. Both are required. |
{
"component_name": "Platform Fee",
"component_code": "PLAT",
"calculation_type": "fixed",
"has_billing_period": true,
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
]
}| Field | Type | Values |
|---|---|---|
adjustment_type | string | add or discount |
type | string | percent or flat_amount |
value | number | 0 or more |
{
"component_name": "Setup Fee",
"component_code": "SETUP",
"base_amount": 2500
}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
}'{
"component_name": "Service Charge",
"component_code": "SVCCHG",
"calculation_type": "adjustment",
"base_amount": 0,
"adjustment": {
"adjustment_type": "add",
"type": "percent",
"value": 4
}
}Each component returns exactly one of base_amount, billing_amounts or adjustment, depending on how it is priced.
{
"id": 10,
"component_code": "SETUP",
"component_name": "Setup Fee",
"calculation_type": "fixed",
"has_billing_period": false,
"is_active": false,
"pricing_plans": [],
"base_amount": 2500
}{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": false,
"pricing_plans": [],
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
]
}{
"id": 12,
"component_code": "SVCCHG",
"component_name": "Service Charge",
"calculation_type": "adjustment",
"has_billing_period": false,
"is_active": false,
"pricing_plans": [],
"adjustment": {
"adjustment_type": "add",
"type": "percent",
"value": 4
}
}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:
| Field | What it is |
|---|---|
id | The plan's id. |
name | The plan's name. |
code | The plan's code. |
"pricing_plans": [
{ "id": 5, "name": "Standard Monthly Plan", "code": "STDMON" },
{ "id": 6, "name": "Yearly Plan", "code": "YRLY" }
]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.pricing_plans key at all — it would point back at the plan you are already reading. Do not expect the field there.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.
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 | Meaning |
|---|---|
| 201 | The component is created. |
| 400 | The component code is already in use. |
| 422 | Validation failed. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
The bodies for each of those are in Pricing Component Errors.
Replace a pricing component with how it should end up.
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.
400 + plans. You can still change a component's name, amounts and other details while it is in use. Only switching how it is priced is blocked.{
"component_name": "Service Charge",
"component_code": "SVCCHG",
"calculation_type": "adjustment",
"base_amount": 0,
"adjustment": {
"adjustment_type": "add",
"type": "percent",
"value": 4
}
}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",
"calculation_type": "adjustment",
"base_amount": 0,
"adjustment": {
"adjustment_type": "add",
"type": "percent",
"value": 4
}
}'Same shape as the create response.
| Status | Meaning |
|---|---|
| 200 | The component is updated. |
| 400 | The component code is already in use, or has_billing_period was changed while the component is used in live pricing plans. |
| 404 | No pricing component with that id. |
| 422 | Validation failed. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Every component at once, with no pagination.
Returns all pricing components in one call — there is no pagination on this endpoint.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"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.
{
"data": [
{
"id": 10,
"component_code": "SETUP",
"component_name": "Setup Fee",
"calculation_type": "fixed",
"has_billing_period": false,
"is_active": true,
"pricing_plans": [],
"base_amount": 2500
},
{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": true,
"pricing_plans": [
{ "id": 5, "name": "Standard Monthly Plan", "code": "STDMON" },
{ "id": 6, "name": "Yearly Plan", "code": "YRLY" }
],
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
]
},
{
"id": 12,
"component_code": "SVCCHG",
"component_name": "Service Charge",
"calculation_type": "adjustment",
"has_billing_period": false,
"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",
"has_billing_period": false,
"is_active": true,
"pricing_plans": [],
"adjustment": { "adjustment_type": "discount", "type": "flat_amount", "value": 100 }
}
]
}| Status | Meaning |
|---|---|
| 200 | The components are returned. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Read one component back by its id.
Returns one pricing component as a plain object — no wrapper. The list endpoint puts its items inside data; this one does not.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The component's id, as returned by POST /pricing-components. The example reads component 14. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components/14' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"id": 14,
"component_code": "LOYALTY",
"component_name": "Loyalty Discount",
"calculation_type": "adjustment",
"has_billing_period": false,
"is_active": true,
"pricing_plans": [],
"adjustment": { "adjustment_type": "discount", "type": "flat_amount", "value": 100 }
}{
"message": "Pricing component not found"
}| Status | Meaning |
|---|---|
| 200 | The component is returned. |
| 404 | No pricing component with that id. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Remove a component.
An active component used in live pricing plans cannot be deleted. The response is 422 + plans.
curl -X DELETE \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components/14' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"deleted": true
}| Status | Meaning |
|---|---|
| 200 | The component is deleted. |
| 404 | No pricing component with that id. |
| 422 | The component is active and used in live pricing plans. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Activate or deactivate a component.
| Field | Type | Required | Description |
|---|---|---|---|
is_active | boolean | Required |
A component used in live pricing plans cannot be activated or deactivated. The response is 422 + plans.
{
"is_active": false
}curl -X PATCH \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-components/14/status' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "is_active": false }'The component, in the same shape as a get.
| Status | Meaning |
|---|---|
| 200 | The status is changed. |
| 404 | No pricing component with that id. |
| 422 | The component is used in live pricing plans. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
What comes back when a component request is refused.
| Action | Blocked when | Response |
|---|---|---|
Changing has_billing_period | The component is used in live pricing plans | 400 + plans |
| Delete | The component is active and used in live pricing plans | 422 + plans |
| Activate / deactivate | The component is used in live pricing plans | 422 + plans |
{
"message": "This component is priced into live pricing plans. Changing how it is priced will change what those plans cost. Detach it from them first.",
"plans": [
{ "id": 12, "name": "Starter Plan", "code": "STARTER" }
]
}{
"message": "This component is priced into live pricing plans. Removing it will change what those plans cost. Detach it from them first.",
"plans": [
{ "id": 12, "name": "Starter Plan", "code": "STARTER" }
]
}{
"message": "This component is priced into live pricing plans. Changing its status will change what those plans cost. Detach it from them first.",
"plans": [
{ "id": 12, "name": "Starter Plan", "code": "STARTER" }
]
}| Status | When | Response |
|---|---|---|
| 422 | validation failed | 422 Unprocessable |
| 400 | code already in use (create, update) | 400 Bad Request |
| 404 | component not found | 404 Not Found |
| 500 | unexpected error | 500 Server Error |
These are dropped if sent:
cost_amountmin_amountmax_amountquantitydisplay_orderitem_type_idis_taxableBuild a plan out of pricing components.
A plan is built from one or more pricing components.
fixed component — one that carries a base_amount. A plan made only of adjustments has nothing to adjust, so it is rejected.price is worked out from the components the plan holds, and it comes back on every response. It is never sent in.| Plan | price |
|---|---|
has_billing_period: false | number — 2500 |
has_billing_period: true | object — { "monthly": 500, "yearly": 5000 } |
This applies to every pricing plan response: list, get, create, update and status.
price shows up as [object Object].0.0, the price is 0.Every date is sent and returned as YYYY-MM-DD — no time, no timezone.
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.| Field | Type | Required | Description |
|---|---|---|---|
name | string | Required | max 255 |
code | string | Required | max 50, must be unique |
plan_type | string | Required | standard |
has_billing_period | boolean | Optional | false = invoice plan (default), true = subscription plan. Every fixed component on the plan must have the same value. |
effective_date | date | Required | YYYY-MM-DD, the day the plan starts. |
description | string | Optional | Free text. |
expiration_date | date | Optional | YYYY-MM-DD, on or after effective_date — the same day is allowed. |
components | array | Required | At least one, and at least one of them must be a fixed component. |
| has_billing_period | Plan |
|---|---|
false (the default) | invoice — used on an invoice line via lines[].pricing_plan_id |
true | subscription |
An invoice plan cannot be used for a subscription, and a subscription plan cannot be used on an invoice. Either one returns 422.
true plans for subscriptions and only false plans for invoices.has_billing_period must match the has_billing_period of every fixed component on it. A subscription plan can only use fixed components priced per billing period (billing_amounts). An invoice plan can only use fixed components priced with a single amount (base_amount). This applies on create and update. The error names the components that do not match.false. A plan built from components priced per billing period is then refused with A one-time plan can only use components priced with a single amount...{
"message": "A subscription plan can only use components priced per billing period. These are not: Platform Fee, Setup Charge.",
"errors": {
"components": ["A subscription plan can only use components priced per billing period. These are not: Platform Fee, Setup Charge."]
}
}| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | Id of the pricing component. |
start_date | date | Required | YYYY-MM-DD, when it starts being charged. Must fall within the plan's dates. |
end_date | date | Optional | YYYY-MM-DD, on or after start_date and within the plan's dates. Required when the plan has an expiration_date. |
start_date and end_date must both fall between the plan's effective_date and expiration_date. Neither date can be before the plan's effective_date, and neither can be after its expiration_date. This is checked separately from the end_date rules above.expiration_date — the plan runs on with no end.end_date on a component — only allowed when the plan has no expiration_date. The component then stays for as long as the plan does.{
"name": "Yearly Plan",
"code": "YRLY",
"plan_type": "standard",
"has_billing_period": true,
"description": "Covers the yearly subscription fee.",
"effective_date": "2026-08-31",
"expiration_date": "2027-03-31",
"components": [
{
"id": 6,
"start_date": "2026-08-31",
"end_date": "2026-09-30"
}
]
}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",
"has_billing_period": true,
"description": "Covers the yearly subscription fee.",
"effective_date": "2026-08-31",
"expiration_date": "2027-03-31",
"components": [
{ "id": 6, "start_date": "2026-08-31", "end_date": "2026-09-30" }
]
}'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.
{
"id": 6,
"name": "Yearly Plan",
"code": "YRLY",
"description": "Covers the yearly subscription fee.",
"plan_type": "standard",
"has_billing_period": true,
"effective_date": "2026-08-31",
"expiration_date": "2027-03-31",
"price": { "monthly": 500, "yearly": 5000 },
"is_active": false,
"components": [
{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": true,
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
],
"start_date": "2026-08-31",
"end_date": "2026-09-30"
}
]
}has_billing_period, and exactly one of base_amount, billing_amounts or adjustment. See Pricing Components for that shape.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 | Meaning |
|---|---|
| 201 | The plan is created. |
| 400 | The plan code is already in use. |
| 422 | Validation failed, or the plan has no fixed component. |
| 404 | A component in the plan does not exist. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
The bodies for each of those are in Pricing Plan Errors.
Replace a plan with how it should end up.
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.
YYYY-MM-DD only — a timestamp is rejected here too. expiration_date must be on or after effective_date, and every component's dates must sit inside the plan's. They are spelled out under Create a Pricing Plan → Dates.has_billing_period returns 400 + used_on. Create a new plan instead.{
"name": "Yearly Plan",
"code": "YRLY",
"plan_type": "standard",
"has_billing_period": true,
"description": "Covers the yearly subscription fee.",
"effective_date": "2026-08-31",
"expiration_date": "2027-03-31",
"components": [
{
"id": 6,
"start_date": "2026-08-31",
"end_date": "2026-09-30"
}
]
}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",
"has_billing_period": true,
"description": "Covers the yearly subscription fee.",
"effective_date": "2026-08-31",
"expiration_date": "2027-03-31",
"components": [
{ "id": 6, "start_date": "2026-08-31", "end_date": "2026-09-30" }
]
}'Same shape as the create response.
| Status | Meaning |
|---|---|
| 200 | The plan is updated. |
| 400 | The plan code is already in use, or has_billing_period was changed on a plan that has already been used on an invoice, lease or subscription. |
| 404 | No pricing plan with that id. |
| 422 | Validation failed, or the plan has no fixed component. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Every plan at once, with no pagination.
Returns every plan in one go. There is no pagination on this endpoint.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"The plans come back inside data, each with its components expanded.
{
"data": [
{
"id": 5,
"name": "Standard Monthly Plan",
"code": "STDMON",
"description": null,
"plan_type": "standard",
"has_billing_period": true,
"effective_date": "2026-08-31",
"expiration_date": null,
"price": { "monthly": 500, "yearly": 5000 },
"is_active": true,
"components": [
{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": true,
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
],
"start_date": "2026-08-31",
"end_date": null
}
]
}
]
}| Status | Meaning |
|---|---|
| 200 | The plans are returned. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Read one plan back by its id.
Returns one plan — the same shape as a list item, without the data wrapper.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The plan's id, as returned by POST /pricing-plans. The example reads plan 5. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans/5' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"id": 5,
"name": "Standard Monthly Plan",
"code": "STDMON",
"description": null,
"plan_type": "standard",
"has_billing_period": true,
"effective_date": "2026-08-31",
"expiration_date": null,
"price": { "monthly": 500, "yearly": 5000 },
"is_active": true,
"components": [
{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": true,
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
],
"start_date": "2026-08-31",
"end_date": null
}
]
}{
"message": "Pricing plan not found"
}| Status | Meaning |
|---|---|
| 200 | The plan is returned. |
| 404 | No pricing plan with that id. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Remove a plan.
| Blocked when | Response |
|---|---|
| The plan has been used on invoices or leases | 422 + used_on. Deactivate it instead. |
| The plan is live and attached to items | 422 + items. Detach it first. |
curl -X DELETE \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans/6' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"deleted": true
}| Status | Meaning |
|---|---|
| 200 | The plan is deleted. |
| 404 | No pricing plan with that id. |
| 422 | The plan has been used on invoices or leases, or is live and attached to items. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Activate or deactivate a plan.
| Field | Type | Required | Description |
|---|---|---|---|
is_active | boolean | Required |
Activating or deactivating a plan is never blocked.
{
"is_active": false
}curl -X PATCH \
'https://services.ap.mochatechnologies.com/quickbill/api/pricing-plans/6/status' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "is_active": false }'The plan, in the same shape as a get.
| Status | Meaning |
|---|---|
| 200 | The status is changed. |
| 404 | No pricing plan with that id. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
What comes back when a plan is refused.
| Action | Blocked when | Response |
|---|---|---|
Changing has_billing_period | The plan has already been used on an invoice, lease or subscription | 400 + used_on. Create a new plan instead. |
| Delete | The plan has been used on invoices or leases | 422 + used_on. Deactivate it instead. |
| Delete | The plan is live and attached to items | 422 + items. Detach it first. |
| Activate / deactivate | Never blocked | n/a |
{
"message": "This plan has already been used on invoices, leases or subscriptions. Its billing type can no longer be changed — create a new plan instead.",
"used_on": [ { type, id, no } ]
}{
"message": "This plan is already used on invoices or leases. It cannot be deleted — switch it off instead.",
"used_on": [ { type, id, no } ]
}{
"message": "This plan is attached to items. Detach it from them before deleting.",
"items": [ { type, id, name } ]
}| Key | Entry | Notes |
|---|---|---|
used_on | { type, id, no } | type is invoice or lease on delete, and invoice, lease or subscription on update. |
items | { type, id, name } | name can be null. |
| Status | When | Response |
|---|---|---|
| 422 | validation failed | 422 Unprocessable |
| 400 | code already in use (create, update) | 400 Bad Request |
| 404 | plan or component not found | 404 Not Found |
| 502 | the service is down | 502 Bad Gateway |
| When | Message |
|---|---|
no fixed component on the plan | At least one component with a fixed calculation type is required. |
| subscription plan with a fixed component priced with a single amount | A subscription plan can only use components priced per billing period. These are not: {component names}. |
| invoice plan with a fixed component priced per billing period | A one-time plan can only use components priced with a single amount. These are not: {component names}. |
component end_date before its start_date | The end date must be a date after or equal to the start date. |
component has no end_date on a plan that expires | The component end date is required because the plan expires on {date}. |
component date before the plan's effective_date | The component {start date|end date} must be on or after the plan effective date ({date}). |
component date after the plan's expiration_date | The component {start date|end date} must be on or before the plan expiration date ({date}). |
expiration_date before effective_date | The expiration date field must be a date after or equal to effective date. |
| timestamp instead of a date | The effective date field must match the format Y-m-d. |
| no components | The components field is required. |
Set which pricing plans a product is on.
Sets which pricing plans a product is on.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | Id of the product — the id from POST /products. |
| Field | Type | Required | Description |
|---|---|---|---|
pricing_plan_ids | array of integers | Required | The plans the product should end up on — ids from POST /pricing-plans. May be empty. |
{
"pricing_plan_ids": [6]
}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]
}'{
"pricing_plan_ids": [5, 6]
}{
"pricing_plan_ids": []
}422, so a forgotten field can never unlink everything by accident.{
"message": "Pricing plans linked successfully"
}| Status | Meaning |
|---|---|
| 200 | The plans are linked. |
| 422 | The field is missing or the wrong type, a plan id does not exist, or the product does not exist. |
| 404 | Product or plan not found. |
| 500 | Unexpected error. |
The bodies for each of those are in Attach Plan Errors.
Which plans a product is on.
Returns the pricing plans a product is on. This is how you check what POST /products/:id/pricing-plans left the product with.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | Id of the product — the id from POST /products. |
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"The plans come back inside data, each with its components expanded — the same shape GET /pricing-plans returns, just narrowed to this one product.
{
"data": [
{
"id": 5,
"name": "Standard Monthly Plan",
"code": "STDMON",
"description": null,
"plan_type": "standard",
"has_billing_period": true,
"effective_date": "2026-08-31",
"expiration_date": null,
"price": { "monthly": 500, "yearly": 5000 },
"is_active": true,
"components": [
{
"id": 6,
"component_code": "PLAT",
"component_name": "Platform Fee",
"calculation_type": "fixed",
"has_billing_period": true,
"is_active": true,
"billing_amounts": [
{ "billing_period": "monthly", "amount": 500 },
{ "billing_period": "yearly", "amount": 5000 }
],
"start_date": "2026-08-31",
"end_date": null
}
]
}
]
}start_date and end_date but no pricing_plans key.| Status | Meaning |
|---|---|
| 200 | The plans are returned. A product on no plans is an empty data array, not an error. |
| 404 | No product with that id. |
| 500 | Unexpected error. |
What comes back when a link is refused.
| Status | When | Response |
|---|---|---|
| 422 | field missing or wrong type | See Request validation below. |
| 422 | a plan id does not exist | See A plan id that does not exist below. |
| 422 | the product does not exist | See A product id that does not exist below. |
| 404 | product or plan not found | 404 Not Found |
| 500 | unexpected error | 500 Server Error |
| When | Response |
|---|---|
pricing_plan_ids left out | 422 Unprocessable |
| not an array | 422 Unprocessable |
| an entry is not an integer | 422 Unprocessable |
{
"message": "The selected pricing plan (574) is invalid.",
"errors": {
"pricing_plan_ids.0": ["The selected pricing plan (574) is invalid."]
}
}{
"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.
The messages are run together, and errors names each field it can.
{
"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."]
}
}Add a discount that can be applied to a subscription.
A coupon is applied to a subscription by sending its id as coupon_id on create or update.
| Field | Type | Required | Description |
|---|---|---|---|
coupon_code | string | Required | max 100. Must be unique across all pricing components, not only coupons — a normal component's code clashes too. |
coupon_name | string | Required | max 191 |
discount_type | string | Required | percent or flat_amount |
discount_value | number | Required | min 0. With percent the max is 100. |
cycle_count | integer | Optional | min 1. Leave it out or send null for a discount that never runs out. |
is_active | boolean | Optional |
Returned by create, update, get and status, inside data for list, and inside coupon for a successful validate.
| Field | Notes |
|---|---|
discount_type | percent or flat_amount. |
discount_value | Always a number. Whole values have no decimals. |
cycle_count | How many billing cycles the discount runs for. null means it never runs out. |
{
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}curl -X POST \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}'{
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}| Status | Meaning |
|---|---|
| 201 | The coupon is created. |
| 400 | coupon_code is already in use. |
| 422 | Validation failed. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
The bodies for each of those are in Coupon Errors.
Replace a coupon with how it should end up.
Takes the same body as create. Update replaces the whole coupon, so every required field must be sent again. The id goes in the path.
{
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}curl -X PUT \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons/7' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}'{
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}| Status | Meaning |
|---|---|
| 200 | The coupon is updated. |
| 400 | coupon_code is already in use. |
| 404 | Coupon not found. |
| 422 | Validation failed. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Every coupon at once, with no pagination.
Returns every coupon inside data. There is no meta key and no pagination.
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"data": [
{
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}
]
}| Status | Meaning |
|---|---|
| 200 | The coupons are returned. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Read one coupon back by its id.
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | Required | The coupon's id. The example reads coupon 7. |
curl -X GET \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons/7' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
}{
"message": "Coupon not found"
}| Status | Meaning |
|---|---|
| 200 | The coupon is returned. |
| 404 | Coupon not found. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Preview what a code takes off a pricing plan.
Works out what a code would take off a pricing plan. It does not apply the coupon.
| Field | Type | Required | Description |
|---|---|---|---|
coupon_code | string | Required | max 100 |
pricing_plan_id | integer | Required | Must be an existing plan. |
billing_cycle | string | Optional | monthly or yearly. Required when the plan is a subscription plan (has_billing_period: true). |
| Plan | billing_cycle | Result |
|---|---|---|
Invoice (false) | not sent | Preview on base_amount. |
Invoice (false) | sent | Ignored. Preview on base_amount. |
Subscription (true) | not sent | 422 |
Subscription (true) | monthly / yearly | Preview on that period's amount. |
| Any | anything other than monthly or yearly | 422 |
{
"message": "This is a subscription plan, so a billing cycle is required to price the discount.",
"errors": {
"billing_cycle": ["This is a subscription plan, so a billing cycle is required to price the discount."]
}
}valid.{
"coupon_code": "WELCOME20",
"pricing_plan_id": 12,
"billing_cycle": "monthly"
}curl -X POST \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons/validate' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"coupon_code": "WELCOME20",
"pricing_plan_id": 12,
"billing_cycle": "monthly"
}'When the code applies:
{
"valid": true,
"coupon": {
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": true
},
"base_amount": 1000,
"discount_amount": 200,
"payable_amount": 800
}When it does not apply:
{
"valid": false,
"message": "This coupon code does not exist."
}| Message |
|---|
| This coupon code does not exist. |
| This coupon is no longer active. |
| Status | Meaning |
|---|---|
| 200 | The check ran. Read valid to see whether the code applies. |
| 422 | Validation failed, including a missing or unknown billing_cycle. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
Remove a coupon.
If the coupon is active and attached to live pricing plans, the delete is refused with 422. An inactive coupon can always be deleted.
curl -X DELETE \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons/7' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY"{
"deleted": true
}| Status | Meaning |
|---|---|
| 200 | The coupon is deleted. |
| 404 | Coupon not found. |
| 422 | The coupon is active and attached to live pricing plans. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
The bodies for each of those are in Coupon Errors.
Activate or deactivate a coupon.
| Field | Type | Required | Description |
|---|---|---|---|
is_active | boolean | Required |
If the coupon is attached to live pricing plans, the change is refused with 422, whether you are activating or deactivating.
{
"is_active": false
}curl -X PATCH \
'https://services.ap.mochatechnologies.com/quickbill/api/coupons/7/status' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "API Key: $MOCHA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "is_active": false }'The updated coupon object.
{
"id": 7,
"coupon_code": "WELCOME20",
"coupon_name": "Welcome offer",
"discount_type": "percent",
"discount_value": 20,
"cycle_count": 3,
"is_active": false
}| Status | Meaning |
|---|---|
| 200 | The status is changed. |
| 404 | Coupon not found. |
| 422 | Validation failed, or the coupon is attached to live pricing plans. |
| 500 | Unexpected error. |
| 502 | The service could not be reached. |
What comes back when a coupon request is refused.
Every error has a message.
| Status | When | Response |
|---|---|---|
| 400 | coupon_code is already used by another pricing component (create or update) | 400 Bad Request |
| 404 | coupon not found | 404 Not Found |
| 422 | validation failed | 422 Unprocessable |
| 422 | coupon is attached to live plans (delete) | 422 Unprocessable |
| 422 | coupon is attached to live plans (status) | 422 Unprocessable |
| When | Message |
|---|---|
percent with a discount_value over 100 | A percentage discount cannot be more than 100. |
Both kinds of 422 share a status code. Tell them apart by the keys: a validation failure has errors, a live-plans block has plans. To get past the block, detach the coupon from each listed plan and try again.
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.
| Field | Why not |
|---|---|
billing_alignment_mode | Whether a change applies now or at the next renewal. The service decides it. |
business_entity_id | Derived from the subscription. |
Every endpoint except create identifies the subscription by its subscription_code, not by a numeric id:
SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001INV-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.| Term | What it means |
|---|---|
| upgrade | The new plan costs the same as, or more than, the current one. |
| downgrade | The new plan costs less. |
prorate defaults to false — an invoice is only raised when you send true.A coupon is applied by sending its id as coupon_id on create or update.
| What you sent | Response |
|---|---|
| An id that is not a coupon | 422 The selected coupon does not exist. |
| An inactive coupon | 422 This coupon is no longer active. |
No coupon_id | No error. On create, no discount is applied. On update, the current discount stays and moves to the new plan. |
{
"discount_type": "percentage",
"discount_value": 20,
"discount_tenure": 3,
"discount_amount": 200
}| Field | What it is |
|---|---|
discount_type | percentage or amount. |
discount_tenure | The number of billing cycles the discount lasts. null means it never ends. |
discount_amount | See below. |
percent coupon: rate x value / 100
flat coupon: the smaller of value and ratePut a customer on a plan, with or without a trial.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
customer_id | integer | Required | — | Must exist for the tenant. |
product_id | integer | Required | — | Must exist for the tenant. |
pricing_plan_id | integer | Required | — | Must be a subscription plan attached to product_id. |
billing_cycle | string | Required | — | monthly or yearly. |
start_date | date | Optional | today | YYYY-MM-DD, today or later. |
trial_days | integer | Optional | none | Minimum 1. trial_end = start_date + trial_days. |
coupon_id | integer | Optional | none | Leave it out and no discount is applied. See Coupons. |
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 date | Trial | Invoice today | trial_end | Term end and recurring anchor | status |
|---|---|---|---|---|---|
| today | no | Yes, full amount | — | start_date + 1 cycle | active |
| today | yes | Yes, zero amount (100% discount) | start_date + trial_days | trial_end | trialing |
| future | no | No | — | start_date | active |
| future | yes | No | start_date + trial_days | trial_end | trialing |
{
"customer_id": 16908,
"product_id": 3210,
"pricing_plan_id": 5,
"billing_cycle": "monthly",
"start_date": "2026-09-07",
"trial_days": 14,
"coupon_id": 7
}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,
"coupon_id": 7
}'Leave the optional two out and the subscription starts today, with no trial.
{
"customer_id": 16908,
"product_id": 3210,
"pricing_plan_id": 5,
"billing_cycle": "monthly"
}| Condition | Status | Message |
|---|---|---|
| Plan not attached to the product | 422 | The selected pricing plan is not attached to this product. |
| Plan is an invoice plan | 422 | This is a one-time plan and cannot be used for a subscription. |
coupon_id is not a coupon | 422 | The selected coupon does not exist. |
| Coupon is inactive | 422 | This coupon is no longer active. |
| Unknown customer, product or plan | 422 | Validation errors on the id fields. |
billing_cycle is not monthly or yearly | 422 | Validation error. |
Read your subscriptions back, a page at a time.
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.
| Field | Type | Required | Description |
|---|---|---|---|
page | integer | Required | Which page to return, starting at 1. |
page_length | integer | Required | How many subscriptions per page. |
search | string | Required | A JSON object, sent as a string, holding your filters. Send {} for no filter, and URL-encode it. |
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 | Meaning |
|---|---|
| 200 | The page is returned, even when it is empty. |
| 500 | Unexpected error. |
Change the plan, apply a coupon, or both.
Moves a subscription onto a different plan of the same product, applies a coupon, or both.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
subscription_code | string | Required | — | Must exist for the tenant. |
product_id | integer | Required | — | Must be the product the subscription is already on. |
pricing_plan_id | integer | Required | — | The plan to move onto. Must be a plan of the same product. |
coupon_id | integer | Optional | none | Leave it out and the current discount stays and moves to the new plan. |
prorate | boolean | Optional | false | true to raise a proration invoice. |
prorate: true explicitly when you want the mid-term difference charged.A new coupon replaces the current discount if the subscription is in trial, has not started yet, or has no discount running.
422. A new coupon can be applied once it ends.You do not send it. The service works it out:
| Situation | Mode | What it means |
|---|---|---|
| Subscription is in trial | immediate | Applies now, even for a downgrade. |
| Upgrade | immediate | Applies now. |
| Downgrade, not in trial | delayed | Nothing changes today; it applies at the next renewal. |
| Plan | In trial | Mode | Proration invoice | Result |
|---|---|---|---|---|
| upgrade | no | immediate | if prorate | Plan changes now. |
same, no coupon_id | no | — | — | 422 |
| downgrade | no | delayed | None | Applies at next renewal. |
| upgrade or downgrade | yes | immediate | None | Plan changes now, nothing charged. |
Charged only when all three hold: the mode is immediate, the subscription is not in trial, and its start date has passed.
proration = (new gross amount - old gross amount)
x remaining days
/ total days in the term<plan name> - Proration Adjustment.{
"subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
"product_id": 3210,
"pricing_plan_id": 6,
"coupon_id": 7,
"prorate": true
}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,
"coupon_id": 7,
"prorate": true
}'| Condition | Status | Message |
|---|---|---|
Same plan and no coupon_id | 422 | Nothing to change on this subscription. |
| A discount is already running and billing has started | 422 | An active discount is already running on this subscription. A new coupon can be applied once it ends. |
coupon_id is not a coupon | 422 | The selected coupon does not exist. |
| Coupon is inactive | 422 | This coupon is no longer active. |
| Plan is an invoice plan | 422 | This is a one-time plan and cannot be used for a subscription. |
| Plan not attached to the product | 422 | The selected pricing plan is not attached to this product. |
| Plan belongs to a different product | 422 | A subscription can only move between plans of the same product. |
| Subscription has no plan item | 422 | This subscription has no plan to update. |
| Unknown subscription, product or plan | 422 | Validation errors on the id fields. |
What a plan change will cost, before you commit to it.
Read-only. Nothing is saved and no invoice is created. Call this before update to show the customer what a plan change will cost.
| Field | Type | Required | Description |
|---|---|---|---|
subscription_code | string | Required | The subscription being previewed. |
product_id | integer | Required | The product the subscription is on. |
pricing_plan_id | integer | Required | The plan being previewed. |
The preview runs the same rules as update, so it always matches what update will actually do.
| Situation | proration_amount |
|---|---|
| Upgrade, not in trial, already started | The calculated amount. |
| Downgrade, not in trial | 0 — the change lands at the next renewal. |
| In trial | 0 — nothing has been billed yet. |
| Start date is in the future | 0 — nothing has been billed yet. |
{
"subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
"product_id": 3210,
"pricing_plan_id": 6
}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
}'{
"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"
}
]
}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.End it now, or let it run to the end of the term.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
subscription_code | string | Required | — | — |
cancel_type | string | Required | — | immediate or period_end. |
cancel_at | date | Optional | see below | Overrides the default cancel date. |
reason | string | Optional | — | Max 255 characters. |
| immediate | period_end | |
|---|---|---|
status | cancelled right away | Unchanged — the customer keeps access. |
cancel_at | now | The current term end. |
cancel_at_period_end | false | true |
| Renewal invoices | Stopped | Stopped |
| Auto-pay | Disabled | Disabled |
period_end cancellation is finalised on its cancel date by a scheduled job, which flips the status to cancelled.{
"subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
"cancel_type": "period_end",
"reason": "Customer request"
}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"
}'{
"subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000002",
"cancel_type": "immediate",
"reason": "Test"
}The response to the immediate cancellation above.
{
"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
}Undo a cancellation and put the subscription back in service.
Undoes a cancellation and puts the subscription back into service.
| Field | Type | Required | Description |
|---|---|---|---|
subscription_code | string | Required | The cancelled subscription. |
reason | string | Optional | Max 255 characters. |
status becomes active.cancel_at is cleared and cancel_at_period_end becomes false.{
"subscription_code": "SUB-4E30EC0B-A218-42CE-B0A9-61E03B606739-000001",
"reason": "Customer changed their mind"
}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"
}'