Dokumentasi · API Email v1

Dokumentasi API Email

Semua yang kamu butuhkan untuk mengirim email, mengelola kontak, dan menerima laporan pengiriman dari aplikasimu — lengkap dengan contoh kode.

Daftar isi

Mulai cepat

  1. Aktifkan domain pengirim di dashboard (Settings → Domains) sampai berstatus active. Email hanya bisa dikirim dari domain yang sudah terverifikasi.
  2. Buat API key di Settings → API Keys dengan scope send_email. Simpan di server, jangan di aplikasi HP atau kode browser.
  3. Cari ID domain lewat GET /v1/domains, lalu kirim email lewat POST /v1/messages.
# 1. Lihat domain pengirim (ambil "id" yang statusnya active)
curl https://api.kirim.id/v1/domains \
  -H "Authorization: Bearer $KIRIM_API_KEY"
# [{"id":1,"domain":"tokomu.com","status":"active"}]

# 2. Kirim email
curl -X POST https://api.kirim.id/v1/messages \
  -H "Authorization: Bearer $KIRIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "[email protected]",
    "subject": "Pesanan {{nama|Kakak}} sudah kami terima",
    "html": "<p>Halo {{nama_depan|Kak}}, pesanan #1001 sedang diproses.</p>",
    "sending_domain_id": 1
  }'
# 201 {"message_id":"9b1c7e2a-5d0f-4b8e-9a51-3f0c2d6e7a10","status":"queued"}

# 3. Cek statusnya
curl https://api.kirim.id/v1/messages/9b1c7e2a-5d0f-4b8e-9a51-3f0c2d6e7a10 \
  -H "Authorization: Bearer $KIRIM_API_KEY"

Dasar & format

HalKeterangan
Base URLhttps://api.kirim.id/v1 — hanya HTTPS. Path yang sama di domain lain tidak dilayani.
Format requestJSON (Content-Type: application/json) atau form (application/x-www-form-urlencoded). Spasi di awal/akhir teks otomatis dibuang; teks kosong dianggap tidak diisi.
Format jawabanSelalu JSON, termasuk saat error — header Accept tidak wajib.
WaktuUTC, format ISO 8601. Data objek memakai 2026-09-22T03:04:05.000000Z; payload webhook memakai 2026-09-22T03:04:05+00:00.
UangRupiah bulat (integer), tanpa desimal.
PemakaianServer-ke-server. API tidak mengirim header CORS, jadi tidak bisa (dan tidak boleh) dipanggil langsung dari browser — API key akan bocor.
ModulWorkspace harus punya modul Email Marketing aktif. Tanpa itu semua endpoint di halaman ini menjawab 403.

Autentikasi & API key

Setiap request wajib membawa header:

Authorization: Bearer api_kirimXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  • Format key: api_kirim diikuti 32 huruf/angka (total 41 karakter). Key tidak bisa dikirim lewat query string.
  • Key bisa dilihat ulang di dashboard kapan pun, dan bisa dicabut — request dengan key yang dicabut langsung ditolak.
  • Satu key boleh punya beberapa scope. Buat key terpisah per sistem (mis. "Website", "CRM") supaya mudah dicabut satu per satu.

Scope

ScopeLabel di dashboardEndpoint
send_emailKirim EmailGET /domains, POST /messages, GET /messages/{id}, GET /suppressions, GET /campaigns, POST /campaigns/{id}/send, GET /journeys, POST /journeys/{id}/enroll, POST /conversions
manage_contactsKelola Kontak/AudiensGET /contacts, GET /contacts/engagement, POST /contacts, DELETE /contacts/{id}, GET /audiences, POST /audiences
manage_webhooksKelola WebhookGET /webhooks, POST /webhooks, DELETE /webhooks/{id}

Jawaban gagal autentikasi

StatusKapanIsi message
401Header Authorization tidak adaMissing API key.
401Key salah atau sudah dicabutInvalid or revoked API key.
403Akun disuspendWorkspace disuspend.
403Key tidak punya scope yang dibutuhkanAPI key ini tidak punya scope 'send_email'.
403Modul Email tidak aktifModul Email Marketing belum aktif untuk akun ini. Aktifkan di app.kirim.id/produk.

