spec
The aice-payment contract: domain types, Provider adapter interface, transaction state machine, OpenAPI, conformance vectors, and Postgres migrations.
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 (
GETfor bKash,POSTfor SSLCommerz) answer302to thereturn_urlsentry chosen by the transaction’s stored status —SUCCEEDED/REFUNDED→success,CANCELLED→cancel, anything else →fail— never by thesuccess/fail/cancelleg the gateway picked, which is an unverified claim.transaction_idandstatusare appended as query parameters; any query already on the stored URL is preserved. - The
ipnleg is server-to-server and keeps answering a bare200. - A transaction with no usable (absolute
http/https) return URL for its outcome answers200with 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.