TeRa

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

  1. 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.

  2. The API address.

    Environment Address Key prefix
    Staging https://tera-api-staging.primeoffice.id tera_staging_
    Production https://tera-api.primeoffice.id tera_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, writing 62… 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.

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-Type must 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.
  • fileName is required, in the query, already encoded (%20 for a space); without /, \ or invisible characters.
  • At most 200 files may wait to be attached per business unit; beyond that the answer is 429 until some are used or expire.
  • When the server is receiving too many uploads at once, the answer is 503 with a Retry-After header; 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 fileId cannot be attached (unknown, already used, expired, another unit’s), the whole message is rejected with 400 and no file is used up.
  • Sending a message again with the same Idempotency-Key stays 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 an href that starts with https://. Other links (http://, mailto:, relative links such as /promo) lose their href; 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>" } }
  }'