The Message Center - Manage Blacklist APIs
The Caller Blacklist API manages the list of callers who are blocked from reaching your numbers. A caller can be blocked from one DID, from several DIDs (one entry each), or from every DID with a single global entry. The same list is checked on every incoming call, so changes made through this API apply to the very next call.
Contents
- Getting started
- How responses work
- Endpoints
- Fields reference
- Identifying an entry
- Behaviour you need to understand
- Errors
- Warnings
- Building an integration
- Limits and known gaps
Getting started
Base URL
Every request goes to one URL, with the action and your token as query parameters:
https://script.google.com/macros/s/<deployment-id>/exec?action=<action>&token=<token>
Your administrator will give you the deployment ID and token.
Authentication
The token goes in the query string as
token, never in a header.Authorization: Bearerheaders are not read, so a request that sends the token only in a header is refused with401 unauthorized.
The token for this API is separate from the token used by the call-time lookup. Neither works in place of the other.
Follow redirects
Every request is answered with a redirect to the actual response. Your client must follow it, and must follow it as a GET, even when the original request was a POST.
| Client | Setting |
|---|---|
| Postman | Settings → Automatically follow redirects on, Follow original HTTP method off |
| curl | Use -L. Send a body with -d and never -X POST, which makes curl repeat the POST after the redirect and fail |
| Most HTTP libraries | Follow redirects is usually on by default; make sure the redirect is not re-sent as POST |
A client that does not follow the redirect receives an HTML page instead of JSON.
Content type
Request bodies are JSON objects. Send them with Content-Type: application/json or text/plain.
First call
curl -L "https://script.google.com/macros/s/<deployment-id>/exec?action=list&limit=5&token=<token>"A POST with a body:
curl -L "https://script.google.com/macros/s/<deployment-id>/exec?action=check&token=<token>" \
-H "Content-Type: application/json" \
-d '{"caller_number": "0390722790", "blocked_did": "0390722794"}'Encode
+as%2Bin query strings. An unencoded+is read as a space.caller_number=%2B61390722790is correct;caller_number=+61390722790is not. In JSON bodies,+needs no encoding.
How responses work
The envelope
The HTTP status is always 200. The real outcome is in the body:
{ "ok": true, "status": 200, "action": "get", "...": "action-specific fields" }{
"ok": false,
"status": 409,
"action": "create",
"error": { "code": "duplicate_entry", "message": "…", "details": { "existing": [] } }
}Always check
okandstatusin the body. Do not rely on the HTTP status.ok: falsealways means nothing was changed.
error.code comes from a fixed list (see Errors) and is safe to branch on. error.message is for people and may change. error.details carries structured data such as the fields that are missing or the entry that conflicts.
Warnings
A successful write can carry warnings[]. A warning means the change was saved, but something about the result deserves attention, such as an entry that has no effect because a broader one already covers it. See Warnings.
Recommended fields
reason is optional but recommended. get, create and update return a recommendedFields object:
"recommendedFields": {
"fields": ["reason"],
"note": "reason is optional. When it is blank, a blocked call is recorded with an empty blacklistReason, so the call record will not show why the call was blocked.",
"blank": ["reason"],
"reminder": "Set a reason with the update action so blocked calls show why."
}blank lists the recommended fields that are empty on this entry; reminder is empty when none are.
Entry position
Every entry includes _sheetRow, its current position in the list. It is informational only: it changes when entries above it are removed, so never use it to identify an entry. Fields starting with _ are ignored when sent, so an entry you fetched can be edited and sent back.
Endpoints
| Action | Method | Purpose |
|---|---|---|
list | GET or POST | Every entry, filtered and paged |
get | GET or POST | Every entry for one caller |
check | GET or POST | Whether a caller is blocked from a DID, and by which entry |
create | POST | Add one entry |
update | POST | Change an entry's reason |
delete | POST | Remove one entry |
unblock | POST | Remove every entry for one caller |
list
GET ?action=list&token=<token>
GET ?action=list&global=true&limit=50&offset=0&token=<token>
| Parameter | Default | Meaning |
|---|---|---|
offset | 0 | Entries to skip |
limit | all | Entries to return, 0 to 10000 |
caller_number | Only this caller's entries | |
blocked_did | Only per-DID entries for this DID | |
global | true for global entries only, false for per-DID entries only |
{
"ok": true,
"status": 200,
"action": "list",
"total": 3,
"offset": 0,
"returned": 1,
"hasMore": true,
"headers": ["caller_number", "global", "blocked_did", "reason"],
"entries": [
{
"caller_number": "08017082571",
"global": false,
"blocked_did": "0687416874",
"reason": "Example entry",
"_sheetRow": 2
}
],
"recommendedFields": { "fields": ["reason"], "note": "…" }
}total counts every entry that matches the filters; returned counts this page.
| Status | Meaning |
|---|---|
| 200 | Entries returned (possibly none) |
400 invalid_filter | A filter number is not a valid phone number, or global is not true or false |
400 invalid_paging | offset or limit is not a whole number in range |
get
GET ?action=get&caller_number=13854463643&token=<token>
Returns every entry for the caller, global and per-DID, as an array.
{
"ok": true,
"status": 200,
"action": "get",
"key": { "type": "caller_number", "value": "13854463643" },
"count": 1,
"entries": [
{
"entry": {
"caller_number": "13854463643",
"global": false,
"blocked_did": "0390722794",
"reason": "Testing",
"_sheetRow": 4
},
"recommendedFields": { "fields": ["reason"], "note": "…", "blank": [], "reminder": "" }
}
]
}| Status | Meaning |
|---|---|
| 200 | One or more entries returned |
400 invalid_key | caller_number missing or not a valid phone number |
404 not_found | The caller has no entries |
check
GET ?action=check&caller_number=%2B13854463643&blocked_did=0390722794&token=<token>
Answers exactly as an incoming call would be answered, and names the entry responsible. blocked_did is optional; without it, only global entries are checked, just as for a call that arrives without a DID. check always reads the current list, so it reflects changes immediately.
{
"ok": true,
"status": 200,
"action": "check",
"message": "Blocked from this DID.",
"blocked": true,
"scope": "did",
"caller_number": "13854463643",
"blocked_did": "390722794",
"entry": { "caller_number": "13854463643", "global": false, "blocked_did": "0390722794", "reason": "Testing", "_sheetRow": 4 },
"dataDipResponse": {
"parameters": {
"Blacklisted": { "value": "true", "displayToAgent": false, "saveToDatabase": true },
"blacklistScope": { "value": "did", "displayToAgent": false, "saveToDatabase": true },
"blacklistReason": { "value": "Testing", "displayToAgent": false, "saveToDatabase": true },
"blacklistCaller": { "value": "13854463643", "displayToAgent": false, "saveToDatabase": true },
"blacklistDid": { "value": "390722794", "displayToAgent": false, "saveToDatabase": true }
}
}
}scope is global, did or none. entry is null when the caller is not blocked. dataDipResponse is exactly what the call-time lookup returns for this caller and DID.
| Status | Meaning |
|---|---|
| 200 | Answered, whether blocked or not |
400 invalid_key | caller_number missing or invalid, or blocked_did given but invalid |
create
POST ?action=create&token=<token>
{
"caller_number": "+61 3 9999 0001",
"blocked_did": "03 9072 2794",
"reason": "Repeated abusive calls"
}For a global entry, send "global": true and no blocked_did.
{
"ok": true,
"status": 200,
"action": "create",
"message": "Created the entry for 399990001 on 390722794.",
"entry": {
"caller_number": "399990001",
"global": false,
"blocked_did": "390722794",
"reason": "Repeated abusive calls",
"_sheetRow": 5
},
"recommendedFields": { "fields": ["reason"], "note": "…", "blank": [], "reminder": "" },
"warnings": []
}Numbers are stored in normalised form (see Identifying an entry). entry is read back after saving, so it shows exactly what was stored.
| Status | Meaning |
|---|---|
| 200 | Created (check warnings) |
400 missing_required_fields | caller_number, or blocked_did on a per-DID entry, is missing |
400 unknown_fields | A field is not one of the fields listed in details.validFields |
400 validation_failed | One or more rules broken; every problem is listed in details.problems |
409 duplicate_entry | The same caller already has this entry; details.existing shows it |
503 busy | Another change was in progress; retry |
update
POST ?action=update&caller_number=0399990001&global=true&token=<token>
POST ?action=update&caller_number=0399990001&blocked_did=0390722794&token=<token>
{ "reason": "Spam calls to every line" }Only reason can change. Send "" to clear it. The entry's key goes in the query string; the body holds the new value. A fetched entry can be sent back as the body, as long as caller_number, global and blocked_did are unchanged.
{
"ok": true,
"status": 200,
"action": "update",
"message": "Updated the entry for 399990001 (global).",
"changes": [{ "field": "reason", "from": "", "to": "Spam calls to every line" }],
"entry": { "caller_number": "399990001", "global": true, "blocked_did": "", "reason": "Spam calls to every line", "_sheetRow": 6 },
"recommendedFields": { "fields": ["reason"], "note": "…", "blank": [], "reminder": "" },
"warnings": []
}changes lists only real changes. Sending the reason the entry already has returns ok: true with an empty changes and writes nothing.
| Status | Meaning |
|---|---|
| 200 | Updated, or nothing to change |
400 invalid_key | The query string does not identify one entry (see Identifying an entry) |
400 missing_required_fields | reason not in the body |
400 key_immutable | The body changes caller_number, global or blocked_did |
400 field_not_updatable | The body changes another field that cannot be updated |
400 unknown_fields / validation_failed | As for create |
404 not_found | No such entry |
409 ambiguous_key | More than one entry has this key; details.candidates lists them |
503 busy | Retry |
delete
POST ?action=delete&caller_number=0399990001&blocked_did=0390722794&token=<token>
{ "caller_number": "0399990001", "blocked_did": "0390722794" }The body must repeat the key as confirmation:
caller_numberplus the sameblocked_did, or"global": truefor a global entry. Any format of the same numbers is accepted.
{
"ok": true,
"status": 200,
"action": "delete",
"message": "Deleted the entry for 399990001 on 390722794.",
"deleted": { "caller_number": "399990001", "global": false, "blocked_did": "390722794", "reason": "Repeated abusive calls", "_sheetRow": 5 },
"warnings": [
{
"code": "still_blocked",
"message": "This caller is still blocked from 390722794 by a global entry.",
"entries": [{ "caller_number": "399990001", "global": true, "blocked_did": "", "reason": "Spam calls to every line", "_sheetRow": 5 }]
}
]
}deleted is the full entry and can be sent unchanged to create to restore it.
| Status | Meaning |
|---|---|
| 200 | Deleted (check warnings) |
400 invalid_key | The query string does not identify one entry |
400 delete_confirmation_mismatch | The body does not repeat the key; details.expected shows what to send |
404 not_found | No such entry |
409 ambiguous_key | More than one entry has this key |
503 busy | Retry |
unblock
POST ?action=unblock&caller_number=0399990001&token=<token>
{ "caller_number": "0399990001" }Removes every entry for the caller, global and per-DID. The body must repeat caller_number.
{
"ok": true,
"status": 200,
"action": "unblock",
"message": "Removed 1 entry for caller 399990001. This caller is no longer blocked.",
"count": 1,
"deleted": [{ "caller_number": "399990001", "global": true, "blocked_did": "", "reason": "Spam calls to every line", "_sheetRow": 5 }],
"warnings": []
}| Status | Meaning |
|---|---|
| 200 | Every entry for the caller removed |
400 invalid_key | caller_number missing or invalid |
400 delete_confirmation_mismatch | The body does not repeat caller_number; details.entries shows what would be removed |
404 not_found | The caller has no entries |
503 busy | Retry |
Fields reference
Required
| Field | Type | Rules |
|---|---|---|
caller_number | text | A phone number in any common format, at least 6 digits once normalised |
blocked_did | text | Required unless global is true; must be empty when it is. Same rules as caller_number |
Recommended
| Field | Type | Rules |
|---|---|---|
reason | text | Why the caller is blocked. Returned with every blocked call |
Blank means no explanation. A blocked call is still blocked when
reasonis blank, but the call record shows an empty reason, so nobody can tell later why it was blocked.
Other fields
| Field | Type | Rules |
|---|---|---|
global | true / false | Default false. true blocks the caller from every DID. Also accepts "true", "false", 1, 0, "yes", "no" |
added_by | text | Who added the entry. Only available if your list has this field; otherwise refused as unknown |
added_at | date | 2026-11-10 09:12, 2026-11-10 or ISO 8601. Only available if your list has this field |
Field rules
- Unknown fields are refused, never ignored.
details.validFieldslists the fields your list accepts. - Text cannot start with
=. - Objects and arrays sent as values are stored as JSON text.
- Every rule is checked before anything is saved, and every problem is reported at once in
details.problems.
Identifying an entry
An entry is identified by its caller and what it blocks:
| Entry | Key |
|---|---|
| Per-DID | caller_number + blocked_did |
| Global | caller_number + global=true |
update and delete take the key in the query string only, with exactly one of blocked_did or global=true. Both, or neither, is refused with invalid_key. get and unblock take caller_number alone and act on every entry for that caller.
Number formats
Every number you send is normalised before it is compared or stored: SIP URIs are reduced to the number inside them, punctuation is removed, and country codes and leading zeros are dropped. All of these are the same caller:
0390722790 +61 3 9072 2790 61390722790 <sip:0390722790@host;user=phone>
New entries are stored in the normalised form (390722790). Matching is always exact on the normalised digits; part of a number never matches.
Behaviour you need to understand
A global entry wins. When a caller has a global entry, they are blocked from every DID, whatever per-DID entries they also have.
- Changes apply to the next call. Every write takes effect immediately for incoming calls.
- Calls that arrive without a DID are checked against global entries only.
- Withheld callers are never blocked.
- To lift a block, delete it. There is no on/off switch.
deleteremoves one entry;unblockremoves all of a caller's entries. - Only the reason can be edited. To move a block to another DID, or switch between global and per-DID, delete the entry and create a new one.
- Entries added by hand may be stored in other formats (for example
08017082571rather than8017082571). They match exactly the same way.
Errors
Request problems
| Code | Status | Meaning |
|---|---|---|
unknown_action | 400 | Action missing or not recognised; details.validActions lists them |
method_not_allowed | 405 | A write was sent as GET |
invalid_json | 400 | The body is not a JSON object |
invalid_key | 400 | The entry or caller could not be identified |
invalid_filter | 400 | A list filter is invalid |
invalid_paging | 400 | offset or limit is invalid |
Access
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Token missing or wrong |
not_configured | 500 | The service has no token configured. Contact your administrator |
Data problems
| Code | Status | Meaning |
|---|---|---|
missing_required_fields | 400 | details.missing lists them |
unknown_fields | 400 | details.validFields lists the accepted fields |
validation_failed | 400 | details.problems lists each problem with field, code and message |
key_immutable | 400 | An update tried to change the entry's key |
field_not_updatable | 400 | An update tried to change a field other than reason |
delete_confirmation_mismatch | 400 | The body did not repeat the key |
details.problems codes: invalid_number, invalid_boolean, global_with_did, invalid_date, formula_not_allowed.
Conflicts
| Code | Status | Meaning |
|---|---|---|
not_found | 404 | No entry for the key |
duplicate_entry | 409 | The entry already exists |
ambiguous_key | 409 | Several entries share this key; details.candidates lists them. Remove the extras first |
Service
| Code | Status | Meaning |
|---|---|---|
busy | 503 | Another change was in progress for up to 10 seconds. Retry |
internal_error | 500 | Unexpected error. Nothing was changed |
sheet_not_found / missing_columns | 500 | The list is not set up as expected. Contact your administrator |
Warnings
| Code | When |
|---|---|
redundant_entry | On create: a per-DID entry was added for a caller who is already blocked globally (it has no effect while the global entry exists), or a global entry was added for a caller with per-DID entries (they are now covered). entries lists the entries involved |
still_blocked | On delete: the caller is still blocked, either from that DID by a global entry, or (after deleting a global entry) from the DIDs in their per-DID entries. entries lists what still blocks them |
Warnings that describe call handling are worked out with the same matching rules incoming calls use.
Building an integration
function blockCaller(caller, did, reason):
body = { caller_number: caller, reason: reason }
if did is empty: body.global = true
else: body.blocked_did = did
res = POST(base + "?action=create&token=" + token, body) // follow redirects as GET
if res.ok:
for w in res.warnings: log(w.code, w.message)
return res.entry
if res.error.code == "duplicate_entry": return res.error.details.existing[0]
if res.error.code == "busy": wait and retry
raise res.error.code, res.error.message
function unblockCaller(caller):
res = POST(base + "?action=unblock&caller_number=" + urlencode(caller) + "&token=" + token,
{ caller_number: caller })
if res.ok or res.error.code == "not_found": return
raise res.error.code, res.error.message
function isBlocked(caller, did):
res = GET(base + "?action=check&caller_number=" + urlencode(caller)
+ "&blocked_did=" + urlencode(did) + "&token=" + token)
return res.ok and res.blocked
Limits and known gaps
limitis at most 10000 per request.- Writes wait up to 10 seconds for another change to finish, then return
busy. - Entries added by hand are not checked for duplicates or rule breaks until the API touches them. Two hand-made entries with the same key make that key
ambiguous_keyfor update and delete. - The call-time lookup keeps its answers for up to 5 minutes. Changes made through this API clear that, but entries removed by hand may keep blocking for up to 5 minutes.
_sheetRowchanges as entries are removed. Never use it as an identifier.- Numbers are normalised for Australia (+61) and Japan (+81). Numbers from other countries match only when written the same way every time, preferably with their country code.
Updated 1 day ago
