IT技術ブログ

予約の前日リマインドをLINEで送る仕組みを、Cloudflare WorkersのCron TriggersとD1で作る。送る対象の抽出、先に送信中にする二重送信の対策、X-Line-Retry-Keyでの再試行、失敗の記録、UTCと日本時間

予約の前日リマインドを、Cloudflare WorkersのCron TriggersとD1、LINEのpushで送る実装です。対象の抽出、二重送信の対策、X-Line-Retry-Keyでの再試行、失敗の記録を動かして確かめました。

この記事の結論:前日リマインドは、Cron Triggersで毎日決まった時刻にWorkerを起こし、D1から取り出した予約に、LINEのpushで1件ずつ送ります。取り出すのは「日本時間で明日・予約中・LINEのIDあり・受け取りに同意あり・まだ送っていない」予約です。二重送信を防ぐ要は2つで、送る前に送信の記録を「送信中」で書き込んで取り合いを防ぐことと、送信ごとにX-Line-Retry-Keyを付けて、送り直すときは同じ値を使うことです。応答が返らなかった送信も、同じ値で送り直せばLINEが409を返すので、お客様に2通届くことはありません。Cron TriggersはUTCで動くので、起動の時刻も「明日」の日付も、日本時間に直して考えます。

この記事では、その仕組みを手元で動かして確かめた結果を、コードと一緒に載せます。動かしたのは wrangler dev --test-scheduled と手元のD1で、LINEのAPIの代わりに、pushの応答を真似る小さなサーバーを立てました。本物のLINEのAPIには一度も送っていません。 連絡の手段の選び方や送る時機の決め方は、結婚式場の見学予約のリマインドとキャンセル防止で扱いました。この記事は「LINEで前日に送る」部分を、壊れにくく作る実装に絞ります。

前日リマインドは、Cron Triggersで起こしたWorkerが、D1の予約を見てpushで送る

全体の流れは、「決まった時刻にWorkerを起こす → 明日の予約から送る相手を選ぶ → 1件ずつ送る → 結果を記録する」の4段です。Webhookのように外から呼ばれる仕組みではないので、受け口のURLは要りません。

段 使うもの ここで決めること
起こす Cron Triggers 何時に送るか(UTCで書く)
選ぶ D1の予約と送信の記録 日本時間の「明日」、予約の状態、LINEのID、同意、送信済みか
送る LINE Messaging API の push X-Line-Retry-Key、タイムアウト
記録する D1の送信の記録 送れた・送り直す・止める、の3つに分ける

起動の時刻は、日本時間の18時と19時の2回にしました。18時が本送信、19時は18時に失敗した分の再試行です。19時の回も同じ処理を通るので、18時以降に入った明日の予約も、この回で拾われます。

wrangler.jsoncjsonc
{
  "name": "visit-reminder",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "triggers": {
    // Cron Triggers は UTC。9:00 UTC = 日本時間 18:00、10:00 UTC = 19:00(失敗分の再試行)
    "crons": ["0 9 * * *", "0 10 * * *"]
  },
  "d1_databases": [
    { "binding": "DB", "database_name": "visit-reminder", "database_id": "00000000-0000-0000-0000-000000000000" }
  ],
  "vars": {
    "LINE_API_BASE": "https://api.line.me",
    "CHANGE_URL_BASE": "https://example.com/reservations/"
  }
}

database_id は手元で動かすための仮の値です。本番では wrangler d1 create で作ったデータベースのIDに置き換えます。チャネルアクセストークンは設定ファイルに書かず、本番では wrangler secret put LINE_CHANNEL_ACCESS_TOKEN で登録し、手元では .dev.vars に置きます。

Cron TriggersはUTCで動くので、時刻も「明日」も日本時間に直す

Cloudflareのドキュメントには、Cron TriggersはUTCで実行されると書かれています。日本時間は1年中UTCより9時間進んでいる(夏時間が無い)ので、cronの式は9時間引いて書き、処理の中では「起動した時刻+9時間」で日本時間の日付を出します。

src/index.js(日付の計算)js
const JST_OFFSET_MS = 9 * 60 * 60 * 1000; // 日本は UTC+9(夏時間なし)

/** UTC のミリ秒 → 日本時間の日付 'YYYY-MM-DD'(days 日後) */
export function jstDate(ms, days = 0) {
  return new Date(ms + JST_OFFSET_MS + days * 86_400_000).toISOString().slice(0, 10);
}

