Updated 23 October 2026 · verified against backend code
Contents
/v1)Getstarvio is a WhatsApp Business messaging platform. This reference is for teams who want Getstarvio to be the WhatsApp channel of a system they already run — an ERP, a POS, a booking tool, or a conversational assistant.
Five integration shapes are supported, and the rest of this page is organised around them:
⚠️ Read The 24-hour window before you design anything. It is a WhatsApp rule rather than a Getstarvio rule, and it is the single thing that most often breaks a first integration: outside that window you cannot send free-form text at all, only a template Meta has already approved.
A note on what this platform does not do: a business phone number is connected by its owner through Meta Embedded Signup. There is no API that connects a number on someone else's behalf.
Server-to-server calls use the API host, not the website domain. The /api path on the frontend domain is an internal proxy and is not an integration address.
https://api.getstarvio.comhttps://dev-api.getstarvio.comPaths on this page are written relative to that base — POST /v1/customers/import means POST https://api.getstarvio.com/v1/customers/import.
⚠️ A 404 with no code on a /v1 path means the path does not exist. A 404 that carries a code (cabang_tidak_ada, percakapan_tidak_ada) comes from a real route that could not find what you named. On an environment where the API is switched off, every /v1 route answers 404 as well, even with a valid key.
Keys are sent as Authorization: Bearer gsv_live_<prefix>.<secret>. The full key is shown when it is issued. After that, whoever may manage API keys — the organization owner, a Developer, or a member given the «Kelola kunci API» permission, each only for keys of the branches they manage — can open it again under Akun → Kunci API with «Lihat» and their own PIN, as often as needed; every opening is logged (who and when). The value is stored encrypted. A key whose value was never stored (most keys issued before 8 October 2026) shows «nilai tidak disimpan» instead of the button: if you lost it, issue a new key and revoke the old one. A key the Getstarvio team issues on your organization's behalf appears in the same key list, can be opened the same way, and is revoked the same way.
A key belongs to an organization, not to a person — your integration does not die when the employee who created it leaves. On top of that there are two modes you can choose when issuing a key, and the difference decides which data the key can touch:
workspaceId; a call that names none goes to its default branch and is never answered cabang_ambigu. Reading conversations and GET /v1/templates?semuaCabang=true cover every branch. GET /v1/keys/me shows it as lingkup: "organisasi" with cabangBawaan: true, and «Lihat» follows the rule for an all-branch key. Two things still follow the default branch: while that branch is locked, every call with the key is answered 403 cabang_disuspend, and on POST /rel/cis/jawaban it can only answer conversations of that branch.The organization owner can issue and revoke every key of the organization. A member with the Developer role can issue keys too — with their own PIN — and can revoke only the keys they issued. What is checked is the Kelola kunci API (Manage API keys) permission in that branch: Developer is the built-in role that holds it, a custom role given that permission can do the same, and so can a member who was given that permission personally while keeping their own role. Every rule below for a Developer applies to each of them. A Developer on only some branches can issue a key for one of those branches; a key for all branches needs the owner, or a Developer on every branch. Every key issued by a Developer is reported to the organization owner (who issued it, its name, branch and permissions; a notification that fails to arrive does not stop the key from being issued), and each key in the list shows who issued it. Other staff accounts do not see the button at all. Inbound-message webhooks stay owner-only. You will find it under Akun → Kunci API (Account → API keys).
Which accounts see that menu. The API menu (keys, webhooks, API templates) is for organizations whose head-office branch (Pusat) is on the Enterprise plan; it then covers all of that organization's branches. To get access, contact the Getstarvio team. When an organization's API access ends, the menu is hidden. It does not undo what is already set up: issued keys keep working, including for API templates that are already approved, an inbound-message address that is already installed keeps receiving messages, and POST /rel/cis/jawaban keeps accepting replies. Notification addresses are the exception: notifications stop being sent while API access is off. What stays possible is closing them: while the organization still holds an active key or an installed address, the menu stays visible in a close-only mode — the same people as before can revoke keys, and the owner can remove the inbound-message address and notification addresses. Issuing keys, installing or changing an address, «Lihat» and «Uji sambungan» stay closed until API access is given again. Once nothing is left to close, the menu disappears.
This is the distinction that costs the most when it is missed, so it is stated here rather than discovered when a first integration fails. This page documents two families of endpoints, and an API key only opens one of them:
Authorization: Bearer gsv_live_…. Almost all of it lives under /v1 — with one exception named below: POST /rel/cis/jawaban.401 here — not because a permission is missing, but because those routes do not check for keys at all. Their request and response shapes are documented so you can design ahead, not so you can call them today.Every key also carries a list of permissions. An empty list means zero permissions, not "everything allowed" — and there is no special value meaning "all". A missing permission is answered with 403 and code: "lingkup_kurang". On /v1 routes the error body lists the missing permissions in butuh, so you never have to guess. On POST /rel/cis/jawaban there is no butuh list: the only permission checked there is inbox.balas.
The cheapest first call is the one that changes nothing. GET /v1/keys/me confirms that your key is installed, tells you its mode and permissions, and has no side effects — run it before you schedule anything else.
curl -s https://api.getstarvio.com/v1/keys/me \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>"Then import a customer. With an organization-wide key in an organization that has more than one branch with its own Getstarvio account, add "workspaceId" to the body; the 400 cabang_ambigu answer lists those branches in cabang. A branch-limited key, or a key with a default branch, needs nothing extra. This call is safe to repeat: matching is done on branch + phone number (0812… and 62812… count as the same number), so a second upload updates the same row instead of duplicating it.
curl -s https://api.getstarvio.com/v1/customers/import \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>" \
-H "Content-Type: application/json" \
-d '{"pelanggan":[{"nama":"Mia","telepon":"081234567890"}]}'If either call answers 401, the key was not accepted — the code field says why (see Error codes). A 403 means the key is valid but the account behind it is on hold — replacing the key does not help: code: "org_disuspend" means the organization is suspended (contact us); code: "cabang_disuspend" means the organization's owner has locked the branch tied to the key in the dashboard (Kunci pengelolaan on the branches page) — ask them to unlock it. A 404 without a code means the path is wrong, or the API is switched off on that environment. A 404 with code: "cabang_tidak_ada" means the branch could not be targeted (see Branch selection).
/v1)availableAPI keyThis is the endpoint family you can call with an API key. Other endpoints on this page run through a browser login session and do not accept keys — do not design a server integration on top of them. The only key-accepting route outside /v1 is POST /rel/cis/jawaban (permission inbox.balas), used by conversational-assistant integrations and described under Inbound messages.
{
"workspaceId": "optional — organization-wide keys only",
"pelanggan": [
{ "nama": "Mia", "telepon": "081234567890" }
]
}Creates or updates customers. Each row carries only nama (trimmed, at most 200 characters; a longer name is cut) and telepon; other fields are ignored. No service or visit is recorded, so an import schedules no reminders by itself. Requires the pelanggan.kelola and pelanggan.massal permissions.
Repeating the same request is safe. Matching uses the branch + phone number pair (0812… and 62812… count as the same number), so a second upload updates the same row instead of duplicating it — including when you repeat it after a timeout.
The response is 200 even when some rows were rejected, so always read it: { "cabang": { "id", "nama" }, "diterima", "dibuat", "diperbarui", "ditolak", "rincianDitolak": [ { "indeks", "sebab" } ], "catatan" }. dibuat counts created rows, diperbarui rows that already existed (only the name is updated), and ditolak rows that were skipped. rincianDitolak lists at most the first 100 skipped rows, each with its zero-based position in your list and a reason: nama_kosong, telepon_kosong, telepon_tidak_valid or gagal_simpan.
At most 5,000 rows per request; anything above that is answered with 413 and code: "terlalu_banyak" (the body repeats the limit in batas). A body without a pelanggan list is answered with 400 bentuk_salah.
⛔ An upload never reverses an opt-out: an existing customer of that branch who has opted out stays opted out when they appear in your upload again. This endpoint never sends anything. A row the import creates (a number that is new to that branch) is linked to the same person across your organization's branches, so an opt-out they gave at any branch of your organization applies to it too.
⚠️ Branch selection. An organization-wide key must name workspaceId when the organization has more than one branch with its own Getstarvio account; otherwise the call is answered with 400 cabang_ambigu, and the error body lists those branches in cabang (id, nama). A branch without its own account cannot be targeted (404 cabang_tidak_ada). A branch-limited key ignores workspaceId in the body — its branch is already attached to the key. A key with a default branch (cabangBawaan: true on /v1/keys/me) may name any branch of its organization in workspaceId; without it, the call goes to the default branch — never cabang_ambigu.
Returns the identity of the key in use, wrapped in kunci: its scope lingkup (organisasi for an organization-wide key, cabang for a branch-limited one), the workspaceId of the attached branch (null for an organization-wide key), cabangBawaan (true for an organization-wide key with a default branch — then lingkup is organisasi and workspaceId is that default branch), the key nama and prefix, and its permission list izin. ⚠️ It does not return an organization id — if your system needs to map a key to an organization, store that mapping on your side when the key is issued. The response also separates recognised permissions (izin) from unrecognised ones (izinTidakDikenal), so a typo made while issuing a key looks like a typo instead of a permission that silently does nothing; a key with no permissions at all carries a catatan saying so. Use this to confirm a key is installed correctly before scheduling other calls — it changes nothing.
200 → { "kunci": { "nama": "Integration key", "prefix": "4a1ecbd12ecc",
"lingkup": "organisasi", "workspaceId": null, "cabangBawaan": false,
"izin": ["pelanggan.kelola", "pelanggan.massal"],
"izinTidakDikenal": [], "catatan": null } }Templates are created in the Getstarvio dashboard, not through the API. There is no endpoint for creating or editing a template with an API key — tell us if your integration needs one. What follows are the rules we enforce before a single byte reaches Meta, so that a Meta rejection shows up while you are typing instead of hours later.
{{1}}, {{2}}, {{3}}…No gaps. {{1}} followed by {{3}} is rejected.Hi {{1}}, … — not {{1}}, …{{3}}.) counts as ending on it. Add words or an emoji: …on {{3}} ✨{{1}} from {{2}}, not {{1}} {{2}}.Your bill: {{2}}. ⚠️ This includes variables wrapped in formatting marks — *{{2}}*, _{{2}}_, ~{{2}}~ still count as standing alone. List lines such as - {{2}} or • {{2}} are exempt.xxx — Meta uses it during review, and a template without examples is rejected.⏱️ When each rule bites. When you save a draft, only shape checks run: the 1,024-character limit, the quick-reply button limits (at most 3, 25 characters each), and for API templates a label, a valid category, the language and the URL-button rules (a half-typed URL button is refused even in a draft). The rest — variable order, examples, the start/end/adjacency rules — are checked only when you submit to Meta. That is deliberate: a draft has to be saveable half-finished. So a draft that saved cleanly has not necessarily passed submission, and an error that appears for the first time on the submit button does not mean something changed. AUTHENTICATION API templates have no body to write: Meta supplies the text, and its only variable is the code.
UTILITY, MARKETING or AUTHENTICATION for API templates; MARKETING or UTILITY for broadcast templates; reminder and re-engagement templates are always MARKETING. An AUTHENTICATION template is submitted in Meta's standard copy-code form: Meta writes the text, with a footer saying the code expires in 5 minutes and a copy-code button. Meta decides the final category and may move it.id or en for API templates; id for reminder and broadcast templates.UTILITY and MARKETING API templates only: at most 2, each with a label of at most 25 characters and an https:// address of at most 2,000 characters with a complete site name, including a dot (https://example.com/…, not https://track/…). The address is either fixed, or ends in one variable written with double braces, {{1}} — for example https://example.com/track?id={{1}} — whose suffix your system supplies on every send in buttonParams (see Sending messages via API). An address ending in {{1}} needs an example suffix, without spaces, for Meta's review. On the message, quick reply buttons come first, then URL buttons. All of this is checked before anything reaches Meta.{business-slug}_api_{N} for API templates (_reminder_ / _broadcast_ for the other types); the API template screen shows the exact name to copy. ⛔ A template name at Meta is permanent per WhatsApp Business Account: once used, it cannot be reused for a different shape. The label you give a template in Getstarvio is separate: it is never sent to Meta and can be renamed at any time, including after submission (it is label in GET /v1/templates).A rejected template cannot be edited. Press Fix (Perbaiki) on it in the dashboard: it copies the template into a new draft under a new generated name, which you correct and submit again. If you had replaced the template's image with «Ganti gambar», the draft starts with that latest image; choosing a gallery image, or no image, in the draft replaces it. The rejected one stays in the list unchanged. An APPROVED template cannot be changed either (only its Getstarvio label can be renamed), so a new version always means a new name — and a new templateName in your calls.
Templates you create directly in WhatsApp Manager are pulled into Getstarvio by synchronisation and treated as API templates: they can be sent with POST /v1/messages/template and are never used by reminders. ⚠️ We only recognise an image header, quick reply buttons and URL buttons — other parts (footer, text headers) do not appear on Getstarvio screens; the header type is read so the API can tell you whether a template can be sent. When sending, we fill body variables, the code button of authentication templates, and — from buttonParams — the suffix of each URL button whose address ends in a variable; a header with its own variable, a video, a document or a location header cannot be filled — such a template is listed with bisaDikirim: false and a send is refused with 400 kepala_tidak_didukung before anything is charged. A template synchronised before URL buttons were read carries no URL buttons on our side until it is synchronised again, and until then a send with buttonParams is answered with 400 tombol_params_mismatch. For an image header we send a Getstarvio-generated image card with your business name, not the image uploaded in WhatsApp Manager — unless the send supplies its own image in headerImageUrl.
Stated plainly so you do not design around them: text / video / document headers · footers and copy code buttons (except the fixed ones Meta puts on AUTHENTICATION templates) · URL buttons on the reminder, broadcast and re-engagement templates you create, a URL variable anywhere but the end of the address, and more than one variable per URL · phone number and Flow buttons · a different header image per send from the app (there the image belongs to the template — an API send can supply its own with headerImageUrl, see Sending messages via API) · named variables ({{name}} — positions are used instead) · languages other than id (and en for API templates). If your integration needs one of these, tell us — this list is the state today, not a permanent decision.
Continues a conversation, including one whose window has expired. It needs an existing conversation id: an unknown id, or one belonging to another business number, is answered with 404. To message a number that has never written to you, use Sending messages via API. The template must already be APPROVED at Meta.
{
"templateName": "pengingat_v8_umum_1",
"languageCode": "id",
"params": ["Cynthia", "haircut", "Salon Melati", "August 27", "30"]
}⚠️ The number of params must match the number of variables in the template body exactly. pengingat_v8_umum_1 has five ({{1}} name · {{2}} service · {{3}} business name · {{4}} date · {{5}} number of days). Too few or too many is answered with 400 and code: "params_mismatch", and the body names both numbers (butuh = required, diterima = received). ⚠️ This is a permanent error: do not retry it, fix the count. We check it ourselves before contacting Meta, so a malformed request burns no quota and deducts no credit.
id.Success is 200. It is rejected with 400 when the template is not found or not yet APPROVED, with 400 and code: "template_otp" when it is an authentication (OTP) template — those go out only through Sending messages via API, which adds the code button Meta requires — and with 400 and code: "opted_out" when the contact has unsubscribed.
Cost. Each send costs 1 credit (nothing while the account is on a complimentary period); not enough credit ⇒ 402 credit_empty, and nothing is sent. Other answers: 403 inbox_kirim_template_ditolak (the logged-in staff member's role may not send templates in that branch), 400 when the business WhatsApp number is not connected, 404 for an unknown conversation, 503 optout_check_unavailable, and 502 when Meta rejects the send (no credit is taken). If Meta rejects the send because the business's WhatsApp connection was cut off, the answer is 409 wa_perlu_sambung_ulang instead: nothing was sent or charged — reconnect WhatsApp in the dashboard settings, then send again.
Send one approved template to one customer from your own system — including a number that has never messaged you. This is the key-authenticated counterpart of Sending a template; the other send endpoints on this page (replies, media, broadcast) still run through a browser login session and answer 401 to a key.
curl -s https://api.getstarvio.com/v1/messages/template \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>" \
-H "Idempotency-Key: 5f0c8e8e-2b0c-4a5e-9d7e-0f6a1b2c3d4e" \
-H "Content-Type: application/json" \
-d '{"to":"081234567890","templateName":"pengingat_v8_umum_1",
"params":["Cynthia","haircut","Salon Melati","August 27","30"]}'400 idempotency_key_wajib and nothing is sent. See Retries below.0812…) or international without + (62812…). A number that is not a valid WhatsApp number ⇒ 400 telepon_tidak_valid.APPROVED at Meta. Unknown or not yet approved ⇒ 400 template_tidak_ada.400 params_mismatch — checked before Meta is contacted, so it costs nothing.{{1}}. One text value per such button, in button order: the suffix appended to that button's address, e.g. "buttonParams": ["INV-2026-0001"] for https://example.com/track?id={{1}}. The count must match exactly — and a template without such buttons takes none — otherwise 400 tombol_params_mismatch. ⚠️ Percent-encode each value yourself (a space as %20, : as %3A, | as %7C, non-ASCII characters as their UTF-8 bytes): we pass it to Meta unchanged and never encode it again. Fixed URL buttons and quick reply buttons need nothing here.https:// link to a JPEG or PNG image (Meta's limits: at most 5 MB, 8-bit RGB or RGBA), at most 2,000 characters, used as the header of this send only — e.g. "headerImageUrl": "https://cdn.example.com/products/brake-pad.jpg". Images uploaded to the branch's gallery already have such a link — read them with GET /v1/images. Leave it out to send the template's own image. The template itself does not change. We pass the link to Meta unchanged and never download it — Meta fetches the image, so the link must open without a login. See Your own header image below.400 cabang_ambigu). The message is sent from that branch's WhatsApp number. A branch-limited key always sends from its own branch and ignores this field. A key with a default branch may name any branch of its organization here; without it, the message is sent from the default branch.200 → { "ok": true, "id": "…", "waMessageId": "wamid.…",
"conversationId": "…", "workspaceId": "…", "creditsCharged": 1 }In the rare case the message left but we could not record it on our side, the answer is still 200 with "id": null and waMessageId set — the message was sent, so do not send it again.
Cost. Each message sent costs 1 credit — the same pay-as-you-go credit a template sent from the inbox costs, and nothing while the account is on a complimentary period (creditsCharged: 0). Not enough credit ⇒ 402 credit_empty, and nothing is sent; free welcome credits that have been locked do not count. If Meta later reports the message as failed, the credit is refunded automatically.
Authentication (OTP) templates. Put the code in params as the template's only value. The code button that Meta requires on every authentication template is added for you, carrying the same code — there is no extra field to send, and buttonParams is not used here (sending it ⇒ 400 tombol_params_mismatch, even though Meta lists that code button as a URL button). Meta accepts at most 15 characters for the code; a longer one is rejected by Meta (502 meta_menolak, nothing charged).
URL buttons with a variable. A template whose URL button ends in {{1}} — an order-tracking link such as https://example.com/track?id={{1}} — needs that button's suffix on every send, in buttonParams. With "buttonParams": ["INV-2026-0001"] the customer's button opens https://example.com/track?id=INV-2026-0001. The values are checked before opt-out, credit and Meta, so a wrong count (400 tombol_params_mismatch, the body names butuh = required and diterima = received) or a value that is empty, longer than 2,000 characters, or contains a space, a line break or another control character (400 tombol_param_tidak_valid, the body names the position in indeks) costs nothing. Both are permanent: fix the request instead of retrying it. A value that is not percent-encoded but has no space — a|b, for example — is not refused by us; it reaches Meta as sent, so encode before you send.
Your own header image. A template with an image header is approved once, with one example image, and normally sends that image every time. With headerImageUrl your system sends a different image per message — a product photo, a receipt — without a new template or a new Meta review. The link is checked before opt-out, credit and Meta, and these cost nothing: a link that is empty, longer than 2,000 characters, contains a space or a line break, is not a valid URL, does not start with https://, or carries a user name or password (400 gambar_tidak_valid); a link sent with a template that has no image header (400 template_tanpa_gambar). Both are permanent: fix the request instead of retrying it. Whether the image itself can be fetched — a dead link, a login page, a file over 5 MB, another format — we cannot see, because we do not download it: Meta refuses it, either straight away (502 meta_menolak, nothing charged) or by reporting the message as failed afterwards (the credit is refunded automatically).
Where templates come from. Any APPROVED template of the branch can be sent. Templates made only for your system can be created in the app under Templates → + Add template → API (Enterprise-plan accounts that see the API menu — see Which accounts see that menu above): Utility, Marketing or Authentication, in Indonesian (id) or English (en), with the Meta name generated for you. Utility and Marketing templates can also carry an image header, quick reply buttons and up to 2 URL buttons (see Creating templates). That screen shows the exact templateName, the language, each variable's position in params, each variable URL button's position in buttonParams, and a ready-to-copy example of this call. Your system can read the same details for every template of the branch with GET /v1/templates. API templates are never used by reminders, broadcasts or re-engagement.
Permission. Requires inbox.kirim-template. Because every call spends credit, that permission is never granted by default — tick it deliberately when issuing the key. A key without it is answered with 403 lingkup_kurang.
A number that has opted out ⇒ 400 opted_out. If we cannot check right now whether the number has opted out, the call is answered with 503 optout_check_unavailable and nothing is sent or charged — retry with the same Idempotency-Key. When the customer replies, the reply lands in the same conversation in the Getstarvio inbox, and the normal 24-hour window rules apply from there.
If the branch's WhatsApp number is not connected ⇒ 400 whatsapp_belum_terhubung. A malformed body (missing to or templateName, params that is not a list, buttonParams that is not a list of text values or has more than 2 of them, headerImageUrl that is not text) ⇒ 400 bentuk_salah.
A network timeout between you and us does not tell you whether the message went out. The Idempotency-Key is what makes retrying safe:
200) or a 502. Repeating the call with the same key and the same body returns that stored result — same status, same body, plus the header Idempotent-Replayed: true — without contacting Meta again and without charging again. Keys are honoured for 24 hours and belong to your API key: another organization using the same value never collides with you.402, opted_out, params_mismatch, tombol_params_mismatch, tombol_param_tidak_valid, gambar_tidak_valid, template_tanpa_gambar, kepala_tidak_didukung, a missing permission — stores nothing. Fix the cause and retry with the same key.409 idempotency_key_dipakai_ulang. Use a new key for a new message.409 idempotency_sedang_diproses. Retry with the same key a few seconds later.409 hasil_kirim_tidak_pasti: the outcome is unknown. Treat it exactly like the 502 below, and check the conversation before sending with a new key.500 galat_internal means we failed before contacting Meta: nothing was sent, and retrying with the same key is safe.⛔ 502 hasil_kirim_tidak_pasti means the outcome is unknown. Meta did not answer in time, so the message may already have been delivered. Retrying with the same key is safe — you get this same answer back. Retrying with a new key can deliver the message twice. Check the conversation before deciding to send again — in the Getstarvio inbox, or through Reading conversations via API if your key also carries inbox.baca.
502 meta_menolak is different: Meta answered with an error, so nothing was sent and nothing was charged. The body carries Meta's reason. If the cause is permanent (for example a paused template), retrying will not help — fix it and send with a new key.
Read the templates of a branch — the exact name to send as templateName, its language and text, how many params and buttonParams a send needs, and the header image that goes out with it — so your system can keep its own list without anyone copying names by hand. Read-only: nothing is written and Meta is not contacted.
Permission. Requires inbox.kirim-template — the same permission that sends; a key that may send may see what it can send. A key without it is answered with 403 lingkup_kurang.
curl -s https://api.getstarvio.com/v1/templates \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>"Query: workspaceId — the same rule as when sending: needed only for an organization-wide key in an organization with more than one branch (otherwise 400 cabang_ambigu, which lists the branches). A branch-limited key always lists its own branch and ignores it; a key with a default branch lists the branch named here, or its default branch without it. The list holds every template of that branch — drafts saved in Getstarvio, templates waiting for Meta, approved, rejected and paused ones — ordered by nama, at most 500; terpotong: true means there are more (tell us if you reach it).
cabang.telepon is the branch's own WhatsApp Business number, digits only (62…) — the number its messages are sent from. null when the branch has not connected its own number yet.
200 → {
"cabang": { "id": "ws_…", "nama": "Cakra Motor 11 CS Pusat", "telepon": "6281234567890" },
"data": [
{
"nama": "cakra_motor_11_status_pesanan_1",
"label": "Status pesanan",
"bahasa": "id",
"kategori": "UTILITY",
"status": "APPROVED",
"bisaDikirim": true,
"alasanTidakBisaDikirim": null,
"kegunaan": "api",
"teks": "Halo {{1}}, pesanan {{2}} sudah siap diambil.",
"jumlahParams": 2,
"kepala": { "jenis": "gambar", "urlGambar": "https://…/kartu.png" },
"jenisKepala": "gambar",
"tombolTautan": [
{ "teks": "Lacak", "alamat": "https://example.com/lacak/?id={{1}}", "dinamis": true }
],
"jumlahButtonParams": 1,
"tombolBalasCepat": null,
"diubahPada": "2026-10-08T05:12:09.000Z"
}
],
"terpotong": false
}templateName. Permanent once submitted.null. It is never sent to Meta and can be renamed in the app at any time, whatever the status — identify templates by nama, not by label.id or en.UTILITY · MARKETING · AUTHENTICATION — the category Meta currently applies.DRAFT (saved in Getstarvio, not submitted to Meta yet) · PENDING · APPROVED · REJECTED · PAUSED · other Meta statuses as Meta reports them.true when the status is APPROVED and the template has no header we cannot fill — the same conditions POST /v1/messages/template checks. A template whose header is a text with a variable, a video, a document or a location shows false even when approved (see jenisKepala).bisaDikirim is false, as a sentence — the status is not APPROVED, or the header cannot be filled. null when bisaDikirim is true.api · reminder · broadcast · reengage, or null. Any approved template of the branch can be sent through the API.{{1}}, {{2}}… where params go. null = not known yet: a template made in WhatsApp Manager can arrive by synchronisation before its text does.params a send needs. ⚠️ null means not known yet, not zero — wait until it shows a number before sending that template.headerImageUrl — { "jenis": "gambar", "urlGambar": "…" }, a direct link you can open — or null when no header image is sent. This is the template's own image: one send can replace it for that message only with headerImageUrl, and the template stays as it is. To change the template's image itself, open the template under Templates and press «Ganti gambar» (PNG or JPG, max 5MB): the next send uses the new image and urlGambar changes — no new Meta approval, same templateName.tanpa (no header) · teks (fixed text) · gambar (image) — all three can be sent — or teks_bervariabel (text with a variable) · video · dokumen · lokasi, which we cannot fill yet. null = not known yet (not synchronised since header types began to be read) — not «no header»; such a template is sent as before.teks, alamat, and dinamis — true when the address ends in {{1}} and each send must supply its suffix in buttonParams. [] = none. null = not known yet (not synchronised since URL buttons began to be read). The code button of an AUTHENTICATION template is not listed — it is filled for you.buttonParams a send needs — the number of buttons with dinamis: true; always 0 for AUTHENTICATION. null = not known yet.null. They need nothing when sending.Other answers: 404 cabang_tidak_ada (the workspaceId is not a branch of the key's organization), 400 bentuk_salah (workspaceId given more than once), and the usual 401 for a missing or rejected key.
All branches in one call. Add ?semuaCabang=true and the answer is a list with one entry per branch, ordered by branch name — each entry is exactly the answer above for that branch (cabang, data, terpotong; at most 500 templates per branch). Without it the answer stays the single-branch object above, unchanged.
curl -s "https://api.getstarvio.com/v1/templates?semuaCabang=true" \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>"200 → [
{
"cabang": { "id": "ws_…", "nama": "Cakra Motor 11 BSD", "telepon": "6281298760116" },
"data": [
{
"nama": "cakra_motor_11_bsd_status_pesanan_1",
"label": "Status pesanan",
"bahasa": "id",
"kategori": "UTILITY",
"status": "APPROVED",
"bisaDikirim": true,
"alasanTidakBisaDikirim": null,
"kegunaan": "api",
"teks": "Halo {{1}}, pesanan {{2}} sudah siap diambil.",
"jumlahParams": 2,
"kepala": { "jenis": "gambar", "urlGambar": "https://…/kartu.png" },
"jenisKepala": "gambar",
"tombolTautan": [
{ "teks": "Lacak", "alamat": "https://example.com/lacak/?id={{1}}", "dinamis": true }
],
"jumlahButtonParams": 1,
"tombolBalasCepat": null,
"diubahPada": "2026-10-08T05:12:09.000Z"
}
],
"terpotong": false
},
{
"cabang": { "id": "ws_…", "nama": "Cakra Motor 11 Taman Surya", "telepon": null },
"data": [],
"terpotong": false
}
]⚠️ Which branches are listed depends on the key. An organization-wide key («all branches» when the key is created) lists every branch of the organization that has its own Getstarvio account. A branch-limited key lists only its own branch — a list of one — because a branch key can never read another branch. A key with a default branch (cabangBawaan: true) lists every branch of its organization, like an organization-wide key. To read every branch, issue a key for all branches with the «Kirim template» permission under Akun → Kunci API (the organization owner, or a Developer on every branch — see Authentication & API keys); your existing branch key keeps sending as before. semuaCabang=true cannot be combined with workspaceId, and any value other than true or false (1 and 0 are read the same way) is answered with 400 bentuk_salah.
Read the images in a branch's gallery — the same images staff see under Templates → Galeri in the app, each with its public link — so your system can use one as headerImageUrl when sending a template without anyone copying links by hand. Read-only: nothing is written, and images can only be uploaded or deleted in the app, not through the API.
Permission. Requires inbox.kirim-template — the same permission that sends, since the gallery exists for send headers. A key without it is answered with 403 lingkup_kurang.
curl -s https://api.getstarvio.com/v1/images \
-H "Authorization: Bearer gsv_live_<prefix>.<secret>"Query: workspaceId — the same rule as when sending: needed only for an organization-wide key in an organization with more than one branch (otherwise 400 cabang_ambigu, which lists the branches). A branch-limited key always lists its own branch and ignores it; a key with a default branch lists the branch named here, or its default branch without it. The list holds the branch's images, newest first, at most 500; terpotong: true means there are more. Images deleted from the gallery are not listed.
200 → {
"cabang": { "id": "ws_…", "nama": "Cakra Motor 11 CS Pusat" },
"data": [
{
"id": "6f1c…",
"url": "https://…/images/…/9b2e….jpg",
"lebar": 1080,
"tinggi": 1350,
"ukuranByte": 204800,
"jenis": "image/jpeg",
"diunggahPada": "2026-10-10T05:12:09.000Z"
}
],
"terpotong": false
}headerImageUrl in POST /v1/messages/template, with a template that has an image header.null when not recorded.null when not recorded.image/jpeg or image/png, or null when not recorded.⚠️ Anyone who holds a link can open the image — Meta has to be able to fetch it — so share links only with systems you trust. Deleting an image from the gallery does not disable an already-copied link right away: the file is kept for about 30 days and then removed, after which that link stops working — unless one of your templates in the app still uses the image.
Other answers: 404 cabang_tidak_ada (the workspaceId is not a branch of the key's organization), 400 bentuk_salah (workspaceId given more than once), and the usual 401 for a missing or rejected key.
Read-only access to the WhatsApp conversations of every branch in the key's organization (or only its branch, for a branch-limited key; a key with a default branch reads every branch) — inbound and outbound, oldest first, so a system can pull history once and then keep up incrementally. Nothing is written: reading through the API does not mark anything as read for staff. Only branches with their own connected WhatsApp number are included; branches that share one number are reported under one of them.
Permission. Requires inbox.baca. The key can read the full text of customer conversations, so that permission is never granted by default — tick it deliberately, and store the key as carefully as a password. A key without it is answered with 403 lingkup_kurang.
Query: since (ISO 8601 — conversations whose pesanTerakhirPada is at or after it; see Pulling everything below for what that time means), cursor (the berikutnya value of the previous page, as-is), limit (1–100, default 50; a value that is not a number or is below 1 falls back to the default, and anything above 100 is treated as 100), urut (optional: pesanTerakhirPada, the default, or diperbaruiPada — see Pulling everything). By default the list is ordered by latest-message time, oldest first. A conversation that received new messages appears again in a later pull — that is how you find what changed. diperbaruiPada is the time we last recorded any change to the conversation; pesanTerakhirPada is the time of its latest message. cabang.nomor is the business number as WhatsApp displays it (it may contain +, spaces and dashes), or null.
200 → {
"data": [
{
"id": "c7a…",
"cabang": {
"id": "ws_…",
"nama": "Cakra Motor BSD",
"nomor": "+62 811-2345-6789"
},
"kontak": { "telepon": "6281234567890", "nama": "Mia" },
"pesanTerakhirPada": "2026-09-27T03:12:09.000Z",
"pesanMasukTerakhirPada": "2026-09-27T03:10:44.000Z",
"status": "open",
"diperbaruiPada": "2026-09-27T03:12:10.000Z"
}
],
"berikutnya": "eyJ0Ijoi…"
}Query: since (ISO 8601, compared with the time we recorded the message, so a message that arrived late is still picked up), cursor, limit (1–100, default 100; a value that is not a number or is below 1 falls back to the default, and anything above 100 is treated as 100). Each message carries arah (masuk inbound / keluar outbound), pengirim (pelanggan · staf sent by a person from the inbox, including templates they send · sistem not sent by a person from the Getstarvio inbox: for example through POST /v1/messages/template, by an assistant, by reminders and broadcasts, or typed in the WhatsApp Business app on a number connected through Coexist), asal (outbound messages only — what kind of sender it was: aplikasi typed in the WhatsApp Business app or a linked device on a number connected through Coexist · inbox typed by a person in the Getstarvio inbox · bot the assistant · otomatis sent by the system: POST /v1/messages/template, reminders, broadcasts and follow-up campaigns; null for inbound messages and for older outbound messages whose origin was not recorded — it never names the person or the assistant), jenis, teks, status, waktu and dicatatPada. Media comes as metadata only (jenis, mime, nama) — files are not downloadable through this API. Outbound messages that failed to send are left out, exactly as in the inbox.
Each message also carries five fields that are null unless they apply (added 27 September 2026 — every earlier field is unchanged): lokasi — a shared WhatsApp location pin (lat, lng, nama, alamat); reaksi — an emoji reaction (emoji, which is null when the customer removed the reaction, and untukPesanId, the id of the message reacted to, or null when that message is not in this conversation); balasanUntukPesanId — the id of the message this one quotes as a reply, or null when that message is not in this conversation; kontak — a shared contact card, a list of nama + nomor (telepon, waId); and jenisTakDidukung — for jenis: "unsupported", the kind of message WhatsApp did not forward (for example location is a live location, whose content WhatsApp never delivers).
200 → {
"percakapan": {
"id": "c7a…",
"cabang": {
"id": "ws_…",
"nama": "Cakra Motor BSD",
"nomor": "+62 811-2345-6789"
},
"kontak": { "telepon": "6281234567890", "nama": "Mia" }
},
"data": [
{
"id": "m1…",
"arah": "masuk",
"pengirim": "pelanggan",
"asal": null,
"jenis": "location",
"teks": null,
"status": "received",
"waktu": "2026-09-27T03:10:44.000Z",
"dicatatPada": "2026-09-27T03:10:45.000Z",
"media": null,
"lokasi": {
"lat": -6.3016,
"lng": 106.6527,
"nama": "AEON Mall BSD City",
"alamat": "Jl. BSD Raya Utama"
},
"reaksi": null,
"balasanUntukPesanId": null,
"kontak": null,
"jenisTakDidukung": null
},
{
"id": "m2…",
"arah": "masuk",
"pengirim": "pelanggan",
"asal": null,
"jenis": "reaction",
"teks": null,
"status": "received",
"waktu": "2026-09-27T03:11:02.000Z",
"dicatatPada": "2026-09-27T03:11:03.000Z",
"media": null,
"lokasi": null,
"reaksi": { "emoji": "❤️", "untukPesanId": "m1…" },
"balasanUntukPesanId": null,
"kontak": null,
"jenisTakDidukung": null
},
{
"id": "m3…",
"arah": "keluar",
"pengirim": "sistem",
"asal": "aplikasi",
"jenis": "text",
"teks": "Siap kak, ditunggu di bengkel jam 10 ya",
"status": "read",
"waktu": "2026-09-27T03:12:30.000Z",
"dicatatPada": "2026-09-27T03:12:31.000Z",
"media": null,
"lokasi": null,
"reaksi": null,
"balasanUntukPesanId": null,
"kontak": null,
"jenisTakDidukung": null
}
],
"berikutnya": null
}An id outside the key's organization or branch is answered with 404 percakapan_tidak_ada — the same answer as an id that does not exist.
Pulling everything. Call with since, then keep passing berikutnya back as cursor until it is null. ⚠️ For an inbound message, pesanTerakhirPada is the time WhatsApp stamped on it, not the time we recorded it, so a message that reaches us late can carry a time before your previous pull — and with the default order its conversation then does not come back in a list pulled with that later since. To catch those messages, pull with urut=diperbaruiPada: since, the order and the cursor then follow the time we recorded a change (diperbaruiPada), so a late message still brings its conversation back. In that mode, take the largest diperbaruiPada you received, subtract one minute, and pass that as the next since; conversations near that edge come back twice by design, so de-duplicate by id. A conversation whose status changed or that staff opened also comes back in that mode, so a pull can return a conversation with nothing new to read. A cursor only continues the order it was issued for — sent with the other urut, it is answered with 400 kursor_rusak. A malformed cursor is answered with 400 kursor_rusak rather than silently starting over. The central Getstarvio support number is never included.
Free-form text. Valid only while the 24-hour window is still open.
{ "text": "Sure — I've moved your booking to 27 September." }200 → { "ok": true, "message": { ... } }
409 → { "error": "Jendela balasan 24 jam sudah lewat...",
"code": "window_closed" }⚠️ Error messages are currently returned in Indonesian; the code field is the stable part. Handle code, not prose — see Error codes. Not every answer here has a code: validation errors (empty or too-long text), the 400 for a business number that is not connected, 404 and 502 carry none — handle those by HTTP status. 403 inbox_balas_ditolak means the logged-in staff member's role may not reply in that branch; 403 web_reply_disabled / web_reply_locked_by_admin mean replying from the web is switched off for this account (sending templates still works).
A 409 here is not a transient error, and it has two causes — tell them apart by code. 409 window_closed: do not retry it — send a template to reopen the conversation. 409 wa_perlu_sambung_ulang: Meta refused the send because the business's WhatsApp connection was cut off; nothing was sent or charged, and retrying will not help until WhatsApp is reconnected in the dashboard settings. The same 409 wa_perlu_sambung_ulang applies to media and template sends.
Cost. Since 1 October 2026 each reply costs 1 credit (nothing while the account is on a complimentary period); not enough credit ⇒ 402 credit_empty, and nothing is sent. If Meta later reports the message as failed, the credit is refunded.
An image, a document or an MP4 video. Same rule as a text reply: inside the window only, and 409 window_closed outside it.
Sent as multipart/form-data: the file in the file part, the accompanying text in a caption field.
400, and a request body above 20 MB is cut off before it reaches us with 413 (without a JSON body). An empty or missing file part is also 400.400, not resized.415. The type is read from the file part's Content-Type (for example image/jpeg, application/pdf, video/mp4), not from the file name — a part sent as application/octet-stream is answered with 415.200. Truncate on your side if the full text matters.Mind the cost. Since 1 October 2026 every media message — like every text reply — costs 1 credit (nothing while the account is on a complimentary period). Not enough credit ⇒ 402 credit_empty, and nothing is uploaded or sent; if Meta later reports the message as failed, the credit is refunded. The same 403 answers as a text reply apply here (inbox_kirim_media_ditolak for the role, web_reply_disabled / web_reply_locked_by_admin when replying from the web is switched off).
PRE-RELEASE — The push machinery exists for inbound messages — Getstarvio really does call a URL of yours when a customer message arrives. And installing that URL is now self-service: Akun → Kunci API → «Pesan masuk ke sistemmu» (Inbound messages to your system), by the organization owner. The address must use https on the standard port (443), contain no ? or #, and must not point or resolve to a local or private network; any other address is refused when you save it. The address is installed per branch: the owner ticks the branches that should forward their messages, and branches that are not ticked are left unchanged — nothing is ever installed on every branch by default. The signing secret is shown once when you install it and is sent in the X-Getstarvio-Secret header — save it right then, because it cannot be read again. It covers only the branches ticked in that install; a branch installed in a separate install keeps its own secret, so a receiver that serves several branches at one address must accept each of those secrets.
⚠️ This is a synchronous question-and-answer channel for a conversational assistant, not a fire-and-forget notification — for one-way notifications, several per branch, see Notification webhooks. For each inbound customer message that carries text (a typed message, the label of a tapped button or list item, or a media caption), and for each shared location pin that is newer than the last one we stored, we POST { "pertanyaan", "idPesan", "ke", "lokasi", "namaKontak", "jumlahPesan", "iklan" } and wait up to X-Getstarvio-Batas-Ms milliseconds for a JSON answer: { "hasil": "jawab" | "eskalasi" | "tunggu" | "menyerah" | "diam", "teks": "…" }. What each field and each answer means is listed under What we send and what you answer. A failure — a non-2xx status, a timeout, or a body that does not match this shape, such as {"ok":true} — means we send the customer a holding message saying the store team will reply. Forwarding also needs a separate switch that Getstarvio controls; saving an address alone does not turn it on.
⚠️ Delivery-status pushes cover only messages sent for your assistant on this channel — answers to /rel/pelanggan/tanya and replies posted to POST /rel/cis/jawaban. They go to your saved address followed by /rel/pesan/status, with the same X-Getstarvio-Secret header, as { "idPesan", "idPesanWa", "jenis": "balasan" | "serah_terima", "status": "sent" | "delivered" | "read" | "failed", "pada" }, or { "idPesan", "jenis", "status": "ditahan", "sebab" } when we held a reply back. Today a "ditahan" push is sent only when an answer was held because the business ran out of credit (sebab: "kredit_habis"). Status pushes are best-effort: we wait 3 seconds, try once more after a 5xx, a timeout or a connection failure, never follow redirects, and do not queue them, so a push your system misses is not resent. Messages sent with POST /v1/messages/template are not pushed on this channel — their statuses go to your notification webhooks. Without one, or to reconcile a notification you missed, read their current status by fetching the conversation again with GET /v1/conversations/:id/messages (without since, or with a since at or before the send). If Meta later marks such a message failed, it is dropped from that list and its credit is refunded — so a stored id that no longer appears means the send failed.
⛔ Polling returns messages; status changes are pushed, not polled. With an API key that carries inbox.baca, Reading conversations via API returns every inbound message, and since lets you pull incrementally. The messages we send on this channel (answers, hand-off and holding messages) are recorded in the business's inbox too, so GET /v1/conversations/:id/messages returns them with arah: "keluar" and pengirim: "sistem". Delivery-status changes are not pollable: since follows the time a message was recorded, so a later status change on an older message never makes it appear again — they reach you as notification webhooks (see the paragraph above). An earlier version of this paragraph said "Polling returns messages, not status changes" with nothing to replace them; that stopped being true when notification webhooks were added. An earlier version of this page said there was no route to poll at all; that stopped being true when the read API above was added. An earlier version also announced an HMAC signature scheme — there is none: the secret itself travels in the header, and you verify it as shown under Verifying requests and testing the connection.
One thing about the shape is already settled: retries mean an event can arrive more than once. Your receiver must be safe against that, and idPesan (the message id in the body we send) is the key used to recognise a repeat.
pertanyaan is the customer's text. A shared location pin arrives as a bracketed marker with the place name and address, with the coordinates in lokasi; a live location, which WhatsApp reports to us as an unsupported message, arrives as a marker only, because WhatsApp does not deliver its content. Media without a caption, stickers, audio, reactions and edits are not forwarded. ke is the customer's WhatsApp number (international format, no +). idPesan is the WhatsApp id of the customer's message. lokasi is { "lat", "lng" } of the customer's latest location pin from the past 24 hours, or null. namaKontak is sent only when WhatsApp gave us a name. iklan is sent only while the conversation counts as opened by a Click-to-WhatsApp ad — within 72 hours of the latest customer message that came from such an ad: { "sumber": "meta_ctwa", "idIklan", "judul", "urlIklan", "jenisMedia", "ctwaClid" } (the ad — or post — id, its headline, its link, its media type and Meta's click id). Every field except sumber is left out when Meta did not send it — ctwaClid, for example, is absent for ads shown in WhatsApp Status — and at least one of idIklan and urlIklan is always present.idPesan is the id of the last one, and jumlahPesan says how many were combined. A single message carries no jumlahPesan."jawab" we send teks to the customer on WhatsApp; with "diam" we send nothing. With "eskalasi" or "menyerah" we send the customer a hand-off message instead of teks. It names the branch, plus its number and https maps link, when you return them as "cabang": { "nama", "nomor", "maps" } (a plain string is read as the branch name); without cabang the customer gets the holding message. With "tunggu" we send nothing yet and wait for your answer on POST /rel/cis/jawaban — 20 seconds, or tungguDetik seconds when you include it (a whole number; values below 20 count as 20 and values above 180 as 180; anything that is not a whole number, such as 45.5 or "90", is ignored); if none arrives, the customer gets the holding message. teks must be non-empty and at most 4,096 characters for every result except "diam"."eskalasi" or "menyerah", or a "tunggu" whose wait runs out sends nothing more that day.5xx or the connection fails (refused, DNS failure, reset), we try once more within the same X-Getstarvio-Batas-Ms budget, with the same idPesan. Timeouts, 3xx/4xx answers and malformed bodies are not retried, redirects are never followed, and the customer then gets the holding message.Every inbound customer message we forward to you — and every test — goes to your saved address followed by /rel/pelanggan/tanya (delivery-status pushes go to /rel/pesan/status), and carries two headers: X-Getstarvio-Secret (your webhook secret, sent as-is — not an HMAC signature) and X-Getstarvio-Batas-Ms (how many milliseconds we wait for your answer). Compare the secret in constant time and reject anything that does not match:
const crypto = require('crypto')
// savedSecret: the webhook secret shown ONCE when the address was saved.
function isFromGetstarvio(headers, savedSecret) {
const got = Buffer.from(String(headers['x-getstarvio-secret'] || ''))
const want = Buffer.from(String(savedSecret || ''))
return want.length > 0 && got.length === want.length && crypto.timingSafeEqual(got, want)
}
// POST <your saved address>/rel/pelanggan/tanya (Express-style req/res)
function handleGetstarvio(req, res) {
if (!isFromGetstarvio(req.headers, process.env.GETSTARVIO_WEBHOOK_SECRET)) return res.status(401).end()
if (req.body && req.body.type === 'test') return res.status(200).json({ ok: true })
// real message: answer { hasil: 'jawab', teks: '…' } — or { hasil: 'diam', teks: '' } to send nothing
}Test connection. The organization owner can press "Uji sambungan" (Test connection) on a branch row under Akun → Kunci API → «Pesan masuk ke sistemmu» (Inbound messages to your system). It sends one test request to that branch's saved address, with the same secret and headers as a real message, and shows your system's HTTP status code and the response time. The body is exactly { "type": "test", "idPesan": "uji_…", "dikirimPada": "<ISO time>", "pesan": "…" } — no customer message, phone number or name is ever included. Answer it with any 2xx and do nothing else.
3xx is reported as-is. Save the final address instead.429.401 or 403 from your system means it rejected the secret. Installing the address again on a branch issues a new secret for that branch, so update your system after every install.POST /rel/cis/jawaban is the one key-accepting route outside /v1. It exists so an external conversational assistant can post its answer back into a live conversation, and it requires the inbox.balas permission. ⚠️ That permission sends billable messages to your customers, so it is never granted by default — you must tick it deliberately when issuing the key. Replies are accepted only for conversations of a branch that has an inbound-message address installed and forwarding switched on.
Authorization header with your key; a request without it is refused, and a malformed or unknown key is answered with 401 kunci_ditolak. The body is { "idPesan", "hasil", "teks" }: idPesan is an idPesan we sent you (at most 200 characters); hasil is jawab, eskalasi, menyerah or diam ("tunggu" belongs to the synchronous answer only and is not accepted here); teks is required, at most 4,096 characters, except with diam, where it is ignored. You may add cabang exactly as in the synchronous answer. A body that does not match is answered with 400 and no code.jawab we send teks to the customer. With eskalasi or menyerah we send our own hand-off message instead of teks, naming the branch when you include cabang. With diam we send nothing and cancel the holding message we would otherwise send when the wait runs out — it is accepted only while we are still waiting after your synchronous "tunggu", and otherwise refused with 400 tenggat_tunggu_nol_hidup before the idPesan is used (repeating a diam we already accepted still returns 200 with "ulangan": true). If the wait ran out at the same moment your diam arrived, we still accept it but answer sebab: "tenggat_sudah_jalan": the holding message may already have gone out. diam uses up the idPesan like any other reply. A tunggu body is refused with 400 before the idPesan is used, so your final reply for that message is still accepted, and the holding message still goes out when the wait runs out if no final reply arrives first.idPesan gets at most one message to the customer, whichever arrives first — your synchronous answer or this reply. Each idPesan is also accepted here only once: repeating it returns 200 with "ulangan": true and status: "ditahan" (sebab: "ulangan"), and nothing is sent again.200 → { "idPesan", "statusKirim": { "idPesanWa", "status": "terkirim" | "gagal" | "ditahan", "sebab" }, "diterima", "ulangan" }. terkirim means Meta accepted the message; delivery is reported later on /rel/pesan/status. ditahan means nothing was sent — for example because of the daily cap, credit running out (sebab: "kredit_habis"), or because that customer message was already answered (sebab: "sudah_dibalas").idPesan we do not know — is 403 tenant_asing / cabang_asing. A conversation whose branch has forwarding switched off or no inbound address installed is 403 rel_mati / otak_nol_disetel, and a reply into a suspended branch is 403 cabang_disuspend whichever key is used. A key without inbox.balas gets 403 lingkup_kurang. These checks run before the idPesan is used up, so a refused reply can be sent again once the cause is fixed. 503 means we could not check right now — retry./v1.Get told when a message you sent with POST /v1/messages/template changes status — and, if you choose, receive a copy of every inbound customer message — at one or more addresses of your own, per branch. These are one-way notifications: we do not read your answer, we never reply to the customer because of it, and no credit is charged. They are separate from the single answering address under Inbound messages & webhooks, which stays one per branch.
Setting up. The organization owner adds addresses under Akun → Kunci API → «Alamat pemberitahuan» (Notification addresses), with their PIN: at most 5 per branch. The address must use https on the standard port (443), contain no ? or # and no user name or password (the secret travels in a header, so the address needs no token), and must not point or resolve to a local or private network; anything else is refused when you save it. Each address gets its own secret, shown once when it is added — save it right then, because it cannot be read again. An address cannot be edited: to change it, delete it and add the new one, which gets a new secret. For each address you choose what it receives:
status_kiriman) — on by default.pesan_masuk) — off by default, because each copy carries the content of a customer conversation to your system. Turning it on needs the owner's PIN.How we deliver. We POST a JSON body to the address exactly as saved (no path is added), with the header X-Getstarvio-Secret carrying that address's secret as-is — not an HMAC signature. Compare it in constant time, as shown under Verifying requests, and answer any 2xx; your response body is not read. We wait 3 seconds and try once more after a 5xx, a timeout or a connection failure. A 4xx is not retried, and redirects are not followed. Notifications are not queued, so one your system misses is not resent. We deliver at most 4 notifications at a time per branch; if one branch has more than 500 waiting at once (a very large burst, or an address that has stopped answering), the extra ones are dropped and counted as failed on that screen. To reconcile, fetch the conversation with GET /v1/conversations/:id/messages, whose status field holds the current status of each message it returns — a send that ended failed is not returned there, so for that one the failed notification (and the refunded credit) is the record. The result of the last delivery to each address — and how many failed in a row — is shown on that screen, and «Uji sambungan» (Test connection) sends one request with "jenis": "uji" that you should answer with 2xx.
⚠️ Every address of a branch receives the status of every message sent through any API key for that branch. If two systems send from the same number — say an ERP and a webstore — each one also sees the other's statuses. Match pesan.waMessageId (or pesan.id) against the value you stored from the send response, and ignore notifications you do not recognise.
POST /v1/messages/template — not messages sent from the inbox, reminders, broadcasts or the assistant channel. Inbound copies cover messages customers send to the branch's own WhatsApp number; a reaction a customer removes is not forwarded.sent, delivered, read, failed — is notified once per message, even when Meta repeats it. A retry after a timeout — or, rarely, a fault on our side — can still deliver the same notification twice; it carries the same idPeristiwa, so drop duplicates on it. Meta can report statuses out of order (read before delivered), so order by the status itself, not by arrival.failed carries Meta's error in galat (kode, judul, pesan); the credit for that message is refunded, as described under Sending messages via API.pesan in exactly the shape of one item of GET /v1/conversations/:id/messages, with percakapan.id for fetching the rest of the conversation.{
"idPeristiwa": "st_6f1c…",
"jenis": "status_kiriman",
"terjadiPada": "2026-10-08T09:41:07.000Z",
"cabang": { "id": "ws_…" },
"percakapan": { "id": "c7a…", "kontak": { "telepon": "6281234567890" } },
"pesan": {
"id": "m2b…",
"waMessageId": "wamid.HBgN…",
"status": "failed",
"galat": { "kode": 131026, "judul": "Message undeliverable", "pesan": "Message Undeliverable." }
}
}{
"idPeristiwa": "masuk_9d4…",
"jenis": "pesan_masuk",
"terjadiPada": "2026-10-08T09:43:12.000Z",
"cabang": { "id": "ws_…" },
"percakapan": { "id": "c7a…", "kontak": { "telepon": "6281234567890", "nama": "Mia" } },
"pesan": {
"id": "m3c…",
"arah": "masuk",
"pengirim": "pelanggan",
"asal": null,
"jenis": "text",
"teks": "Pesanan saya sudah sampai belum?",
"status": "received",
"waktu": "2026-10-08T09:43:12.000Z",
"dicatatPada": "2026-10-08T09:43:13.000Z",
"media": null,
"lokasi": null,
"reaksi": null,
"balasanUntukPesanId": null,
"kontak": null,
"jenisTakDidukung": null
}
}Send one template to many recipients, with different variable values per person.
{
"templateId": "tpl_01J8X...",
"recipients": [
{ "phone": "628123456789", "values": { "1": "Cynthia", "2": "August 27" } },
{ "phone": "628198765432", "values": { "1": "Rani", "2": "August 28" } }
]
}Phone numbers are written in international format without a +. Recipients who have unsubscribed are filtered out automatically and are not billed.
400 "Pilih template broadcast dulu" — there is no default template. It must be an APPROVED broadcast template of the account (branch) you are sending from. A template of another branch is answered with 404, and one not yet approved with 400. An authentication (OTP) template is refused with 400 template_otp before anything is charged."1", "2", …) matching {{1}}, {{2}} in the template body — not by variable name. An unrecognised key is ignored silently and its slot is filled with an empty value. The request still returns 200, but Meta rejects a template with an empty variable, so those messages fail (and are not charged) instead of going out.A broadcast estimated to need more than one day, because of your WhatsApp daily limit, is answered with 409 and needsConfirm: true. That is not a rejection — see Error codes.
Limits and refusals. At most 5,000 recipients per request (400 above that), and one broadcast at a time per account: while another is still sending or paused, a new one is answered with 409. Invalid numbers are skipped and duplicates removed. Not enough credit gets 402 with need and balance (no code); send "allowPartial": true to send as far as your credit reaches (at least 1 credit is needed). 409 with quotaBlocked: true means reminders already use the whole daily WhatsApp limit. 403 broadcast_disabled means the organization switched broadcasts off for that branch. Other refusals carry no code: an empty recipient list, or a list with no valid WhatsApp number, is answered with 400; 409 with needsWaba: true means WhatsApp Business is not connected, or needs reconnecting; 409 with dedupSkipped means every number in the list still has an unfinished send from another campaign of the account that is sending or paused.
Large broadcasts go out in stages. A first sample is sent, and the rest is released only once Meta's results for that sample look healthy. Otherwise the broadcast is paused and the owner is notified.
This is a WhatsApp rule, not a Getstarvio rule, and it is the one that most often breaks a first integration.
You may send free-form text to someone only within 24 hours of the last message they sent to you. Outside that, the only thing you may send is a template Meta has approved.
409 and code: "window_closed" — not a transient error, and retrying will not help.A conversation is identified by the pair business WhatsApp number + contact number. The same contact on two different business numbers is two different conversations, with windows that run independently.
An error carrying a code means something specific and actionable. Handle errors by code, never by the message text — the text can change, the code does not. ⚠️ Message text is currently returned in Indonesian.
window_closed — handle it by code.POST /v1/messages/template, which adds the code button Meta requires. Permanent — choose a Marketing or Utility template. Nothing was sent or charged.params does not match the template. The body names both numbers (butuh = required, diterima = received). Permanent — fix the count, do not retry./v1/messages/template: the number of buttonParams does not match the template's URL buttons that end in a variable (butuh = required, diterima = received). Also returned when buttonParams is sent to a template that has no such button — including a WhatsApp Manager template that has not been synchronised since URL buttons began to be read. Permanent — fix the request, do not retry it unchanged. Nothing was sent or charged./v1/messages/template: a buttonParams value is empty, longer than 2,000 characters, or contains a space, a line break or another control character; indeks names which one. Percent-encode it (%20 for a space). Permanent — fix the value. Nothing was sent or charged./v1/messages/template: headerImageUrl is empty, longer than 2,000 characters, contains a space or a control character, is not a valid URL, does not start with https://, or carries a user name or password. Permanent — fix the link. Nothing was sent or charged./v1/messages/template: the template's header is a text with a variable, a video, a document or a location — Meta would refuse every send, because we cannot fill that header yet. The body names the header type in jenisKepala; GET /v1/templates shows such a template with bisaDikirim: false. Permanent — use a template with no header, a fixed text header or an image header. Nothing was sent or charged./v1/messages/template: headerImageUrl was sent with a template that has no image header. Permanent — leave the field out, or use a template with an image header. Nothing was sent or charged./v1 route, a 404 with no code means the path does not exist (or the API is switched off on that environment); a 404 with a code is listed below./v1: the Authorization header is missing, or is not of the form Bearer gsv_live_<prefix>.<secret>. On POST /rel/cis/jawaban a malformed key is answered with kunci_ditolak instead.Content-Type: application/json is set. These come from our HTTP framework, so their error is "Bad Request" rather than a sentence./v1/messages/template: Meta answered with an error — nothing was sent, nothing charged. Read the reason before retrying; permanent causes do not change./v1/conversations/:id/messages: the conversation does not exist or is outside your key's organization/branch. We do not tell the two apart./v1/conversations*: the cursor is not a value we issued, or it was issued for the other urut. Pass berikutnya back exactly as received, with the same urut.Idempotency-Key is safe; a new key can deliver it twice. The 409 form means the first call with that key never finished.Idempotency-Key header is missing, empty, or longer than 255 characters. Nothing was sent.Idempotency-Key was already used for a request with a different body. Use a new key for a new message.APPROVED yet.to number is not a valid WhatsApp number.since is not a valid ISO 8601 time (or urut is not one of its two values). The message names the field.Idempotency-Key is safe./v1 the error body lists the missing permissions in butuh, so you do not have to guess; on POST /rel/cis/jawaban the missing permission is always inbox.balas. ⚠️ An empty permission list means zero permissions, not "everything allowed".org_disuspend: the organization is suspended; contact us. cabang_disuspend: the organization's owner has locked that branch in the dashboard (Kunci pengelolaan on the branches page); they can unlock it. ⚠️ On /v1 an organization-wide key is unaffected by any branch's status; only a branch-limited key — or a key with a default branch, while that default branch is locked — can be answered with cabang_disuspend. On POST /rel/cis/jawaban, a reply into a conversation of a suspended branch is refused with 403 cabang_disuspend whichever key is used.POST /rel/cis/jawaban: that idPesan is unknown, or its conversation is outside your key's organization (tenant_asing) or, for a branch-limited key or a key with a default branch, outside that branch (cabang_asing). Permanent for that idPesan and key; do not retry.POST /rel/cis/jawaban: forwarding is not switched on (rel_mati) or no inbound-message address is installed (otak_nol_disetel) for that conversation's branch, or the account behind that conversation no longer exists (tenant_nol_dikenal). Nothing was used up: once forwarding is on and the address is installed, the same reply is accepted.workspaceId. The error body lists those branches in cabang. Name the branch. A key with a default branch never gets this answer. ⚠️ A branch key ignores workspaceId in the body (and in the query of GET /v1/templates and GET /v1/images) — its branch is attached to the key.workspaceId you named is not a branch of that organization, or the branch has no Getstarvio account of its own — including the branch of a branch-limited key.code field, so check needsConfirm. It is not a rejection: resend the same request with "confirmMultiDay": true. The body also carries estDays, perDay, todayCapacity and budget.batas (the limit) — split your list to match it./v1: the body was sent with a content type we do not read (code: "FST_ERR_CTP_INVALID_MEDIA_TYPE"). A text/plain body is not rejected with 415: it is read as plain text and answered with 400 bentuk_salah, so always send Content-Type: application/json. On the media endpoint: unsupported file type. Allowed: JPG, PNG, PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, TXT, CSV, MP4.retry-after.⚠️ This list contains the codes the backend returns today, derived from the code itself. If you receive a code that is not listed here, treat it like an error without a code — handle it by HTTP status, and tell us so the list can catch up.
Rule of thumb: retry 503 and 429 (after retry-after), and, with the same Idempotency-Key, 500 galat_internal and 409 idempotency_sedang_diproses. Retry 502 only when the error message points at a momentary Meta outage. Nothing else changes its outcome by being retried.
⛔ 502 is not a reliable "try again" signal. It is also returned for permanent rejections from Meta — a template that is not APPROVED, or content rejected by policy. Retrying those produces an endless loop that will never succeed. Read Meta's original reason in the error body before retrying, and back off.
Every message that is actually delivered carries an identity from Meta — waMessageId. That value is the only thing that decides whether two records refer to the same message.
Sending via API requires an Idempotency-Key request header, and that header is what stops a duplicate message when your network retries a call that had in fact already succeeded. The exact rules — what is stored, for how long, and what each 409 means — are under Retries and the Idempotency-Key.
Customer import needs no Idempotency-Key and ignores that header (it still needs your API key): it matches on branch + phone number, so repeating it updates instead of duplicating.
⚠️ The session-login endpoints on this page do not read an idempotency header. If you build on their shapes, your own system still has to record what it has already sent.
The /v1 endpoints are limited to 60 requests/minute per key, and each endpoint keeps its own count — sending does not eat into the import allowance. The allowance belongs to your own key: a server that handles many businesses no longer makes them compete for one allowance, and one busy business does not block another. Each counted /v1 response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (seconds until the count resets). Over the limit ⇒ 429 with a retry-after header in seconds; the window is a fixed 60 seconds that starts with the first counted request, so wait for retry-after rather than retrying. A 429 body has no code field.
POST /rel/cis/jawaban has its own allowance of 60 requests/minute per key, counted separately from /v1.
Customer import has a separate limit: 5,000 rows per request (413 above that). Split larger lists across several requests.
The other endpoints on this page — the ones that run through a browser login session — are still counted per IP address: 200 requests/minute overall, 30/minute for text replies, 20/minute for templates, 20/minute for media and 4/minute for broadcast sends. Those numbers are not a server-integration allowance; do not size your sending volume against them.
The /v1 prefix marks the endpoint family; it is not yet a compatibility promise. We have not committed to a deprecation policy or a support window, and claiming otherwise here would be a promise nobody has made.
What we do commit to today is narrower and concrete: changes that affect an integration are communicated to that integration's owner directly, not published only as a silent edit to this page. If you are integrating, send us a technical contact address at hello@getstarvio.com so changes reach you directly.
The code field on errors is the part designed to be stable. Message text is not, and it may be translated at any time.
Integration questions: hello@getstarvio.com.