PayssageBackend
Coding Conventions
How Payssage backend code is written — wallets, intents, webhooks, testmode.
Spec-first development
api-spec.ymlis the contract and the single source of truth.- Regenerate bindings after touching it:
just generate-oapi payssage(internal/openapi/*.gen.go, not committed). - The embedded spec is served at
/docs/openapi.yml— this site's API reference is that file.
Feature layout
One directory per feature under internal/handlers/ (wallets, sellers, collectors, intents, oauth, webhooks, testmode, orgs). Routes are registered in internal/app/router.go.
Wallet model & ownership
- Wallets are ownership units:
owner_id, optionalorganization_id,sandbox,fee_bps(500 = 5%). CheckWalletAccessrequires the caller to be the wallet owner (or an org member of an org-scoped wallet).- The platform wallet is owned by the payssage svc actor — it's created with the service API key, not a user JWT. Univents' boot check calls
GetWalletwith the service key and fails fast if it's missing. - Sellers/collectors are provider accounts bound to a wallet; they are created only via the provider OAuth callback — there is no create-seller API. Don't add one; seed the table for local e2e.
Intents
POST /wallets/{wallet_id}/checkoutcreates a payment attempt. Statuses:pending | processing | succeeded | cancelled | failed | rejected | refunded.- State transitions come from the provider webhook, not from guesswork — a
succeededintent that never received the webhook should not be trusted. - Test mode:
POST /testmode/intents/createhard-creates an intent with a chosen status (e.g.succeeded) so downstream flows observe the webhook without a real provider.
Webhooks
- The provider calls
POST /webhooks/{provider}; Payssage normalizes the event into the D2 envelope and fans it out to tenant endpoints:
{ "intent_id": "...", "wallet_id": "...", "provider": "mercadopago",
"external_id": "<provider_payment_id>", "event_type": "payment.succeeded",
"payload": { "...": "raw provider payload" } }- Deliveries are signed:
X-Payssage-Signature= hex(HMAC-SHA256(raw body, endpoint secret)). The endpoint secret is returned once when the webhook endpoint is created — consumers must store it. - Non-2xx deliveries are retried up to 5×. Don't swallow signature failures.
Response envelope & errors
- Every response is the
fun.Responseenvelope; failures areAppErrorwith sharederrxsentinels. - An API-key identity must come from unwrapping the introspect envelope (
data.subject) — reading the envelope root zeroes the identity and every wallet created that way getsowner_id = 00000000-….
Auth: two kinds of callers
- User JWT — bearerAuth routes (most wallet/intent/org ops).
- Service API key —
X-API-Keyaccepted on wallet create/get/fee, sandbox, webhook endpoints, and testmode, so the platform can manage the wallet with the service key alone. - Only
POST /webhooks/{provider}andGET /providers/{provider}/callbackare unauthenticated.
Database
- Schema in
db/, sqlc queries ininternal/sqlc, repos ininternal/repos.
Tests & quality
- Parity tests in
internal/appkeep the router surface in sync with the spec. just payssage test(gotestsum),just payssage lint(golangci-lint).- Sandbox is PATCH, not POST —
setWalletSandboxreturns 405 on POST; the parity tests catch regressions.
Don't
- Don't reimplement HTTP serving/telemetry — boot through
lib/go/httpserver. - Don't create sellers outside the OAuth callback.
- Don't return the envelope root as the payload.