SMSGatewayCenter Blog

Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports

A field-level guide to the SMSGatewayCenter Voice Call API: fourteen operations across nine paths, one call identifier spelled four ways, nineteen-digit IDs that break doubles, an OTP pin typed as an integer, platform redial versus your own retries, and how to page delivery reports when the request has no page parameter.

Featured image for Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports
Abstract teal sound waveform passing through blue hexagonal nodes and fanning out into orange lines on a navy background
One voice call, from a single API request to many delivery report rows.

Table of Contents

  1. The Short Answer
  2. TL;DR
  3. The Fourteen Operations at a Glance
  4. Authentication and Request Encoding
  5. Sending a Call: quick, group and bulkupload
  6. Voice OTP: the PIN Send
  7. The Audio Library: Upload, IDs and Renewal
  8. One Call, Four Identifiers
  9. Big Numbers and the Types That Break Them
  10. Redial: Who Owns the Retry
  11. Scheduling, Moving and Cancelling Calls
  12. Reading Delivery Reports
  13. What a Voice DLR Row Tells You
  14. Paging Without a Page Parameter
  15. Summary, Upload History and the Daily Archive
  16. Time Zones and Date Formats
  17. Four Client Samples, Four Traps
  18. A Data Model for Voice Calls
  19. Production Checklist
  20. Unspecified Behaviour and How to Code Around It
  21. FAQs

The Short Answer

The SMSGatewayCenter Voice Call API lives under https://unify.smsgateway.center/VoiceApi/ and covers fourteen operations on nine paths: sending a call (VoiceApi/send with sendMethod set to quick, group or bulkupload), sending a voice OTP (VoiceApi/send?sendMethod=pin), uploading and renewing audio clips (VoiceApi/save), reading, moving and cancelling scheduled calls, and five kinds of report, from per-call delivery rows to a zipped daily archive.

Getting a call to ring is the easy part. The parts that bite in production are all about data:

  • One call carries four identifiers: transactionId in the send reply, uuid in schedule requests, uuId in report rows, and a per-call msgId in delivery rows.
  • Those IDs are 18 or 19 digits long. Several are typed as integers in the reference, and any language that parses JSON numbers into doubles will silently change them.
  • The voice OTP pin is typed as an integer, so an OTP that starts with zero can lose a digit before it’s ever spoken.
  • The platform redials unanswered calls for you (reDial 0, 1 or 2, redialInterval 5, 10 or 15 minutes). If your code also retries, one person can get four or more calls.
  • The report endpoints return cursors and page counts, but the request schemas have no page parameter, so you control result size by narrowing the date window.

Store every identifier as text, send the PIN as a string, pick one owner for retries, and page by splitting date ranges. The rest of this guide goes through each endpoint field by field and gives you code that holds up whichever way the open questions resolve.

TL;DR

  • Base URL: https://unify.smsgateway.center. The reference is an OpenAPI 3.0 file rendered as Swagger on the Voice Call API page.
  • Auth: userid plus password, or userid plus the apikey header. Use the header. userid still goes in the request.
  • Encoding: POST endpoints take multipart/form-data. Every form field is text on the wire, whatever type the schema gives it.
  • Send reply: {status, mobile, invalidMobile, transactionId, statusCode, reason}, with transactionId as a quoted 19-digit string and statusCode as the string "200".
  • Voice OTP: pin and pinClipId with a GET to VoiceApi/send?sendMethod=pin. Always send pin as a string so leading zeros survive.
  • Audio library: VoiceApi/save uploads an mp3 and returns libraryId as a string. VoiceApi/save?reUpload=true renews an expired clip. There’s no endpoint to list clips, so keep your own registry.
  • Redial: at most two platform redials, at most 15 minutes apart. Worst case, the last attempt starts 30 minutes after the first. Make sure a voice OTP is still valid by then, or set reDial to 0.
  • Scheduling: scheduleTime on send, then report/schedule, report/schedulelog, updateSchedule and cancelSchedule, all keyed by uuid.
  • Reports: report/dlr by date range, by uuid (?tId=true) or by mobile (?isMob=true); report/summary for daily buckets; report/uploadhistory for CSV uploads; report/day for a zipped archive.
  • Rows: delivery rows sit directly in response.reports_dlrList, not wrapped in an extra object the way several SMSApi/ lists are.
  • Types: treat msgId, uuId, transactionId, libraryId, mobileNo and pin as strings in your code and your database.
  • Paging: no page parameter is defined on any report request. Keep each window small enough that count.total equals the rows you got back, and split it when it isn’t.

The Fourteen Operations at a Glance

Here’s every operation in the reference, with the method each one is defined under and the key it’s addressed by.

#OperationMethod and pathAddressed byMain reply payload
1Send a library callPOST VoiceApi/sendlibraryId, sendMethodflat {status, transactionId, ...}
2Send a voice OTPGET VoiceApi/send?sendMethod=pinpin, pinClipIdflat {status, transactionId, ...}
3Upload a clipPOST VoiceApi/savelibraryName, audioTrackflat {status, length, frequency, libraryId, ...}
4Renew a clipGET VoiceApi/save?reUpload=truelibraryIdsame as upload
5List a scheduled transactionPOST VoiceApi/report/scheduleuuidresponse.reports_scheduleList[]
6List the calls in a schedulePOST VoiceApi/report/scheduleloguuidresponse.reports_scheduleList[]
7Move a schedulePOST VoiceApi/updateScheduleuuid, scheduleTimeflat {status, transactionId, ...}
8Cancel a schedulePOST VoiceApi/cancelScheduleuuidflat {status, transactionId, ...}
9Delivery report by datesPOST VoiceApi/report/dlrfromdate, todateresponse.reports_dlrList[]
10Delivery report by transactionGET VoiceApi/report/dlr?tId=trueuuidresponse.reports_dlrList[]
11Delivery report by mobileGET VoiceApi/report/dlr?isMob=truemobileresponse.reports_dlrList[]
12Daily summaryPOST VoiceApi/report/summaryfromdate, todateresponse.report_summaryList[]
13Bulk upload historyPOST VoiceApi/report/uploadhistoryfromdate, todateresponse.reports_uploadHistoryList[]
14Daily archiveGET VoiceApi/report/daydatea .zip file

Two shapes come back. Write operations (send, save, update, cancel) return a flat object with status, statusCode and reason at the top level. Read operations wrap everything in response, with status, code, msg, api, action, cursors, pages and count alongside the list.

Look closely at the list names. Delivery, schedule and upload lists are reports_...List, plural. The summary list is report_summaryList, singular. A generic reader that builds the key from a pattern will miss one of them, so map each endpoint to its list key explicitly.

