Type A Or Type B: The One Checkbox That Broke Our HighLevel Integration
If you want messages sent from HighLevel to go out through your own SIM instead of theirs, you register as a conversation provider. The docs make it sound like a form. It mostly is. There is one field on it that changes everything and is not obvious.
The checkbox
When you create the provider there is a box labelled roughly "Is this a Custom Conversation Provider?"
Tick it and you get what HighLevel internally treats as a Type B provider. Type B is an additional channel. It appears alongside SMS. Your users have to consciously pick it. The default SMS provider keeps doing its thing.
Leave it unticked and you get Type A. Type A replaces the default SMS provider. Every SMS the sub-account sends comes to you.
We ticked it, because it was literally describing what we were building, and then spent a while confused about why messages were not arriving at our webhook. They were going out through HighLevel's own provider. Ours was sitting there as an extra option nobody selected.
Unticked it. Everything worked. Same setup the other SIM-gateway products use.
That is the whole lesson and it cost an afternoon. If you are building one of these: Type A is almost certainly what you want, and you get it by not ticking the box that sounds like it describes you.
The rest of the wiring
With that sorted, the integration is two directions.
HighLevel to us. They POST to our endpoint when a sub-account sends a message. We resolve the tenant from the location id, pick their device and SIM, and queue it to the handset. Their message id comes along for the ride so we can report status back against it.
Us to HighLevel. Inbound texts get reported into the conversation thread with POST /conversations/messages/inbound, matched to a contact by phone number. Delivery status goes back with a PUT against the message id.
Two API quirks worth writing down
Status mapping is unreliable from carriers, so we help it along. Carrier delivery receipts are patchy enough that a message can sit on "sent" forever. We auto-report delivered a short time after sent rather than leave threads looking stuck. Not ideal, honest about it, and better than the alternative.
Empty arrays are not the same as absent. HighLevel started returning a 422 when attachments was an empty array, requiring at least one element. So for a plain text message you cannot send attachments: []. You have to leave the field out entirely. That one produced a lovely spread of rejected inbound messages until we spotted it in the error body.
And the OAuth bit, which took two goes
Agency-level OAuth returns a companyId and no locationId. Our callback was falling back to the company id when the location id was missing, which created a duplicate tenant keyed on the wrong identifier. Then it wrote the tokens to it. Then nothing worked, in a way that looked like a token problem rather than a tenant problem.
Now the callback detects a company-level token, and instead of inventing a tenant it assigns the token to tenants that are missing one. New location-level tenants get their display name fetched from the HighLevel API automatically, so you are not left staring at a list of ids.
If your HighLevel integration is behaving strangely after an agency-level install, go and look at what your callback did with locationId when it was undefined. That is where ours was hiding.