Sandeza API (H2H & CGW)
This page is for clients moving a Sandeza (PT Sprint Asia) integration to SIPESAN. Endpoints, payload shapes, and reply codes are preserved, so all you change is the base URL and the credentials. Everything that is NOT the same is listed in full in the last section.
Endpoint
| Purpose | URL |
|---|---|
| Send template (H2H) | https://omnimanguntama.id/h2h/json/cgw |
| Status inquiry (H2H) | https://omnimanguntama.id/h2h/inq_status.php |
| Send chat message (CGW) | https://omnimanguntama.id/cgw/{channel}/send/{clientID} |
Example requests
Send a template (H2H)
The password is never sent. You send a signature, and we accept TWO forms. The vendor form: SHA-256 of username + password + time concatenated with no separator, alongside the exact username and time used to compute it; use this if you are moving from Sandeza, because your code already produces it. The short form: SHA-256 of username + password only, with no time. The time field is still required in the payload for both forms, only its value is no longer checked.
curl -X POST https://omnimanguntama.id/h2h/json/cgw \
-H 'Content-Type: application/json' \
-d '{
"username" : "USERNAME",
"time" : "1756282800",
"signature" : "sha256(USERNAME + PASSWORD + time)",
"type" : "2",
"ref_id" : "INV-2026-0001",
"subject" : "Notifikasi tagihan",
"sender_id" : "6281100001111",
"budget_code": "billing",
"channel": {
"whatsapp": {
"msisdn" : "628123456789",
"template_id": "nama_template_anda",
"header" : "",
"message" : "text:=:Budi",
"attachment" : "",
"backup_on" : "",
"backup_exp" : ""
}
}
}'
Send a chat message (CGW)
CGW uses the Auth-username and Auth-password headers rather than a signature, and the clientID sits in the URL. Send over HTTPS only: the password travels as-is in the header, exactly as it does with the vendor.
curl -X POST https://omnimanguntama.id/cgw/{channel}/send/{clientID} \
-H 'Auth-username: USERNAME' \
-H 'Auth-password: PASSWORD' \
-H 'Content-Type: application/json' \
-d '{
"id" : "MSG-0001",
"reply_id" : "",
"times" : 0,
"timestampt": "2026-08-27T15:00:00.000000+07:00",
"reply_token": "",
"recipient" : "628123456789",
"type" : "text",
"body" : { "text": "Halo, tagihan Anda sudah terbit." }
}'
Which WhatsApp number a message goes out from
Settle this first: without the right channel binding, no send succeeds. The sending number is decided differently in each protocol.
H2H uses sender_id
The sender_id field in your payload decides which number sends. Each sender_id you use is registered against a WhatsApp channel on your credential in the panel. An unregistered sender_id is answered with rc=8 rather than sent from some other number. The safest value is the WhatsApp number itself: a value shaped like a number is matched against the channel number, so a mistyped binding is caught before a single message goes out. Format does not matter, 0812… and 62812… count as the same, and a number you write with separators still matches. In the panel the value is stored as digits only with a 62 prefix, and that is the safest form to copy into your payload.
One number for every sender_id
If everything you send goes out from one number, register a single binding and leave its sender_id blank. That binding serves any sender_id you send. If one particular sender_id must use a different number, register a binding for it: a binding naming the sender_id exactly always wins over a blank one.
CGW uses the credential itself
A CGW payload carries nothing about the channel, so your credential chooses it. One CGW credential points at exactly one WhatsApp channel. If you need to send from two numbers, ask for a second credential rather than adding a second binding to the same one: we reject sends from a credential pointing at more than one channel instead of picking one, because that pick is not predictable for you and customer replies would land on a number you are not watching.
subject does not select a channel
subject is stored as-is alongside your message record and is never used to choose a sending number. Putting a channel name or number there has no effect; sender_id decides for H2H and the credential decides for CGW.
A bound channel stops being answered by our automation
Conversations starting on a channel bound to your credential belong to your bot, so the SIPESAN AI and chatbot stop replying there. That is deliberate: without it a customer receives two answers to one question.
Status numbers that collide
H2H and CGW use the same numbers for different meanings. Copying status handling from one protocol to the other produces no error at all, only wrong statuses on your dashboard. Check this table before you copy a single number.
| Number | Means in H2H | Means in CGW |
|---|---|---|
1 |
Delivered to device | Sent to carrier |
2 |
Undelivered | Delivered to device |
3 |
Pending | Read |
Full status scales
| State | H2H | CGW |
|---|---|---|
| Sent to carrier | tidak dilaporkan | 1 |
| Delivered to device | 1 | 2 |
| Read | 9 | 3 |
| Pending | 3 | 0 |
| Unsent | 4 | -1 |
| Undelivered | 2 | -2 |
| Failed | 5 | -3 |
What we check before a message goes out
In order. Anything failing here is answered immediately as a non-zero rc, so you know in the same second rather than through a webhook minutes later.
| Checked | Fails as rc |
|---|---|
| Signature, username, and time match, and the signature belongs to this message body. | 4 |
| The ref_id has not been used before. It is recorded first, so the same ref_id cannot be sent twice even if the first attempt failed. | 9 |
| The whatsapp channel only, and type is not primary+backup. Backup routes are not served yet. | 2 |
| sender_id points at a channel registered on your credential. If that sender_id is a phone number, it must be that channel number. | 8 |
| The channel payload parses, and msisdn is a valid Indonesian number. | 1 |
| template_id exists and is APPROVED on that channel WABA. | 2 |
| The number of parameters in message matches the template placeholder count exactly. Too few or too many is rejected here rather than left to fail at Meta. | 1 |
| The balance covers that template category. | 6 |
rc=0 means accepted, not delivered. Your message is queued once every check passes, and the call to Meta happens after you already hold the rc=0 response. The actual delivery outcome arrives only through the status webhook.
Reply codes (rc)
rc is sent as a STRING, not a number. The "When" column is what makes this list useful: it names the condition that produces the code rather than restating its name.
| rc | Meaning | When |
|---|---|---|
0 |
Success | The send was accepted and forwarded. code_sms carries the codetrx for inq_status.php. |
1 |
Invalid parameter | Body is not JSON, ref_id is empty, type is outside 1 and 2, channel is empty, a channel parameter is malformed, or attachment is filled. |
2 |
Internal error | Template not found, channel not served, type=1, no price configured for your template category, or more than one channel in a single send. |
3 |
Invalid recipient | msisdn is empty or not a valid Indonesian number. Formats starting with 0, 62, or + are all accepted. |
4 |
Invalid signature | Bad signature, unknown username, time outside tolerance, or a signature reused for a different message body. |
6 |
IP not allowed | The credential is restricted to a list of IPs and the request came from another address. |
7 |
Not enough quota | Insufficient balance or the active period has ended. |
8 |
Unknown Sender Id | sender_id is not bound to any WhatsApp number yet, or template_id was sent empty. |
9 |
Duplicate reference number | ref_id has been used before. This also applies when the first send failed: one ref_id can only be used once. |
Webhook
We send two kinds of webhook: status changes for messages you sent, and incoming messages from customers. The rules below apply to both.
rc=0 means accepted, not delivered
Once every check passes, your message is queued; the call to Meta happens after you already hold the rc=0 response. So rc=0 means "accepted and will be sent". The actual delivery outcome arrives only through the status webhook, and a failure at Meta never appears as an rc. A client counting rc=0 as delivered will misread their own reports.
Registering the URL
Two separate URLs on your credential in the panel: one for delivery status, one for incoming messages. Both are optional, and an empty one means we send nothing there. Filling in the incoming URL also sets a starting point: only messages arriving after that are delivered, so a new integration is not flooded with history.
Verifying a delivery came from us
The webhook-signature header carries v1,HMAC-SHA256 over webhook-id.webhook-timestamp.body using your webhook secret. What is signed is the RAW BODY, so compute the HMAC before parsing it: JSON that is decoded and re-encoded produces different bytes and the signature will never match.
The reply we count as success
Reply with {"code":"0"}. We read the body, not just the HTTP code, so a 200 OK carrying anything else still counts as a failure and is retried. This is deliberate: a broken endpoint often still answers 200.
Your endpoint must tolerate duplicates
Failed deliveries are retried, so the same payload can arrive more than once. Use webhook-id to recognise a delivery you have already processed. For status, arrival order is not guaranteed either: an older status can arrive after a newer one, and overwriting blindly makes an already-read message look merely delivered again.
Payload shapes
Delivery status has two shapes, chosen by your credential protocol. They differ by more than field names: the status number uses a different scale in each, which is the collision explained above.
Delivery status, H2H shape
Sent when a message status changes, for H2H credentials. Fields sit at the top level, with no wrapper.
URL taken from
status_webhook_url
· status scale
H2H
| Field | Description |
|---|---|
status |
A number on the H2H scale. 1 means delivered to the device, 9 means read. |
channel |
The channel code in H2H naming. |
ref_id |
The ref_id you sent when sending the message. This is what you match this status against. |
time |
When the status change happened, not when this webhook was sent. |
division_id |
Passed through as-is from your credential. Empty when unset. |
Delivery status, CGW shape
Sent when a message status changes, for CGW credentials. Everything is wrapped inside a status object.
URL taken from
status_webhook_url
· wrapped inside
status
· status scale
CGW
| Field | Description |
|---|---|
message_id |
The id YOU sent when sending the message, not ours. |
provider_message_id |
The id from WhatsApp. Useful when tracing an issue with us. |
recipient_id |
The number the message was addressed to. |
status |
A number on the CGW scale, which is not the same scale as the H2H shape. 2 means delivered to the device, 3 means read. |
tid |
The transaction id on our side. |
times |
When the status changed, as Unix seconds. |
timestamp |
The same moment in ISO 8601 form. |
Incoming message from a customer
Sent when a customer messages a channel handled by your bot. Messages on channels not assigned to you are not delivered.
URL taken from
content_webhook_url
| Field | Description |
|---|---|
from |
An object holding name and username. username is the customer number. |
message |
The message itself: id, text, type, times, timestamp, and reply_id when it replies to another message. type is postback when the customer taps a button, and the value inside is the button key you sent, not the label on it. |
reply_token |
Always empty. Present so the shape matches what you received before, and used for nothing. |
What you do when you migrate
Base URL
Point the base URL at your SIPESAN domain. Paths, field names, and response structure stay the same. One thing differs: our reason_status is longer on a rejection: it names what is wrong rather than only that something is. If your code matches reason_status against an exact string, match on its prefix instead.
Credentials
A new username and password are issued in the SIPESAN panel, Developers menu group. How you use them does not change: CGW still sends the Auth-username and Auth-password headers, H2H still sends signature SHA256(username + password + time) inside the body.
Sender name (sender_id)
Your sender_id is mapped to a WhatsApp number in the panel, via Channel Binding on the credential. Until that binding exists, sends are answered with rc=8 Unknown Sender Id.
Templates
template_id refers to a WhatsApp template already approved by Meta on your SIPESAN account. Your existing Sandeza templates do not carry over and need to be registered again.
codetrx history does NOT carry over
Any codetrx you stored from earlier sends is only known to the Sandeza system. After the move, inq_status.php only finds sends made through SIPESAN. Export your old records before cutover if you still need them.
Webhooks
Your webhook URLs are registered on the credential in the panel. Payload shapes do not change. Every delivery is signed: the webhook-signature header carries v1,HMAC-SHA256 over webhook-id.webhook-timestamp.body using your webhook secret. Reply with {"code":"0"} so we count it as received.
IP restriction (optional)
You can restrict a credential to your own server addresses in the panel. Left empty means accepted from anywhere, same as before.
What we deliberately made different
This list is complete. Apart from what is listed here, our replies follow Sandeza.
Unknown username answered with rc=4
Sandeza answers rc=5 Invalid Corporate for a username that does not exist, and rc=4 Invalid Sign for a bad signature. We answer rc=4 for both, so our reply cannot be used to guess which usernames exist. A client with correct credentials never reaches this branch.
attachment on the WhatsApp channel is rejected
A WhatsApp template message can carry only one media item, and the header field already uses that slot. Filling attachment is answered with rc=1 Invalid Parameter Channel rather than silently accepted, so you know the attachment would not have arrived.
The short signature form, and what it costs
Besides the vendor form, we accept the SHA-256 of username + password alone. Its value is FIXED for a credential, so it behaves like a token: anyone capturing one of your requests can use that signature to send other messages until the password is changed. The vendor form that includes time does not have that property, which is why it stays our recommendation. If you use the short form, send over HTTPS only, fill in the IP allowlist on your credential, and use the Regenerate Password button the moment you suspect a leak. Replaying an identical request is still blocked by the unique ref_id.
The age of time is not checked
Sandeza rejects a signature whose time has passed. We do not, because time may not take part in the signature at all, and rejecting a value that binds nothing would only turn away clients whose server clock drifts. The time field is still required and must still be epoch seconds or YYYYMMDDHHIISS.
A signature is valid for one message body
One signature is valid for one message body. Repeating an identical request is still served normally, but reusing a signature for a different payload is answered with rc=4. This closes the risk of a leaked signature being used to send other messages in your name.
We reject earlier, and we name the reason
Some payloads that Sandeza accepts and fails later, we reject up front: the number of message parameters must exactly match the template placeholders, the header field must match the template header format (and must be absent when the template has no header), and a sender_id written as a phone number must equal the number of the channel bound to it. Otherwise rc=8. The reason is the same in all three: a failure that arrives later reaches you as a status rather than as an answer to your request, and is far more expensive to trace.
Channels served
WhatsApp only for now, on both protocols. Other channels are answered with each protocol own rejection code: rc=2 Channel Not Found for H2H, and meta.status false for CGW.
Primary Backup (type=1) is not served yet
Sends with type=1 are answered with rc=2 Routes Not Found rather than accepted and delivered as a single channel. Accepting them would let you believe a fallback route is active when none exists. Use type=2.