Postmark Webhookをlocalhostで試すには、ローカルの受信処理を起動し、npx portpreview PORTでポートを公開して、生成されたHTTPSルートを対象のPostmark Message Streamに登録します。Basic Authenticationまたは秘密のカスタムヘッダーで保護し、JSONを検証して冪等に保存した後、速やかにHTTP 200を返してください。
PostmarkがWebhookへ送る内容
Postmarkはメールイベント発生時にHTTP POSTを送ります。Outbound Message Streamでは配信、バウンス、開封、クリック、迷惑メール報告、購読変更を、Inbound Message Streamでは解析済み受信メールを通知します。RecordTypeで分岐し、種類ごとのスキーマを検証してください。Deliveryは宛先サーバーが受理したことを示すだけで、受信トレイ到達の証明ではありません。BounceにはType、TypeCode、Inactive、CanActivateなどが含まれます。 公式Webhook概要 · バウンスWebhook仕様
小さなローカルExpress受信サーバーを作る
この例は3000番ポートでBasic Authを先に確認し、最小限の形式と重複排除キーを永続化してから応答します。DB処理は自分のトランザクションまたは耐久性のあるキューに置き換えてください。受信メールには個人情報、認証リンク、添付ファイルがあり得るため、サイズを制限し本文全体をログへ出さないでください。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));
function safeEqual(actual, expected) {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authorized(req) {
const value = req.get('authorization') ?? '';
if (!value.startsWith('Basic ')) return false;
const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return false;
return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}
app.post('/webhooks/postmark', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const event = req.body;
if (typeof event?.RecordType !== 'string' ||
typeof event?.MessageID !== 'string') {
return res.status(400).json({ error: 'Invalid Postmark event' });
}
const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
res.sendStatus(200);
});
app.listen(3000);
localhostを公開HTTPS URLで公開する
受信サーバーを起動してローカルで確認し、別ターミナルでnpx portpreview 3000を実行します。生成されたオリジンにhttps://YOUR-TUNNEL.portpreview.dev/webhooks/postmarkのようにルートを足し、送信中はトンネルを維持します。トンネルは到達性とHTTPSを提供しますがPostmarkを認証しないため、認証とpayload検証は必須です。 localhostトンネルのセキュリティガイド
正しいPostmark Webhookを設定する
OutboundではServerと対象Message StreamのWebhooks画面でURLを追加し、実装したトリガーだけを有効にします。InboundはInbound Message Stream固有のURLを設定します。コード管理にはWebhooks APIを利用でき、HttpAuth、HttpHeaders、トリガーを指定できます。管理APIのX-Postmark-Server-Tokenは受信側へ送る資格情報ではありません。 Webhooks API
Postmarkの認証は暗号学的署名ではない
現行ドキュメントではPostmark WebhookのHMAC署名検証はサポートされません。raw bodyから署名を再計算する秘密鍵やX-Postmark-Signatureはありません。HTTP Basic Authentication、IP許可リスト、カスタムヘッダーは共有秘密の所持を確かめる仕組みで、本文へ暗号学的に結び付ける署名ではありません。HTTPSとpayload検証を併用し、IP範囲は常に最新版を使ってください。HttpAuthを優先し、URL形式https://username:[email protected]/pathを使う場合は専用の強い資格情報を正しくエンコードします。Server APIトークンをWebhookパスワードへ流用してはいけません。
配信とバウンスを種類別に処理する
業務処理はHTTP経路から分離し、保存済みイベントをworkerで冪等に処理します。フィールド名だけで恒久的な抑止を決めず、現在のバウンス分類と送信ポリシーに従ってください。迷惑メール報告と購読変更はBounceではなく独立した種類で、開封とクリックは複数回発生します。
async function processPostmarkEvent(event) {
switch (event.RecordType) {
case 'Delivery':
await markAcceptedByRecipientServer({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt
});
break;
case 'Bounce':
await recordBounce({
bounceId: String(event.ID),
messageId: event.MessageID,
type: event.Type,
inactive: event.Inactive,
canActivate: event.CanActivate
});
break;
default:
await recordUnhandledPostmarkType(event.RecordType);
}
}
再試行と重複配信を前提に設計する
HTTP 200が届かないとPostmarkは再試行します。BounceとInboundは長め、Click、Open、Delivery、購読変更は短めで、403は再試行を停止します。DB commit後のtimeoutでも正当な重複が生じるため、一意制約付きの安定キーを使います。MessageIDに加え、混在endpointではRecordTypeとBounceのIDなどを含めます。最小限の耐久保存後に200を返し、外部API処理は非同期化します。 Webhookの再試行と冪等性
実際のイベント処理を安全に試す
まずcurlの合成POSTでルーティング、認証、検証、保存を確認し、その後に管理下のアドレスへ通常メールを送りDeliveryを試します。Bounceは架空アドレスへ繰り返し送らず、利用可能ならblack-hole test domainなどPostmark公式の試験手段を使います。送信時のMessageIDを記録し、匿名化fixtureを二度送って副作用が一度だけか確認します。
Postmark Webhookのよくある不具合を調べる
エンドポイントに届かない
トンネル、完全なルート、PortPreviewへ渡したポートを確認します。401ならローカル資格情報と再起動、proxyがAuthorizationを落としていないかを値を記録せず調べます。処理済みなのに再試行される場合は公開endpointで実際のstatusとlatencyを確認し、200以外や保存後の500をなくします。サンプルとの差はRecordType、トリガー、Inbound/Outboundを照合し、必須欠落を拒否しつつ文書化された追加項目を許容します。
すべてHTTP 401になる
処理後も再試行される
トンネル、完全なルート、PortPreviewへ渡したポートを確認します。401ならローカル資格情報と再起動、proxyがAuthorizationを落としていないかを値を記録せず調べます。処理済みなのに再試行される場合は公開endpointで実際のstatusとlatencyを確認し、200以外や保存後の500をなくします。サンプルとの差はRecordType、トリガー、Inbound/Outboundを照合し、必須欠落を拒否しつつ文書化された追加項目を許容します。
payloadが一致しない
トンネル、完全なルート、PortPreviewへ渡したポートを確認します。401ならローカル資格情報と再起動、proxyがAuthorizationを落としていないかを値を記録せず調べます。処理済みなのに再試行される場合は公開endpointで実際のstatusとlatencyを確認し、200以外や保存後の500をなくします。サンプルとの差はRecordType、トリガー、Inbound/Outboundを照合し、必須欠落を拒否しつつ文書化された追加項目を許容します。
本番環境のセキュリティチェックリスト
HTTPSと専用の強いBasic Authまたは秘密ヘッダーを使い、APIトークンとWebhook資格情報を分離します。試験後はローテーションして古いURLを削除し、content type、サイズ、種類、ID、必須項目を検証します。ログのメール情報と秘密を伏せ、最小権限を適用し、認証失敗、遅延、重複、dead letterを監視してください。
