「翌朝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_timeは ISO 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.xxx は data.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 が成功するとステータスが EDIT → RESERVE に遷移します。
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"
予約時刻を過ぎると WAIT → SENDING → SENT と進みます。バッチで定期的にステータスを取得して、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-tz の formatInTimeZone を使うとシンプル。
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_timeは ISO 8601 + JST タイムゾーン明示 が必須 - 配信ステータス遷移(EDIT → RESERVE → WAIT → SENDING → SENT)を把握する
- キャンセル → 編集 → 再予約のフローで差し替え可能
- EDIT 30件上限 と 62日保管期限 は運用初期に必ず引っかかるので、最初から掃除ジョブを組んでおく
公式リファレンス:blastengine API ドキュメント