blastengine APIには配信種別が TRANSACTION / BULK / SMTP の3つあります。本記事では特に詰まりやすい トランザクション配信 と 一斉配信(BULK) について、APIフローの違いと判断基準を実装コード付きで整理します。
筆者環境:Node.js 20(標準の fetch を使用。外部HTTPライブラリ不要)、curl 8 系。
TL;DR
| 観点 | TRANSACTION | BULK |
|---|---|---|
| API呼び出し回数 | 1リクエストで完結 | 3ステップ(begin → 宛先登録 → commit) |
| 1リクエストあたりの宛先 | 1件(cc/bcc各10件まで) | 多数(一括CSV/JSONで最大ジョブ化) |
| 予約配信 | 不可(即時のみ) | 可能(reservation_time 指定) |
| 差し込みコード | リクエスト内に同梱 | 宛先ごとに insert_code を保持 |
| 配信ログ取得 |
delivery_id 単位 |
delivery_id 単位(宛先数分のログが生成) |
| 想定ユース | 会員登録通知・パスワードリセット | メルマガ・キャンペーン告知 |
迷ったら 「同じ瞬間に何件送るか」 で判断すれば9割正解です。
前提:BearerToken の生成
どちらの配信種別でも認証は共通で、apiKey と ログインID から SHA256 → 小文字化 → base64 を経由して BearerToken を作ります。
YOUR_LOGIN_ID="your_login_id"
YOUR_API_KEY="your_api_key"
YOUR_BEARER_TOKEN=$(echo -n "${YOUR_LOGIN_ID}${YOUR_API_KEY}" | shasum -a 256 | awk '{print $1}')
YOUR_BEARER_TOKEN=$(echo -n "${YOUR_BEARER_TOKEN}" | tr A-Z a-z)
YOUR_BEARER_TOKEN=$(echo -n "${YOUR_BEARER_TOKEN}" | base64 | tr -d "\n")
echo "${YOUR_BEARER_TOKEN}"
Node.js で書くとこうなります。
import { createHash } from "node:crypto";
export function buildBearerToken(loginId, apiKey) {
const sha256 = createHash("sha256")
.update(`${loginId}${apiKey}`)
.digest("hex")
.toLowerCase();
return Buffer.from(sha256, "utf8").toString("base64");
}
以降のサンプルでは、生成したトークンを BE_TOKEN 環境変数に入れて使い回します。
共通クライアント(fetch ベース)
HTTP 呼び出しは Node.js 20 標準の fetch だけで完結します(axios などの外部パッケージは不要)。以降のサンプルで使い回す薄いラッパを1つ用意しておきます。
const BASE_URL = "https://app.engn.jp/api/v1";
async function beFetch(method, path, { body, params } = {}) {
const url = new URL(`${BASE_URL}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) value.forEach((v) => url.searchParams.append(key, v));
else url.searchParams.set(key, value);
}
}
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${process.env.BE_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const raw = await res.text();
const data = raw ? JSON.parse(raw) : null;
if (!res.ok) {
throw Object.assign(new Error(`blastengine ${method} ${path} -> ${res.status}`), {
status: res.status,
data,
});
}
return data;
}
// axios の client.get/post/... と同じ感覚で使えるようにしておく
const be = {
get: (path, params) => beFetch("GET", path, { params }),
post: (path, body) => beFetch("POST", path, { body }),
put: (path, body) => beFetch("PUT", path, { body }),
patch: (path, body) => beFetch("PATCH", path, { body }),
delete: (path) => beFetch("DELETE", path),
};
fetch は axios と違って HTTP 4xx/5xx でも例外を投げないため、上のように res.ok を見て自前で投げています。レスポンス本文はそのまま返るので、axios の res.data.xxx は data.xxx に読み替えてください。
トランザクション配信(TRANSACTION)
フロー
[POST /deliveries/transaction]
↓
配信ID発行 & 即時配信
API 1回で完結します。サインアップ完了通知や、ECサイトの注文確認メールのような 「ユーザー操作の直後に1通送る」 ケースに向きます。
実装例:会員登録時の確認メールを送る
export async function sendWelcomeMail({ email, name, memberId }) {
const data = await be.post("/deliveries/transaction", {
from: {
email: "noreply@example.com",
name: "Example サポート",
},
to: email,
insert_code: [
{ key: "__name__", value: name },
{ key: "__memberid__", value: memberId },
],
subject: "__name__ 様、ご登録ありがとうございます",
encode: "UTF-8",
text_part: [
"__name__ 様",
"",
"ご登録ありがとうございます。",
"会員番号:__memberid__",
"",
"https://example.com/login からログインしてください。",
].join("\n"),
html_part: `<!DOCTYPE html><html><body>
<p>__name__ 様</p>
<p>ご登録ありがとうございます。<br>会員番号:__memberid__</p>
<p><a href="https://example.com/login">ログインはこちら</a></p>
</body></html>`,
});
return data.delivery_id;
}
レスポンスは { "delivery_id": 12345 } のみで、配信は即時に走ります。
差し込みコード(insert_code)の動作
リクエスト内の subject / text_part / html_part / list_unsubscribe 内に __name__ のようなトークンを書いておくと、insert_code の value で置換されます。TRANSACTIONでは1リクエスト=1宛先なので、配列はその宛先専用の値になります。
キー名の命名規則(要注意)
差し込みコードの key は __[A-Za-z0-9]+__ にする必要があります。
| 例 | 可否 |
|---|---|
__name__ |
OK |
__memberid__ |
OK |
__id1__ |
OK |
__ABC__ |
OK |
__member_id__ |
NG(内部アンダースコア不可) |
__user-id__ |
NG(ハイフン不可) |
__プロップ__ |
NG(マルチバイト不可) |
スネークケースに慣れていると __member_id__ と書きがちですが、即 400 エラーになります。__memberid__ か __memberId__ のようにアンダースコアを取りましょう。
cc / bcc を付ける
await be.post("/deliveries/transaction", {
from: { email: "noreply@example.com", name: "Example" },
to: "primary@example.com",
cc: ["cc1@example.com", "cc2@example.com"],
bcc: ["audit@example.com"],
subject: "請求書を送付します",
encode: "UTF-8",
text_part: "本文",
});
cc / bcc はそれぞれ最大10件。差し込みコードは to を起点に解決されます。
添付ファイル
ファイル付きで送る場合は Content-Type: multipart/form-data に切り替えます。
import fs from "node:fs";
// FormData / Blob は Node.js 20 にグローバルで入っている(form-data パッケージ不要)
const form = new FormData();
// data パートは application/json、file パートはファイル種別を明示する
form.append(
"data",
new Blob(
[JSON.stringify({
from: { email: "noreply@example.com", name: "Example" },
to: "user@example.com",
subject: "請求書を送付します",
encode: "UTF-8",
text_part: "添付の請求書をご確認ください。",
})],
{ type: "application/json" },
),
);
form.append("file", await fs.openAsBlob("./invoice.pdf", { type: "application/pdf" }), "invoice.pdf");
await fetch("https://app.engn.jp/api/v1/deliveries/transaction", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BE_TOKEN}`,
// Content-Type は指定しない。multipart の boundary は fetch が自動で付ける
},
body: form,
});
添付できる合計サイズはデフォルト 1MB(契約プランにより異なる)、ファイル数は最大100件まで。.exe .bat 等は仕様上ブロックされるので、ユーザーアップロードを転送する場合は事前にバリデーションを入れておきます。
一斉配信(BULK)
フロー
[POST /deliveries/bulk/begin] ← 配信ID発行(status=EDIT)
↓
[POST /deliveries/{id}/emails/import] ← 宛先CSVをアップロード(ジョブ起動)
↓
[GET /deliveries/-/emails/import/{job_id}] ← ジョブ完了を待つ
↓
[PATCH /deliveries/bulk/commit/{id}/immediate] ← 即時配信を確定
宛先が数千〜数万件規模になる場合は BULK 一択です。1件ずつ TRANSACTION を回すと Rate Limit (500 req/m) に張り付きます。
Step 1:配信を作る(begin)
const begin = await be.post("/deliveries/bulk/begin", {
from: { email: "news@example.com", name: "Example ニュース" },
encode: "UTF-8",
subject: "【__name__ 様】今月の新着情報をお届けします",
text_part: "__name__ 様\n\n今月の新着情報です。\n...",
html_part: "<p>__name__ 様</p><p>今月の新着情報です。</p>",
list_unsubscribe: {
mailto: "mailto:unsubscribe@example.com?subject=unsubscribe&body=__token__",
url: "https://example.com/unsubscribe/__token__",
},
});
const deliveryId = begin.delivery_id;
この段階では配信ステータスは EDIT。宛先はまだ0件です。
Step 2:宛先を一括登録する(CSV import)
CSV を作って /deliveries/{delivery_id}/emails/import に POST します。
email,__name__,__token__
alice@example.com,Alice,abc123
bob@example.com,Bob,def456
__name__ のような列は差し込みコードとして自動的に紐付きます。
import fs from "node:fs";
async function importRecipients(deliveryId, csvPath) {
const form = new FormData();
// 各パートの Content-Type を明示する。標準 FormData は型を自動推定しないため、
// file を text/csv、data を application/json にしないと API が 415 を返す。
form.append("file", await fs.openAsBlob(csvPath, { type: "text/csv" }), "recipients.csv");
form.append(
"data",
new Blob(
[JSON.stringify({
ignore_errors: true, // バリデーションエラー行を除外して登録
immediate: false, // 登録完了と同時に配信開始するか
})],
{ type: "application/json" },
),
);
const res = await fetch(
`https://app.engn.jp/api/v1/deliveries/${deliveryId}/emails/import`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.BE_TOKEN}` },
body: form,
},
);
if (!res.ok) throw new Error(`import failed: ${res.status}`);
const data = await res.json();
return data.job_id;
}
ハマりどころ:
form-dataパッケージは拡張子からtext/csvを、文字列パートは型なしで送っていましたが、blastengine の import はdataパートにapplication/json、fileパートにtext/csvが付いていないとHTTP 415 "Content-Type specified in the multipart is not supported."を返します。標準FormDataは型を推測しないので、上記のようにBlobのtypeで明示してください。
immediate: true にすると、宛先登録完了直後に自動で commit まで走るので、運用ジョブを1本に減らせます。事前に内容確認したい場合は false のままで。
Step 3:ジョブの完了を待つ
async function waitForImport(jobId, { intervalMs = 3000, maxWaitMs = 600_000 } = {}) {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
const data = await be.get(`/deliveries/-/emails/import/${jobId}`);
const { status, percentage, success_count, failed_count, error_file_url } = data;
console.log(`[import:${jobId}] ${status} ${percentage}% (ok=${success_count} ng=${failed_count})`);
if (status === "FINISHED") return data;
if (["FAILED", "STOP", "SYSTEM_ERROR", "TIMEOUT"].includes(status)) {
throw new Error(`import job ${status}: ${error_file_url ?? "(no error csv)"}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`import job ${jobId} timed out`);
}
error_file_url が返ってきた場合は /deliveries/-/emails/import/{job_id}/errorinfo/download から原因ファイルをダウンロードできます(戻りは ZIP で、中にエラー行を記したCSVが入っています)。アドレス形式エラーや差し込みコード不整合の特定に必須。
Step 4:commit する(即時 or 予約)
// 即時配信
await be.patch(`/deliveries/bulk/commit/${deliveryId}/immediate`);
// 予約配信したい場合
await be.patch(`/deliveries/bulk/commit/${deliveryId}`, {
reservation_time: "2026-06-01T10:00:00+09:00",
});
commit 後はステータスが WAIT → SENDING → SENT と遷移します。GET /deliveries/{delivery_id} でいつでも確認可能。
一連の流れをまとめる
export async function sendNewsletter({ csvPath }) {
// 1. begin
const { delivery_id } = await be.post("/deliveries/bulk/begin", {
from: { email: "news@example.com", name: "Example ニュース" },
encode: "UTF-8",
subject: "【__name__ 様】今月の新着情報",
text_part: "__name__ 様\n\n今月の新着情報です。",
});
// 2. 宛先登録ジョブを起動
const jobId = await importRecipients(delivery_id, csvPath);
// 3. ジョブ完了待ち
await waitForImport(jobId);
// 4. 即時配信
await be.patch(`/deliveries/bulk/commit/${delivery_id}/immediate`);
return delivery_id;
}
使い分けの判断軸
軸①:宛先数
1〜数件 → TRANSACTION
数十〜数百件・同一文面 → BULK
数百件・宛先別本文 → BULK + 差し込みコード
数千件以上 → BULK 一択(CSV import 必須)
TRANSACTION を1件ずつ叩く運用は Rate Limit (500 req/m) と認証のオーバーヘッドで頭打ちになります。
軸②:即時性
| やりたいこと | 選ぶべき |
|---|---|
| ボタンを押した直後にメール | TRANSACTION |
| 翌朝9時に全顧客へ配信 | BULK + reservation_time
|
| 集計バッチが終わった瞬間に配信 | BULK + immediate
|
軸③:エラーハンドリング
TRANSACTION は HTTPレスポンスで成否がそのまま返ってくる(400 系のバリデーションエラーは即座にわかる)一方、BULK は ジョブの結果を後追いで取得 する必要があります。
-
TRANSACTION:try/catch で十分。
429 Too Many Requestsだけ指数バックオフでリトライ。 - BULK:import ジョブのステータスポーリング + 失敗時の error_file_url 取得を必ず実装する。
軸④:パーソナライズの粒度
insert_code の上限は 50 件まで。「宛先ごとに本文の一部だけ差し替えたい」 ユースケースは BULK の方が圧倒的に効率的です。
email,__name__,__plan__,__renewdate__
alice@example.com,Alice,Pro,2026-08-01
bob@example.com,Bob,Free,2026-07-15
CSV側にパーソナライズ値を全部寄せられるので、本文テンプレートは1つで済みます。
よくある判断ミス
NG 1:TRANSACTION で大量配信ループ
// アンチパターン
for (const user of users) {
await be.post("/deliveries/transaction", { /* ... */ });
}
直列で回すと 1リクエスト 200ms としても約 200 秒(200ms × 1000)かかり、さらに Rate Limit が 500 req/m なので 1000 件なら最低でも 2 分以上は張り付きます。素直に BULK に切り替えましょう。
NG 2:BULK でステータス確認をしない
commit を叩いた後 SENT を確認せずに「送れたつもり」になると、FAILED を見逃します。
const detail = await be.get(`/deliveries/${deliveryId}`);
if (detail.status === "FAILED") {
// アラート発火
}
最低でも commit 後に1回はステータスを取りに行く設計に。
NG 3:EDIT 状態の配信を放置して上限到達
EDIT ステータスの配信は 30件まで しか保持できません。テスト用に作ってそのままにしている配信は、定期的に DELETE /deliveries/{delivery_id} で掃除しましょう。
まとめ
- TRANSACTION:1リクエストで完結、即時性◯、宛先数は少なめ
- BULK:3ステップで非同期、大量配信◯、予約配信◯
- 判断軸は 「宛先数 × 即時性 × パーソナライズ粒度」 の3つで決まる
- TRANSACTION の大量ループと、BULK のステータス未確認は典型的なアンチパターン
実装時は本記事のサンプルを叩き台に、各社のRate Limit 上限・配信規模に合わせてポーリング間隔とリトライ戦略をチューニングしてください。
公式リファレンス:blastengine API ドキュメント