予約の前日リマインドをLINEで送る仕組みを、Cloudflare WorkersのCron TriggersとD1で作る。送る対象の抽出、先に送信中にする二重送信の対策、X-Line-Retry-Keyでの再試行、失敗の記録、UTCと日本時間
予約の
この
このwrangler dev --test-scheduled と
前日リマインドは、Cron Triggersで起こしたWorkerが、D1の予約を見てpushで送る
全体の
| 段 | 使うもの | ここで |
|---|---|---|
| 起こす | Cron Triggers | 何時に |
| 選ぶ | D1の |
日本時間の |
| 送る | LINE Messaging API の |
X-Line-Retry-Key、 |
| 記録する | D1の |
送れた |
起動の
{
"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 でwrangler secret put LINE_CHANNEL_ACCESS_TOKEN で.dev.vars に
Cron TriggersはUTCで動くので、時刻も「明日」も日本時間に直す
Cloudflareの
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(予定されていたDate.now() を
18時に
$ 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行目の
予約の2026-10-09)と時刻(11:00)で
予約と送信の記録は、テーブルを分けて持つ
予約の
-- 予約(日時は日本時間で持つ)
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)
);分けてkind をstatus は
| status | 意味 | 次の実行で |
|---|---|---|
sending |
送る |
10分たっても |
sent |
LINEが |
何もしない |
retry |
500や |
3回目までは |
failed |
400・429など、 |
何もしない。 |
送る対象は、明日・予約中・LINEのIDあり・同意あり・未送信で絞る
送る
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の |
同意 | 状態 | 用意した |
|---|---|---|---|---|---|
| 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回目は |
| R007 | 10月9日 17:00 | あり | あり | 予約中 | 429 |
| R008 | 10月10日 11:00 | あり | あり | 予約中 | 明後日 |
10月8日18時
送る前に「送信中」を書き込み、書き込めた実行だけが送る
二重送信をsending で
/** 送る前に「送信中」の行を書いて、この予約を自分の担当にする。取れたら 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;
}送ったsending の
同じ
{"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}どちらのskipped)、
X-Line-Retry-Keyを付けて、送り直しても二重に届かないようにする
LINEのX-Line-Retry-Key ヘッダーにx-line-accepted-request-id ヘッダーに
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 };
}応答の
retry key がretry をsent と
送り直しはsending の
文面は
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');
}失敗は、送り直すものと止めて人が見るものに分けて記録する
送信のretry は、failed に
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のx-line-accepted-request-id)を
failed に
200は「LINEが受け付けた」までで、届いたとは限らない
LINEのsent は
月の通数の上限に当たると429が返る
LINEのYou have reached your monthly limit. とfailed と
LINE公式アカウントの
処理の全体と、手元での動かし方
ここまでの
// 予約の前日リマインドを 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件ずつ順にsent_at などのcontroller.scheduledTime から
LINEのAPIの代わりに立てた、真似るサーバー
手元では、.dev.vars で LINE_API_BASE を http://127.0.0.1:8790 に
// 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'));LINE_CHANNEL_ACCESS_TOKEN=test-token
LINE_API_BASE=http://127.0.0.1:8790動かした
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 に time を/cdn-cgi/local/scheduled に cron と time を
手元で動かした結果
18時の
{"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時の
真似た
| 回 | 宛先 | retry key | 返した状態 |
|---|---|---|---|
| 18時 | U_500once |
e2702427 | 500 |
| 18時 | U_ok_1 |
85af80ad | 200 |
| 18時 | U_slow |
f6e07515 | 200 |
| 18時 | U_429 |
9d2f38a8 | 429 |
| 19時 | U_500once |
e2702427 | 200 |
| 19時 | U_slow |
f6e07515 | 409 |
| 19時 | U_ok_9 |
077ed60a | 200 |
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 |
ほかに、
- 送信中の
まま 止まった 行の 拾い直し。 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_reminder_consentを0に 戻す経路 - LINEの
ユーザーIDと 予約した予約の 結びつけ。 人の line_user_idをどうやって 予約に 入れるか。 友だち追加の ときの Webhookで 受けるなら、 署名の 検証や 再送の 扱いも 必要です - 失敗した
ときの 連絡。 failedになった 予約を、 誰が、 いつまでに、 何で 連絡するか - 前日の
夕方に 19時の予約が 入る 場合。 回より 後に 入った 明日の 予約には 送られません。 予約の 確認の 連絡で、 来館の 案内まで 済ませて おきます
LINE公式アカウントの
動作確認した環境
2026年10月4日に、
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 22.23.2 |
| wrangler | 4.147.0wrangler dev --test-scheduled、wrangler d1 execute --local) |
| D1 | wrangler dev の |
| compatibility_date | 2026-10-01 |
| LINEの |
手元の127.0.0.1:8790)。 |
本物のwrangler secret put での
参照した公式ドキュメント(確認日:2026年10月4日)
- Cloudflare Docs「Cron Triggers」:UTCで
実行される こと、 設定の 書き方、 /cdn-cgi/local/scheduledとcron・timeでの手元の 試験、 変更の 反映に 最大15 分かかる こと - Cloudflare Docs「Scheduled Handler」:
controller.cron・controller.scheduledTime、処理の 15分の 上限 - Cloudflare Docs
「Local development :手元での(D1)」 D1の 使い方 - Cloudflare Docs「Secrets」:
wrangler secret putと.dev.vars - LINE Developers「Messaging API reference」:pushの
エンドポイントと ヘッダー、 ブロックした 人などへの 送信でも 200が 返る こと、 409と 429の 理由 - LINE Developers「Retry failed API requests」:
X-Line-Retry-Keyの形式、 24時間の 有効期間、 409と x-line-accepted-request-id、送り直して よい 状態コード - LINE Developers「Messaging API pricing」:通数に
数えられる 送り方と 数え方、 上限を 超えた ときの 扱い - LINEヤフー for Business
「料金プラン」 :プランごとの上限、 通数の 数え方、 Push APIは 数えられReply APIは 数えられない こと
よくある質問
Cloudflare WorkersのCron Triggersは日本時間で書けますか?
書けません。
LINEのpushで二重に送らないためには、何をすればよいですか?
送る
LINEのpushで200が返れば、お客様に届いたと考えてよいですか?
そうとは
前日リマインドをLINEで送ると、月のメッセージ通数に数えられますか?
数えられます。