PAYATHON 2026

如何使用 Python 在 PayFast sandbox 取消 subscription?

支付阿杰

结论

取消 PayFast recurring subscription 时,应继续使用文档规定的 PUT 请求。HTTP 419 通常不代表 endpoint 已经失效,而是 PayFast 拒绝了请求认证。最常见的原因有两个:

  1. signature 不正确。取消请求的签名必须根据本次 API 请求的 headers 重新计算,不能复用创建付款时的 signature,也不能使用 notify_url(ITN)中收到的 signature。
  2. URL 中必须填写 PayFast 返回的 subscription token。有些代码或数据库会将它命名为 subscription_id,但这里真正需要的是 recurring billing 创建成功后 PayFast 返回的 token。

正确的 sandbox endpoint 是:

https://sandbox.payfast.co.za/subscriptions/{token}/cancel

不需要添加 /api/,也不要改用 DELETE、POST 或 HTTP Basic Authentication。

requests 显示的 419 Client Error: unknown status 中,unknown status 只是因为 HTTP 419 没有标准 reason phrase,并不表示客户端未能连接到 endpoint。PayFast 已经返回了 HTTP 响应。

为什么现有 signature 不能复用

PayFast 使用的 signature 分为三类,用途各不相同:

  • 创建付款时生成的 signature,用来签署付款表单中的 merchant_id、amount、item_name 等字段。
  • ITN notification 中的 signature,用来验证通知是否确实来自 PayFast。
  • Subscription API signature,用来认证当前 API 请求,需要根据 merchant-id、version、timestamp 和 passphrase 重新生成。

这三种 signature 不能互换。

取消 subscription 时,请求签名通常基于以下内容:

merchant-id=...&version=v1&timestamp=...&passphrase=...

字段顺序、URL encoding、空格处理和 passphrase 都会影响最终生成的 MD5。即使 merchant ID 和 passphrase 正确,只要 timestamp 或编码后的字符串不同,请求仍会返回 419。

Python 实现

下面的实现让待签名字段与实际发送的 headers 保持一致,并在添加 signature 之前计算签名:

import hashlib
from datetime import datetime
from urllib.parse import quote_plus

import requests


MERCHANT_ID = "1003XXXX"
PASSPHRASE = "XXXXXXXXXXXX"

# 必须是 PayFast 为 recurring subscription 返回的 token
SUBSCRIPTION_TOKEN = "4a113e9b-4bda-4007-af87-xxxxxxxxxxxx"


def payfast_urlencode(value):
    """
    按 PayFast 请求签名常用规则进行 URL encoding。
    quote_plus 会把空格编码为 '+'。
    """
    return quote_plus(str(value).strip())


def generate_api_signature(headers, passphrase):
    """
    headers 的迭代顺序必须与 PayFast 期望的签名字段顺序一致。
    不要把 signature 本身放进待签名数据。
    """
    parts = [
        f"{key}={payfast_urlencode(value)}"
        for key, value in headers.items()
        if value is not None and str(value) != ""
    ]

    if passphrase:
        parts.append(f"passphrase={payfast_urlencode(passphrase)}")

    signature_string = "&".join(parts)

    return hashlib.md5(
        signature_string.encode("utf-8")
    ).hexdigest()


def cancel_subscription():
    url = (
        "https://sandbox.payfast.co.za"
        f"/subscriptions/{SUBSCRIPTION_TOKEN}/cancel"
    )

    headers = {
        "merchant-id": MERCHANT_ID,
        "version": "v1",
        "timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
    }

    headers["signature"] = generate_api_signature(
        headers,
        PASSPHRASE,
    )

    response = requests.put(
        url,
        headers=headers,
        timeout=30,
    )

    print("Status:", response.status_code)
    print("Content-Type:", response.headers.get("content-type"))
    print("Response:", response.text)

    response.raise_for_status()

    if response.content:
        try:
            return response.json()
        except requests.exceptions.JSONDecodeError:
            return response.text

    return None


if __name__ == "__main__":
    result = cancel_subscription()
    print("Result:", result)

取消请求没有 request body,因为认证参数都放在 headers 中。merchant-key 也不属于这组 API authentication headers。

签名字段顺序

