0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

トランザクション配信 vs 一斉配信:blastengine APIのdelivery_typeを使い分ける実装ガイド

0
Posted at

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.xxxdata.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_codevalue で置換されます。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/jsonfile パートに text/csv が付いていないと HTTP 415 "Content-Type specified in the multipart is not supported." を返します。標準 FormData は型を推測しないので、上記のように Blobtype で明示してください。

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 後はステータスが WAITSENDINGSENT と遷移します。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 ドキュメント

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?