Error & kode status

Semua error berbentuk JSON dengan field message. Error validasi (422) juga membawa errors per field:

{
  "message": "Penerima wajib diisi. (and 1 more error)",
  "errors": {
    "to": ["Penerima wajib diisi."],
    "subject": ["Subjek wajib diisi."]
  }
}
StatusArtiYang sebaiknya dilakukan
200 / 201Berhasil (201 = data baru dibuat/diterima)
401API key tidak ada / salah / dicabutPeriksa key. Jangan diulang otomatis.
403Scope kurang, akun disuspend, atau modul tidak aktifPerbaiki pengaturan key/akun. Jangan diulang otomatis.
404Data tidak ditemukan, milik akun lain, atau ID salah bentukPeriksa ID.
405Method HTTP salah (mis. GET ke endpoint POST)Periksa method.
422Validasi gagal atau aturan bisnis menolak (domain belum aktif, koin habis, penerima di suppression list, dll.)Baca message/errors. Jangan diulang tanpa perubahan.
429Terlalu banyak request (lihat batas kecepatan)Tunggu sesuai header Retry-After, lalu ulangi.
500Gangguan di sisi kami ({"message":"Server Error"})Ulangi dengan jeda (mis. 1, 5, 30 detik).
503REST API dimatikan sementara oleh admin Kirim.idUlangi beberapa saat lagi.

Batas kecepatan

Request API

  • 60 request per menit per API key (tanpa key: per alamat IP). Kuota ini dipakai bersama oleh semua endpoint.
  • Setiap jawaban membawa header X-RateLimit-Limit dan X-RateLimit-Remaining.
  • Kalau terlampaui: 429 {"message":"Too Many Attempts."} dengan header Retry-After (detik) dan X-RateLimit-Reset (unix time).

Kecepatan kirim email

Terpisah dari batas request: email yang diterima API (201 queued) dikirim bertahap sesuai jenis akun. Kalau batasnya penuh, email menunggu di antrean — tidak ditolak, tidak perlu dikirim ulang.

Jenis akunKecepatan
Free (belum pernah beli koin)10 email/menit, maksimal 300 email/hari
Premium (punya koin hasil beli yang masih berlaku)60 email/menit, tanpa batas harian

Batas ini berlaku bersama untuk semua jalur kirim (aplikasi, API, SMTP relay, journey). Selama server pengiriman kami dalam masa pemanasan IP, ada juga batas harian se-platform; kelebihannya dikirim hari berikutnya (hari berganti pukul 00.00 WIB).

Kirim email

POST/v1/messages scope: send_email

Mengirim satu email ke satu penerima. Cocok untuk email transaksional: kode verifikasi, reset password, invoice, notifikasi pesanan.

FieldTipeWajibAturan
tostringyaSatu alamat email, maks. 255 karakter. Bukan array.
subjectstringyaMaks. 255 karakter. Boleh berisi merge tag.
htmlstringyaIsi HTML, maks. 500.000 karakter. Boleh berisi merge tag. Versi teks biasa dibuat otomatis dari HTML.
sending_domain_idintegeryaID domain milikmu yang berstatus active (lihat GET /v1/domains).

Pengirim otomatis no-reply@domainmu dengan nama tampilan = nama bisnismu. Field lain (mis. from, cc, lampiran) diabaikan — lihat batasan.

Urutan pemeriksaan

  1. Alamat to rusak (bukan format email / lebih dari 255 karakter) → penalti 4 koin, lalu 422.
  2. Validasi field (422).
  3. Domain harus milikmu & aktif → kalau tidak: 422 Domain pengirim belum aktif/terverifikasi.
  4. Saldo koin minimal 1 → kalau tidak: 422 Saldo koin habis — top-up koin untuk lanjut mengirim.
  5. Penerima ada di suppression list → penalti + 422 Penerima ada di suppression list.
  6. Penerima jadi kontak (dibuat otomatis bila belum ada). Kalau kontak sudah berhenti langganan / bounce / melapor spam → penalti + 422 Penerima sudah unsubscribe.
  7. Email masuk antrean → 201.