签名对字段顺序敏感。上面的代码利用 Python 3.7 及更高版本中字典保持插入顺序的特性,生成以下字符串:

merchant-id=...&version=v1&timestamp=...&passphrase=...

生成签名之前不要随意排序字段,除非当前 PayFast 文档明确要求按字母顺序排列。更稳妥的方式是按照文档示例中的顺序构造 headers。

调试时可以临时打印待签名字符串,但不要在生产日志中输出完整的 passphrase:

safe_debug_string = (
    f"merchant-id={MERCHANT_ID}"
    f"&version=v1"
    f"&timestamp={headers['timestamp']}"
    "&passphrase=[REDACTED]"
)
print(safe_debug_string)

逐项检查 419

如果重新生成 signature 后仍收到 419,可以按以下顺序检查。

1. 确认使用的是 subscription token

URL 中的值应来自 PayFast recurring billing 流程返回的 token,不要使用:

  • 自己生成的 m_payment_id
  • PayFast payment ID
  • ITN signature
  • 数据库内部订单 ID

如果数据库字段名是 subscription_id,需要确认其中保存的实际内容就是 PayFast token。

2. 确认环境与凭据匹配

sandbox 请求必须使用对应 sandbox merchant 的以下信息:

  • merchant ID
  • passphrase
  • subscription token

生产环境创建的 token 不能直接用于 sandbox endpoint,反过来也一样。

还要仔细检查 passphrase 是否包含以下内容:

  • 开头或结尾的空格
  • 从环境变量中读入的换行符
  • +、&、% 等需要 URL encoding 的字符
  • 测试环境与生产环境使用了不同的值

从环境变量读取 passphrase 时,可以显式去掉意外的换行或空白:

passphrase = PASSPHRASE.strip()

但如果 passphrase 本身有意包含首尾空格,就不能这样处理。

3. 不要传递 ITN signature

下面这种写法不适用于取消 API:

signature = data.get("signature")

这个 signature 只能验证对应的 notification payload。它签署的是另一组字段,与当前 API headers 不匹配。

4. 检查 timestamp

必须先确定 timestamp,再用它生成 signature。签名生成后不能再次修改 timestamp:

timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")

下面的写法会导致 timestamp 与签名不一致:

headers["timestamp"] = make_timestamp()
headers["signature"] = generate_api_signature(headers, passphrase)
headers["timestamp"] = make_timestamp()  # 错误:签名后又修改

同时要确认运行机器的时间没有明显漂移。服务器时间不准确时,即使 signature 字符串本身正确,API 也可能拒绝请求。

5. 确认 header 名称

这里使用的是带连字符的 API header:

merchant-id
version
timestamp
signature

不要改成付款表单字段所用的下划线形式:

merchant_id

创建付款时的 merchant_id 与 Subscription API header 中的 merchant-id 不是同一种字段表示方式。

6. 查看原始响应内容

不要只看 raise_for_status() 抛出的异常。PayFast 可能会在响应正文中提供更有用的信息:

response = requests.put(url, headers=headers, timeout=30)

print(response.status_code)
print(response.headers)
print(response.text)

response.raise_for_status()

也不要无条件调用 response.json()。取消成功时,响应可能为空,也可能不是 JSON。强行解析会产生新的异常,反而掩盖实际结果。

与创建 recurring payment 的签名区别

创建 payment 时,签名可能包含以下表单字段:

params = {
    "m_payment_id": transaction_id,
    "merchant_id": merchant_id,
    "merchant_key": merchant_key,
    "return_url": return_url,
    "cancel_url": cancel_url,
    "notify_url": notify_url,
    "email_address": email_address,
    "amount": amount,
    "item_name": item_name,
    # ...
}

这只能说明付款表单的 signature 生成流程有效,不能证明 Subscription API 的 signature 也正确。取消接口使用不同的字段名、数据集合和请求位置,因此需要单独实现并测试签名函数。

简而言之,保留 PUT /subscriptions/{token}/cancel,使用真正的 subscription token,并根据本次请求的 merchant-id、version、timestamp 和 passphrase 重新计算 signature。当前 HTTP 419 最值得优先检查的,就是是否复用了付款或 ITN signature。

备注:内容仅供参考。