Webhook

Outgoing Webhooks

CRISISから外部システムへリアルタイムにイベント通知を配信する仕組みです。

概要

Outgoing Webhookを設定すると、CRISISの標準トピックで提供された情報(地震情報、津波情報、ミサイル発射情報など)、ユーザー定義のトピックで受信した情報、CRISIS内で発生したイベント(インシデント作成、資産状態変更など)をHTTP POSTで即座に通知します。

ユースケース

クイックスタート

Step 1: CRISISコンソールでWebhookを作成

  1. 組織 > 設定 > Webhook を開く
  2. 「+ 新規作成」をクリック
  3. 以下を入力:
    • Name: My First Webhook
    • URL: https://your-server.example.com/webhook(HTTPS必須)
  4. 「作成」をクリック

作成完了後、signing_secret が表示されます。この値はこの画面でのみ確認できます。安全に保管してください。

Step 2: 受信サーバーを構築

最小限の受信サーバー例(Node.js):

const crypto = require('crypto');
const express = require('express');
const app = express();

app.use(express.raw({ type: 'application/json' }));

const SIGNING_SECRET = process.env.CRISIS_SIGNING_SECRET;

app.post('/webhook', (req, res) => {
  // 署名検証
  const timestamp = req.headers['x-crisis-timestamp'];
  const signature = req.headers['x-crisis-signature'];
  const signedPayload = `${timestamp}.${req.body}`;
  const expected = 'v1=' + crypto
    .createHmac('sha256', SIGNING_SECRET)
    .update(signedPayload)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).send('Invalid signature');
  }

  // ペイロード処理
  const event = JSON.parse(req.body);
  console.log(`Received: ${event.event_type}`, event.data);

  res.status(200).send('OK');
});

app.listen(8080);

Step 3: テスト送信で確認

  1. Webhook一覧画面で作成したWebhookの「テスト送信」をクリック
  2. 結果を確認:
    • success: true + status_code: 200 → 正常に動作しています
    • ❌ 失敗した場合はURLの疎通やHTTPS証明書を確認してください

