What we write
What we write.
Quantity updates on billing lines the MSP matched on an existing agreement.
The MSP matches each service to a billing line that already exists, at Connect and later on the Billing tab. You do not send the match. POST /tenants/{tenant_id}/commerce/agreement-line-mappings with a billing: product ref is refused (request_invalid). So is POST /tenants/{tenant_id}/commerce/agreement-lines for that ref. Both say to send quantities with POST /tenants/{tenant_id}/billing/quantities. Bitlync never creates companies, agreements, or billing lines.
List your services with PUT /isv/billing-services. unit is per_user, per_device, per_gb, or flat. sent is daily or monthly: how often you send quantities. Omit key on a new service and one is made from the name.
Billing services
Configure > Billing services is where the catalog is edited, and only while Billing is on. An empty catalog starts as three equal cards: upload a CSV or Excel price list, scan your website, or add a service by hand. A price list guesses the unit and how you bill. How you bill stays unset when the wording is not clear enough.
Click a service name, SKU, or unit cell to edit it. Enter or leaving the cell saves. Escape cancels. The row shows Saving until the save finishes. Edit is still in the row menu.
Review imported services before they are published. The review actions are Add and Discard. Discard drops the staged price list. The live catalog and its matches stay.
The MSP billing panel includes a billing method line. How you bill is Latest count, Count on a set day, Peak this month, or Average. GET /tenants/{tenant_id}/billing on the sandbox returns how_you_bill. Any other value on PUT /isv/billing-services is 422 request_invalid with the message "How you bill must be Latest count, Count on a set day, Peak this month, or Average."
sent must be daily or monthly. The sandbox refused weekly with 422 request_invalid and the message "How often you report must be Daily or Monthly."
A price-list upload is at most 2 MB and 64 columns. The same 2 MB cap applies to an Alerts upload. See Alerts.
On the sandbox, PUT /isv/billing-services requires catalog_token from GET /isv/billing-services. A token that does not match the saved catalog is refused, and nothing is saved. OpenAPI 0.100.0 lists catalog_token on BillingServicesBody and requires it.
Two services with the same key are refused. The sandbox returned 422 request_invalid and the message "Two billing services use the key 'same-id'."
In the console, Add as service saves a service pick as a product. It is resolved on each customer's own agreement. Unresolved picks show as Unmatched in Activity. Report ids are unique in that flow. A duplicate there says "Two billing services use the report id {id}." report_id is not a public API field. The API duplicate is the key message above.
A billing write happens when you send a quantity, on that cadence. Bitlync does not send quantities on its own. The MSP sets the change limit on the Billing tab. It defaults to on, at 25%. Advanced review defaults to off. A quantity inside the limit is written on the matched line. The same quantity as last time comes back unchanged. A change over the limit comes back waiting with reason big_change (the MSP sees "More than your change limit"). With Advanced review on, the row is waiting with reason review. You cannot skip that review by writing the line yourself.
Send absolute quantities with POST /tenants/{tenant_id}/billing/quantities. quantity is a JSON number, 0 or greater. Send quantity 0 to stop billing. There is no end-a-line call. action end on POST /tenants/{tenant_id}/commerce/agreement-lines is refused with 422 agreement_line_action_not_allowed. Billing off returns 409 feature_off before anything is read from or written to the MSP's stack. The grant is psa.agreement.line.write. dry_run defaults to false. dry_run true returns the same statuses and writes nothing.
{
"idempotency_key": "docs-probe-1",
"dry_run": true,
"quantities": [
{
"customer_ref": "cust-42",
"customer_name": "Brightside Pediatrics",
"service": "managed-edr",
"quantity": 44
}
]
}
Request fields match BillingQuantitiesBody in OpenAPI 0.100.0. quantities is required (1 to 500 rows). customer_ref, service, and quantity are required on each row. customer_name, customer_status, trial_end_date, and idempotency_key are optional. service is a key from GET /isv/billing-services.
trial_end_date is a string or null. The schema calls it an ISO date, YYYY-MM-DD, for this customer. Trial ending, Trial converted, and Trial expired use it. Omit it and the stored date stays. A bad date fails that row. customer_status is a string or null: active (the default), trial, nfr, or suspended. Any other value fails that row and stores nothing.
POST /tenants/{tenant_id}/billing/quantities/csv sends the same quantities from a CSV. The request is BillingQuantityCsvBody: csv (string, default empty) and dry_run (boolean, default false). Columns are customer_ref, service, quantity, customer_status, and trial_end_date. At most 500 rows and 1 MB. The route description says the result matches the JSON quantities call. An empty body returns 422 request_invalid and "The file is empty." A header with no rows returns "The file has no quantities." A row whose service is not in the catalog returns 422 billing_service_unknown.
{
"tenant_id": "sandbox-mainstreet",
"results": [
{
"tenant_id": "sandbox-mainstreet",
"customer_ref": "cust-42",
"service": "managed-edr",
"quantity": "44.0000",
"previous_quantity": null,
"status": "not_mapped",
"reason": "customer_not_matched",
"dry_run": true,
"sandbox": true
}
],
"counts": { "not_mapped": 1 },
"dry_run": true,
"sandbox": true
}
That response is what the sandbox returned for the request above, with the service in the catalog and the customer not matched yet. The spec types the 200 body as an object. Each row status is written, unchanged, waiting, not_mapped, not_billed, or failed. Quantity 0 uses the same body. On this call it came back "0.0000" with status not_mapped, because the customer was not matched. It was not refused for being 0.
If the agreement is not active, the line is missing or has ended, or the line starts on a later date, the row is failed with error.code billing_line_not_writable and a reason. Nothing is written. The MSP sees the same reason. agreement_inactive reads "Agreement is not active in your stack".
Tickets are two-way when the MSP grants it. You can create, update, and close. You can subscribe to events when the MSP changes the ticket in their PSA.
Closing is the MSP's call. On Configure, under Ticketing, the MSP turns on "Allow closing tickets with a resolution" for that connection; it is off by default. On, a close_with_resolution write closes the ticket in your stack with the resolution note. Off, the resolution lands as a note and the ticket stays open.
Time on a linked ticket needs a required member. List with GET /tenants/{tenant_id}/members. Halo and ConnectWise return that list when the member permission is on. A missing ConnectWise members permission returns 502 upstream_auth_failure. The time picker uses the listed id.
When a device is created or updated, you can list those events or get a signed webhook. After a tenant sync, GET /tenants/{id}/connections/events?kind=device.updated (also created). Optional signed webhook. Grant is the connection (rmm.device.events). Missing is grant_missing.
When a company is created or updated, you can list those events or get a signed webhook. After a tenant sync, GET /tenants/{id}/connections/events?kind=company.updated (also created). Optional signed webhook. Grant is the connection (psa.company.events). Missing is grant_missing.
Reads include vendor: { connector, native_id, native }. native is the same payload as .raw. Ticket writes accept optional vendor_native on POST /tenants/{tenant_id}/tickets and POST /tenants/{tenant_id}/tickets/write. POST /tenants/{tenant_id}/companies/write writes a Halo or ConnectWise company custom-field value on an existing company via vendor_native, and ensure_custom_field creates the missing field definition. The field works live on Halo and ConnectWise. It is created once in your stack under the MSP's standing psa.company.field.create grant, and an existing field with the same label is reused. The field label shows on Configure. Value writes use the psa.company.update grant, off by default. A missing grant is grant_missing.
PSA company create is not supported: companies are created by the MSP. A PSA create returns 403 company_create_not_supported with no PSA call. Match to an existing company instead.
Device write runs when the MSP turns on that grant. Exact-name Match fills Match when one PSA company name hits one ISV customer. Ambiguous names stay Needs match. Each row is Match, company import from PSA, or Skip. Bulk unmatched import takes a dry_run preview. Company import creates the customer in the ISV. Contacts are list and match. Ticket create and update take board, status, and priority ids from the ConnectWise and Halo lists. An unknown id returns 422. A missing grant is grant_missing.
A write that does not apply returns a stable code, one sentence, and doc_url.
agreement_header_not_supported: the call asked to create an agreement.
agreement_requires_existing: missing or unknown agreement.
ConnectWise and Halo write on an existing project.
project_requires_existing: missing or unknown project.
project_header_not_supported: the call asked to create a project.
project_not_writable: this project will not take the write.
project_company_cannot_update: the project’s company cannot be updated.
Pax8 matches companies it already has, then creates the rest. That is the distributor, not the PSA.
company_create_not_supported: a PSA company create; companies are created by the MSP.
psa_company_update_refused: the grant is on and this company update did not run.
company_match_ambiguous: more than one match.
distributor_required: no connected distributor for this write.