The voice family also differs from the SMSApi/ families you may already have integrated. There, list rows are often wrapped in an extra object (scheduleList[i].schedule, for example). Here, each row is the object itself: response.reports_dlrList[i].msgId, not response.reports_dlrList[i].dlr.msgId. If you share a parser between products, branch on product, not on a guess.


Authentication and Request Encoding

Every request needs userid. Then you either add password as a field, or send your API key in the apikey header. The reference defines the header scheme in so many words: if you use an API key, there’s no need to send the password. Use the header. It keeps the password out of query strings, server logs and browser history, and it lets you rotate the key without touching the login. (The API key rotation guide covers doing that without dropping traffic.)

POST /VoiceApi/send HTTP/1.1
Host: unify.smsgateway.center
apikey: YOUR_API_KEY
Content-Type: multipart/form-data; boundary=----b

------b
Content-Disposition: form-data; name="userid"

YourUsername
------b
Content-Disposition: form-data; name="sendMethod"

quick
------b--

Header names are case-insensitive in HTTP (RFC 9110, section 5.1), so apikey, apiKey and APIKEY all reach the server as the same header.

The POST operations declare multipart/form-data request bodies. That matters more than it looks. In a multipart form, every field is a string of bytes. The schema can say libraryId is an integer and duplicateCheck is a boolean, but on the wire you’re sending the characters 7432106150028594097 and true. The integer typing only becomes dangerous when a tool reads the schema and generates a client in a language with lossy numbers. We’ll get to that in Big Numbers.

The GET operations (voice OTP, clip renewal, the two DLR searches and the daily archive) take their parameters in the query string. For those, URL-encode every value, and keep password out of them entirely by using the header.

Every operation accepts output with json, plain or xml. Ask for json and parse JSON. The renewal and cancellation descriptions mention format instead of output; the parameter tables for both say output. Send output=json, and if you want belt and braces, send format=json too. An extra form field costs nothing.


Sending a Call: quick, group and bulkupload

POST VoiceApi/send places one or more outbound calls that play a clip from your audio library. The request fields, as the reference defines them:

FieldType in the referenceValues and notes
useridstringAlways required
passwordstringOmit when you send the apikey header
audioTypestringlibrary for this operation
sendMethodstring enumquick needs mobile, group needs group, bulkupload needs phoneList
libraryIdintegerThe clip to play, a 19-digit value such as 7432106150028594097
mobilestringComma-separated numbers in international format, for quick
groupstringGroup IDs or names, for group
phoneListbinary fileA CSV upload, for bulkupload
identifierstringFree text to label the campaign, such as My Campaign
scheduleTimestringOptional, YYYY-MM-DD HH:MM
duplicateCheckbooleantrue or false
reDialinteger enum0, 1 or 2
redialIntervalinteger enum5, 10 or 15 (minutes)
outputstring enumjson, plain, xml

The success reply is flat:

{
  "status": "success",
  "mobile": "919xxxxxxxx1",
  "invalidMobile": "",
  "transactionId": "6305583318236810379",
  "statusCode": "200",
  "reason": "success"
}

A few things to do with it straight away.

Save transactionId as text before you do anything else. It’s the only handle you get on this request. Everything later (schedule reads, moves, cancels, delivery lookups) needs it.

Read invalidMobile. It’s a string, empty in the sample. When you send a comma list, some numbers can be accepted and some rejected in one call. Split invalidMobile on commas, trim, drop empties, and mark those recipients as rejected at submission in your own table. Don’t wait for a delivery report that will never come for them.

Treat mobile as an echo, not a receipt. It tells you what the platform accepted. Your per-recipient truth comes from the delivery rows, which carry their own msgId for each call.

Check status, not statusCode. statusCode is the string "200" here, but voice and SMS families quote and unquote their codes differently, and the error shape for voice isn’t spelled out in the reference. Read status, return on anything other than success, and only then bind the rest of the payload.

Label every campaign with identifier. It comes back on delivery rows as identifier, and on schedule rows the campaign label appears as campaignName. Put your own campaign or job ID in it (a short ASCII string), so you can join platform rows back to your jobs even when a transaction ID goes missing.

Which sendMethod to use

Use quick for anything driven by an event in your system: one person, or a handful you already know. Use group when the audience already lives in a platform contact group you maintain through the group and contact APIs. Use bulkupload when you’re generating a large list offline and want the platform to ingest a file. Bulk uploads produce an upload history row (see the summary and archive section) that tells you how many entries were valid, so read it before you assume the campaign went out.

There’s a second, older sendMethod in the knowledge base. The Voice Call API KB entry shows sendMethod=simpleVoice with audioType set to audio (upload a file in the same call), t2s (text to speech, with msg and msgType=text) or library. The OpenAPI file only describes the library path. If you need text to speech or inline audio, test those calls in your account first and pin the exact field names your account accepts in a contract test; the contract testing guide shows how.


Voice OTP: the PIN Send

The voice OTP call reads a one-time code aloud. The OpenAPI file defines it as GET VoiceApi/send?sendMethod=pin with these query parameters:

ParameterType in the referenceNotes
useridstringRequired
passwordstringOmit with the apikey header
mobilestringRecipient in international format
pinintegerThe OTP to read out, sample 123456
pinClipIdstringThe clip played with the PIN
outputstring enumjson, plain, xml

The KB pages for voice OTP add audioType=pin, pinRepeatCount (how many times the clip and PIN repeat, 1 or 2), reDial, redialInterval, duplicateCheck, and name the stock clips: OneTimePassword, VerificationCode, Default, FollowingCode, Registration and UseThisCode (see what parameters a voice OTP needs and how the PIN API works). The reply has the same flat shape as a normal send, with a transactionId.

The integer PIN trap

pin is typed as an integer. OTPs are not integers. They’re short strings of digits, and a meaningful share of them start with zero. With six-digit codes drawn uniformly, one in ten starts with 0.

If your code holds the OTP in an integer variable, or a generated client serialises it from an integer field, 012345 becomes 12345. The user hears five digits, types five digits, and your verifier compares against six. Your login funnel quietly loses about one attempt in ten, and nothing in your logs says why.

Fix it at both ends:

  1. Generate OTPs as strings, and keep them as strings everywhere.
  2. Put the string straight into the query: pin=012345. URL encoding leaves digits alone.
  3. If you use a generated client, override the pin field to a string type, or bypass the client for this one call.
  4. Or generate codes without a leading zero (first digit from 1 to 9). That shrinks the code space by 10 percent, which is a fair trade if you can’t control every serialiser in the path.

Voice OTP next to SMS OTP

