# Shipping Status Webhook

`POST https://your_server_address/{your_shipping_endpoint}`

> Webhook — 向き: Recustomer → マーチャント（通知）

配送ステータスの更新を通知します。

## ヘッダー

- `X-Recustomer-Signature-256` (string, 必須): ペイロードのHMAC SHA256署名。データの完全性と送信元の認証を保証するためにヘッダーで提供される。署名は'sha256=signature'の形式と一致する必要があり、signatureは64文字の16進文字列である。
  - 形式: `^sha256=[a-fA-F0-9]{64}$`
- `X-Recustomer-Sender-ID` (string, 必須): このヘッダは、受信者がウェブフックの送信元を識別できるように、ウェブフックをトリガーした一意の識別子を含んでいます。

## リクエストボディ

- `order_number` (string, 必須): order number
- `shipping_address` (object, 必須): 配送先（配送先住所そのものが無い場合も、各項目を`null`にしたオブジェクトとして必ず含まれます）
  - `phone_number` (string, 必須, null のことがある): 配送先の電話番号（未登録の場合、または配送先住所そのものが無い場合は`null`）
- `tracking_number` (string, 必須): tracking number
- `estimated_delivery_date` (string, date): お届け予定日（`YYYY-MM-DD`形式）
- `actual_delivery_date` (string, date, null のことがある): お届け完了日（`YYYY-MM-DD`形式。お届け完了前は`null`）
- `actual_delivery_datetime` (string, date-time, null のことがある): お届け完了日時（ISO 8601 形式、JST オフセット付き。例：`2026-01-15T19:30:00+09:00`。お届け完了前、または配送会社側で時刻が取得できなかった場合は`null`）
- `latest_delivery_status` (string, 必須): latest tracking status
  - 取りうる値: `荷物受付け`, `配送中`, `お届け完了`, `受取可能`, `ご不在のため持ち戻り`, `配送トラブル`, `保管期限の超過`
- `delivery_company` (string, 必須): delivery company
- `logistics_centers` (array of objects)
  - `center` (object)
    - `center_name` (string): logistics center name
    - `center_address` (string): logistics center address
    - `updated_at` (string): arrived at the center
    - `status` (string): status name at the center

## レスポンス

### 200 OK

- `results` (string)

## 受け口の実装例

### curl

```bash
# Send a signed test notification to your local endpoint
RECUSTOMER_WEBHOOK_SECRET='your_webhook_secret'
BODY=$(cat <<'JSON'
{
  "order_number": "string",
  "shipping_address": {
    "phone_number": "09012345678"
  },
  "tracking_number": "string",
  "estimated_delivery_date": "2024-01-15",
  "actual_delivery_date": "2024-01-15",
  "actual_delivery_datetime": "2024-01-15T10:00:00+09:00",
  "latest_delivery_status": "荷物受付け",
  "delivery_company": "string",
  "logistics_centers": [
    {
      "center": {
        "center_name": "string",
        "center_address": "string",
        "updated_at": "string",
        "status": "string"
      }
    }
  ]
}
JSON
)
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$RECUSTOMER_WEBHOOK_SECRET" | sed 's/^.* //')"

curl -X POST 'http://localhost:3000/your_shipping_endpoint' \
  -H "X-Recustomer-Signature-256: $SIGNATURE" \
  -H 'X-Recustomer-Sender-ID: 1bad6b8cf97131fceab8543e81f7757195fbb1d36b376ee994ad1cf17699c464' \
  -H 'Content-Type: application/json' \
  --data-binary "$BODY"
```

### Node.js

```javascript
// npm install express
import crypto from 'node:crypto';
import express from 'express';

const secret = process.env.RECUSTOMER_WEBHOOK_SECRET;
if (!secret) throw new Error('RECUSTOMER_WEBHOOK_SECRET is not set');

// Compare in constant time so the response time does not reveal the expected value
const safeEqual = (a, b) => {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
};

const app = express();

// Verify the signature against the raw body, before parsing it as JSON
app.post(
  "/your_shipping_endpoint",
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex');
    if (!safeEqual(req.get("X-Recustomer-Signature-256") ?? '', expected)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    console.log(event);
    res.status(200).json({
      "results": "string"
    });
  },
);

app.listen(3000);
```

