如何使用 Python 在 PayFast sandbox 取消 subscription?
结论
取消 PayFast recurring subscription 时,应继续使用文档规定的 PUT 请求。HTTP 419 通常不代表 endpoint 已经失效,而是 PayFast 拒绝了请求认证。最常见的原因有两个:
signature不正确。取消请求的签名必须根据本次 API 请求的 headers 重新计算,不能复用创建付款时的 signature,也不能使用notify_url(ITN)中收到的 signature。- 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×tamp=...&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×tamp=...&passphrase=...
生成签名之前不要随意排序字段,除非当前 PayFast 文档明确要求按字母顺序排列。更稳妥的方式是按照文档示例中的顺序构造 headers。
调试时可以临时打印待签名字符串,但不要在生产日志中输出完整的 passphrase:
safe_debug_string = (
f"merchant-id={MERCHANT_ID}"
f"&version=v1"
f"×tamp={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。
备注:内容仅供参考。