Voice OTP is usually a fallback: the SMS didn’t arrive, or the user tapped “call me instead”. Two timing rules keep it honest.

First, the OTP has to still be valid when the call is answered. With platform redial enabled, the last attempt can start up to reDial x redialInterval minutes after the first (see Redial). With reDial=2 and redialInterval=15, that’s 30 minutes. Most OTPs expire long before that. For voice OTP, set reDial=0 and let the user ask again, or make sure your OTP lifetime covers the redial window.

Second, a voice fallback for the same login attempt should read the same code as the SMS, not a new one. Two live codes for one attempt confuses people and doubles your attack surface. The OTP delivery checklist covers the SMS side of this.


The Audio Library: Upload, IDs and Renewal

Library calls play a clip you’ve uploaded. POST VoiceApi/save takes three fields beyond auth:

  • libraryName: a label you choose, such as Test
  • audioTrack: the mp3 file, sent as a multipart file part
  • output

The reply:

{
  "status": "success",
  "length": "15",
  "frequency": "41000",
  "libraryId": "6305583318236810379",
  "statusCode": "200",
  "reason": "success"
}

libraryId comes back as a quoted string. Then the send and renew requests define libraryId as an integer. Same value, two types. Store the string you received and send exactly that string back. Never round-trip it through a number.

length and frequency are quoted strings too, with no units attached. Save them raw next to the clip, and if you want a duration in seconds for your own UI, measure the file you uploaded with your own audio tooling rather than reading meaning into length.

Renewal, and why you need your own clip registry

GET VoiceApi/save?reUpload=true with libraryId renews a clip “if audio track is expired”. So clips expire. There’s no published clip lifetime, and no endpoint to list your clips or read a clip’s status.

That leaves you one practical design: keep a clip registry in your own database.

ColumnWhy
library_id (text)The string from the upload reply
library_nameWhat you sent as libraryName
source_sha256Hash of the mp3 you uploaded, so you know which file a clip is
source_pathWhere you keep the original; never delete it while the clip is in use
uploaded_at, renewed_atYour own timestamps
length_raw, frequency_rawThe strings from the reply
last_ok_send_atUpdated whenever a send with this clip succeeds

Then add a pre-flight step before every scheduled campaign: renew the clip, check status is success, and only then submit. Renewing a clip that hasn’t expired yet should be harmless, but nothing confirms it, so try it once on a scratch clip in your account and watch what happens to the libraryId and your balance. If the ID changes on renewal, update the registry from the reply every time.

The voice library walkthrough shows the same library in the dashboard, which is handy when you need to compare what your registry says with what the account holds.


One Call, Four Identifiers

Follow a single scheduled library call through the API and you’ll meet its identifier under four names:

  1. transactionId in the send reply, a quoted string: "6305583318236810379".
  2. uuid in the request to report/schedule, report/schedulelog, updateSchedule, cancelSchedule and report/dlr?tId=true, typed as an integer.
  3. uuId (capital I) in schedule rows and delivery rows, typed as a string.
  4. msgId in each delivery row, typed as an integer. This one is different: it identifies one call to one number, while the other three identify the whole request.

The reply from updateSchedule and cancelSchedule goes back to calling it transactionId. Their descriptions also say transactionId for the request, while their parameter tables say uuid. When the table and the prose disagree, send what the table says (uuid), because that’s what the Swagger “Try it out” form sends.

Three-panel flow of a voice call through the API: send returns transactionId, schedule operations take uuid, delivery reports return uuId and a per-call msgId
One request, four names. Normalise them to one text column the moment each reply arrives.

The fix is boring and it works: pick one internal name, say provider_txn_id, and map all three request-level spellings onto it at the edge of your code. Keep msgId separately as provider_call_id. Both are text columns.

Here’s a small adapter that does the mapping for every reply shape in this family:

TXN_KEYS = ("transactionId", "uuId", "uuid")

def provider_txn_id(obj: dict) -> str | None:
    """Return the request-level ID from any voice reply or row, as text."""
    for key in TXN_KEYS:
        value = obj.get(key)
        if value not in (None, ""):
            return str(value)
    return None

def provider_call_id(row: dict) -> str | None:
    value = row.get("msgId")
    return None if value in (None, "") else str(value)

str(value) is only safe if value was never a float. In Python, the standard json module parses big integers as exact int, so str() is fine. In other languages it isn’t, which is the next section.


Big Numbers and the Types That Break Them

Every identifier in the voice API is 18 or 19 digits. A 64-bit double, the only number type in JavaScript and the default for many JSON parsers, holds integers exactly only up to 2^53, which is 9,007,199,254,740,992 (16 digits). Above that, it rounds to the nearest value it can represent. RFC 8259, section 6 warns about exactly this range for interoperable JSON numbers, and the IEEE 754 format is why.

Here’s what happens to the sample values from the reference when they pass through a double, computed in Python with int(float(v)):

FieldSample valueAfter a double
transactionId63055833182368103796305583318236810240
libraryId74321061500285940977432106150028594176
msgId83587301713967370178358730171396737024
uuId (DLR row)759513504945227190759513504945227136
uuid (schedule sample)30760707607692742503076070760769274368

Every one changes. And JSON.parse in Node prints 7432106150028594097 as 7432106150028594000, so the corruption even looks plausible in a log.

The schedule row sample in the reference shows uuId as "6653312612485645000". Those three trailing zeros are the pattern a 19-digit value shows after it’s been through a JavaScript number. Whatever produced that sample, it’s a good reminder of how quietly this happens.

Table of six voice API fields showing where each appears, its type in the reference, a sample value, whether it survives a double, and the safe storage type
Six fields, one rule: text in your code, text in your database.

Where each field can bite

  • transactionId is a quoted string in the send reply. Safe on arrival. The risk is later, if you put it in a numeric column or a JSON body as a bare number.
  • uuid is typed integer in requests. In a multipart form it’s text anyway. In a generated TypeScript client it becomes number, and every schedule call you make with it targets the wrong ID.
  • uuId is typed string in report rows. Safe, as long as the server sends it quoted.
  • msgId is typed integer in delivery rows. If the server sends it as a bare number, JSON.parse in Node rounds it before your code ever sees it. Use a lossless parser.
  • libraryId is a string in the upload reply and an integer in send and renew requests. Keep the string.
  • mobileNo is typed integer in delivery rows, with a sample of "919xxxxxxxx1", which isn’t a number at all. A twelve-digit Indian mobile number fits in a double, but it overflows a 32-bit int, and phone numbers are identifiers, not quantities. Text.

The column types

provider_txn_id   TEXT  NOT NULL,   -- transactionId / uuid / uuId
provider_call_id  TEXT,             -- msgId, one per call
library_id        TEXT,
mobile_e164       TEXT  NOT NULL