起動した時刻は、controller.scheduledTime(予定されていた実行時刻のミリ秒、UTC基準)から取ります。Date.now() を使わないのは、手元の試験で時刻を指定して動かせるようにするためです。

18時に送るだけなら、UTCで計算しても日付はたまたま一致します。ずれるのは、日本時間の0時から9時のあいだに動かすときです。Node.jsでこの関数を動かして、UTCのまま計算した場合と並べました。

$ node tz-check.mjs
2026-10-08T09:00:00Z  JSTの今日=2026-10-08  JSTの明日=2026-10-09  (UTCで計算した明日=2026-10-09)
2026-10-08T14:59:00Z  JSTの今日=2026-10-08  JSTの明日=2026-10-09  (UTCで計算した明日=2026-10-09)
2026-10-08T15:00:00Z  JSTの今日=2026-10-09  JSTの明日=2026-10-10  (UTCで計算した明日=2026-10-09)
2026-10-31T09:00:00Z  JSTの今日=2026-10-31  JSTの明日=2026-11-01  (UTCで計算した明日=2026-11-01)

3行目のUTC 15時は、日本時間では翌日の0時です。ここでUTCのまま「明日」を求めると1日ずれ、当日の予約に「明日です」と送ってしまいます。いまは18時に動かす設定でも、「朝に送りたい」と時刻を変えた日に、この差が表に出ます。日付の計算は最初から日本時間に直しておきます。

予約の日時も、D1には日本時間の日付(2026-10-09)と時刻(11:00)で持たせました。お客様にも式場のスタッフにも日本時間でしか見せない値なので、UTCに直して保存すると、表示するたびに戻す手間と間違いが増えるからです。

予約と送信の記録は、テーブルを分けて持つ

予約のテーブルには送信の状態を持たせず、送信の記録を別のテーブルにしました。主キーを「予約ID+リマインドの種類」にして、1つの予約に前日のリマインドの行が2つできないようにしています。

schema.sqlsql
-- 予約(日時は日本時間で持つ)
CREATE TABLE IF NOT EXISTS reservations (
  id TEXT PRIMARY KEY,
  customer_name TEXT NOT NULL,
  line_user_id TEXT,                               -- LINE とつながっていなければ NULL
  line_reminder_consent INTEGER NOT NULL DEFAULT 0, -- 1 = リマインドを LINE で受け取ることに同意
  visit_date TEXT NOT NULL,                        -- 'YYYY-MM-DD'(日本時間)
  visit_time TEXT NOT NULL,                        -- 'HH:MM'(日本時間)
  status TEXT NOT NULL DEFAULT 'booked'            -- booked / cancelled
);
CREATE INDEX IF NOT EXISTS idx_reservations_visit ON reservations (visit_date, status);

-- 送信の記録。1つの予約に「前日のリマインド」は1行だけ
CREATE TABLE IF NOT EXISTS reminder_sends (
  reservation_id TEXT NOT NULL,
  kind TEXT NOT NULL,              -- 'day_before'
  status TEXT NOT NULL,            -- sending / sent / retry / failed
  retry_key TEXT NOT NULL,         -- X-Line-Retry-Key。同じ送信の再試行では同じ値を使う
  attempts INTEGER NOT NULL DEFAULT 1,
  http_status INTEGER,
  line_request_id TEXT,
  error TEXT,
  claimed_at TEXT NOT NULL,        -- 送信中にした時刻(UTC の ISO 形式)
  sent_at TEXT,
  PRIMARY KEY (reservation_id, kind)
);

分けておくと、あとで「3日前のリマインド」や「来館後のお礼」を足すときも、kind を増やすだけで済みます。送信の記録の status は4つです。

status 意味 次の実行で
sending 送る直前に書き込んだ。結果はまだ 10分たっても残っていれば、途中で止まったとみなして同じ retry key で送り直す
sent LINEが受け付けた(200、または409) 何もしない
retry 500やタイムアウト。届いたかどうか分からない 3回目までは同じ retry key で送り直す
failed 400・429など、送り直しても通らない。または再試行の回数を使い切った 何もしない。人が見て判断する

送る対象は、明日・予約中・LINEのIDあり・同意あり・未送信で絞る

送る相手は、1本のSQLで選びます。条件を処理の中に散らさず1か所に置くと、「なぜこの人に送ったのか(送らなかったのか)」を後から説明しやすくなります。

