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?

blastengine APIで予約配信を実装する〜reservation_timeと配信ステータスについて解説〜

0
Posted at

「翌朝9時にメルマガを送りたい」「キャンペーン開始と同時に通知したい」といった時限メール配信を blastengine API で実装する方法をまとめます。

ポイントは 配信ステータスのライフサイクルreservation_time の正しい指定方法 の2つだけ。ここを押さえれば予約配信は安定して回せます。

筆者環境:Node.js 20(標準の fetch を使用。外部HTTPライブラリ不要)。

TL;DR

予約配信は BULK(一斉配信)の commit 時に reservation_time を渡すだけ。

await be.patch(`/deliveries/bulk/commit/${deliveryId}`, {
  reservation_time: "2026-06-01T10:00:00+09:00",
});

ただし以下を理解していないと運用で詰まります。

  • 配信ステータスは EDIT → RESERVE → WAIT → SENDING → SENT と遷移する
  • reservation_timeISO 8601 拡張形式 + タイムゾーン必須
  • 予約をキャンセルすると EDIT に戻り、本文の差し替えが可能
  • EDIT 状態の配信は 30件まで しか保持できない

配信ステータスのライフサイクル

予約配信を理解する第一歩は、blastengine の配信ステータス遷移を把握することです。

[begin]
   ↓
 EDIT  ← この状態でのみ宛先登録・本文修正が可能
   ↓ (emails/import)
IMPORTING
   ↓
 EDIT
   ↓ (commit with reservation_time)
RESERVE  ← 予約配信中
   ↓ (予約時刻到達)
 WAIT
   ↓
SENDING
   ↓
 SENT (成功) or FAILED (失敗)

各ステータスの意味は以下のとおり。

ステータス 意味 修正可否
EDIT 編集中
IMPORTING 宛先CSV取り込み中 不可
RESERVE 予約済み(送信時刻待ち) キャンセル後に編集可
WAIT 送信キュー投入済み 不可
SENDING 送信中 不可
SENT 配信成功 不可
FAILED 配信失敗 不可

重要:RESERVE 状態でも cancel を叩けば EDIT に戻せます。「予約時刻の直前に本文を差し替えたい」という運用要件にも対応可能。

前提:fetch クライアント

HTTP 呼び出しは Node.js 20 標準の fetch だけで完結します(axios などの外部パッケージは不要)。以降のサンプルで使い回す薄いラッパを用意します。BE_TOKEN には事前に生成した BearerToken が入っている前提です。

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;
}

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 は HTTP 4xx/5xx でも例外を投げないので、res.ok を見て自前で投げています。レスポンス本文はそのまま返るため、axios の res.data.xxxdata.xxx に読み替えてください。

予約配信を実装する4ステップ

Step 1:配信を作る(begin)

const { delivery_id } = await be.post("/deliveries/bulk/begin", {
  from: {
    email: "news@example.com",
    name: "Example ニュース",
  },
  encode: "UTF-8",
  subject: "【__name__ 様】6月のお知らせ",
  text_part: [
    "__name__ 様",
    "",
    "いつもご利用ありがとうございます。",
    "6月のお得な情報をお届けします。",
    "",
    "詳細: https://example.com/news/202606",
  ].join("\n"),
  html_part: `<!DOCTYPE html><html><body>
    <p>__name__ 様</p>
    <p>いつもご利用ありがとうございます。<br>6月のお得な情報をお届けします。</p>
    <p><a href="https://example.com/news/202606">詳細はこちら</a></p>
  </body></html>`,
  list_unsubscribe: {
    mailto: "mailto:unsubscribe@example.com?subject=unsubscribe&body=__token__",
    url: "https://example.com/unsubscribe/__token__",
  },
});

console.log("delivery_id:", delivery_id); // ステータス: EDIT

この時点でステータスは EDIT。宛先は0件。

Step 2:宛先を登録する

宛先が少数なら個別登録、多数ならCSVで一括登録します。

パターンA:個別登録(最大数十件)

async function addRecipient(deliveryId, email, vars) {
  return be.post(`/deliveries/${deliveryId}/emails`, {
    email,
    insert_code: Object.entries(vars).map(([key, value]) => ({
      key: `__${key}__`,
      value: String(value),
    })),
  });
}

await addRecipient(delivery_id, "alice@example.com", {
  name: "Alice",
  token: "abc123",
});
await addRecipient(delivery_id, "bob@example.com", {
  name: "Bob",
  token: "def456",
});

パターンB:CSV一括登録(数百件以上)

email,__name__,__token__
alice@example.com,Alice,abc123
bob@example.com,Bob,def456
import fs from "node:fs";

async function importRecipients(deliveryId, csvPath) {
  const form = new 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: false, 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;
}