Don’t use BIGINT “because it fits”. Java’s long and Postgres BIGINT do hold 19-digit values up to 9,223,372,036,854,775,807, but the first time one of those IDs passes through a dashboard, a spreadsheet export or a JavaScript service, it gets rounded. Text survives all of them.


Redial: Who Owns the Retry

The platform can redial an unanswered call for you. reDial takes 0, 1 or 2, and redialInterval takes 5, 10 or 15 minutes. So the platform will make at most three attempts, and if each wait is the interval, the last attempt starts at most reDial x redialInterval minutes after the first:

reDialredialIntervalAttemptsLast attempt starts after
0any10 minutes
1525 minutes
115215 minutes
25310 minutes
210320 minutes
215330 minutes

Now add your own retry logic. A worker that resubmits when it doesn’t see “answered” within ten minutes, on top of reDial=2, can put six calls on one person’s phone. For a reminder, that’s annoying. For a collections or healthcare line, it’s a complaint.

Pick one owner per call:

  • Let the platform own it for campaign calls. Set reDial and redialInterval, and make your own worker never resubmit a transactionId that the platform accepted. Your job is to read the result, not to call again.
  • Own it yourself for anything with business logic between attempts: an OTP that should switch channel, a call that should stop if the customer paid in the meantime. Send with reDial=0, and make the next attempt a new, deliberate send with its own idempotency key.

Never mix them for the same message. If you do want your own retry after the platform gives up, wait until the window in the table above has passed and the delivery row is final, then decide.

duplicateCheck=true asks the platform to drop duplicates, but the window and the matching rule (same number? same clip? same day?) aren’t spelled out. Keep your own idempotency key per logical call, as the idempotency guide describes, and treat duplicateCheck as a second net, not the first.


Scheduling, Moving and Cancelling Calls

Add scheduleTime to a send and the call is booked for later. The send reply gives you the transactionId as usual. From there, four operations manage the booking.

Read the booking. POST VoiceApi/report/schedule with uuid returns:

{
  "response": {
    "msg": "success",
    "cursors": { "next": "a", "start": "a" },
    "reports_scheduleList": [
      {
        "scheduledTimestamp": "1722487200000",
        "total": "1",
        "amount": 0.12,
        "uuId": "6653312612485645000",
        "campaignName": "",
        "cursorId": 10,
        "status": "PENDING"
      }
    ],
    "code": "200",
    "pages": { "current": 1, "last": 1 },
    "count": { "total": 1, "current": 1 },
    "action": "schedule",
    "api": "reports",
    "status": "success"
  }
}

scheduledTimestamp is epoch milliseconds as a quoted string. total is a quoted count of recipients. amount is an unquoted float. The status inside the row (PENDING) is the booking state; the status at the end of response is the API call’s result. Read the outer one first.

Read the calls inside it. POST VoiceApi/report/schedulelog takes the same uuid and is described as returning the individual messages in the booking. Its sample reply is identical to the report/schedule sample, down to action: "schedule". Parse it loosely, and log the first real reply you get so you can see which fields actually come back.

Move it. POST VoiceApi/updateSchedule with uuid and a new scheduleTime (sample 2024-01-31 10:00:00). The reply is flat: {status, transactionId, statusCode, reason}. Notice the transactionId in that sample (4740098350240514988) is a different number from the uuid in the request sample (1658333450312107482). Whether a move keeps the original ID or issues a new one, you can’t tell from the samples alone. Handle both: if the reply’s transactionId differs from what you sent, record it as the booking’s current ID and keep the old one as an alias.

Cancel it. POST VoiceApi/cancelSchedule with uuid. Same flat reply.

After a move or a cancel, read the booking back with report/schedule and check scheduledTimestamp or status changed the way you asked. A success reply tells you the request was accepted. The read-back tells you what the platform now holds.

A cutoff keeps cancels safe

What happens if you cancel a booking that has already started dialling? Some numbers may have been called and some not. Rather than depend on the answer, set a cutoff: your system refuses to move or cancel a booking less than, say, ten minutes before scheduledTimestamp. Inside the cutoff, the booking is final, and any change becomes a new send. The scheduling guide uses the same pattern for SMS bookings.


Reading Delivery Reports

There’s no push for voice delivery in the reference, so you poll. VoiceApi/report/dlr has three variants:

VariantMethodFilterUse it for
By datesPOSTfromdate, todateYour regular sync job
By transactionGET with ?tId=trueuuidChecking one request right after it’s due
By mobileGET with ?isMob=truemobileSupport lookups (“did we call this person?”)

All three return the same shape. Here it is assembled from the example values in the reference:

{
  "response": {
    "msg": "success",
    "cursors": { "next": "0", "start": "0" },
    "code": "200",
    "pages": { "current": 1, "last": 0 },
    "count": { "total": 0, "current": 1 },
    "reports_dlrList": [
      {
        "identifier": "",
        "amount": 0.2,
        "deliveryTime": 0,
        "answeredDuration": 0,
        "audioType": "pin",
        "channel": "API",
        "msgId": 8358730171396737017,
        "cause": "Waiting For Report",
        "pickUp": 0,
        "audioLength": 26,
        "mobileNo": "919xxxxxxxx1",
        "uuId": "759513504945227190",
        "globalErrorCode": 108,
        "cursorId": 0,
        "hangUp": 0,
        "submitTime": 1720330311336,
        "pulse": 1,
        "status": "SUBMITTED"
      }
    ],
    "action": "dlr",
    "api": "reports",
    "status": "success"
  }
}

msgId is shown unquoted because the schema types it as an integer. Plan for the server to send it that way, and parse accordingly.

The sample’s pages.last is 0 and count.total is 0 while the list holds one row. Don’t use count.total to decide whether the list is empty. Use the list’s length.

The ?tId=true and ?isMob=true suffixes are part of the path as the reference defines it. Send them. They’re how the server tells the three searches apart on a single path, and sending a flag the server ignores costs nothing.

The descriptions for the by-transaction and by-mobile searches say “prepare a POST request”, while the definitions are GET. Use GET, which is what the definition says and what the Swagger form sends, and keep the method in one constant so you can flip it if your account answers differently.

A sync job that doesn’t miss late rows

Voice rows change after they’re written. A row starts at SUBMITTED with cause “Waiting For Report” and gets updated once the call ends. So a sync job has to read the same window more than once.

  1. Every few minutes, read the window from 2 hours ago to now.
  2. Upsert each row keyed on provider_call_id (the msgId as text).
  3. Never overwrite a final status with a non-final one (see the next section for “final”).
  4. Once a day, re-read the previous day in full, to catch anything the short window missed.