sql
SELECT r.id, r.customer_name, r.line_user_id, r.visit_date, r.visit_time,
       s.status AS send_status, s.attempts, s.retry_key
  FROM reservations r
  LEFT JOIN reminder_sends s ON s.reservation_id = r.id AND s.kind = ?1
 WHERE r.visit_date = ?2                -- 日本時間の明日
   AND r.status = 'booked'              -- キャンセル済みは除く
   AND r.line_user_id IS NOT NULL       -- LINE とつながっている
   AND r.line_reminder_consent = 1      -- LINE で受け取ることに同意している
   AND (s.reservation_id IS NULL                            -- まだ送っていない
        OR (s.status = 'retry' AND s.attempts < ?3)         -- 送り直せる失敗
        OR (s.status = 'sending' AND s.claimed_at < ?4))    -- 送信中のまま止まった
 ORDER BY r.visit_time, r.id

同意の列を別に持っているのは、LINEで友だちになっていることと、予約の連絡をLINEで受け取ってよいことは別の話だからです。同意が無い予約や、LINEとつながっていない予約は、ここで外れるので、メールやSMSなど別の手段で送る処理に回します。

ここで外した予約が、本当に送られないことも手元で確かめました。用意した予約は次の8件です。

予約 来館日(日本時間) LINEのID 同意 状態 用意した理由
R001 10月9日 11:00 あり あり 予約中 ふつうに送れる
R002 10月9日 13:00 あり なし 予約中 同意なし
R003 10月9日 14:00 なし あり 予約中 LINEとつながっていない
R004 10月9日 15:00 あり あり キャンセル済み キャンセル
R005 10月9日 10:00 あり あり 予約中 1回目だけ500が返る
R006 10月9日 16:00 あり あり 予約中 1回目は受け付けるが、応答が12秒返らない
R007 10月9日 17:00 あり あり 予約中 429(月の上限)が返る
R008 10月10日 11:00 あり あり 予約中 明後日

10月8日18時(日本時間)として動かすと、対象は4件(R001・R005・R006・R007)になり、R002・R003・R004・R008への送信は、真似たサーバーの記録に1件もありませんでした。

送る前に「送信中」を書き込み、書き込めた実行だけが送る

二重送信を防ぐために、送る直前に送信の記録を sending で書き込み、書き込めた実行だけが送るようにします。主キーがあるので、同じ予約の行を2つの実行が同時に書こうとしても、片方は何も書けずに終わります。

src/index.js(送る権利を取る)js
/** 送る前に「送信中」の行を書いて、この予約を自分の担当にする。取れたら retry key を返す */
async function claim(env, r, nowIso) {
  if (r.send_status == null) {
    const retryKey = crypto.randomUUID();
    const res = await env.DB.prepare(
      `INSERT INTO reminder_sends (reservation_id, kind, status, retry_key, attempts, claimed_at)
       VALUES (?1, ?2, 'sending', ?3, 1, ?4)
       ON CONFLICT (reservation_id, kind) DO NOTHING`,
    )
      .bind(r.id, KIND, retryKey, nowIso)
      .run();
    return res.meta.changes === 1 ? retryKey : null;
  }
  // 再試行:読んだときと同じ状態・回数のときだけ更新する(同時に動いた別の実行と取り合わない)
  const res = await env.DB.prepare(
    `UPDATE reminder_sends
        SET status = 'sending', attempts = attempts + 1, claimed_at = ?1
      WHERE reservation_id = ?2 AND kind = ?3 AND status = ?4 AND attempts = ?5`,
  )
    .bind(nowIso, r.id, KIND, r.send_status, r.attempts)
    .run();
  // retry key は最初の送信と同じものを使う。LINE が受け付け済みなら 409 が返り、二重には届かない
  return res.meta.changes === 1 ? r.retry_key : null;
}

送ったあとに「送信済み」を書く順番だと、送った直後にWorkerが止まった場合、記録が残らず、次の実行でもう一度送ってしまいます。先に書く順番なら、その場合は sending のまま残るだけで、次の段の仕組みで安全に片づけられます。

同じ時刻の実行が2つ重なった場合も試しました。手動で動かした分と定時の分が重なった、という場面を想定しています。新しく用意し直した予約に対して、同じ時刻の実行を2つ同時に起こした結果が次のとおりです。

{"cron":"0 9 * * *","target":"2026-10-09","candidates":5,"sent":1,"retry":1,"failed":1,"skipped":2}
{"cron":"0 9 * * *","target":"2026-10-09","candidates":5,"sent":0,"retry":2,"failed":0,"skipped":3}

どちらの実行も5件を候補として読みましたが、それぞれが「取れなかった」予約を飛ばし(skipped)、真似たサーバーに届いたpushは5人に1回ずつでした。