// ジョブ完了待ち
async function waitForImport(jobId) {
  while (true) {
    const data = await be.get(`/deliveries/-/emails/import/${jobId}`);
    if (data.status === "FINISHED") return data;
    if (["FAILED", "STOP", "SYSTEM_ERROR", "TIMEOUT"].includes(data.status)) {
      throw new Error(`import ${data.status}: ${data.error_file_url ?? ""}`);
    }
    await new Promise((r) => setTimeout(r, 3000));
  }
}

const jobId = await importRecipients(delivery_id, "./recipients.csv");
await waitForImport(jobId);

Step 3:reservation_time を指定して commit する

ここが予約配信の本体。/deliveries/bulk/commit/{delivery_id} (PATCH) に reservation_time を渡します。

async function reserve(deliveryId, isoDateTime) {
  return be.patch(`/deliveries/bulk/commit/${deliveryId}`, {
    reservation_time: isoDateTime,
  });
}

// 2026/06/01 10:00 JST に予約
await reserve(delivery_id, "2026-06-01T10:00:00+09:00");

commit が成功するとステータスが EDITRESERVE に遷移します。

Step 4:ステータスを確認する

const detail = await be.get(`/deliveries/${delivery_id}`);
console.log(detail.status);          // "RESERVE"
console.log(detail.reservation_time); // "2026-06-01T10:00:00+09:00"

予約時刻を過ぎると WAITSENDINGSENT と進みます。バッチで定期的にステータスを取得して、FAILED が出たらアラートを上げる運用にしておくと安心。

reservation_time で詰まりやすい3つのポイント

① タイムゾーン省略はNG