Upserts make re-reading free: the same row arriving twice just updates nothing. The delivery report ingestion guide builds the same pattern for SMS in more depth.


What a Voice DLR Row Tells You

Voice rows carry more than SMS rows, because a call has a life of its own after it’s delivered. Here’s every field, with what you can safely do with it.

FieldSampleTypeWhat to do with it
msgId8358730171396737017integerPrimary key for one call. Text.
uuId“759513504945227190”stringJoins the call to its request. Text.
mobileNo“919xxxxxxxx1”integer (sample is a string)Recipient. Text.
identifier“”stringYour campaign label from the send.
status“SUBMITTED”stringStore raw; map with an allowlist.
cause“Waiting For Report”stringHuman-readable reason. Store, display, never parse.
globalErrorCode108integerMachine-readable reason. Store; map with an allowlist.
submitTime1720330311336integerEpoch ms; the sample is 2024-07-07 05:31:51 UTC.
deliveryTime0integerStore raw; 0 means nothing recorded yet.
pickUp0integerStore raw; 0 means nothing recorded yet.
hangUp0integerStore raw; 0 means nothing recorded yet.
answeredDuration0integerStore raw; 0 in a fresh row.
audioLength26integerLength of the clip. Store raw.
pulse1integerStore raw for billing reconciliation.
amount0.2floatCharge for the row.
audioType“pin”stringpin for voice OTP, library for clips.
channel“API”stringWhere the send came from.
cursorId0integerThe row’s position in the result.

Three rules make this table safe to build on.

Zero means “nothing yet”, not “midnight 1970”. deliveryTime, pickUp and hangUp are 0 in the sample row. Store them raw, add a nullable column for each that you fill only when the value is nonzero, and chart from those. Otherwise every chart you draw has a spike at 1 January 1970.

Classify status with an allowlist that fails open to “pending”. The only values in the reference are SUBMITTED for rows and PENDING for bookings. The daily summary hints at the rest: it counts success, pending, notSent, failed, others and refund. So build a mapping from the raw status strings you actually observe to your own states, and send anything you haven’t mapped yet to pending. An unknown value then means “keep polling”, never “delivered” or “failed”.

FINAL = {}           # fill from statuses you have observed, e.g. {"ANSWERED": "answered"}
NON_FINAL = {"SUBMITTED": "pending"}

def call_state(raw_status: str) -> tuple[str, bool]:
    s = (raw_status or "").strip().upper()
    if s in FINAL:
        return FINAL[s], True
    return NON_FINAL.get(s, "pending"), False

Derive “answered” from the call fields, not from the status string. In a fresh row, pickUp and answeredDuration are both 0. Make one test call that you answer and one that you let ring out, and compare the rows. Once you’ve seen those fields go nonzero on an answer, they give you “answered” whatever the status vocabulary turns out to be. Leave voicemail on for a third call to see whether a voicemail box counts as a pickup.

If you want “heard the whole message”, compare answeredDuration with audioLength. Before you rely on that ratio, make three test calls to a phone you control with a clip of known length: hang up immediately, hang up halfway, and listen to the end. Look at the three rows. That tells you the units and what “full” looks like in your account.


Paging Without a Page Parameter

Every report reply has cursors (next, start), pages (current, last), count (total, current) and a cursorId on each row. None of the report request schemas has a field to send a cursor or a page number back.

So don’t plan on paging forward. Plan on asking for windows small enough that one reply holds everything, and prove it on every call:

  1. Request a window, say one hour.
  2. If pages.last is greater than 1, or count.total is greater than the rows you got, the window was too big.
  3. Split it in half and request both halves.
  4. Repeat until every window comes back complete.
from datetime import datetime, timedelta

def fetch_window(client, start: datetime, end: datetime, min_span=timedelta(minutes=1)):
    rows, meta = client.dlr_by_dates(start, end)
    total = int(meta.get("count", {}).get("total") or 0)
    last = int(meta.get("pages", {}).get("last") or 0)
    complete = last <= 1 and total <= len(rows)
    if complete or (end - start) <= min_span:
        if not complete:
            client.alert("dlr window still truncated", start, end, total, len(rows))
        return rows
    mid = start + (end - start) / 2
    return (fetch_window(client, start, mid, min_span)
            + fetch_window(client, mid, end, min_span))

This works whatever the server’s page size is, and it keeps working if a page parameter appears later. Simulate a one-hour window holding 450 rows against a server that returns 100 rows per reply, and the function makes 15 requests and returns all 450. The halves overlap at mid, and the upsert on msgId absorbs any row that lands in both.

The sample’s count.total of 0 next to a one-row list (above) is why the check uses total <= len(rows) rather than equality: a reply with more rows than its own count is fine; a reply with fewer is truncated.