X-Line-Retry-Keyを付けて、送り直しても二重に届かないようにする

LINEのpushには、X-Line-Retry-Key ヘッダーにUUIDを付けられます。LINEのドキュメントでは、同じ retry key の送信がすでに受け付けられていれば、2回目は409が返り、x-line-accepted-request-id ヘッダーに最初の送信のリクエストIDが入ると説明されています。retry key が有効なのは、最初の送信から24時間です。

src/index.js(pushで送る)js
async function pushReminder(env, r, retryKey) {
  let res;
  try {
    res = await fetch(`${env.LINE_API_BASE}/v2/bot/message/push`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
        'X-Line-Retry-Key': retryKey,
      },
      body: JSON.stringify({ to: r.line_user_id, messages: [{ type: 'text', text: reminderText(r, env) }] }),
      signal: AbortSignal.timeout(10_000),
    });
  } catch (e) {
    // タイムアウト・通信の失敗:LINE に届いたかどうか分からないので、同じ retry key で後で送り直す
    return { status: 'retry', httpStatus: null, error: `${e.name}: ${e.message}` };
  }
  const requestId = res.headers.get('x-line-request-id');
  if (res.ok) return { status: 'sent', httpStatus: res.status, requestId };
  if (res.status === 409) {
    // 同じ retry key の送信を LINE がすでに受け付けている=前回の送信は届いていた
    return { status: 'sent', httpStatus: 409, requestId: res.headers.get('x-line-accepted-request-id') };
  }
  const body = (await res.text()).slice(0, 500);
  if (res.status >= 500) return { status: 'retry', httpStatus: res.status, requestId, error: body };
  // 400・401・403・429 など:同じ内容で送り直しても通らないので止め、人が見る
  return { status: 'failed', httpStatus: res.status, requestId, error: body };
}

応答の分け方は、LINEの再試行のドキュメントに合わせています。ドキュメントで送り直してよいとされているのは500とタイムアウトで、2xx・409・それ以外の4xxは送り直しません。このコードでは、500以外の5xxも同じく送り直す側に入れています。

retry key が効くのは、「LINEは受け付けたのに、こちらに応答が届かなかった」場面です。手元の試験では、R006の1回目で、真似たサーバーが送信を受け付けたまま12秒応答を返さないようにしました。Workerは10秒で打ち切って retry を記録し、19時の回に同じ retry key で送り直したところ、真似たサーバーは409を返して、Workerはそれを sent として記録しました。retry key を付けていなければ、この1件はお客様に2通届いていたことになります。

送り直しは18時と19時の回と、sending のまま10分たったものの拾い直しだけなので、どれも最初の送信から24時間以内に収まります。翌日の実行では「明日」の日付が変わり、前日の予約はもう対象になりません。24時間を過ぎてから同じ retry key を使う作りにはしていません。

文面は短くし、日時の変更とキャンセルの入口だけを入れました。LINEの通知は人目に触れることがあるので、名前は姓だけにしています。

src/index.js(文面)js
function reminderText(r, env) {
  const [, m, d] = r.visit_date.split('-').map(Number);
  return [
    `${r.customer_name} 様`,
    `明日 ${m}月${d}日 ${r.visit_time} からのご予約のお知らせです。`,
    `日時の変更・キャンセルはこちらから:${env.CHANGE_URL_BASE}${encodeURIComponent(r.id)}`,
  ].join('\n');
}

失敗は、送り直すものと止めて人が見るものに分けて記録する

送信の結果は、送信の記録の行に、HTTPの状態コード、LINEのリクエストID、エラーの本文(先頭500文字)と一緒に書き戻します。再試行の回数を使い切った retry は、ここで failed に変えて止めます。

src/index.js(結果の記録)js
async function record(env, reservationId, o, nowIso) {
  // 再試行の回数を使い切った 'retry' は 'failed' にして止める
  const row = await env.DB.prepare(
    `UPDATE reminder_sends
        SET status = CASE WHEN ?1 = 'retry' AND attempts >= ?8 THEN 'failed' ELSE ?1 END,
            http_status = ?2, line_request_id = ?3, error = ?4,
            sent_at = CASE WHEN ?1 = 'sent' THEN ?5 ELSE sent_at END
      WHERE reservation_id = ?6 AND kind = ?7
      RETURNING status`,
  )
    .bind(o.status, o.httpStatus ?? null, o.requestId ?? null, o.error ?? null, nowIso, reservationId, KIND, MAX_ATTEMPTS)
    .first();
  return row.status;
}

