開発者/連携

Webhook

Webhookとは、注文が完了したときにCardVから貴社のサーバーに送られる通知です。Webhookを使えば、ポーリングの必要がなくなります。Webhookにはコードは含まれません。Webhookを受け取ったら、 GET/api/v1/orders/{order_id} で注文を照会してください。

WebhookのURLは、Portalで設定します(Owner、Integrations → Webhooks)。LiveとSandboxでそれぞれ別に設定してください。APIからは設定できません。

関連ドキュメント:商品と注文 · セキュリティ

#イベント

イベント送信されるタイミング(注文のステータス)
order.succeededsucceeded になったとき
order.partially_succeededpartially_succeeded になったとき
order.failedfailed になったとき(まだ返金ではありません)
order.refundedrefunded になったとき(代金がウォレットに戻っています)
  • 各イベントは、1つの注文とWebhook URLにつき最大1回送信されます(再送を除く)。
  • accepted や processing、入金に関するイベントはありません。

#送信内容

CardVは、貴社のHTTPSのURLに POST を送ります。

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

本文は次のとおりです(ここでは見やすく整形していますが、実際には1行で送られます)。

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id はCardVの注文IDです。order.id にも同じ値が入っています。
  • order.external_order_id は自社の注文番号です。
  • order.status はイベントが発生した時点のステータスです。最新ではない場合があります。
  • X-CardV-Delivery はこの通知のIDです。Portalの送信履歴に表示されます。

#署名の検証

すべてのWebhookには、そのWebhookの署名シークレット(whsec_...)で署名が付いています。シークレットがPortalに表示されるのは、Webhookを作成したときか、シークレットを再発行したときの1回だけです。

署名のしくみ:

Text
X-CardV-Signature: t=<Unix時間(秒)>,v1=<16進数の小文字>
v1 = HMAC-SHA256(鍵 = 署名シークレット, メッセージ = "<t>" + "." + 本文の生のバイト列)

検証の手順:

  1. JSONを解析する前に、本文の生のバイト列を読み取ります。
  2. ヘッダーから t と v1 を取り出します。
  3. t が貴社のサーバー時刻と300秒以上ずれていたら、通知を拒否します。
  4. 正しい v1 を計算し、実行時間が一定の比較方法(constant-time compare)で照合します。
  5. 照合できてから、JSONを解析します。

署名の対象は t と本文だけです。イベントの種類は、X-CardV-Event ヘッダーではなく、本文の event フィールドから取得してください。

テストベクター(シークレットはダミーです):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

このテストベクターの本文は、次の1行そのものです(266バイト、末尾に改行なし)。

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python(Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js(Express)

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

どちらのサンプルも、テストベクターで正しく検証できます。

#再送

  • イベントを保存したら、15秒以内に2xxのステータスを返してください。注文の照会やコードの送付など、時間のかかる処理はその後で行ってください。
  • それ以外はすべて失敗とみなされます。2xx以外のステータス、タイムアウト、接続エラーが該当します。
  • CardVはリダイレクトをたどりません。3xxの応答も失敗になります。リダイレクト先の最終的なURLをそのまま登録してください。
  • 失敗した通知は、合計6回まで送信されます。
回数タイミング
1イベントの発生直後
21回目の失敗から約1分後
32回目の失敗から約5分後
43回目の失敗から約15分後
54回目の失敗から約30分後
65回目の失敗から約60分後

実際の送信は最大1分ほど遅れることがあります。6回目も失敗すると、CardVは送信をやめます。すべての通知はPortalの送信履歴で確認でき、そこから再送することもできます。

#重複

同じイベントが2回以上届くことや、イベントの届く順番が前後することがあります。

  • 処理済みのイベントは無視してください。検証済みの本文にある order.order_id と event の組み合わせで判定します。Portalから再送した通知には新しい X-CardV-Delivery のIDが付くため、このIDだけでは判定できません。
  • 本文は、イベントが発生した時点の内容です。必ず注文を照会し直して、最新のステータスに基づいて処理してください。
  • Webhookが届かないこともあります。数分以上たっても完了していない注文を確認する定期処理も用意してください。

#受信URLの条件

  • URLは https:// で始まり、インターネットから到達できる公開アドレスである必要があります。プライベートアドレスやローカルアドレスは登録できません。
  • 4つのイベントすべてを受け取ることも、一部だけを選ぶこともできます。
  • 署名シークレットを再発行すると、すぐに反映され、古いシークレットは使えなくなります。サービスを止めずに切り替えるには、新しいシークレットで2つ目のWebhookを追加して反映し、その後に古いWebhookを無効にしてください。
  • CardVは、トラブル調査のために貴社の応答の先頭1,000文字を保存します。応答には秘密情報や個人情報を含めないでください。

連携についてご不明な点は、Merchant ID と注文 ID またはリクエスト ID を添えて [email protected] までお問い合わせください。