Summary, Upload History and the Daily Archive {#summary-archive}

Daily summary

POST VoiceApi/report/summary with fromdate and todate returns one row per day in response.report_summaryList (singular report_):

{
  "date": 1720290600000,
  "amount": 0.6,
  "total": 6,
  "success": 0,
  "pending": 3,
  "notSent": 2,
  "failed": 1,
  "others": 0,
  "cursorId": 0,
  "refund": 0
}

The buckets add up in the sample: 0 + 3 + 2 + 1 + 0 = 6. Check that sum on every row you ingest, and alert if it doesn’t hold. It’s the cheapest data quality test you’ll ever write.

date is 1720290600000, which is 2024-07-06 18:30 UTC, exactly midnight on 7 July in India Standard Time. So the summary’s days are IST days. If your own reporting runs in UTC, your daily totals and the summary’s won’t line up until you bucket in IST too.

Use the summary to reconcile. Count your own delivery rows per IST day and per bucket, and compare with the summary. A gap tells you your sync job missed a window.

Upload history

POST VoiceApi/report/uploadhistory lists your bulkupload files:

{
  "identifier": "668a72f0d346b.csv",
  "filename": "668a72f0d346b.csv",
  "comments": "Total 9 uploaded, valid entries of 0 found and 9 invalid entries found. Total Duplicate entries found are 0.",
  "uuId": "6764150675538529832",
  "cursorId": 1567,
  "timestamp": 1720349424941
}

The valid and invalid counts live inside the free-text comments. Parse them with a forgiving pattern, keep the raw string, and alert if the pattern stops matching:

import re

UPLOAD_RE = re.compile(
    r"Total\s+(\d+)\s+uploaded.*?valid entries of\s+(\d+).*?(\d+)\s+invalid"
    r".*?Duplicate entries found are\s+(\d+)", re.I | re.S)

def parse_upload_comment(text: str) -> dict | None:
    m = UPLOAD_RE.search(text or "")
    if not m:
        return None   # keep the raw comment and raise an alert
    total, valid, invalid, dupes = map(int, m.groups())
    return {"total": total, "valid": valid, "invalid": invalid, "duplicates": dupes}

The sample is worth a second look: 9 uploaded, 0 valid. A file in the wrong format gets accepted, gets a uuId, and calls nobody. After every bulk upload, find its history row by uuId and stop the campaign in your own system if valid is 0 or far below the row count of the file you sent.

Daily archive

GET VoiceApi/report/day with date (YYYY-MM-DD) returns a zip of that day’s delivery report; the sample reply is a message saying you’ll be prompted with a .zip file. In code, check the first two bytes of the body are PK (the zip signature) before you unzip. If they aren’t, you’ve got a JSON or text error, so log it and stop. Use the archive for backfills and audits; use report/dlr for anything recent.


Time Zones and Date Formats

Voice requests take dates in three shapes, and none carries a zone:

WhereFormat in the field definitionFormat in the description
scheduleTime on sendYYYY-MM-DD HH:MMYYYY-MM-DD HH:MM:SS (spelled scheduletime)
scheduleTime on updateSchedulesample 2024-01-31 10:00:00YYYY-MM-DD HH:MM:SS
fromdate, todate on report/dlrYYYY-MM-DD HH:MMYYYY-MM-DD HH:MM:SS
fromdate, todate on summary and upload historyYYYY-MM-DD HH:MM:SSYYYY-MM-DD HH:MM:SS
date on report/dayYYYY-MM-DDYYYY-MM-DD

Replies, on the other hand, give epoch milliseconds, which have no zone problem at all. That asymmetry gives you a clean way to pin the input zone once:

  1. Book a test call (to your own number) for a wall-clock time well in the future, such as 2030-01-01 10:00.
  2. Read it back with report/schedule.
  3. Convert scheduledTimestamp to UTC. If it’s 04:30 UTC, the platform read your input as IST. If it’s 10:00 UTC, it read UTC.
  4. Cancel the booking, and store the result as configuration.

Run the probe in CI or at deploy time, and refuse to schedule anything if the answer ever changes. A scheduler that silently shifts every call by five and a half hours is worse than one that stops.

The daily summary’s IST midnight (above) suggests IST, which matches the account default for an Indian platform. Measure it anyway. The probe takes a minute and replaces a guess with a fact.

For the format, send what the field definition asks for. If the platform rejects it, try the description’s format, and pin whichever one the read-back confirms in a contract test.


Four Client Samples, Four Traps

Each sample below is short and does one thing, and each one shows a different way voice integrations lose data. Replace YourUsername and the key with your own, and point the first runs at your own phone number.

Python: send a voice OTP without losing the leading zero

import os
import requests

BASE = "https://unify.smsgateway.center"
HEADERS = {"apikey": os.environ["SGC_API_KEY"]}
USERID = os.environ["SGC_USERID"]

def send_voice_otp(mobile: str, pin: str, clip: str = "OneTimePassword") -> str:
    if not (pin.isdigit() and 4 <= len(pin) <= 8):
        raise ValueError("pin must be a string of 4 to 8 digits")
    params = {
        "userid": USERID,
        "sendMethod": "pin",
        "audioType": "pin",
        "mobile": mobile,
        "pin": pin,              # a string: "012345" stays six digits
        "pinClipId": clip,
        "pinRepeatCount": "2",
        "reDial": "0",           # the OTP owns its own retry
        "output": "json",
    }
    r = requests.get(f"{BASE}/VoiceApi/send", params=params, headers=HEADERS, timeout=15)
    body = r.json()
    if body.get("status") != "success":
        raise RuntimeError(f"voice OTP rejected: {body!r}")
    return str(body["transactionId"])

The trap is the type of pin. The validation at the top refuses anything but a digit string, so an integer can’t sneak in from a caller. requests sends sendMethod=pin in the query along with everything else, which matches the path in the reference. Note the timeout: a voice send that hangs should fail fast so your OTP flow can offer another channel.

Node.js: parse delivery rows without rounding msgId

// Node 18+ (global fetch and FormData)
const BASE = "https://unify.smsgateway.center";

// Quote bare 15+ digit numbers for the keys we treat as identifiers,
// before JSON.parse can round them.
const ID_KEYS = ["msgId", "mobileNo", "uuId", "uuid", "transactionId", "libraryId"];
const BARE_ID = new RegExp(`"(${ID_KEYS.join("|")})"\\s*:\\s*(-?\\d{15,})`, "g");

function parseLossless(text) {
  return JSON.parse(text.replace(BARE_ID, '"$1":"$2"'));
}

async function dlrByDates(fromdate, todate) {
  const form = new FormData();
  form.set("userid", process.env.SGC_USERID);
  form.set("fromdate", fromdate);   // "2026-10-26 10:00"
  form.set("todate", todate);
  form.set("output", "json");
  const res = await fetch(`${BASE}/VoiceApi/report/dlr`, {
    method: "POST",
    headers: { apikey: process.env.SGC_API_KEY },
    body: form,
  });
  const body = parseLossless(await res.text());
  const r = body.response || {};
  if (r.status !== "success") throw new Error(`dlr read failed: ${JSON.stringify(r)}`);
  return Array.isArray(r.reports_dlrList) ? r.reports_dlrList : [];
}

module.exports = { parseLossless, dlrByDates };

The trap is JSON.parse. Fed {"msgId":8358730171396737017}, it hands you 8358730171396737000, and every upsert keyed on that value collides with the wrong row or misses the right one. The regex runs on the raw text first and turns those bare numbers into strings. It only touches the named keys and only numbers of 15 digits or more, so amount, pulse and timestamps stay numbers. If you’d rather use a library, json-bigint or a reviver with source access does the same job.

Running the function above on {"msgId":8358730171396737017,"amount":0.2} gives { msgId: '8358730171396737017', amount: 0.2 }.

Java: keep phone numbers and PINs out of int

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class VoiceOtp {
    private static final Pattern TXN =
        Pattern.compile("\"transactionId\"\\s*:\\s*\"?(\\d+)\"?");

    static String enc(String v) {
        return URLEncoder.encode(v, StandardCharsets.UTF_8);
    }

    public static String send(String userid, String apiKey, String mobile, String pin)
            throws Exception {
        // mobile and pin are Strings on purpose:
        // 919999999999 overflows int, and "012345" as an int is 12345.
        String query = "userid=" + enc(userid)
            + "&sendMethod=pin&audioType=pin"
            + "&mobile=" + enc(mobile)
            + "&pin=" + enc(pin)
            + "&pinClipId=OneTimePassword&reDial=0&output=json";
        HttpRequest req = HttpRequest.newBuilder(
                URI.create("https://unify.smsgateway.center/VoiceApi/send?" + query))
            .header("apikey", apiKey)
            .timeout(Duration.ofSeconds(15))
            .GET()
            .build();
        String body = HttpClient.newHttpClient()
            .send(req, HttpResponse.BodyHandlers.ofString()).body();
        if (!body.contains("\"success\"")) {
            throw new IllegalStateException("voice OTP rejected: " + body);
        }
        Matcher m = TXN.matcher(body);
        if (!m.find()) throw new IllegalStateException("no transactionId: " + body);
        return m.group(1);   // keep it a String
    }
}

The trap here is quieter. A Java long holds every voice ID, so Java developers tend to relax. But generated clients and DTOs often map “integer” to Integer, and a twelve-digit mobile number like 919999999999 is far past Integer.MAX_VALUE (2,147,483,647). Keep mobile, pin and every ID as String in your DTOs, whatever the schema says. The regex extraction keeps this sample on the JDK alone; in a real service, bind with your JSON library into String fields. For brevity, the success check is a substring test; in production, parse the body and read status.

cURL: upload a clip, and don’t trust jq with IDs

# Upload an mp3 to the audio library
curl -sS https://unify.smsgateway.center/VoiceApi/save \
  -H "apikey: $SGC_API_KEY" \
  -F "userid=$SGC_USERID" \
  -F "libraryName=welcome-v3" \
  -F "[email protected];type=audio/mpeg" \
  -F "output=json" \
  -o save-reply.json

# Pull libraryId as raw text, not as a number
grep -o '"libraryId"[[:space:]]*:[[:space:]]*"\?[0-9]*' save-reply.json \
  | grep -o '[0-9]*$'

Two traps. First, the @ in [email protected] tells curl to attach the file. Leave it out and curl sends the literal text welcome-v3.mp3 as the field value, and you get an error, or worse, a “clip” that isn’t one. Second, jq 1.6 parses numbers as doubles: echo '{"msgId":8358730171396737017}' | jq .msgId prints 8358730171396737000. libraryId arrives quoted, so jq -r .libraryId is safe for that field, but msgId in delivery rows may not be. Check jq --version, test it on a known 19-digit value, and when in doubt pull IDs out with grep -o as above.


A Data Model for Voice Calls

Voice fits next to the outbound table you may already have for SMS (the outbound message table guide covers the general shape). The difference is that one voice request fans out into many calls, each with a life after delivery. Two tables cover it.

CREATE TABLE voice_request (
  id                BIGSERIAL PRIMARY KEY,
  idempotency_key   TEXT NOT NULL UNIQUE,     -- yours, per logical send
  provider_txn_id   TEXT UNIQUE,              -- transactionId / uuid / uuId
  previous_txn_ids  TEXT[] NOT NULL DEFAULT '{}', -- if a reschedule issues a new one
  kind              TEXT NOT NULL CHECK (kind IN ('library','pin')),
  send_method       TEXT NOT NULL,            -- quick | group | bulkupload | pin
  library_id        TEXT,                     -- for library calls
  pin_clip_id       TEXT,                     -- for voice OTP
  identifier        TEXT,                     -- what you sent as identifier
  redial            SMALLINT NOT NULL CHECK (redial BETWEEN 0 AND 2),
  redial_interval   SMALLINT CHECK (redial_interval IN (5,10,15)),
  scheduled_for     TIMESTAMPTZ,              -- your intent, in UTC
  scheduled_epoch_ms TEXT,                    -- scheduledTimestamp as read back
  invalid_mobiles   TEXT[] NOT NULL DEFAULT '{}',
  state             TEXT NOT NULL,            -- queued | submitted | booked | cancelled | rejected
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE voice_call (
  provider_call_id  TEXT PRIMARY KEY,         -- msgId
  provider_txn_id   TEXT NOT NULL,            -- uuId on the row
  mobile_e164       TEXT NOT NULL,            -- mobileNo
  status_raw        TEXT NOT NULL,
  cause_raw         TEXT,
  global_error_code TEXT,
  state             TEXT NOT NULL,            -- from the allowlist; unknown -> pending
  is_final          BOOLEAN NOT NULL DEFAULT false,
  submit_time_ms    TEXT,
  delivery_time_raw TEXT,
  pick_up_raw       TEXT,
  hang_up_raw       TEXT,
  answered_duration_raw TEXT,
  audio_length_raw  TEXT,
  pulse_raw         TEXT,
  amount_raw        TEXT,                     -- keep the provider's exact value
  audio_type        TEXT,
  channel           TEXT,
  first_seen_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_seen_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX voice_call_txn ON voice_call (provider_txn_id);
CREATE INDEX voice_call_mobile ON voice_call (mobile_e164);

The upsert from a delivery row should only ever move a call forward:

INSERT INTO voice_call AS c (provider_call_id, provider_txn_id, mobile_e164,
       status_raw, cause_raw, global_error_code, state, is_final, last_seen_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (provider_call_id) DO UPDATE
SET status_raw = EXCLUDED.status_raw,
    cause_raw = EXCLUDED.cause_raw,
    global_error_code = EXCLUDED.global_error_code,
    state = EXCLUDED.state,
    is_final = EXCLUDED.is_final,
    last_seen_at = now()
WHERE NOT c.is_final;      -- a final row is never overwritten

Everything raw goes in as text. You’ll want it the day someone asks why a call cost what it did, or why your “answered” count doesn’t match the dashboard. Derived columns (answered, heard to the end, cost in your currency) live in views on top, so you can fix a derivation without re-ingesting history.

If you keep call records about people, they fall under your retention and erasure rules too. The data retention and erasure guide shows how to redact phone numbers while keeping opt-outs working.


Production Checklist

  • Auth uses userid plus the apikey header; no passwords in query strings.
  • Every identifier (transactionId, uuid, uuId, msgId, libraryId) is text in code, queues, logs and the database.
  • mobile, mobileNo and pin are strings everywhere, including generated DTOs.
  • Node services parse voice replies with a lossless parser; shell scripts don’t use jq on IDs without a version check.
  • transactionId is saved before any other processing of a send reply.
  • invalidMobile is split and recorded as rejected at submission.
  • Every send carries identifier set to your own campaign or job ID.
  • Each call has exactly one retry owner: platform reDial, or your code with reDial=0.
  • Voice OTP lifetime covers reDial x redialInterval, or voice OTP uses reDial=0.
  • A clip registry holds library_id, source file hash and path; clips are renewed in a pre-flight before campaigns.
  • The input time zone is pinned by a schedule-and-read-back probe at deploy time.
  • Moves and cancels respect a cutoff before scheduledTimestamp, and every change is read back.
  • The DLR sync re-reads a trailing window, upserts on msgId, and never overwrites a final row.
  • Report windows are split until pages.last is at most 1 and count.total is at most the rows returned.
  • Status mapping is an allowlist; unknown values map to pending.
  • Zero timestamps are treated as “nothing yet”.
  • Daily summary rows are checked for success + pending + notSent + failed + others = total, and reconciled against your own rows by IST day.
  • Every bulk upload is followed by an upload history check on valid entries.
  • Archive downloads are checked for the PK zip signature before unzipping.
  • A contract test pins the reply shapes your account actually returns.

Unspecified Behaviour and How to Code Around It

Some behaviours of the voice API aren’t pinned down by anything you can read today. Each item below gives you the choice that stays safe whichever way the answer goes, and none of them needs you to wait for one.

One. Send pin as a digit string and generate it as one. Whether the server reads pin as text or as a number, a string with its leading zero intact is correct in both cases, and an integer is wrong in one of them.

Two. Try the OTP sendMethod values in a sandbox and pin the one that works. The reference path uses sendMethod=pin, one KB page shows quick, another shows quickce, each with audioType=pin. Send a test OTP to your own phone with each, keep the one your account answers with success and an actual call, and lock it in a contract test so a change shows up as a failing test rather than silent OTP failures.

Three. Make one owner responsible for every retry. Whether redialInterval is measured from the start or the end of the previous attempt, a call owned by either the platform or your code (never both) can’t ring someone more times than you intended.

Four. Keep your own idempotency key, and treat duplicateCheck as a backstop. Whatever window and matching rule duplicateCheck uses, a unique key per logical call in your own database stops the duplicate before it leaves your system.

Five. Treat every report as unpageable and size your windows to fit. If a cursor or page parameter exists and isn’t listed, the window-splitting reader still returns every row. If it doesn’t exist, the window-splitting reader is the only thing that does.

Six. Map statuses through an allowlist where unknown means pending. The full set of row statuses isn’t published. An unknown value that maps to pending means more polling. An unknown value that maps to delivered or failed means a wrong answer you’ll never notice.

Seven. Measure the input time zone with a booking and a read-back. scheduleTime, fromdate and todate carry no zone, while scheduledTimestamp comes back as epoch milliseconds. One test booking turns the question into a number you can compare, and a deploy-time check catches it if the answer ever changes.

Eight. After a reschedule, record whatever transactionId comes back. Whether updateSchedule keeps the ID or issues a new one, storing the reply’s value as current and keeping the old one as an alias means every later lookup works.

Nine. Never cancel inside your cutoff. Nobody can tell you exactly what cancelSchedule does to a booking that has started dialling. A cutoff before scheduledTimestamp means you never have to find out.

Ten. Renew clips before campaigns and keep the source file. With no published clip lifetime and no endpoint to list clips, a pre-flight renewal plus your own registry means an expired clip shows up as a failed pre-flight, not as a campaign that played silence.

Eleven. Parse every voice reply losslessly, even fields the schema calls strings. If msgId arrives bare, a lossless parser keeps it exact. If it arrives quoted, the lossless parser leaves it alone. Either way the ID you store is the ID the platform sent.

Twelve. Validate test calls before trusting call-progress fields. Three calls to your own phone (rejected, answered briefly, answered to the end) tell you what pickUp, hangUp, answeredDuration and audioLength look like in practice, and protect every metric you build on them.


FAQs

What is the base URL for the Voice Call API?

https://unify.smsgateway.center, with every operation under /VoiceApi/. Calls are sent with VoiceApi/send, clips are uploaded with VoiceApi/save, and reports live under VoiceApi/report/.

Can I use an API key instead of a password?

Yes. Send userid as a field and the key in the apikey header, and leave password out. That keeps the password out of URLs and logs.

How do I send a voice OTP?

Use a GET to VoiceApi/send?sendMethod=pin with userid, mobile, pin, pinClipId and output=json. Send pin as a string so a code like 012345 keeps its leading zero, and set reDial=0 unless your OTP stays valid through the whole redial window.

Why did my stored transaction ID stop matching the delivery report?

Almost always because it passed through a double somewhere. Voice IDs are 18 or 19 digits, and doubles are exact only up to 16. Store IDs as text, parse JSON losslessly in Node, and check jq before using it on IDs.

How many times will the platform redial an unanswered call?

At most twice. reDial takes 0, 1 or 2, and redialInterval takes 5, 10 or 15 minutes, so the last attempt can start up to 30 minutes after the first.

Can I change or cancel a scheduled voice call?

Yes. VoiceApi/updateSchedule moves it and VoiceApi/cancelSchedule cancels it, both keyed by uuid. Read the booking back with VoiceApi/report/schedule afterwards, and stop making changes shortly before the scheduled time.

Is there a webhook for voice delivery reports?

Not in the Voice Call API reference. Poll VoiceApi/report/dlr by date range, re-read a trailing window so late updates are picked up, and upsert on msgId.

How do I get more rows than one report reply returns?

Make the date window smaller. The report requests have no page parameter, so split any window whose pages.last is above 1 or whose count.total exceeds the rows returned, and merge the halves.

Where can I see which clips are in my audio library?

Not through the API. There’s no list endpoint for clips, so keep your own registry of libraryId, name and source file, and use the dashboard when you need to compare.

Do the daily summary dates use UTC or IST?

IST. The sample date of 1720290600000 is exactly midnight on 7 July 2024 in India Standard Time. Bucket your own counts by IST day before you compare them with the summary.

Can I test voice calls before going live?

Yes. Point the first runs at your own phone, and use the sandbox and demo to get a feel for the platform. For voice specifically, three test calls (rejected, answered briefly, answered to the end) teach you more about the delivery rows than any spec.


Building voice into a product?

Start with a test call to your own phone, then wire up the delivery report sync before you send anything at volume. When you’re ready to run campaigns or voice OTP in production, talk to the SMSGatewayCenter team about routes, pricing and the account settings that fit your use case, or compare plans on the voice call pricing page.


More from our blog

Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs

SMS Account Security Audit Trail: Activity Logs, Login Alerts and Connected Devices via API

Building an SMS and WhatsApp Preference Centre: Purposes, Channels, Frequency and the Send Gate

API Key Rotation for an SMS API: Rotating a Single-Slot Key Without Dropping Messages

SMS Message Encryption: What It Hides, What It Doesn’t, and How to Get the Wire Format Right


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!