LINEのリクエストIDを残しておくと、LINE側に問い合わせるときに、どの送信の話かを特定できます。409のときは、最初の送信のID(x-line-accepted-request-id)を入れています。

failed になった予約は、その日のうちにスタッフが電話やSMSで連絡できるように、管理画面の一覧や通知に出します。この記事のコードには、その通知の部分は含めていません。

200は「LINEが受け付けた」までで、届いたとは限らない

LINEのAPIリファレンスには、公式アカウントをブロックした人や、LINEのアカウントを削除した人が宛先でも、応答は200になる一方で、相手の画面にはメッセージが出ないと書かれています。そのため、送信の記録の sent は「LINEが受け付けた」という意味で扱い、「お客様が読んだ」とは扱いません。前日リマインドが来館なしを減らしているかは、送信の記録と来館の記録を突き合わせて見ます。

月の通数の上限に当たると429が返る

LINEのAPIリファレンスでは、pushの429の理由の1つに「今月の送信の上限を超えた」ことが挙げられています。真似たサーバーではR007に You have reached your monthly limit. という本文で429を返し、Workerは送り直さずに failed として記録しました。

LINE公式アカウントの料金プランのページでは、通数は「メッセージを送った回数」×「送った友だちの数」で数え、Messaging APIのPush APIは通数にカウントされ、Reply APIはカウントされないと書かれています。コミュニケーションプランは200通、ライトプランは5,000通を超えて配信できず、上限を超えて送りたい場合はプランの変更が必要だとも書かれています。前日リマインドは送った件数のぶんだけ通数を使う(ブロックされている相手への送信は数えない)ので、ほかの配信と合わせて月の通数を見積もっておきます。

処理の全体と、手元での動かし方

ここまでの部分をつなげた、Workerの全体です。

src/index.jsjs
// 予約の前日リマインドを LINE の push で送る Worker(Cron Triggers で起動)
const KIND = 'day_before';
const MAX_ATTEMPTS = 3; // 500・タイムアウトのときの最大の試行回数
const STUCK_MS = 10 * 60 * 1000; // 「送信中」のまま10分たったものは、途中で止まったとみなす
const JST_OFFSET_MS = 9 * 60 * 60 * 1000; // 日本は UTC+9(夏時間なし)

/** UTC のミリ秒 → 日本時間の日付 'YYYY-MM-DD'(days 日後) */
export function jstDate(ms, days = 0) {
  return new Date(ms + JST_OFFSET_MS + days * 86_400_000).toISOString().slice(0, 10);
}

export default {
  async scheduled(controller, env, ctx) {
    const result = await runReminders(env, controller.scheduledTime);
    console.log(JSON.stringify({ cron: controller.cron, ...result }));
  },
};

export async function runReminders(env, nowMs) {
  const target = jstDate(nowMs, 1); // 明日(日本時間)
  const nowIso = new Date(nowMs).toISOString();
  const stuckBefore = new Date(nowMs - STUCK_MS).toISOString();

  // 送る対象:明日・予約中・LINE の ID あり・同意あり、かつ
  // 「まだ送っていない」か「再試行できる失敗」か「送信中のまま止まったもの」
  const { results: targets } = await env.DB.prepare(
    `SELECT r.id, r.customer_name, r.line_user_id, r.visit_date, r.visit_time,
            s.status AS send_status, s.attempts, s.retry_key
       FROM reservations r
       LEFT JOIN reminder_sends s ON s.reservation_id = r.id AND s.kind = ?1
      WHERE r.visit_date = ?2
        AND r.status = 'booked'
        AND r.line_user_id IS NOT NULL
        AND r.line_reminder_consent = 1
        AND (s.reservation_id IS NULL
             OR (s.status = 'retry' AND s.attempts < ?3)
             OR (s.status = 'sending' AND s.claimed_at < ?4))
      ORDER BY r.visit_time, r.id`,
  )
    .bind(KIND, target, MAX_ATTEMPTS, stuckBefore)
    .all();

  const counts = { target, candidates: targets.length, sent: 0, retry: 0, failed: 0, skipped: 0 };
  for (const r of targets) {
    const retryKey = await claim(env, r, nowIso);
    if (!retryKey) {
      counts.skipped++; // ほかの実行が先に取った
      continue;
    }
    const outcome = await pushReminder(env, r, retryKey);
    const status = await record(env, r.id, outcome, nowIso);
    counts[status]++;
  }
  return counts;
}

