API guide
For systems that send messages to customers through TeRa.
Sending messages to TeRa from another system
A guide for systems that send notifications to customers through TeRa, such as PrimeCRM. A message is sent to a customer’s WhatsApp number and appears in the inbox of the TeRa app that belongs to that number.
What you need
-
An API key. Ask the TeRa admin for one. The key is shown only once, when it is created; keep it as a secret on your server. One key stands for one business unit.
-
The API address.
Environment Address Key prefix Staging https://tera-api-staging.primeoffice.idtera_staging_Production https://tera-api.primeoffice.idtera_live_A staging key does not work in production, and the other way round.
Do not call this API from a browser or a mobile app: the key would be readable by others.
Sending a message
POST /v1/messages
Authorization: Bearer tera_staging_ab12cd34_…
Idempotency-Key: 7f3c1e9a-2b1d-4c53-9a55-0d6f3e1b2a77
Content-Type: application/json
{
"category": "transaction",
"recipients": ["+6281234567890", "6281234567891"],
"content": {
"id": { "title": "Poin bertambah", "bodyHtml": "<p>Anda mendapat <strong>120</strong> poin.</p>", "ctaLabel": "Lihat" },
"en": { "title": "Points added", "bodyHtml": "<p>You earned <strong>120</strong> points.</p>", "ctaLabel": "View" }
},
"imageUrl": "https://example.com/banner.jpg",
"cta": { "type": "url", "target": "https://example.com/points" },
"expiresAt": "2026-11-01T00:00:00Z"
}
| Field | Required | Notes |
|---|---|---|
category |
yes | One of transaction, info, promo |
recipients |
yes | 1 to 1,000 numbers with country code: +6281234567890 or 6281234567890. Spaces, dashes and brackets are ignored. Numbers starting with 0 (0812…) are not accepted. Each entry is at most 32 characters; a longer entry, or more than 1,000 entries, gets the whole request rejected (400) |
content.id |
yes | The content in Indonesian |
content.en |
no | The content in English. When absent, users who chose English see the Indonesian version |
content.*.title |
yes | Plain text, at most 120 characters |
content.*.bodyHtml |
yes | HTML, at most 65,536 characters as sent and at most 50 KB after cleaning. See “Message body” |
content.*.ctaLabel |
when cta is set |
The button’s text, at most 40 characters. Required in every language when cta is set, and not allowed when cta is absent |
imageUrl |
no | Banner image, must be https:// |
cta |
no | Action button. {"type": "url", "target": "https://…"} opens the browser; {"type": "screen", "target": "route-name"} opens a screen in the app |
attachments |
no | At most 5 attachments. See “Attachments” |
expiresAt |
no | When the message disappears from the inbox, in UTC with a Z suffix (2026-11-01T00:00:00Z). Must be in the future |
Any other field is rejected. Pinning is not available through the API. A whole request is at most 1 MB.
The Idempotency-Key header
Required. Use a value unique to each message, such as a UUID or a transaction number: 1 to 200 ASCII characters without spaces.
- When a request fails on the network or times out, send the same request again with the same key. TeRa answers with the message already created and does not send twice.
- The same key for a different message (recipients, content or any other field changed) is
answered with
409. Use a new key for a new message. Field order, the order of numbers, writing62…or+62…, and entries that are not phone numbers do not count as differences. - A key is scoped to one business unit.
Response
202 Accepted:
{
"id": "0b6f2c1e-6d0a-4f0e-9b1e-0f6c8a1d2e3f",
"status": "sent",
"recipients": {
"total": 2,
"registered": 1,
"unregistered": ["+6281234567891"],
"invalid": []
}
}
| Field | Meaning |
|---|---|
id |
The message id; use it to check the status |
recipients.total |
How many valid numbers were addressed, after duplicates were merged |
recipients.registered |
Those that already have a TeRa account; the message is in their inbox at once |
recipients.unregistered |
Numbers without an account yet. The message is kept and appears if the number’s owner signs up within 30 days |
recipients.invalid |
Entries that are not phone numbers. Nothing is sent to them |
The request is rejected (400) when not a single number is valid.
Attachments
A message may carry up to 5 attachments of type PDF (application/pdf), JPEG (image/jpeg)
or PNG (image/png). There are two ways, and both may be mixed in one message. The order in
attachments is the order shown in the app.
Option 1: a link to a file on your server
The file stays on the sender’s server; TeRa stores only the link.
"attachments": [
{
"url": "https://crm.example.com/files/receipt-123.pdf",
"fileName": "Receipt 123.pdf",
"contentType": "application/pdf",
"sizeBytes": 20480
}
]
| Field | Required | Notes |
|---|---|---|
url |
yes | Must be https://, without a user name before the host. The customer’s phone opens it directly, so it must be reachable without signing in for as long as the message is valid |
fileName |
yes | The name shown, at most 150 characters, without / or \ |
contentType |
yes | One of the three types above |
sizeBytes |
no | The file’s size; shown in the app when given |
TeRa does not download or inspect that file. Anyone who gets the link can open it; for personal documents (bills, receipts) use a link that is hard to guess, or option 2.
Option 2: upload the file to TeRa
The file is kept in TeRa’s private storage. The customer opens it through a link that is made when the message is opened and is valid for 10 minutes only.
First, upload the file. The request body is the file itself (not JSON, not a form):
POST /v1/files?fileName=October%20bill.pdf
Authorization: Bearer tera_staging_ab12cd34_…
Content-Type: application/pdf
<file contents>
curl -sS -X POST "$TERA_API_URL/v1/files?fileName=October%20bill.pdf" \
-H "Authorization: Bearer $TERA_API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @bill.pdf
Response 201 Created:
{
"id": "5d1f0f0e-8a52-4a3a-9c1c-2f2f6a5b7c11",
"fileName": "October bill.pdf",
"contentType": "application/pdf",
"sizeBytes": 48213,
"expiresAt": "2026-10-08T10:00:00.000Z"
}
Second, name that id when sending the message:
"attachments": [{ "fileId": "5d1f0f0e-8a52-4a3a-9c1c-2f2f6a5b7c11" }]
Upload rules:
- At most 10 MB per file.
Content-Typemust match the file’s contents; a file that is not really a PDF/JPEG/PNG is rejected (400) even when the label is right. An empty file is rejected too.fileNameis required, in the query, already encoded (%20for a space); without/,\or invisible characters.- At most 200 files may wait to be attached per business unit; beyond that the answer is
429until some are used or expire. - When the server is receiving too many uploads at once, the answer is
503with aRetry-Afterheader; try again after that pause. - An uploaded file can be attached to one message only, by the business unit that uploaded
it, within 24 hours (
expiresAt). After that, upload it again. - When any
fileIdcannot be attached (unknown, already used, expired, another unit’s), the whole message is rejected with400and no file is used up. - Sending a message again with the same
Idempotency-Keystays safe even though its files were used by the first attempt.
Uploads count towards the same limit of 60 requests per minute.
Checking the status
GET /v1/messages/0b6f2c1e-6d0a-4f0e-9b1e-0f6c8a1d2e3f
Authorization: Bearer tera_staging_ab12cd34_…
{
"id": "0b6f2c1e-6d0a-4f0e-9b1e-0f6c8a1d2e3f",
"status": "sent",
"category": "transaction",
"createdAt": "2026-10-06T10:00:00.000Z",
"sentAt": "2026-10-06T10:00:00.000Z",
"expiresAt": null,
"recipients": { "total": 2, "registered": 1, "read": 0 },
"push": { "pending": 0, "sent": 1, "failed": 0, "skipped": 1 }
}
Only messages of the business unit the key belongs to can be seen.
push counts the notifications to recipients’ phones. A recipient is counted as skipped when
there is no phone to notify: no account yet, no device that allows notifications, or, for a
promo message, no consent to promotions. The message is in the inbox either way.
Message body
bodyHtml is cleaned on the server. What is kept:
- The tags
p br strong em u s h2 h3 ul ol li blockquote a. - On
a, only anhrefthat starts withhttps://. Other links (http://,mailto:, relative links such as/promo) lose theirhref; the text still shows.
Other tags are dropped and their text kept; tags nested more than 12 levels deep are flattened;
script, style, textarea and option are dropped together with their contents; every other
attribute (style, class, onclick, …) is dropped. Images inside the body are not supported
yet; use imageUrl for a banner. A body that is empty after cleaning is rejected.
Errors
Every error has the same shape:
{ "code": "validation_failed", "messageKey": "errors.validation_failed", "requestId": "…" }
Include the requestId (also in the x-request-id header) when reporting a problem.
| Status | code |
Cause | What to do |
|---|---|---|---|
| 400 | validation_failed |
The request is wrong: a field is missing or extra, unknown category, no valid number, Idempotency-Key empty or malformed, bodyHtml too large or empty after cleaning, expiresAt in the past |
Fix the request; do not repeat it as it is |
| 400 | bad_request |
The body is not valid JSON | Fix the request |
| 401 | unauthorized |
The key is wrong, revoked, or belongs to the other environment | Check the key |
| 403 | forbidden |
The key’s business unit is disabled | Contact the TeRa admin |
| 404 | not_found |
The message does not exist, or belongs to another business unit. An id that is not a UUID is answered with 400 |
|
| 409 | conflict |
The Idempotency-Key was already used for a different request |
Use a new key |
| 413 | payload_too_large |
The request is larger than 1 MB, or the uploaded file larger than 10 MB | Make it smaller |
| 415 | unsupported_media_type |
Content-Type is not application/json (including text/plain or no Content-Type); for uploads: not PDF, JPEG or PNG |
Fix the header |
| 429 | rate_limited |
More than 60 requests per minute for this key (sending and status checks are counted together) | Wait as the Retry-After header says (seconds), then try again |
| 503 | service_unavailable |
Too many uploads in progress, or file storage is not available yet | Retry the upload after Retry-After |
| 5xx | internal_error |
A fault in TeRa | Try again with the same Idempotency-Key, with growing pauses |
Example with curl
curl -sS -X POST "$TERA_API_URL/v1/messages" \
-H "Authorization: Bearer $TERA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"category": "info",
"recipients": ["+6281234567890"],
"content": { "id": { "title": "Halo", "bodyHtml": "<p>Pesan percobaan.</p>" } }
}'