Hindari penalti "kirim paksa". Panggil GET /v1/suppressions?email= sebelum mengirim ke alamat yang belum pasti. Penalti yang sama (alamat + alasan) hanya dikenakan sekali per 24 jam.

Jawaban

201 Created
{"message_id":"9b1c7e2a-5d0f-4b8e-9a51-3f0c2d6e7a10","status":"queued"}

queued berarti email diterima dan menunggu giliran kirim. Koin dipotong saat email benar-benar diserahkan ke server pengiriman (bukan saat diterima API). Simpan message_id untuk mengecek status dan mencocokkan webhook.

Contoh kode

// PHP (Laravel HTTP client)
$res = Http::withToken(env('KIRIM_API_KEY'))
    ->post('https://api.kirim.id/v1/messages', [
        'to' => '[email protected]',
        'subject' => 'Kode verifikasi kamu',
        'html' => '<p>Kode: <b>481920</b>. Berlaku 10 menit.</p>',
        'sending_domain_id' => 1,
    ]);
if ($res->failed()) {
    logger()->warning('Kirim email gagal', ['status' => $res->status(), 'body' => $res->json()]);
}
$messageId = $res->json('message_id');
// Node.js 18+ (fetch bawaan)
const res = await fetch('https://api.kirim.id/v1/messages', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.KIRIM_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    to: '[email protected]',
    subject: 'Kode verifikasi kamu',
    html: '<p>Kode: <b>481920</b>. Berlaku 10 menit.</p>',
    sending_domain_id: 1,
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${data.message}`);
console.log(data.message_id);
# Python (requests)
import os, requests
r = requests.post(
    "https://api.kirim.id/v1/messages",
    headers={"Authorization": f"Bearer {os.environ['KIRIM_API_KEY']}"},
    json={
        "to": "[email protected]",
        "subject": "Kode verifikasi kamu",
        "html": "<p>Kode: <b>481920</b>. Berlaku 10 menit.</p>",
        "sending_domain_id": 1,
    },
    timeout=15,
)
r.raise_for_status()
print(r.json()["message_id"])

Status pesan

GET/v1/messages/{message_id} scope: send_email
200 OK
{
  "message_id": "9b1c7e2a-5d0f-4b8e-9a51-3f0c2d6e7a10",
  "status": "delivered",
  "failed_reason": null,
  "dispatched_at": "2026-09-22T03:04:07.000000Z"
}

message_id harus UUID milik akunmu; selain itu 404.

StatusArti
queuedDiterima, menunggu giliran (termasuk saat menunggu batas kecepatan). Kalau failed_reason terisi, penolakan sementara sedang dicoba ulang otomatis.
sendingSedang diserahkan ke server pengiriman (sesaat).
sent_to_mtaSudah diserahkan ke server pengiriman (dispatched_at terisi). Kalau failed_reason diawali "Ditunda sementara", server penerima minta ditunda dan akan dicoba lagi.
deliveredDiterima server email penerima.
bouncedDitolak server penerima. failed_reason berisi kode & alasan dari server penerima, mis. 550 5.1.1 user unknown.
failedTidak jadi dikirim — lihat failed_reason di bawah.

Isi failed_reason untuk status failed:

  • Domain pengirim tidak aktif., Workspace disuspend.
  • Kontak sudah dihapus., Kontak sudah tidak berlangganan., Penerima ada di suppression list. — kondisi berubah setelah API menerima email.
  • Saldo koin habis — top-up koin untuk lanjut mengirim. — saldo habis saat giliran kirim tiba.
  • Penolakan permanen dari server pengiriman, atau MTA menolak sementara terus-menerus: … setelah percobaan ulang habis (maks. 3 hari).
  • Tidak ada catatan pengiriman di server MTA — status akhir tidak diketahui. Koin dikembalikan.
Laporan buka, klik, dan laporan spam tidak mengubah status. Untuk menerima semuanya secara langsung, pakai webhook — lebih hemat daripada mengecek status berulang-ulang.

Personalisasi (merge tag)

Tulis {{nama_tag}} di subject atau html. Tambahkan teks cadangan dengan {{nama_tag|Cadangan}} — dipakai kalau datanya kosong. Nama tag tidak peka huruf besar/kecil; tag yang tidak dikenal dibiarkan apa adanya. Nilai otomatis di-escape untuk HTML.

Tag (dan alias)Isi
{{email}}Email penerima
{{nama}} · {{name}}Nama kontak. Kontak yang dibuat otomatis oleh POST /messages belum punya nama — tambahkan dulu lewat POST /contacts, atau pakai cadangan: {{nama|Kak}}.
{{nama_depan}} · {{first_name}}Kata pertama dari nama
{{bisnis}} · {{nama_bisnis}} · {{perusahaan}} · {{company}}Nama bisnismu
{{alamat_bisnis}} · {{alamat_perusahaan}}Alamat bisnismu (Pengaturan Akun)
{{unsubscribe_url}} · {{link_berhenti_langganan}} · {{link_unsubscribe}}Link berhenti langganan khusus penerima ini
{{tahun}}Tahun sekarang (WIB)
{{judul_email}} · {{subjek}}Subjek email (untuk dipakai di isi)
{{link_browser}} · {{versi_web}} · {{preheader}}Hanya untuk campaign — kosong di email API (pakai cadangan atau jangan dipakai)

Pelacakan, unsubscribe & header

Pelacakan buka & klik (selalu aktif)

  • Klik: setiap link href="http(s)://…" dengan tanda kutip ganda diganti link pelacak app.kirim.id/t/c/… yang langsung meneruskan ke tujuan asli. Link mailto:, tel:, atau tanpa tanda kutip ganda tidak dilacak.
  • Buka: gambar 1×1 piksel ditambahkan di akhir email. Sebagian aplikasi (mis. Apple Mail) memuat gambar otomatis, jadi "dibuka" bisa sedikit lebih tinggi dari kenyataan. Klik otomatis juga dihitung sebagai dibuka.
  • Kalau pelacakan konversi aktif di akunmu, link tujuan mendapat parameter kirim_mid=<message_id> (lihat konversi).

Unsubscribe (wajib ada)

  • Kalau isi email belum memuat link unsubscribe (lewat {{unsubscribe_url}} atau link bertuliskan "unsubscribe" / "berhenti langganan"), kami menambahkan footer berisi nama & alamat bisnis dan link "Berhenti berlangganan".
  • Email juga membawa header List-Unsubscribe + List-Unsubscribe-Post: List-Unsubscribe=One-Click (syarat Gmail & Yahoo untuk pengirim massal).
  • Penerima yang berhenti langganan berstatus unsubscribed; pengiriman berikutnya ke alamat itu ditolak (dan dikenai penalti kalau dipaksa).

Header yang ditambahkan

Message-ID: <message_id@domainmu>, List-Unsubscribe, List-Unsubscribe-Post, X-Campaign-id: api, X-Subscriber-id, X-Message-id. Return-Path memakai subdomain bounce domainmu, dan email ditandatangani DKIM domainmu oleh server pengiriman.

Domain pengirim

GET/v1/domains scope: send_email
200 OK
[{"id":1,"domain":"tokomu.com","status":"active"}]

Urut abjad, tanpa paginasi. Status: pending_dns, dns_verifying, provisioning, active, dns_failed, suspended. Hanya active yang bisa dipakai mengirim.

Cek boleh dikirimi

GET/v1/[email protected] scope: send_email
200 OK
{"email":"[email protected]","can_send":false,"reason":"hard_bounce"}
reasonArti
nullBoleh dikirimi (can_send: true)
hard_bounceAlamat tidak ada / ditolak permanen
complaintPenerima pernah melapor spam
manualKamu memblokirnya di Daftar Filter
globalDiblokir untuk semua pengirim Kirim.id
unsubscribed · bounced · complainedStatus kontak di akunmu

Kontak

GET/v1/contacts?page=1 scope: manage_contacts

50 kontak per halaman, urut ID. Format paginasi:

{
  "current_page": 1,
  "data": [
    {"id":12,"workspace_id":3,"email":"[email protected]","name":"Rina","status":"subscribed",
     "subscribed_at":"2026-09-20T02:11:00.000000Z","unsubscribed_at":null,
     "created_at":"2026-09-20T02:11:00.000000Z","updated_at":"2026-09-20T02:11:00.000000Z"}
  ],
  "first_page_url": "https://api.kirim.id/v1/contacts?page=1",
  "from": 1, "last_page": 3, "last_page_url": "https://api.kirim.id/v1/contacts?page=3",
  "links": [...], "next_page_url": "https://api.kirim.id/v1/contacts?page=2",
  "path": "https://api.kirim.id/v1/contacts", "per_page": 50, "prev_page_url": null, "to": 50, "total": 123
}

Status kontak: subscribed, unsubscribed, bounced, complained.

POST/v1/contacts scope: manage_contacts
FieldWajibAturan
emailyaEmail valid, maks. 255
nametidakMaks. 255
consentyaHarus true — kontak sudah memberi izin (opt-in) menerima email darimu

Jawaban 201 berisi objek kontak. Kalau email sudah ada, kontak lama dikembalikan tanpa diubah (nama tidak diperbarui, status tidak dikembalikan ke subscribed). Kontak tidak otomatis masuk audience mana pun.

DELETE/v1/contacts/{id} scope: manage_contacts

Menghapus kontak permanen; riwayat pesannya dianonimkan. Jawaban 200 {"deleted":true}. Menghapus kontak tidak memasukkannya ke suppression list — kalau ingin memblokir, pakai Daftar Filter di dashboard.

Kontak teraktif

GET/v1/contacts/engagement scope: manage_contacts

Kontak yang paling rajin membuka atau mengklik emailmu. Dihitung per email unik: satu email yang dibuka berkali-kali tetap dihitung 1. Hanya email yang sudah diserahkan ke server pengiriman yang dihitung sebagai "diterima".

ParameterBawaanKeterangan
byopensopens = paling rajin buka, clicks = paling rajin klik
days90Periode ke belakang (hari), 1–3650. 0 = semua waktu
audience_idHanya kontak di audience ini (404 kalau bukan milikmu)
per_page · page50 · 1per_page maks. 200
200 OK
{
  "data": [
    {"rank":1,"contact_id":12,"email":"[email protected]","name":"Rina","status":"subscribed",
     "received":24,"opened":21,"open_rate":87.5,"clicked":9,"click_rate":37.5,"total_clicks":14,
     "last_activity_at":"2026-09-21T13:40:02+00:00"}
  ],
  "meta": {"by":"opens","days":90,"audience_id":null,"current_page":1,"last_page":4,"per_page":50,"total":187}
}

Urutan: by=opens → jumlah email dibuka, lalu diklik, lalu total klik. by=clicks → jumlah email diklik, lalu total klik, lalu dibuka. Kontak yang tidak pernah membuka/mengklik pada periode itu tidak muncul. open_rate dan click_rate dalam persen.

Audience

GET/v1/audiences scope: manage_contacts
200 OK
[{"id":4,"workspace_id":3,"name":"Pelanggan","description":null,"public_token":"…","opt_in_mode":"single",
  "created_at":"…","updated_at":"…","contacts_count":320}]

opt_in_mode: single atau double (anggota wajib konfirmasi lewat email sebelum menerima campaign). public_token dipakai di link form subscribe publik audience.

POST/v1/audiences scope: manage_contacts

Field: name (wajib, maks. 255), description (opsional, maks. 1000). Audience baru selalu single opt-in. Jawaban 201 berisi audience yang dibuat.

Campaign

GET/v1/campaigns scope: send_email
200 OK
[{"id":28,"subject":"Promo September","status":"draft","scheduled_at":null,"sent_at":null,"created_at":"…"}]

Terbaru dulu, tanpa paginasi. Status: draft, scheduled, live, paused, completed.

POST/v1/campaigns/{id}/send scope: send_email

Mengirim campaign berstatus draft yang sudah kamu siapkan di dashboard (isi, audience, pengirim) — cocok untuk memicu campaign dari sistemmu sendiri.

200 OK
{"id":28,"status":"live"}      // atau "completed" kalau tidak ada penerima yang memenuhi syarat

Penerima = anggota audience yang subscribed, tidak di suppression list, dan sudah konfirmasi (untuk audience double opt-in). Email dikirim bertahap sesuai kecepatan akunmu. Penolakan (422, pesan pertama yang cocok):

  • Alamat bisnis belum diisi. … — wajib untuk email marketing (isi di Pengaturan Akun).
  • Domain pengirim belum aktif/terverifikasi.
  • Konten email campaign ini belum diisi.
  • Saldo koin tidak cukup: butuh N koin, saldo M. … — seluruh penerima harus tertutup saldo di awal.
  • Campaign ini bukan draft. — yang terjadwal/berjalan/selesai tidak bisa dikirim lewat API.

Journey

GET/v1/journeys scope: send_email
200 OK
[{"id":5,"name":"Welcome Series","status":"active","trigger_type":"api"}]

status: draft, active, paused. trigger_type: form_subscribe, audience_join, api.

POST/v1/journeys/{id}/enroll scope: send_email

Memasukkan kontak ke journey yang pemicunya API (mis. pelanggan baru mendaftar di websitemu).

FieldWajibAturan
emailyaEmail valid, maks. 255
nametidakMaks. 255 (hanya untuk kontak baru)
consentyaHarus true
curl -X POST https://api.kirim.id/v1/journeys/5/enroll \
  -H "Authorization: Bearer $KIRIM_API_KEY" -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Budi","consent":true}'

201 {"journey_id":5,"email":"[email protected]","enrolled":true,"message":"Kontak masuk journey."}
200 {"journey_id":5,"email":"[email protected]","enrolled":false,"message":"Kontak sudah pernah masuk journey ini (tidak diulang)."}

Satu kontak hanya bisa masuk satu journey sekali. Langkah journey diproses tiap menit. Penolakan 422: Journey ini tidak dipicu lewat API., Journey ini belum aktif., Penerima ada di suppression list. (tanpa penalti), Kontak ini sudah berhenti berlangganan.

Konversi (revenue)

POST/v1/conversions scope: send_email

Catat penjualan dari sistemmu supaya dashboard bisa menunjukkan berapa rupiah yang datang dari email. Penjualan dihubungkan ke klik email terakhir dalam 7 hari sebelum waktu penjualan.

FieldWajibAturan
amountyaRupiah bulat, 0 s.d. 10.000.000.000
emailsalah satuEmail pembeli (dicocokkan ke kontak)
message_idsalah satuUUID pesan yang diklik — paling akurat. Didapat dari parameter kirim_mid di URL tujuan klik. Kalau diisi, email diabaikan.
order_idtidakMaks. 191. Kalau sama dengan yang sudah pernah dicatat → dianggap duplikat (aman untuk diulang).
occurred_attidakWaktu penjualan, tidak boleh di masa depan. Sertakan zona waktu (mis. 2026-09-22T10:00:00+07:00); tanpa zona dianggap UTC.
@__raw_block_16__{{ $convDays }} hari terakhir — tidak dihitung sebagai revenue email."}

Konversi unattributed tidak disimpan.

Webhook

Kami mengirim POST ke URL-mu setiap ada kejadian pada email dari akunmu — dari semua jalur (campaign, journey, API, SMTP relay).

EventKapan
deliveredEmail diterima server penerima. occurred_at = waktu email diserahkan ke server pengiriman.
bounceDitolak server penerima (sementara maupun permanen)
complaintPenerima melapor spam
openSetiap kali email dibuka (bisa lebih dari sekali per email)
clickSetiap klik link yang dilacak
unsubscribePenerima berhenti langganan lewat link di email

Mendaftarkan webhook

POST/v1/webhooks scope: manage_webhooks
curl -X POST https://api.kirim.id/v1/webhooks \
  -H "Authorization: Bearer $KIRIM_API_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://tokomu.com/webhook/kirim","event_types":["delivered","bounce","complaint","click"]}'

201 {"id":3,"url":"https://tokomu.com/webhook/kirim","secret":"q8Xk…40 karakter…","event_types":["delivered","bounce","complaint","click"]}
Simpan secret sekarang — hanya ditampilkan sekali. Untuk ganti secret: hapus webhook lalu daftarkan lagi.
  • url: wajib https://, maks. 255, tidak boleh berisi username/password, host harus bisa ditemukan di DNS dan tidak boleh mengarah ke jaringan internal/privat.
  • event_types: minimal satu dari daftar di atas.
  • Maksimal 10 webhook per akun (422 Maksimal 10 webhook per workspace. …).
GET/v1/webhooks scope: manage_webhooks
200 [{"id":3,"url":"https://tokomu.com/webhook/kirim","event_types":["delivered","bounce"],"is_active":true,"created_at":"…"}]
DELETE/v1/webhooks/{id} scope: manage_webhooks

Jawaban 200 {"deleted":true}. Pengiriman yang masih antre untuk webhook itu dibatalkan.

Isi yang kami kirim

POST https://tokomu.com/webhook/kirim
Content-Type: application/json
X-Kirim-Signature: 5d41402abc4b2a76b9719d911017c592…  (hex HMAC-SHA256)

{"event":"click","message_id":"9b1c7e2a-5d0f-4b8e-9a51-3f0c2d6e7a10","occurred_at":"2026-09-22T03:10:44+00:00"}

Payload hanya berisi jenis event, message_id, dan waktu. Simpan message_id saat mengirim supaya bisa dicocokkan ke pesanan/pengguna di sistemmu.

Verifikasi tanda tangan (wajib)

Hitung HMAC-SHA256 dari body mentah (byte persis seperti diterima — jangan parse lalu encode ulang) memakai secret, ubah ke hex huruf kecil, lalu bandingkan dengan header X-Kirim-Signature memakai perbandingan waktu-konstan.

// PHP
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('KIRIM_WEBHOOK_SECRET'));
if (! hash_equals($expected, $_SERVER['HTTP_X_KIRIM_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);   // proses, lalu jawab 2xx secepatnya
http_response_code(200);
// Node.js (Express) — pakai body mentah, bukan express.json()
const crypto = require('crypto');
app.post('/webhook/kirim', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', process.env.KIRIM_WEBHOOK_SECRET).update(req.body).digest('hex');
  const got = req.get('X-Kirim-Signature') || '';
  if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
  // proses event di sini (setelah menjawab)
});
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort

@app.post("/webhook/kirim")
def kirim_webhook():
    raw = request.get_data()
    expected = hmac.new(os.environ["KIRIM_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Kirim-Signature", "")):
        abort(401)
    event = request.get_json()
    return "", 200

Pengiriman ulang

  • Jawab dengan status 2xx dalam 10 detik (koneksi maks. 5 detik). Redirect tidak diikuti.
  • Kalau gagal/bukan 2xx, kami mencoba lagi dengan jeda 30, 60, 90, 120 detik — maksimal 5 percobaan.
  • Event bisa datang lebih dari sekali atau tidak berurutan. Buat pemrosesanmu aman terhadap duplikat (mis. simpan kombinasi message_id + event + occurred_at).
  • Tidak ada stempel waktu anti-replay di tanda tangan — jaga secret tetap rahasia dan pakai HTTPS.

Webhook dari langkah Journey

Langkah "Webhook" di journey mengirim ke URL yang kamu atur di dashboard, ditandatangani dengan secret journey (lihat halaman detail journey) memakai cara verifikasi yang sama:

{"event":"journey.webhook","journey":{"id":5,"name":"Welcome Series"},"contact":{"email":"[email protected]","name":"Budi"},
 "step":3,"goal_reached":false,"occurred_at":"2026-09-22T03:12:00+00:00"}

Dicoba hingga 5 kali (jeda 30 detik, 2 menit, 10 menit, 30 menit). Journey tetap berlanjut apa pun hasilnya.

SMTP relay

Alternatif tanpa coding untuk aplikasi yang sudah bisa kirim lewat SMTP (WordPress, WooCommerce, Laravel, Odoo, dll.). Kredensial dibuat di Settings → Kredensial SMTP.

PengaturanNilai
Hostmail.kirim.id
Port587 (STARTTLS) atau 465 (SSL/TLS). Login wajib lewat koneksi terenkripsi.
Usernamekir + 6 angka (dari dashboard)
Password32 karakter dari dashboard (bisa diganti kapan saja; username tetap)
Metode loginPLAIN atau LOGIN
PengirimAlamat bebas, asal domainnya aktif di akunmu (header From & MAIL FROM)
BatasMaks. 50 penerima per email, 10 MB per email, kecepatan per menit = kecepatan akunmu. Lampiran tidak dikirim.
MAIL_MAILER=smtp
MAIL_HOST=mail.kirim.id
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=kir123456
MAIL_PASSWORD=password-dari-dashboard
[email protected]

Setiap penerima menjadi satu pesan dengan fitur yang sama seperti API (merge tag, pelacakan, footer unsubscribe, webhook). Kode balasan yang perlu dikenali: 535 login salah, 550 5.7.1 pengirim bukan domain aktif / penerima di suppression list / sudah berhenti langganan / saldo koin habis, 553 5.1.3 alamat penerima rusak, 452 batas kecepatan per menit tercapai (coba lagi), 451 gangguan sementara (kirim ulang).

Koin & biaya

KejadianKoin
Email diserahkan ke server pengiriman1 koin (= Rp0,5). Dipotong saat giliran kirim, bukan saat API menerima.
Server pengiriman menolak (sementara/permanen) atau tidak ada catatan pengirimanKoin dikembalikan
Bounce dari server penerimaTidak dikembalikan
Hard bounce atau laporan spamPenalti 4 koin (sekali per pesan); alamat otomatis masuk suppression list
Kirim paksa lewat API/SMTP ke alamat rusak, di suppression list, atau yang sudah berhenti langgananPenalti 4 koin (sekali per alamat + alasan per 24 jam)

Saldo bisa minus karena penalti; pengiriman berhenti sampai saldo kembali positif. Harga & aturan terbaru ada di halaman harga.

Batasan saat ini

Supaya tidak ada kejutan saat integrasi:

  • POST /messages: satu penerima per request; pengirim selalu no-reply@domainmu; belum mendukung nama pengirim/Reply-To sendiri, CC/BCC, lampiran, header kustom, atau isi teks terpisah (teks dibuat dari HTML). Butuh alamat pengirim bebas? Pakai SMTP relay.
  • Pelacakan buka/klik dan footer unsubscribe tidak bisa dimatikan.
  • Belum ada API untuk memasukkan kontak ke audience atau mengatur double opt-in — lakukan dari dashboard (impor CSV / form subscribe).
  • POST /contacts selalu menjawab 201, termasuk untuk kontak yang sudah ada.
  • Payload webhook belum membawa email penerima, URL yang diklik, atau alasan bounce — pakai message_id + GET /messages/{id}.
  • Kontak yang dihapus lalu dikirimi lagi lewat API akan dibuat ulang sebagai subscribed. Untuk memblokir alamat, tambahkan ke Daftar Filter.

Riwayat perubahan

  • 22 Sep 2026 — Baru: GET /v1/contacts/engagement (kontak teraktif). ID berbentuk salah di URL kini menjawab 404. GET /v1/contacts kini selalu urut ID.
  • 21 Sep 2026 — Batas kapasitas harian selama pemanasan IP berlaku untuk semua jalur kirim.
  • Sep 2026 — Rilis API Email v1.

Ada pertanyaan integrasi? Hubungi tim kami — kami bantu sampai jalan.

Daftar Gratis — 1.000 Koin