PAYATHON 2026

调用 List Payment API 允许的最短间隔是多少?

支付阿杰

结论

List Payments API 通常不会强制规定两次调用至少间隔多少秒。单台设备每隔 5~10 秒查询一次,频率较低,一般不会因为调用间隔本身出现问题。

需要关注的是 API 的速率限制。如果请求过于频繁,服务端可能返回 429 Too Many Requests。具体限额可能因账户、接口版本或平台策略而调整,应以当前官方文档和实际响应头为准,不要把某个固定的 QPS 数值当作长期不变的保证。

建议的实现方式

查询时用 location_id 限定门店,并通过 begin_time 缩小时间范围。实现时还要注意:

  1. 保存最后一次成功同步的时间。
  2. 下一次查询保留几秒的重叠窗口,避免因网络或交易写入延迟而漏单。
  3. 使用 payment ID 去重,不要只依赖时间戳。
  4. 正确处理分页游标 cursor,即使每次通常只有 1~3 笔记录。
  5. 收到 429 后暂停固定频率轮询,并根据 Retry-After 或指数退避策略重试。
  6. 处理完当前查询及全部分页后,再更新同步时间。

例如,上次成功同步到 10:00:10Z,下次可以从 10:00:05Z 开始查询,不必严格从 10:00:10Z 开始。重复返回的 payment 可通过唯一 ID 排除。

Swift 示例

下面是基本的轮询逻辑。URL、鉴权方式和响应结构需要根据实际使用的 API 版本调整:

import Foundation

struct PaymentsResponse: Decodable {
    let payments: [Payment]?
    let cursor: String?
}

struct Payment: Decodable {
    let id: String
    let createdAt: String?

    enum CodingKeys: String, CodingKey {
        case id
        case createdAt = "created_at"
    }
}

final class PaymentPoller {
    private let accessToken: String
    private let locationID: String
    private let session = URLSession.shared

    private var lastSuccessfulSync = Date()
    private var processedPaymentIDs = Set<String>()

    init(accessToken: String, locationID: String) {
        self.accessToken = accessToken
        self.locationID = locationID
    }

    func poll() async {
        // 保留 5 秒重叠窗口,降低漏单风险。
        let beginTime = lastSuccessfulSync.addingTimeInterval(-5)
        let syncStartedAt = Date()

        do {
            try await fetchAllPages(beginTime: beginTime)
            lastSuccessfulSync = syncStartedAt
        } catch {
            // 失败时不要推进 lastSuccessfulSync,否则可能漏掉交易。
            print("Payment polling failed: \(error)")
        }
    }

    private func fetchAllPages(
        beginTime: Date,
        cursor: String? = nil
    ) async throws {
        var components = URLComponents(
            string: "https://connect.squareup.com/v2/payments"
        )!

        var queryItems = [
            URLQueryItem(name: "location_id", value: locationID),
            URLQueryItem(
                name: "begin_time",
                value: ISO8601DateFormatter().string(from: beginTime)
            )
        ]

        if let cursor {
            queryItems.append(URLQueryItem(name: "cursor", value: cursor))
        }

        components.queryItems = queryItems

        var request = URLRequest(url: components.url!)
        request.httpMethod = "GET"
        request.setValue(
            "Bearer \(accessToken)",
            forHTTPHeaderField: "Authorization"
        )
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }

        if httpResponse.statusCode == 429 {
            let retryAfter = httpResponse.value(
                forHTTPHeaderField: "Retry-After"
            )
            print("Rate limited; Retry-After: \(retryAfter ?? "not provided")")
            throw URLError(.resourceUnavailable)
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.badServerResponse)
        }

        let result = try JSONDecoder().decode(
            PaymentsResponse.self,
            from: data
        )

        for payment in result.payments ?? [] {
            guard processedPaymentIDs.insert(payment.id).inserted else {
                continue
            }

            display(payment)
        }

        if let nextCursor = result.cursor, !nextCursor.isEmpty {
            try await fetchAllPages(
                beginTime: beginTime,
                cursor: nextCursor
            )
        }
    }

    private func display(_ payment: Payment) {
        print("New payment: \(payment.id)")
    }
}

可以用定时任务触发轮询:

let poller = PaymentPoller(
    accessToken: "<ACCESS_TOKEN>",
    locationID: "<LOCATION_ID>"
)

Task {
    while !Task.isCancelled {
        await poller.poll()

        // 正常情况下每 5 秒执行一次。
        try? await Task.sleep(for: .seconds(5))
    }
}

速率限制与退避

请求失败后不要立即无限重试。应优先遵循 Retry-After。如果服务端没有返回该响应头,可以采用指数退避:

1s → 2s → 4s → 8s → 16s

请求成功后,再恢复正常的 5~10 秒轮询间隔。也可以加入少量随机抖动,避免多个客户端同时集中重试。

注意事项

  • begin_time 应使用服务端接受的 ISO 8601 时间格式,并统一采用 UTC。
  • 不要把设备当前时间视为绝对可靠的交易边界,设备时钟偏差可能造成遗漏。
  • Payment 的创建、更新和最终状态变化可能发生在不同时间。如果业务只展示已完成订单,需要确认筛选的是正确状态。
  • 同步时间和去重信息应持久化,避免应用重启后漏单或大量重复展示。
  • 如果服务端提供 updated_at、排序条件或其他增量同步机制,应根据实际接口语义选择同步边界。
  • 不宜把访问令牌长期硬编码在公开发布的 iOS 应用中。生产环境中,更稳妥的做法是由受控后端调用支付 API,再向门店设备提供必要的订单数据。
  • 轮询间隔采用 5 秒还是 10 秒,应根据实际延迟要求、错误率和 429 响应动态调整。没有收到限流响应,不代表可以无限提高调用频率。

备注:内容仅供参考。