/** 送る前に「送信中」の行を書いて、この予約を自分の担当にする。取れたら retry key を返す */
async function claim(env, r, nowIso) {
  if (r.send_status == null) {
    const retryKey = crypto.randomUUID();
    const res = await env.DB.prepare(
      `INSERT INTO reminder_sends (reservation_id, kind, status, retry_key, attempts, claimed_at)
       VALUES (?1, ?2, 'sending', ?3, 1, ?4)
       ON CONFLICT (reservation_id, kind) DO NOTHING`,
    )
      .bind(r.id, KIND, retryKey, nowIso)
      .run();
    return res.meta.changes === 1 ? retryKey : null;
  }
  // 再試行:読んだときと同じ状態・回数のときだけ更新する(同時に動いた別の実行と取り合わない)
  const res = await env.DB.prepare(
    `UPDATE reminder_sends
        SET status = 'sending', attempts = attempts + 1, claimed_at = ?1
      WHERE reservation_id = ?2 AND kind = ?3 AND status = ?4 AND attempts = ?5`,
  )
    .bind(nowIso, r.id, KIND, r.send_status, r.attempts)
    .run();
  // retry key は最初の送信と同じものを使う。LINE が受け付け済みなら 409 が返り、二重には届かない
  return res.meta.changes === 1 ? r.retry_key : null;
}

function reminderText(r, env) {
  const [, m, d] = r.visit_date.split('-').map(Number);
  return [
    `${r.customer_name} 様`,
    `明日 ${m}月${d}日 ${r.visit_time} からのご予約のお知らせです。`,
    `日時の変更・キャンセルはこちらから:${env.CHANGE_URL_BASE}${encodeURIComponent(r.id)}`,
  ].join('\n');
}

async function pushReminder(env, r, retryKey) {
  let res;
  try {
    res = await fetch(`${env.LINE_API_BASE}/v2/bot/message/push`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
        'X-Line-Retry-Key': retryKey,
      },
      body: JSON.stringify({ to: r.line_user_id, messages: [{ type: 'text', text: reminderText(r, env) }] }),
      signal: AbortSignal.timeout(10_000),
    });
  } catch (e) {
    // タイムアウト・通信の失敗:LINE に届いたかどうか分からないので、同じ retry key で後で送り直す
    return { status: 'retry', httpStatus: null, error: `${e.name}: ${e.message}` };
  }
  const requestId = res.headers.get('x-line-request-id');
  if (res.ok) return { status: 'sent', httpStatus: res.status, requestId };
  if (res.status === 409) {
    // 同じ retry key の送信を LINE がすでに受け付けている=前回の送信は届いていた
    return { status: 'sent', httpStatus: 409, requestId: res.headers.get('x-line-accepted-request-id') };
  }
  const body = (await res.text()).slice(0, 500);
  if (res.status >= 500) return { status: 'retry', httpStatus: res.status, requestId, error: body };
  // 400・401・403・429 など:同じ内容で送り直しても通らないので止め、人が見る
  return { status: 'failed', httpStatus: res.status, requestId, error: body };
}

async function record(env, reservationId, o, nowIso) {
  // 再試行の回数を使い切った 'retry' は 'failed' にして止める
  const row = await env.DB.prepare(
    `UPDATE reminder_sends
        SET status = CASE WHEN ?1 = 'retry' AND attempts >= ?8 THEN 'failed' ELSE ?1 END,
            http_status = ?2, line_request_id = ?3, error = ?4,
            sent_at = CASE WHEN ?1 = 'sent' THEN ?5 ELSE sent_at END
      WHERE reservation_id = ?6 AND kind = ?7
      RETURNING status`,
  )
    .bind(o.status, o.httpStatus ?? null, o.requestId ?? null, o.error ?? null, nowIso, reservationId, KIND, MAX_ATTEMPTS)
    .first();
  return row.status;
}

1件ずつ順に送り、タイムアウトは10秒にしています。Cloudflareのドキュメントでは、scheduled ハンドラーの処理には15分の上限があると説明されているので、予約が数百件を超えるような規模になったら、1回の実行で送る件数に上限を設けるか、Cloudflare Queues に1件ずつ積んで別のWorkerで送る形に分けます。sent_at などの時刻は、試験で時刻を指定できるように controller.scheduledTime から取っているため、実際に送った瞬間ではなく、その回の予定の時刻が入ります。

LINEのAPIの代わりに立てた、真似るサーバー

