webhookで注文の支払いを自動確認する方法

インボイスと注文を関連付け、Webhook署名を検証し、重複通知、支払い不足、取り消しによる誤った提供を防ぎます。

更新日:

サイトが注文を自動で支払い済みにするには、Cryptanioで注文用のインボイスを作り、webhookを接続します。webhookは支払い状態が変わった際にサービスが自分のサーバーへ送る通知です。サーバーがメッセージの真正性を検証し、注文を特定してインボイス状態を照合した後、購入者への有料アクセス付与や注文履行への引き渡しができます。

ドルを計算単位とするインボイスを作り、購入者が決済ページで利用可能な暗号資産とネットワークを選ぶショップを例に説明します。資金はショップのアドレスに届きます。注文と商品の提供はショップのアプリケーションで管理し、Cryptanioはインボイスを送金に関連付け、その状態を通知します。連携には開発者、またはサイトのサーバー側を変更できる環境が必要です。

決済用のプロジェクトとアドレスを用意する

Cryptanio管理画面でプロジェクトを選び、自分が所有する受取アドレスを追加します。Paymentsに接続してください。通常のアドレス監視だけではインボイスの発行に足りません。アドレスが有効で、選択したネットワークに対応していることを確認します。実際の支払いには本番モードのアドレスが必要です。

複数の顧客が同時に支払える場合は、アドレスも複数用意します。未完了のインボイスで使用中になったり、終了後しばらく利用できなかったりする場合があります。空きがないときは分かりやすいエラーを示し、後で再試行できるようにしてください。

APIキーを作成し、ショップのサーバーに保存します。https://api.cryptanio.com/v1への呼び出しに使用します。ページのJavaScript、モバイルアプリ、公開リポジトリにキーを含めてはいけません。

通知の受信先を設定する

対象プロジェクトの「通知」を開きます。「アカウント通知設定を使用します」が有効な場合は「アカウント設定」でWebhookを設定するか、継承を無効にしてプロジェクト独自の受信先を指定します。

Webhookカードの「エンドポイント URL」にハンドラーのHTTPSアドレスを入力し、「保存」を押します。ハンドラーはPOSTを直接受け付ける必要があり、別URLへのリダイレクトは使いません。同じカードの「署名シークレット」を取得し、APIキーとは別にサーバーへ保管してください。

Webhookが一時停止されていないことを確認します。ハンドラーでpayment.confirmedとpayment.rolled_backを扱い、必要に応じてpayment.pendingを途中状態の表示に使います。注文履行前に本番モードと以下の条件を確認してください。インボイスのアドレスで個別にWebhookチャネルを有効にする必要はありません。

注文専用のインボイスを作成する

先に注文をショップのデータベースへ保存します。その後、サーバーがPOST /v1/invoicesでインボイスを作成します。referenceに自分の注文識別子を渡します。この例の注文価格は12.50 USDです。invoice_currencyとinvoice_amountで価格を指定し、購入者が決済ページで利用可能なネットワークと資産を選びます。そのため、このリクエストではchain、asset、amount、address、toleranceを送信しません。USDの指定は、法定通貨の受付や受け取った暗号資産のドルへの交換を意味しません。

