すべての記事
HTTPSでPostmark Webhookをローカルテストする方法
Postmarkemail webhookslocalhostwebhook security

HTTPSでPostmark Webhookをローカルテストする方法

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にはTypeTypeCodeInactiveCanActivateなどが含まれます。 公式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を利用でき、HttpAuthHttpHeaders、トリガーを指定できます。管理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になる

Webhook 401/403ガイド

処理後も再試行される

トンネル、完全なルート、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を監視してください。

ローカルWebhookデバッグガイド

よくある質問

localhostでPostmark Webhookをテストするには?
ローカル受信処理を起動し、npx portpreview PORTで公開して、生成HTTPS URLへルートを追加し、対象Message Streamへ設定します。
PostmarkはWebhookをHMACで署名しますか?
いいえ。現行仕様はHMAC署名をサポートしません。HTTPSとBasic Authentication、必要に応じて最新IP許可リストを使い、payloadを検証します。
同じWebhookが再送されるのはなぜですか?
HTTP 200を受け取れないと再試行するためです。安定したイベントキーを一意制約付きで保存してください。
Delivery Webhookはメールが読まれた意味ですか?
いいえ。宛先メールサーバーが受理しただけで、受信トレイ到達や開封・既読を保証しません。