Persisting payments¶
How to store eupago payments in your database so that nothing is lost when a process crashes, a webhook arrives twice (or before you expect it), or a customer pays a Pay By Link with a method you didn't predict.
This recipe gives you a reference schema (PostgreSQL, with a DynamoDB mapping at the end), the state machine, and the four functions that drive it. It is framework-agnostic — pair it with the FastAPI, Django or Flask recipe for the HTTP layer.
The one rule: write before you call¶
Insert the payment row before calling eupago, in the same process, committed:
If everything falls apart between the INSERT and the response — process
killed, network gone, deploy mid-request — you are left with an
initiated row. That orphan is visible: a reconciliation job can find
it and decide (query eupago, or mark it abandoned). Without write-ahead,
a crash leaves you with a payment eupago knows about and you don't —
invisible until an angry customer calls.
Write-ahead has a second, less obvious benefit: the row always exists before eupago does, so a webhook can never arrive before there is something to attach it to. One whole class of race condition is gone by construction.
The state machine¶
stateDiagram-v2
[*] --> initiated: INSERT (before API call)
initiated --> pending: eupago accepted (201)
initiated --> submit_failed: API error / validation
pending --> paid: webhook PAID or sync confirm
pending --> authorized: A&C authorize hold
authorized --> paid: capture
authorized --> released: release / hold expired
pending --> declined
pending --> expired: webhook, or LOCAL for Pay By Link
pending --> cancelled
pending --> error
paid --> refund_pending: refund requested (Multibanco settles async)
paid --> refunded: refund settled (or sync for MB WAY / Card)
refund_pending --> refunded: settlement webhook
initiated and submit_failed are local-only states — eupago never
sees them. Everything else maps to the SDK's PaymentStatus enum, which
already normalizes eupago's raw codes ("Paga", "Canceled",
"REFUNDED", …) for you.
Confirmation converges to the same place whether it is synchronous
(the API response already says paid — e.g. MB WAY / Card refunds return
"Reembolsado" immediately) or asynchronous (the webhook arrives
minutes or days later — Multibanco can take up to 30 days). Your handler
code shouldn't care which path delivered the truth.
Schema (PostgreSQL)¶
Three tables: current state, append-only history, and subscriptions.
CREATE TABLE payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- THE correlation key. You generate it, eupago echoes it back in
-- every response and webhook ("identifier"). Always match on this.
order_id TEXT NOT NULL UNIQUE,
-- What you asked for vs what the webhook said was actually used.
-- They differ for Pay By Link: you request 'pay_by_link', the
-- customer picks MB WAY, the webhook reports 'mbway'.
method_requested TEXT NOT NULL,
method_paid TEXT,
-- Money is NUMERIC, never float. amount_refunded accumulates:
-- partial refunds add up, they don't flip a boolean.
amount NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'EUR',
amount_refunded NUMERIC(12,2) NOT NULL DEFAULT 0,
-- Normalized lifecycle state (CHECK, not native ENUM — adding a
-- state later is an ALTER, not a type migration).
status TEXT NOT NULL DEFAULT 'initiated'
CHECK (status IN ('initiated', 'submit_failed', 'pending',
'authorized', 'released', 'paid', 'declined',
'expired', 'cancelled', 'error',
'refund_pending', 'refunded')),
-- What eupago literally said, for debugging ("Paga", "REFUNDED", …)
raw_status TEXT,
-- eupago gives you TWO different ids for the same transaction:
-- provider_payment_id: "transactionID" from the create response
-- (a hex string for Card / Apple Pay / Google Pay hosted flows)
-- provider_trid: "trid" from the webhook (numeric) — this is the
-- one the refund endpoint wants.
-- Store both. Verified live: a Google Pay create returned
-- 019ebcbb… while its webhook carried trid 29748670.
provider_payment_id TEXT,
provider_trid TEXT,
-- Multibanco
reference TEXT,
entity TEXT,
-- Hosted flows (Pay By Link, Card form, wallet sheets)
payment_url TEXT,
-- Pay By Link expiry is SILENT: no webhook fires, the link just
-- becomes a 404. You own this deadline — see reconcile().
expires_at TIMESTAMPTZ,
-- Set for recurring charges made against a subscription
subscription_id BIGINT REFERENCES subscriptions (id),
initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
submitted_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payments_status_idx ON payments (status);
CREATE INDEX payments_trid_idx ON payments (provider_trid);
Note what is not here: no card data (the hosted forms mean PAN never
touches your server — keep it that way), and no customer PII. If you need
to link a payment to a person, add a customer_id foreign key into your
own users table instead of copying phone/email around.
CREATE TABLE payment_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments (id),
type TEXT NOT NULL
CHECK (type IN ('initiated', 'create_requested', 'create_ok',
'create_failed', 'webhook_received',
'webhook_rejected', 'status_changed',
'refund_requested', 'refund_ok', 'refund_failed',
'expired_locally', 'reconciled')),
-- Where the truth came from
source TEXT NOT NULL
CHECK (source IN ('api', 'webhook', 'reconciliation',
'backoffice', 'local')),
-- The raw payload — ALWAYS stored already redacted (see snippet).
-- This is your audit trail and your debugging lifeline.
raw JSONB,
-- Webhook redelivery becomes a no-op instead of a duplicate.
dedup_hash TEXT UNIQUE,
-- Did the HMAC check out? Rejected payloads are stored too (type
-- 'webhook_rejected') but NEVER drive a status change.
signature_verified BOOLEAN,
-- GDPR retention: a purge job deletes raw payloads past this date.
-- The financial state in `payments` lives forever; the raw PII-ish
-- payloads don't have to.
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payment_events_payment_idx ON payment_events (payment_id);
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- eupago gives subscriptions TWO identifiers. Mixing them up is the
-- #1 subscription integration bug:
-- eupago_token: hex string returned by create_subscription —
-- charge_subscription() takes THIS one.
-- provider_subscription_id: the integer id (visible in the
-- backoffice URL) — get/edit/revoke take THIS one.
eupago_token TEXT UNIQUE,
provider_subscription_id INTEGER UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'revoked', 'error')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Lock the history down¶
payment_events is append-only. Enforce that with grants, not
discipline — the application role simply cannot rewrite history:
GRANT SELECT, INSERT ON payment_events TO app_role;
-- no UPDATE, no DELETE
GRANT SELECT, INSERT, UPDATE ON payments TO app_role;
-- no DELETE on payments either: cancelled payments are a status, not
-- a missing row
(The retention purge job runs under a separate maintenance role that may
UPDATE payment_events SET raw = NULL past purge_after.)
The four functions¶
The whole lifecycle is driven by four small functions. Shown with plain SQL placeholders — adapt to your driver/ORM.
1. begin_payment() — write-ahead¶
import uuid
from decimal import Decimal
def begin_payment(db, method: str, amount: Decimal) -> str:
# Non-guessable on purpose: order_id travels in URLs and webhooks.
# Sequential ids leak your sales volume and invite enumeration.
order_id = f"ORD-{uuid.uuid4().hex[:16]}"
db.execute(
"""INSERT INTO payments (order_id, method_requested, amount)
VALUES (%s, %s, %s)""",
(order_id, method, amount),
)
db.execute(
"""INSERT INTO payment_events (payment_id, type, source)
SELECT id, 'initiated', 'local' FROM payments
WHERE order_id = %s""",
(order_id,),
)
db.commit() # ← committed BEFORE eupago hears about it
return order_id
2. on_create_response() — record what eupago said¶
from eupago import EupagoClient, ValidationError, ApiError
from eupago.utils import redact_pii
def create_mbway_payment(db, client: EupagoClient, amount, phone):
order_id = begin_payment(db, "mbway", amount)
try:
result = client.mbway.create_payment(
order_id=order_id, amount=amount, phone_number=phone,
)
except (ValidationError, ApiError) as exc:
db.execute(
"""UPDATE payments SET status = 'submit_failed',
updated_at = now() WHERE order_id = %s""",
(order_id,),
)
_add_event(db, order_id, "create_failed", "api",
raw={"error": str(exc)})
db.commit()
raise
db.execute(
"""UPDATE payments SET
status = 'pending',
raw_status = %s,
provider_payment_id = %s,
payment_url = %s,
expires_at = %s, -- Pay By Link: NOT NULL here!
submitted_at = now(),
updated_at = now()
WHERE order_id = %s""",
(str(result.status), result.transaction_id, result.payment_url,
getattr(result, "expires_at", None), order_id),
)
_add_event(db, order_id, "create_ok", "api",
raw=redact_pii(result.raw_response))
db.commit()
return result
3. on_webhook() — the only place state becomes paid¶
import hashlib
import json
from eupago.exceptions import WebhookSignatureError
from eupago.utils import redact_pii
def on_webhook(db, client, body: bytes, headers: dict) -> None:
dedup = hashlib.sha256(body).hexdigest()
try:
event = client.webhooks.parse(body=body, headers=headers)
except WebhookSignatureError:
# Quarantine: store the evidence, change NOTHING. An unsigned
# "Paid" webhook is the oldest e-commerce attack there is.
_add_quarantine_event(db, dedup, redact_pii(body.decode()))
db.commit()
return # respond 200 so a misconfigured sender stops retrying
row = db.fetch_one(
"SELECT id, amount, currency, status FROM payments WHERE order_id = %s",
(event.order_id,),
)
if row is None:
# Possible for payments created outside this app (e.g. a refund
# issued in the backoffice). Store as an unmatched event for
# the reconciliation job.
_add_quarantine_event(db, dedup, redact_pii(body.decode()),
note="no matching payment")
db.commit()
return
# Verify the money before believing the status. A webhook that says
# PAID for the wrong amount/currency does not fulfil the order.
if (event.amount, event.currency) != (row.amount, row.currency):
_add_event_by_id(db, row.id, "webhook_rejected", "webhook",
raw=redact_pii(json.loads(body)),
dedup_hash=dedup, signature_verified=True)
db.commit()
return
inserted = _add_event_by_id(
db, row.id, "webhook_received", "webhook",
raw=redact_pii(json.loads(body)),
dedup_hash=dedup, signature_verified=True,
)
if not inserted: # UNIQUE(dedup_hash) hit → redelivery
db.commit()
return # idempotent no-op
db.execute(
"""UPDATE payments SET
status = %s,
raw_status = %s,
method_paid = %s, -- Pay By Link: the REAL method
provider_trid = %s, -- the id refunds will need
confirmed_at = CASE WHEN %s = 'paid'
THEN now() ELSE confirmed_at END,
updated_at = now()
WHERE id = %s""",
(event.status.value, event.raw_status, event.method,
event.transaction_id, event.status.value, row.id),
)
db.commit()
if event.status.value == "paid" and row.status != "paid":
fulfil_order(event.order_id) # exactly once, after commit
4. reconcile() — the safety net¶
def reconcile(db, client) -> None:
# (a) Orphans: initiated > 15 min ago and never submitted.
# The crash happened between INSERT and the API call (or the
# response was lost). Ask eupago whether it knows the order.
for row in db.fetch_all(
"""SELECT order_id FROM payments
WHERE status = 'initiated'
AND initiated_at < now() - interval '15 minutes'"""
):
# The Management API has GET /api/management/v1.02/transactions
# (OAuth) to search by identifier — the SDK doesn't wrap it yet,
# so either call it directly with your Bearer, or take the
# simpler route: mark the row 'submit_failed' and let the
# webhook resurrect it if eupago turns out to know the order
# (the write-ahead row is still there to attach to).
... # update + event(type='reconciled', source='reconciliation')
# (b) Pay By Link silent expiry: no webhook will EVER come.
db.execute(
"""UPDATE payments SET status = 'expired', updated_at = now()
WHERE method_requested = 'pay_by_link'
AND status = 'pending'
AND expires_at < now()"""
)
... # one event(type='expired_locally', source='local') per row
# (c) Purge raw payloads past retention.
db.execute(
"UPDATE payment_events SET raw = NULL WHERE purge_after < now()"
)
db.commit()
Run it from cron / Celery beat every few minutes. It is intentionally boring — boring reconciliation is the kind that works at 3 a.m.
The flows¶
Asynchronous confirmation (MB WAY, Multibanco, hosted flows)¶
sequenceDiagram
participant App
participant DB
participant eupago
App->>DB: INSERT initiated (commit)
App->>eupago: create_payment(order_id, …)
eupago-->>App: 201 pending (transactionID, reference…)
App->>DB: UPDATE pending + event create_ok
Note over eupago: customer pays (minutes… or 30 days)
eupago->>App: webhook (HMAC signed)
App->>App: verify signature + amount
App->>DB: event webhook_received (dedup) + UPDATE paid
App->>App: fulfil_order() — exactly once
Pay By Link — the two exceptions¶
sequenceDiagram
participant App
participant eupago
participant Customer
App->>eupago: pay_by_link.create_payment(expires_at=…)
eupago-->>App: payment_url
App->>Customer: send link
alt customer pays
Customer->>eupago: opens link, picks MB WAY
eupago->>App: webhook method=MB:PT ← not "paybylink"!
Note over App: match by order_id, store method_paid='mbway'
else link expires
Note over eupago: NO webhook. Link becomes 404.
App->>App: reconcile() marks expired locally (expires_at)
end
Refunds — a transaction of their own¶
sequenceDiagram
participant App
participant eupago
App->>eupago: refunds.refund(transaction_id=provider_trid, amount)
alt MB WAY / Card
eupago-->>App: 201 "Reembolsado" (sync) → amount_refunded += x
else Multibanco
eupago-->>App: 201 "Pendente" → status refund_pending
eupago->>App: webhook RB:PT + originalTrid (hours later)
Note over App: original_transaction_id links back to the<br/>payment row → refunded
end
The refund webhook carries method = "RB:PT" and an originalTrid
pointing at the payment being refunded — WebhookEvent.original_transaction_id
in the SDK. Match on that, not on order_id (a backoffice-initiated
refund may carry an identifier you never issued).
Authorize & capture¶
authorize puts a hold (status='authorized', store the authorized
amount), capture settles for ≤ the authorized amount → paid. If you
never capture, the hold falls off → released. Keep both amounts: the
captured amount is the one that ends up in amount.
Quirks checklist¶
| # | Quirk | What the schema does about it |
|---|---|---|
| 1 | Pay By Link webhook reports the method the customer chose, never paybylink |
method_requested vs method_paid; match by order_id only |
| 2 | Pay By Link expiry is silent — no webhook, link 404s | expires_at + reconcile() marks expired locally |
| 3 | Create transactionID ≠ webhook trid (hosted flows; proven live: 019ebcbb… vs 29748670) |
Two columns; refunds use provider_trid |
| 4 | Refunds are separate transactions (RB:PT + originalTrid); partials accumulate |
amount_refunded numeric accumulator, never a boolean |
| 5 | Webhooks can be redelivered and can race your own writes | dedup_hash UNIQUE no-op + write-ahead means the row always exists first |
| 6 | Multibanco confirms in 1–30 days; Multibanco refunds settle async (Pendente → webhook) |
pending is a perfectly healthy long-lived state; refund_pending |
| 7 | Subscriptions have two ids (eupagoToken hex vs integer subscriptionId) |
Both columns in subscriptions, charges link via subscription_id FK |
| 8 | eupago raw statuses are inconsistent ("Paga", "Canceled", "REFUNDED") |
SDK normalizes into status; the literal value is kept in raw_status |
Security notes¶
The schema already encodes most of the security posture — this is the short version; the Security guide has the reasoning:
- No card data, ever. The hosted forms keep PAN off your server (SAQ A territory). Don't proxy the card form to "improve" UX.
- Only signature-verified webhooks change state. Failed HMAC →
webhook_rejectedquarantine row, no status transition. - The browser redirect is not a confirmation. Anyone can navigate to
your
success_url. Only the signed webhook (or an authenticated API poll) flipsstatustopaid. - Verify amount + currency against the stored row before fulfilling.
- Raw payloads are stored redacted (
eupago.utils.redact_pii) and expire viapurge_after. State lives forever; PII doesn't. - History is append-only by grant, not by convention.
order_idis non-enumerable (UUID-based) — it travels in URLs.
DynamoDB mapping¶
The same design fits a single-table layout:
| Item | PK | SK | Notes |
|---|---|---|---|
| Payment (current state) | ORDER#{order_id} |
META |
All payments columns as attributes |
| Event | ORDER#{order_id} |
EVENT#{iso_ts}#{seq} |
Raw redacted payload; ttl attribute = purge_after (native TTL deletes it) |
| Subscription | SUB#{eupago_token} |
META |
Holds both identifiers |
Global secondary indexes:
- GSI1
provider_trid→ look up the payment when a webhook/refund only gives you a trid (backoffice-initiated refunds). - GSI2
status(+initiated_atrange key) → thereconcile()scans:initiatedorphans andpendingPay By Links past expiry.
Differences that matter vs. PostgreSQL:
- Idempotency uses a conditional put on the event item
(
attribute_not_exists(PK)with the dedup hash in the key) instead of a UNIQUE constraint. - Append-only is an IAM policy that allows
PutItem/UpdateItemonMETAitems but onlyPutItemonEVENT#items. - Retention is the native TTL attribute — set it on event items at write time and DynamoDB deletes them for free.
- Money: store amounts as strings (
"49.90") or integer cents — never the float type.Decimalround-trips cleanly with boto3.
Checklist before going live¶
- [ ]
begin_paymentcommits before the eupago call - [ ] Webhook handler: verify → dedup → amount check → transition → fulfil
- [ ]
reconcile()scheduled (orphans, PBL expiry, purge) - [ ] Grants: app role cannot UPDATE/DELETE
payment_events - [ ] Raw payloads pass through
redact_pii()on the way in - [ ] Refund path uses
provider_trid(from the webhook), not the create-response id - [ ]
fulfil_order()fires exactly once (guard on the status change)