仕様は ISO 8601 拡張形式(yyyy-MM-ddTHH:mm:ss+09:00 です。タイムゾーン部分を省略するとバリデーションエラーになります。

// NG: タイムゾーン無し
"2026-06-01T10:00:00"

// NG: Z(UTC)は受け付けない場合あり → JST 明示が安全
"2026-06-01T01:00:00Z"

// OK
"2026-06-01T10:00:00+09:00"

JavaScript の Date.toISOString() は UTC (Z) を返すので、そのまま渡さずに JST に変換します。

function toJstIso(date) {
  // 日付を JST に変換して ISO 拡張形式 + +09:00 を返す
  const jstOffsetMs = 9 * 60 * 60 * 1000;
  const jst = new Date(date.getTime() + jstOffsetMs);
  // 公式形式は yyyy-MM-ddTHH:mm:ss+09:00(ミリ秒なし)なので .000 を落とす
  return jst.toISOString().replace(/\.\d{3}Z$/, "+09:00");
}

const target = new Date("2026-06-01T10:00:00+09:00");
const iso = toJstIso(target);
// "2026-06-01T10:00:00+09:00"

ライブラリ派なら date-fns-tzformatInTimeZone を使うとシンプル。

import { formatInTimeZone } from "date-fns-tz";

const iso = formatInTimeZone(
  new Date("2026-06-01T10:00:00+09:00"),
  "Asia/Tokyo",
  "yyyy-MM-dd'T'HH:mm:ssXXX",
);
// "2026-06-01T10:00:00+09:00"

② 過去日時を指定したときの挙動

reservation_time に過去日時を指定したときの挙動(バリデーションエラーになるのか、即時送信扱いになるのか)は環境や仕様変更で変わり得るため、API の挙動に依存しない作りにしておくのが安全です。送る側で「未来時刻であること」を保証するガードを必ず入れておきましょう。

function assertFutureTime(isoString, minMarginSec = 60) {
  const target = new Date(isoString).getTime();
  const now = Date.now();
  if (target - now < minMarginSec * 1000) {
    throw new Error(`reservation_time must be at least ${minMarginSec}s in future`);
  }
}

assertFutureTime("2026-06-01T10:00:00+09:00");

③ サマータイム・うるう秒は気にしない

JST にサマータイムは無く、うるう秒は API レイヤで吸収されるので、+09:00 固定で問題なし。海外配信を考慮する場合のみタイムゾーン処理を真面目にやりましょう。

予約後にキャンセル・差し替える

「やっぱり本文を直したい」「日時を変更したい」というケースは現場では頻発します。

キャンセル → 編集 → 再予約のフロー

// 1. 予約をキャンセル(RESERVE → EDIT)
await be.patch(`/deliveries/${delivery_id}/cancel`);

// 2. 本文を更新(EDIT 状態のみ可)
await be.put(`/deliveries/bulk/update/${delivery_id}`, {
  subject: "【__name__ 様】6月のお知らせ(重要:日時変更)",
  text_part: "更新後の本文…",
  html_part: "<p>更新後の本文…</p>",
});

// 3. 新しい日時で再予約
await be.patch(`/deliveries/bulk/commit/${delivery_id}`, {
  reservation_time: "2026-06-01T15:00:00+09:00",
});

キャンセル可能な条件

cancel API が成功するのは以下を すべて満たす とき。

  • 配信ステータスが EDIT, RESERVE, WAIT のいずれか
  • 配信種別が BULK

SENDING に入ってからはキャンセル不可。WAIT の段階でもタイミング次第では失敗するので、戻り値で必ず確認します。

try {
  await be.patch(`/deliveries/${delivery_id}/cancel`);
} catch (err) {
  if (err.status === 400) {
    console.error("既に送信処理が始まっています");
  }
  throw err;
}

宛先の追加・削除

EDIT 状態なら宛先の追加・更新・削除も可能。

// 追加
await be.post(`/deliveries/${delivery_id}/emails`, {
  email: "newuser@example.com",
  insert_code: [{ key: "__name__", value: "Newbie" }],
});

// 更新(email_id は宛先登録時のレスポンスに含まれる)
await be.put(`/deliveries/-/emails/${email_id}`, {
  email: "updated@example.com",
  insert_code: [{ key: "__name__", value: "Updated Name" }],
});

// 削除
await be.delete(`/deliveries/-/emails/${email_id}`);

運用で詰まる3つのポイント

① 配信登録上限:EDIT は30件まで

HTTP 400: "EDIT 状態の配信が上限に達しています"

EDIT のまま放置している配信が30件溜まるとAPIが新規 begin を拒否します。

対策:

// 古い EDIT 配信を一覧から削除する定期ジョブ
const list = await be.get("/deliveries", { "status[]": "EDIT", size: 100 });

for (const d of list.data) {
  // 7日以上経過した EDIT は削除
  const ageDays = (Date.now() - new Date(d.created_time).getTime()) / 86400000;
  if (ageDays > 7) {
    await be.delete(`/deliveries/${d.delivery_id}`);
    console.log(`deleted stale delivery ${d.delivery_id}`);
  }
}

② 配信保管期限:62日で消える

配信開始から 62日経過 で、配信情報・宛先・ログがすべて削除されます。

予約日: 2026-06-01
削除日: 2026-08-02 頃

開封率レポートや配信ログを社内に残したい場合は、62日以内に外部DWHへエクスポートするバッチを必ず用意します。

// 配信ログを取得して保存
const logs = await be.get("/logs/mails/results", { delivery_id, count: 1000 });

await saveToBigQuery(logs.data);

③ ステータス遷移の見落とし

SENT 後に FAILED 件数を確認しないと、部分的に失敗していても気づきません。配信詳細には集計値が入っています。

const data = await be.get(`/deliveries/${delivery_id}`);

console.log({
  status: data.status,             // "SENT"
  total: data.total_count,         // 1000
  sent: data.sent_count,           // 980
  hardError: data.hard_error_count, // 15
  softError: data.soft_error_count, // 3
  drop: data.drop_count,           // 2
});

if (data.hard_error_count > data.total_count * 0.01) {
  // ハードエラー率 1% 超でアラート
  await sendSlackAlert(data);
}

実装例:予約配信を1関数にまとめる

export async function scheduleNewsletter({ csvPath, subject, textPart, htmlPart, sendAt }) {
  assertFutureTime(sendAt);

  // 1. begin
  const { delivery_id } = await be.post("/deliveries/bulk/begin", {
    from: { email: "news@example.com", name: "Example ニュース" },
    encode: "UTF-8",
    subject,
    text_part: textPart,
    html_part: htmlPart,
  });

  try {
    // 2. 宛先登録
    const jobId = await importRecipients(delivery_id, csvPath);
    await waitForImport(jobId);

    // 3. 予約 commit
    await be.patch(`/deliveries/bulk/commit/${delivery_id}`, {
      reservation_time: sendAt,
    });

    return delivery_id;
  } catch (err) {
    // エラー時はゴミ配信を残さない
    await be.delete(`/deliveries/${delivery_id}`).catch(() => {});
    throw err;
  }
}

// 使い方
await scheduleNewsletter({
  csvPath: "./recipients_202606.csv",
  subject: "【__name__ 様】6月のお知らせ",
  textPart: "__name__ 様\n\n6月の新着情報です…",
  htmlPart: "<p>__name__ 様</p><p>6月の新着情報です…</p>",
  sendAt: "2026-06-01T10:00:00+09:00",
});

エラー発生時に delete で EDIT 配信を片付けるのがポイント。これがないとリトライのたびに EDIT 配信が溜まって上限に到達します。

まとめ

  • 予約配信は BULK の commit に reservation_time を渡す だけ
  • reservation_timeISO 8601 + JST タイムゾーン明示 が必須
  • 配信ステータス遷移(EDIT → RESERVE → WAIT → SENDING → SENT)を把握する
  • キャンセル → 編集 → 再予約のフローで差し替え可能
  • EDIT 30件上限62日保管期限 は運用初期に必ず引っかかるので、最初から掃除ジョブを組んでおく

公式リファレンス: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?