手元では、.dev.vars で LINE_API_BASE を http://127.0.0.1:8790 に差し替え、次のサーバーを立てました。宛先のユーザーIDによって返し方を変え、受け付けた retry key を覚えて、同じ値が来たら409を返します。

mock-line.mjsjs
// LINE Messaging API の push を真似る手元のサーバー(本物の LINE には送らない)
// 宛先の userId で返し方を変える。受け付けた retry key を覚えて、同じ key には 409 を返す
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';

const accepted = new Map(); // retry key → 受け付けたときの request id
const seen = new Map(); // userId → 届いた回数
const log = (o) => appendFileSync('mock-requests.jsonl', JSON.stringify(o) + '\n');

createServer(async (req, res) => {
  let raw = '';
  for await (const c of req) raw += c;
  const body = JSON.parse(raw || '{}');
  const key = req.headers['x-line-retry-key'];
  const to = body.to;
  const n = (seen.get(to) ?? 0) + 1;
  seen.set(to, n);
  const requestId = randomUUID();
  const reply = (status, json = {}, headers = {}) => {
    log({ at: new Date().toISOString(), to, retryKey: key, n, status, auth: req.headers.authorization === 'Bearer test-token' });
    res.writeHead(status, { 'Content-Type': 'application/json', 'x-line-request-id': requestId, ...headers });
    res.end(JSON.stringify(json));
  };
  if (req.url !== '/v2/bot/message/push') return reply(404, { message: 'Not found' });
  if (accepted.has(key)) {
    return reply(409, { message: 'The retry key is already accepted' }, { 'x-line-accepted-request-id': accepted.get(key) });
  }
  if (to === 'U_500once' && n === 1) return reply(500, { message: 'Internal server error' });
  if (to === 'U_500always') return reply(500, { message: 'Internal server error' });
  if (to === 'U_429') return reply(429, { message: 'You have reached your monthly limit.' });
  accepted.set(key, requestId);
  if (to === 'U_slow' && n === 1) {
    // 受け付けたが、応答が12秒返らない(Worker 側は10秒で打ち切る)
    await new Promise((r) => setTimeout(r, 12_000));
  }
  return reply(200, { sentMessages: [{ id: String(Date.now()) }] });
}).listen(8790, () => console.log('mock LINE on :8790'));
.dev.vars(git に入れない)
LINE_CHANNEL_ACCESS_TOKEN=test-token
LINE_API_BASE=http://127.0.0.1:8790

動かした手順は次のとおりです。

bash
npx wrangler d1 execute visit-reminder --local --file=schema.sql
npx wrangler d1 execute visit-reminder --local --file=seed.sql   # 上の表の8件
node mock-line.mjs &
npx wrangler dev --test-scheduled --port 8788

# 2026-10-08 09:00 UTC(日本時間18:00)として起こす
curl "http://localhost:8788/cdn-cgi/local/scheduled?cron=0+9+*+*+*&time=1791450000000"

--test-scheduled を付けて起動すると、/__scheduled でも scheduled ハンドラーを起こせました。ただ、手元のwrangler 4.147.0では、/__scheduled に time を付けても起動の時刻は変わらず、その日の実際の時刻(10月4日)で動いて、「明日」は10月5日になりました。時刻を指定して試すときは、Cloudflareのドキュメントにある /cdn-cgi/local/scheduled に cron と time を付けて呼びます。

手元で動かした結果

18時の回、19時の回、同じ18時の回をもう一度、の順に動かしました。各回でWorkerが出したログは次のとおりです。

{"cron":"0 9 * * *","target":"2026-10-09","candidates":4,"sent":1,"retry":2,"failed":1,"skipped":0}
{"cron":"0 10 * * *","target":"2026-10-09","candidates":3,"sent":3,"retry":0,"failed":0,"skipped":0}
{"cron":"0 9 * * *","target":"2026-10-09","candidates":0,"sent":0,"retry":0,"failed":0,"skipped":0}

19時の回の前に、18時以降に入った明日の予約(R009)を1件足しています。19時の回の3件は、R005とR006の送り直しと、このR009です。3回目は、同じ18時の回をもう一度動かした結果で、送る相手は0件でした。

真似たサーバーが受け取ったpushの記録です(retry key は先頭8文字に縮めています)。

回 宛先 retry key 返した状態
18時 U_500once(R005) e2702427 500
18時 U_ok_1(R001) 85af80ad 200
18時 U_slow(R006) f6e07515 200(12秒後。Workerは10秒で打ち切り済み)
18時 U_429(R007) 9d2f38a8 429
19時 U_500once(R005) e2702427 200
19時 U_slow(R006) f6e07515 409
19時 U_ok_9(R009) 077ed60a 200