Step 4: ワークフローでWebhookアクションを追加

  1. ワークフロー で新規作成 or 既存を編集
  2. トリガーソースを選択(例: jma.earthquakecrisis.events
  3. アクション追加で「Webhook送信」を選択
  4. 作成したWebhookをドロップダウンから選択
  5. 保存

以降、条件にマッチするイベントが発生するたびにWebhookが配信されます。

トリガーソース

Webhookはワークフローのアクションとして配信されます。ワークフローのトリガーソース(トピック)によって、どのイベントでWebhookが発火するかが決まります。

CRISIS提供トピック

トピックキー説明フィルター例
jma.earthquake地震情報震度5弱以上で通知
jma.nankai_trough_temporary_information南海トラフ地震臨時情報調査中・巨大地震注意・巨大地震警戒で通知
jma.weather.warning気象警報・注意報大雨警報で通知
mlit.river.waterlevel河川水位情報水防団待機水位以上で通知
mlit.kaiho.missileミサイル発射情報発射情報で通知

これらはCRISISが気象庁XMLや河川情報、Lアラート情報を自動的にパースし、ワークフローエンジンに配信します。ワークフロー条件(フィルター)で震度や対象地域をフィルタリングできます。

ユーザー定義トピック

独自のトピックを作成し、外部システムからPublish APIでイベントを送信できます。詳しくは Incoming Webhooks (ワークフロートピック) を参照してください。

ユースケーストピック例
IoT水位センサーcustom.river_level
社内監視システムcustom.monitoring_alert
外部SaaS連携custom.saas_case

crisis.events(システム内部イベント)

CRISIS内部で発生するリソース変更イベントです。インシデント作成、資産更新、フォーム回答など、CRISISの操作に対してWebhookを配信できます。

Note

crisis.eventsトピックでは通知系アクション(Webhook送信、メール、プッシュ通知)のみ利用可能です。オペレーションブック起動はループ防止のため無効化されます。

ペイロード形式

HTTPヘッダー

ヘッダー条件
Content-Typeapplication/json常に付与
X-Crisis-TimestampUnixタイムスタンプ(秒)signing_secret設定時
X-Crisis-Signaturev1=<hex HMAC-SHA256>signing_secret設定時
Authorization認証設定によるauth_type設定時
カスタムヘッダーWebhook設定 + ワークフローアクション設定設定時

JSONボディ

{
  "event_type": "incident.created",
  "organization_id": "01HJYZ...",
  "topic_id": "01HDYZ",
  "topic_key": "crisis.events",
  "message_id": "01HXYZ...",
  "published_at": "2026-05-01T09:15:30Z",
  "status": "ACTUAL",
  "data": {
    "event_type": "incident.created",
    "organization_id": "org_abc123",
    "actor_id": "user_def456",
    "actor_name": "田中太郎",
    "resource_id": "incident_ghi789",
    "resource_crn": "crn:crisis:organizations:org_abc123:incidents/incident_ghi789",
    "timestamp": "2026-05-01T09:15:30Z",
    "incident_id": "incident_ghi789",
    "name": "最大震度5弱",
    "severity": "high"
  },
  "title": "インシデント作成通知",
  "body_text": "最大震度5弱 が作成されました"
}
フィールド説明
event_typeイベントタイプ(トップレベル)
organization_id対象組織ID
topic_idワークフロートピックID
topic_keyトピックキー(crisis.events, jma.earthquake 等)
message_idメッセージ一意ID(冪等性チェックに使用可能)
published_atイベント発行時刻(ISO 8601)
statusACTUAL(実災害)/ DRILL(訓練)/ TEST(テスト)
dataイベント固有のデータ
title通知タイトル(ワークフローアクション設定時)
body_text通知本文テキスト(ワークフローアクション設定時)

署名検証

signing_secret を設定すると、各配送に HMAC-SHA256 署名が付与されます。受信サーバーで署名を検証することで、リクエストがCRISISから送信されたことを保証できます。

検証手順

  1. X-Crisis-Timestamp ヘッダーからタイムスタンプを取得
  2. 署名対象文字列を構築: "{timestamp}.{raw_request_body}"
  3. HMAC-SHA256を計算: HMAC-SHA256(signing_secret, signed_payload)
  4. 期待値を構築: "v1=" + hex(result)
  5. X-Crisis-Signature ヘッダーと比較

Node.js

const crypto = require('crypto');

function verifySignature(signingSecret, timestamp, body, signature) {
  const signedPayload = `${timestamp}.${body}`;
  const expected = 'v1=' + crypto
    .createHmac('sha256', signingSecret)
    .update(signedPayload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

Go

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
)

func VerifySignature(signingSecret, timestamp, body, signature string) bool {
    signedPayload := fmt.Sprintf("%s.%s", timestamp, body)
    mac := hmac.New(sha256.New, []byte(signingSecret))
    mac.Write([]byte(signedPayload))
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

cURL (テスト用)

# 署名の再計算
TIMESTAMP="1714546530"
BODY='{"event_type":"incident.created",...}'
SECRET="whsec_your_signing_secret"

SIGNATURE=$(echo -n "${TIMESTAMP}.${BODY}" | \
  openssl dgst -sha256 -hmac "${SECRET}" | \
  awk '{print "v1="$2}')

echo $SIGNATURE

タイムスタンプ検証(推奨)

リプレイ攻撃を防ぐため、タイムスタンプが5分以内であることを確認してください:

const MAX_AGE_SECONDS = 300; // 5分

function isTimestampValid(timestamp) {
  const now = Math.floor(Date.now() / 1000);
  return Math.abs(now - parseInt(timestamp)) < MAX_AGE_SECONDS;
}

イベント一覧

Incident(インシデント)

event_type説明data に含まれる主なフィールド
incident.createdインシデント作成incident_id, name, severity
incident.updatedインシデント更新incident_id, name
incident.closedインシデント終了incident_id
incident.archivedインシデントアーカイブincident_id
incident.reopenedインシデント再開incident_id
incident.deletedインシデント削除incident_id
incident.iam.updatedインシデントIAM変更

Incident Headquarters(本部)

event_type説明
incident.headquarters.established本部設置
incident.headquarters.updated本部更新
incident.headquarters.disbanded本部解散

Incident Operation Book(オペレーションブック)

event_type説明
incident.operation_book.createdオペブック起動
incident.operation_book.updatedオペブック更新
incident.operation_book.severity_changed重大度変更
incident.operation_book.action.createdアクション作成
incident.operation_book.action.updatedアクション更新
incident.operation_book.action.status_changedアクションステータス変更
incident.operation_book.action.acknowledgedアクション確認
incident.operation_book.task.createdタスク作成
incident.operation_book.task.updatedタスク更新
incident.operation_book.task.status_changedタスクステータス変更
incident.operation_book.task.acknowledgedタスク確認
incident.operation_book.task.deadline_changedタスク期限変更

Incident Form(安否確認フォーム)

event_type説明
incident.form.createdフォーム作成
incident.form.updatedフォーム更新
incident.form.deletedフォーム削除
incident.form.remindedフォームリマインド
incident.form.revision.createdリビジョン作成
incident.form.revision.updatedリビジョン更新
incident.form.answer.submitted回答送信
incident.form.answer.updated回答更新

Incident Sub-resources

event_type説明
incident.chronicle.createdタイムライン追加
incident.chronicle.updatedタイムライン更新
incident.note.createdノート作成
incident.note.updatedノート更新
incident.note.deletedノート削除
incident.report.createdレポート作成
incident.report.deletedレポート削除

Incident Group

event_type説明
incident_group.createdインシデントグループ作成
incident_group.updatedインシデントグループ更新
incident_group.deletedインシデントグループ削除

Asset(資産管理)

event_type説明data
asset.group.created資産グループ作成asset_group_id, name
asset.group.updated資産グループ更新asset_group_id, name
asset.group.deleted資産グループ削除asset_group_id
asset.item.created資産作成asset_item_id, name, kind
asset.item.updated資産更新asset_item_id, name, kind
asset.item.deleted資産削除asset_item_id, name, kind
asset.item.state.updated資産状態変更asset_item_id
asset.vehicle_model.created車両モデル作成vehicle_model_id, name
asset.vehicle_model.updated車両モデル更新vehicle_model_id, name
asset.vehicle_model.deleted車両モデル削除vehicle_model_id

Emergency(緊急通報)

event_type説明
emergency.created緊急通報作成

その他

event_type説明
webhook.delivery_failedWebhook配送失敗

リトライと配送保証

リトライポリシー

Webhookの配送が失敗(非2xxレスポンスまたは接続エラー)した場合、指数バックオフでリトライします。

設定デフォルト説明
max_retries5最大リトライ回数
retry_interval_seconds60初回リトライ間隔(秒)
retry_backoff_multiplier2.0バックオフ倍率
retry_max_interval_seconds3600リトライ間隔上限(秒)

リトライスケジュール例(デフォルト設定)

試行待機時間累計
1回目即時0s
2回目60s1分後
3回目120s3分後
4回目240s7分後
5回目480s15分後
6回目 (最後)960s31分後

配送保証

受信サーバーの実装例

Google Cloud Run Functions (Node.js)

const crypto = require('crypto');
const functions = require('@google-cloud/functions-framework');

const SIGNING_SECRET = process.env.CRISIS_SIGNING_SECRET;

functions.http('crisisWebhook', (req, res) => {
  // 署名検証
  const timestamp = req.headers['x-crisis-timestamp'];
  const signature = req.headers['x-crisis-signature'];

  if (!timestamp || !signature) {
    return res.status(401).json({ error: 'Missing signature headers' });
  }

  // タイムスタンプ検証(5分以内)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) {
    return res.status(401).json({ error: 'Timestamp too old' });
  }

  // HMAC検証
  const body = JSON.stringify(req.body);
  const signedPayload = `${timestamp}.${body}`;
  const expected = 'v1=' + crypto
    .createHmac('sha256', SIGNING_SECRET)
    .update(signedPayload)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // イベント処理
  const { event_type, data, message_id } = req.body;

  switch (event_type) {
    case 'incident.created':
      console.log(`New incident: ${data.name} (${data.severity})`);
      // TODO: 外部システムに通知
      break;
    case 'asset.item.state.updated':
      console.log(`Asset state changed: ${data.asset_item_id}`);
      break;
    default:
      console.log(`Unhandled event: ${event_type}`);
  }

  res.status(200).json({ received: true, message_id });
});

Go (net/http)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "math"
    "net/http"
    "os"
    "strconv"
    "time"
)

var signingSecret = os.Getenv("CRISIS_SIGNING_SECRET")

type WebhookPayload struct {
    EventType      string         `json:"event_type"`
    OrganizationID string         `json:"organization_id"`
    MessageID      string         `json:"message_id"`
    PublishedAt    time.Time      `json:"published_at"`
    Status         string         `json:"status"`
    Data           map[string]any `json:"data"`
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    // 署名検証
    timestamp := r.Header.Get("X-Crisis-Timestamp")
    signature := r.Header.Get("X-Crisis-Signature")

    if !verifySignature(timestamp, body, signature) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // タイムスタンプ検証
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        http.Error(w, "Timestamp too old", http.StatusUnauthorized)
        return
    }

    // ペイロード処理
    var payload WebhookPayload
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    log.Printf("Event: %s | Resource: %v", payload.EventType, payload.Data["resource_id"])
    w.WriteHeader(http.StatusOK)
}

func verifySignature(timestamp string, body []byte, signature string) bool {
    signedPayload := fmt.Sprintf("%s.%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(signingSecret))
    mac.Write([]byte(signedPayload))
    expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
    http.HandleFunc("/webhook", webhookHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

ベストプラクティス

項目推奨事項
レスポンス速度10秒以内に200を返す。重い処理は非同期キューに入れる
冪等性message_id で処理済みイベントを記録し、重複実行を防ぐ
署名検証必ず実装する。タイミング安全な比較関数を使う
タイムスタンプ検証5分以上古いリクエストは拒否する
HTTPS受信URLは必ずHTTPS。自己署名証明書は使用不可
エラーハンドリング一時的エラーは5xxを返す(リトライされる)。永続的エラーは4xxを返す
ログmessage_idevent_type をログに記録し、トラブルシューティングに備える
秘密情報の管理signing_secret はSecret Manager等に格納し、ソースコードにハードコードしない。可能であれば Workload Identity Federation を使用する。