← All projects

spec

The aice-payment contract: domain types, Provider adapter interface, transaction state machine, OpenAPI, conformance vectors, and Postgres migrations.

  • Go 100%
git@gitlab.com:aice-lab/payment/spec.git

Latest commit

9cadd4b4 ·

README

payment/spec

The payment contract for the AiCE-Lab payment framework: domain types, the Provider adapter interface, the transaction state machine, OpenAPI, conformance vectors, and Postgres migrations. Pinned by tag by payment-server and the SDKs.

Pure Go + static artifacts. No service, no network, no database.

Payer return from a gateway callback

return_urls are the CONSUMER’s pages; the gateway is never given them. The server registers its own callback URLs with the gateway so that verification happens server-side before anyone is told an outcome, then hands the payer on:

  • The browser-facing legs (GET for bKash, POST for SSLCommerz) answer 302 to the return_urls entry chosen by the transaction’s stored status — SUCCEEDED/REFUNDEDsuccess, CANCELLEDcancel, anything else → fail — never by the success/fail/cancel leg the gateway picked, which is an unverified claim. transaction_id and status are appended as query parameters; any query already on the stored URL is preserved.
  • The ipn leg is server-to-server and keeps answering a bare 200.
  • A transaction with no usable (absolute http/https) return URL for its outcome answers 200 with a short plain-text body, not an empty page.

Outbound events

A tenant may register a webhook endpoint plus a signing secret. One event is emitted per REAL terminal transition — SUCCEEDED, FAILED, CANCELLED, EXPIRED, REFUNDED — while replays and no-op transitions emit nothing:

{"foundation_ref":"7c1e…","order_ref":"ORD-1","status":"SUCCEEDED",
 "amount_minor":50000,"currency":"BDT","gateway":"bkash",
 "occurred_at":"2026-01-02T03:04:05Z"}

amount_minor is minor units (poisha for BDT) and never a float. foundation_ref is the payment transaction id and is the consumer’s dedupe key.

Delivery is at-least-once: retried with backoff until any 2xx, resumed after a restart, and possibly duplicated (a producer replica or a lost response can re-send). Retries carry the byte-identical body, so the signature is stable across attempts — dedupe on foundation_ref, never on the signature. Do not treat the event as the source of truth for anything irreversible without also reading GET /v1/transactions/{id}, which stays authoritative.

Verifying the signature

X-Payment-Signature is the lowercase hex HMAC-SHA256 of the RAW request body under the tenant’s webhook secret. Compute it over the bytes as received — re-serialising the parsed JSON will change them and the check will fail. Compare in constant time:

import hashlib, hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

# e.g. Django/Flask: pass request.body (bytes), not request.POST / json.dumps(...)
if not verify(raw, request.headers.get("X-Payment-Signature", ""), SECRET):
    return HttpResponse(status=401)   # a non-2xx makes the server retry

License: FSL-1.1-Apache-2.0 (converts to Apache-2.0 after two years).

This is a snapshot generated from GitLab. For the live README, see the project page.