19時の回のR005とR006は、18時と同じ retry key で送られています。19時の回を終えたあとの送信の記録は、次のようになりました。

予約 status attempts http_status
R001 sent 1 200
R005 sent 2 200
R006 sent 2 409
R007 failed 1 429
R009 sent 1 200

ほかに、次の3つも確かめました。

  • 送信中のまま止まった行の拾い直し。 claimed_at が19時00分の sending の行を作っておき、19時05分として動かすと拾わず、19時15分として動かすと、その行の retry key のまま送りました
  • 再試行の上限。 毎回500を返す宛先を用意すると、18時・19時・19時30分の3回で attempts が3になって failed に変わり、20時の回では送りませんでした。真似たサーバーへの送信は3回でした
  • 同時の実行。 前の章に書いたとおり、2つの実行が重なっても、宛先ごとのpushは1回でした

本番に出す前に決めておくこと

ここまでで動きは確かめましたが、本番のLINEのチャネルにつなぐ前に、運用の側で決めておくことが残っています。

  • 同意の取り方と取り消し。 予約フォームで「LINEでリマインドを受け取る」に同意してもらう場所と、取り消したときに line_reminder_consent を0に戻す経路
  • LINEのユーザーIDと予約の結びつけ。 予約した人の line_user_id をどうやって予約に入れるか。友だち追加のときのWebhookで受けるなら、署名の検証や再送の扱いも必要です
  • 失敗したときの連絡。 failed になった予約を、誰が、いつまでに、何で連絡するか
  • 前日の夕方に予約が入る場合。 19時の回より後に入った明日の予約には送られません。予約の確認の連絡で、来館の案内まで済ませておきます

LINE公式アカウントの拡張ツールで足りるのか、こうした仕組みを自社で持つべきかの判断は、会社のコラムLINEの拡張ツールで足りるか、自社で作るかに整理しています。株式会社bundlyzeでは、予約管理システムを企画から運用まで開発していて、LINE公式アカウントと予約をつなぐ仕組みのご相談は、システム開発のページで受け付けています。

動作確認した環境

2026年10月4日に、手元のMacで確かめました。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2(真似るサーバーと日付の計算の確認)
wrangler 4.147.0(wrangler dev --test-scheduled、wrangler d1 execute --local)
D1 wrangler dev の手元のD1
compatibility_date 2026-10-01
LINEのAPI 手元の真似るサーバー(127.0.0.1:8790)。本物のLINEには送っていない

本物のLINE Messaging APIへの送信、Cloudflareへのデプロイと本番のCron Triggersでの起動、wrangler secret put でのトークンの登録は行っていません。真似るサーバーの409・500・429の返し方はLINEのドキュメントの説明に合わせて作ったもので、本物のLINEが返す応答の本文やヘッダーと完全に同じかは確かめていません。予約が数百件ある場合の実行時間も測っていません。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

Cloudflare WorkersのCron Triggersは日本時間で書けますか?

書けません。Cloudflareのドキュメントでは、Cron TriggersはUTCで動くと説明されています。日本時間の18時に動かしたいなら、9時間引いた「0 9 * * *」と書きます。処理の中でも、「明日」の日付はUTCのままではなく、日本時間に直してから求めます。

LINEのpushで二重に送らないためには、何をすればよいですか?

送る前に、予約ごとの送信の記録を「送信中」で書き込み、書き込めた実行だけが送るようにします。そのうえで、送信のたびに作ったUUIDをX-Line-Retry-Keyとして付け、送り直すときは同じ値を使います。LINEがすでに受け付けた送信なら409が返るので、二重には届きません。

LINEのpushで200が返れば、お客様に届いたと考えてよいですか?

そうとは限りません。LINEのAPIリファレンスには、公式アカウントをブロックした人や、LINEのアカウントを削除した人が宛先でも応答は200になり、その人の画面には何も出ないと書かれています。200は「LINEが受け付けた」という意味に留めて記録し、来館の有無とあわせて見直します。

前日リマインドをLINEで送ると、月のメッセージ通数に数えられますか?

数えられます。LINE公式アカウントの料金プランのページでは、Messaging APIのPush APIは通数にカウントされ、Reply APIはカウントされないと書かれています。コミュニケーションプランは200通、ライトプランは5,000通を超えて配信できないとも書かれているので、予約の件数から月の通数を見積もってプランを選びます。