### Python

```python
# pip install flask
import hashlib
import hmac
import os

from flask import Flask, jsonify, request

SECRET = os.environ.get("RECUSTOMER_WEBHOOK_SECRET", "")
if not SECRET:
    raise RuntimeError("RECUSTOMER_WEBHOOK_SECRET is not set")

app = Flask(__name__)

@app.route("/your_shipping_endpoint", methods=["POST"])
def handle():
    # Verify the signature against the raw body, before parsing it as JSON
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Recustomer-Signature-256", "")
    if not hmac.compare_digest(received.encode(), expected.encode()):
        return "", 401

    event = request.get_json()
    print(event)
    return jsonify({
        "results": "string",
    }), 200

if __name__ == "__main__":
    app.run(port=3000)
```

### PHP

```php
<?php
// Run: php -S localhost:3000 handler.php  (POST /your_shipping_endpoint)
$secret = getenv('RECUSTOMER_WEBHOOK_SECRET');
if ($secret === false || $secret === '') {
    http_response_code(500);
    exit('RECUSTOMER_WEBHOOK_SECRET is not set');
}
// Verify the signature against the raw body, before parsing it as JSON
$body = file_get_contents('php://input');
$headers = [];
foreach (getallheaders() as $name => $value) {
    $headers[str_replace('_', '-', strtolower($name))] = $value;
}
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $headers['x-recustomer-signature-256'] ?? '')) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);

http_response_code(200);
header('Content-Type: application/json');
echo <<<'JSON'
{
  "results": "string"
}
JSON;
```

### Go

```go
package main

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

const responseBody = `{
  "results": "string"
}`

func main() {
	secret := os.Getenv("RECUSTOMER_WEBHOOK_SECRET")
	if secret == "" {
		log.Fatal("RECUSTOMER_WEBHOOK_SECRET is not set")
	}
	http.HandleFunc("POST /your_shipping_endpoint", func(w http.ResponseWriter, r *http.Request) {
		// Verify the signature against the raw body, before parsing it as JSON
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(body)
		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
		received := r.Header.Get("X-Recustomer-Signature-256")
		if !hmac.Equal([]byte(received), []byte(expected)) {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		var event map[string]any
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		log.Println(event)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(200)
		w.Write([]byte(responseBody))
	})
	http.ListenAndServe(":3000", nil)
}
```

### Rust

```rust
// Cargo.toml: axum = "0.8", tokio = { version = "1", features = ["full"] },
//             hmac = "0.12", sha2 = "0.10", hex = "0.4"
use axum::{
    body::Bytes,
    extract::State,
    http::{header, HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::post,
    Router,
};
use hmac::{Hmac, Mac};
use sha2::Sha256;

const RESPONSE_BODY: &str = r#"{
  "results": "string"
}"#;

// Verify the signature against the raw body, before parsing it as JSON
async fn handle(State(secret): State<String>, headers: HeaderMap, body: Bytes) -> Response {
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
    mac.update(&body);
    let signature = headers
        .get("X-Recustomer-Signature-256")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("sha256="))
        .and_then(|v| hex::decode(v).ok());
    if !signature.is_some_and(|s| mac.verify_slice(&s).is_ok()) {
        return StatusCode::UNAUTHORIZED.into_response();
    }

    println!("{}", String::from_utf8_lossy(&body));
    (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], RESPONSE_BODY).into_response()
}

#[tokio::main]
async fn main() {
    let state = std::env::var("RECUSTOMER_WEBHOOK_SECRET")
        .ok()
        .filter(|v| !v.is_empty())
        .expect("RECUSTOMER_WEBHOOK_SECRET is not set");
    let app = Router::new().route("/your_shipping_endpoint", post(handle)).with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
```

## レスポンス (200)

```json
{
  "results": "string"
}
```
