Skip to content

Python

相容性

Python 3.6+。僅依賴 requests,其他都是標準庫。

安裝依賴

bash
pip install requests

客戶端實現

python
import time, hmac, hashlib, secrets, json
import requests

APP_ID = "cp_a1b2c3d4e5f6071829304a5b6c7d8e9f"
SECRET = "f1e2d3c4b5a69788776655443322110099aabbccddeeff00112233445566778899"
BASE = "https://api.coinepay.net"


def call(method: str, path: str, body_dict: dict | None = None,
         idempotency_key: str | None = None) -> dict:
    """統一呼叫 OpenAPI"""
    body = json.dumps(body_dict or {}, separators=(",", ":")).encode("utf-8")
    body_hash = hashlib.sha256(body).hexdigest()
    ts = str(int(time.time()))
    nonce = secrets.token_hex(16)
    sign_input = f"{method}\n{path}\n\n{ts}\n{nonce}\n{body_hash}"
    sig = hmac.new(SECRET.encode(), sign_input.encode(), hashlib.sha256).hexdigest()

    headers = {
        "X-App-Id": APP_ID,
        "X-Timestamp": ts,
        "X-Nonce": nonce,
        "X-Signature": sig,
        "Content-Type": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    return requests.request(method, BASE + path, headers=headers, data=body, timeout=30).json()


# Webhook 驗籤
def verify_webhook(signature_header: str, timestamp_header: str,
                   raw_body: bytes, webhook_secret: str) -> bool:
    if not signature_header.startswith("v1,"):
        return False
    sig = signature_header[3:]
    expected = hmac.new(
        webhook_secret.encode(),
        f"{timestamp_header}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

使用示例

python
import uuid

# 1. 列卡頭
resp = call("POST", "/api/v1/openapi/card_headers/list", {"page": 1, "page_size": 20})
print(resp["data"]["list"][0]["header_id"])

# 2. 列卡配置
resp = call("POST", "/api/v1/openapi/card_configs/list", {"header_id": "hdr_5"})
package_id = resp["data"]["list"][0]["package_id"]

# 3. 申請虛擬卡(帶冪等鍵)
resp = call("POST", "/api/v1/openapi/card/apply",
            {"header_id": "hdr_5", "package_id": package_id,
             "first_name": "John", "last_name": "Doe"},
            idempotency_key=str(uuid.uuid4()))
card_id = resp["data"]["card_id"]
print(f"Created card: {card_id}, status={resp['data']['status']}")

# 4. 建立充值
resp = call("POST", "/api/v1/openapi/card/recharge",
            {"card_id": card_id, "amount": "100.00"},
            idempotency_key=str(uuid.uuid4()))
print(f"Transaction: {resp['data']['transaction_id']}")

Webhook 接收(FastAPI)

python
from fastapi import FastAPI, Request, HTTPException
import os

app = FastAPI()
WEBHOOK_SECRET = os.environ["COINEPAY_WEBHOOK_SECRET"]

@app.post("/webhook")
async def webhook(request: Request):
    raw = await request.body()
    sig = request.headers.get("Webhook-Signature", "")
    ts  = request.headers.get("Webhook-Timestamp", "")
    event_id = request.headers.get("Webhook-Id", "")
    event_type = request.headers.get("Webhook-Type", "")

    if not verify_webhook(sig, ts, raw, WEBHOOK_SECRET):
        raise HTTPException(401, "invalid signature")

    # 時間戳 ±5 分鐘內
    if abs(int(time.time()) - int(ts)) > 300:
        raise HTTPException(401, "timestamp expired")

    # 基於 event_id 去重
    if already_processed(event_id):
        return {"ok": True}

    payload = json.loads(raw)
    handle_event(event_type, payload)
    mark_processed(event_id)

    return {"ok": True}

重活丟佇列

handle_event 應只做"持久化 + 入隊"。不要在 webhook handler 裡做耗時操作(資料庫慢查詢、外部呼叫),5 秒不返回 2xx 就會重投。

錯誤處理與重試

python
import time

class coinepayError(Exception): ...
class RateLimited(coinepayError): ...
class IdempotencyConflict(coinepayError): ...

def call_with_retry(method, path, body=None, idempotency_key=None,
                    max_attempts=4):
    for attempt in range(max_attempts):
        resp = call(method, path, body, idempotency_key)
        code = resp.get("code", 500)

        if code == 200:
            return resp["data"]
        if code == 429 and attempt < max_attempts - 1:
            time.sleep(min(2 ** attempt, 30))
            continue
        if code >= 500 and attempt < max_attempts - 1:
            time.sleep(2 ** attempt)
            continue
        if code == 409:
            raise IdempotencyConflict(resp.get("message"))
        raise coinepayError(f"{code}: {resp.get('message')}")

    raise coinepayError("max attempts exceeded")

採用 MIT 等價條款釋出