#!/usr/bin/env bash
set -euo pipefail
: "${CRYPTANIO_API_KEY:?Set CRYPTANIO_API_KEY}"
: "${CRYPTANIO_OPERATION_ID:?Set CRYPTANIO_OPERATION_ID}"
: "${CRYPTANIO_PROJECT_ID:?Set CRYPTANIO_PROJECT_ID}"
json_string() {
  local text="$1" char code index output='"'
  for ((index=0; index<${#text}; index++)); do
    char="${text:index:1}"
    case "$char" in
      '"'|'\') output+="\\$char" ;;
      *) printf -v code '%d' "'$char"
         if ((code < 32)); then printf -v char '\\u%04x' "$code"; fi
         output+="$char" ;;
    esac
  done
  printf '%s"' "$output"
}
response_file="$(mktemp)"
trap 'rm -f "$response_file"' EXIT
transfer_status=0
http_status="$(curl \
  --silent --show-error --max-time 30 --request POST \
  --header "Authorization: Bearer $CRYPTANIO_API_KEY" \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header "Idempotency-Key: $CRYPTANIO_OPERATION_ID" \
  --data-binary '{"project":'"$(json_string "$CRYPTANIO_PROJECT_ID")"',"invoice_currency":"USD","invoice_amount":"12.50","reference":"order-42"}' \
  "https://api.cryptanio.com/v1/invoices" \
  --output "$response_file" --write-out '%{http_code}')" || transfer_status=$?
if [[ "$http_status" == 2?? ]]; then
  cat "$response_file"
  exit "$transfer_status"
fi
printf 'HTTP %s\n' "$http_status" >&2
cat "$response_file" >&2
exit 1

サンプルをダウンロード

実行前にサーバーでCRYPTANIO_API_KEY、CRYPTANIO_PROJECT_ID、CRYPTANIO_OPERATION_IDを設定します。インボイス作成権限のある本番用APIキーを使ってください。CRYPTANIO_PROJECT_IDは準備したPaymentsプロジェクトを指定します。CRYPTANIO_OPERATION_IDは、この注文の作成操作用に保存した識別子で、英字、数字、ハイフン、アンダースコアによる16〜80文字です。例ではJSONフィールドではなくIdempotency-Keyヘッダーに渡します。新しい注文には別の値を作り、同じリクエストの再試行では元の値を維持してください。

作成に成功するとAPIはHTTP 201を返します。インボイスのidと返されたcheckout_urlを注文とともに保存し、購入者をそのリンクへ案内します。支払い方法を選ぶまではネットワーク、アドレス、暗号資産額は未設定です。購入者は決済ページで利用可能な方法を選び、その時点の送金情報と支払期限を受け取ります。自分の画面に送金情報を表示する場合は、該当する支払い試行のデータ、amountの正確な金額、期限expires_atを使用してください。金額を丸めたり、askedで置き換えたりしないでください。後続のGETリクエストやインボイス一覧で返されるリンクは閲覧用です。作成応答の操作可能な決済リンクは別に保存し、対象の購入者だけに渡します。

リクエスト送信後に接続が切れた場合は、同じ本文を同じIdempotency-Keyで再送します。再試行に成功するとHTTP 201と同じidが返ります。使用済みキーでパラメーターを変更するとHTTP 409になります。referenceはインボイスと注文を関連付けますが、作成を1回に限定するキーを代替しません。タイムアウトだけを理由にキーを変更しないでください。

受信したwebhookの真正性を検証する

CryptanioはX-Timestamp、X-Signature、X-Event-Id、X-Event-Type、X-Attemptを送信します。署名は次の式で計算されます。

hex(HMAC-SHA256(secret, timestamp + "." + raw_body))

raw_bodyはリクエストの元のバイト列です。JSON解析の前に取得してください。再シリアライズすると空白やフィールド順が変わり、検証に失敗する場合があります。本文の許容サイズを制限し、正しい署名のないリクエストは拒否します。

以下では鮮度の許容範囲を5分にしています。これは自分のハンドラーの設定であり、正しく機能させるにはサーバーの時計の同期が必要です。

import hashlib
import hmac
import re
import time

def verify_signature(secret, timestamp, raw_body, signature):
    if not secret or not timestamp or not re.fullmatch(r"[0-9]{1,12}", timestamp):
        return False
    if not signature or not re.fullmatch(r"[0-9a-f]{64}", signature):
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

この関数は署名とリクエスト時刻を検証します。成功後にJSONを解析し、schema_version="v1"、期待したproject_id、mode="live"、sandbox=falseを確認します。

判断には署名済み本文のevent_idとevent_typeを使います。同名のヘッダーと照合できますが、そのヘッダー自体は署名で保護されていません。想定したpaymentオブジェクトで、方向がinの場合だけ決済ロジックを実行してください。

送金の確定と注文の支払い完了を区別する

ショップで扱う主なイベントは次のとおりです。

  • payment.pending:送金を検出し、確定を待っています。
  • payment.confirmed:必要な承認数に達しました。
  • payment.rolled_back:以前検出した送金がチェーンの変更で取り消されました。

payment.confirmedだけで特定の注文が全額支払い済みとは判断できません。invoiceを調べ、invoice.idとinvoice.referenceを保存済み注文と照合します。関連するインボイスがないメッセージを、金額が似ているだけで購入に自動対応付けしてはいけません。

提供前にはGET /v1/invoices/{id}で現在の状態を取得します。id、project_id、reference、invoice_currencyとinvoice_amountの注文価格、sandbox=falseを照合します。ネットワーク、資産、アドレス、選択された支払い試行も確認し、試行が複数ある場合はsettled_attempt_idとsettled_attemptを考慮してください。支払い成功を表すインボイス状態はpaidです。pending、seen、partialではまだ提供できません。購入者がサイトに戻ったことや決済リンクを開いたことは支払いの確認にはなりません。

不足払いはunderpaid、追加入金を待つ部分払いはpartial、期限後の入金はlateです。過払いではプロジェクトの方針によりpaidのままになる場合と、needs_decision=trueを伴うoverpaidになる場合があります。underpaid、overpaid、lateでは、注文を履行する前に個別の判断が必要です。amount_rawとpaidは文字列で届きます。比較時は資産の最小単位を表す正確な整数値として解析し、decimalsがスケールを指定します。二進浮動小数点は使用しないでください。差額の取扱いを事前に決めます。これによって自動返金が行われるわけではありません。

再送と巻き戻しを処理する

同じイベントが繰り返し届く場合があります。検証済みイベントを永続ストレージに保存し、event_idを一意キーとして使います。受理済みイベントの再送には、商品を再提供せず成功応答を返します。

送金は複数の状態を経るため、取引識別子だけで以後の全メッセージを除外するのは不十分です。chain、tx_id、index、address、資産の識別情報の組み合わせごとに、最後に適用したpayment.revisionを保存します。処理済みの値よりrevisionが大きい場合だけ状態遷移を適用してください。

注文変更と提供ジョブの登録は、並行処理から保護し、一貫した形で確定します。ジョブには注文ごとの一意キーが必要です。実行直前に、その注文を引き続き提供してよいか再確認します。巻き戻しを受けたら状態を更新し、未実行の提供を止めてください。既に付与したアクセスは、見直しや一時停止の手順を用意します。

全体の流れを確認する

実際の決済前に、用意したデータで本文の解析、署名、重複防止を確認します。サービスイベントwebhook.testは購入を確認せず、全体の流れの検証も代替しません。

続いて自分の確認用注文で、インボイス作成、送金、確定、注文更新までを確認します。準備したデータで、イベントの再送、不足払い、巻き戻しも個別に検証します。HTTP 2xxは受理したメッセージを確実に保存した後に返してください。一時的に保存できない場合はサーバーエラーにし、再送できるようにします。

管理画面のイベントと配信記録を、自分のアプリケーションの記録と照合します。確定した注文が1回だけ履行され、途中段階、別の注文、再送のイベントが誤った提供を起こさなければ確認完了です。

参考:API v1のインボイス、webhook署名の検証。