External Integrations
At 10:03 on sale day a fan clicks Pay. BoxOffice's payment service calls the payment provider, and the call times out after 5 seconds. Did the card get charged? The fan, seeing a spinner, clicks Pay again. Thirty seconds later the provider sends a notification that the first charge succeeded β and then another one for the second. Meanwhile, the e-mail provider is rate-limiting BoxOffice because 40 thousand confirmation e-mails were sent in 5 minutes, and a venue-map API used on the event page is down.
BoxOffice deliberately buys its generic subdomains (services) β card processing, e-mail delivery, maps. That is the right call, and it turns each of them into a dependency you do not control: its latency, its availability, its limits, its API changes and, most importantly, the fact that it decides some outcomes asynchronously. This page covers the techniques that make such integrations safe: idempotency keys, verified webhooks, retries that cannot duplicate, reconciliation, and a plan for provider outages. The palette's EXTERNAL category β 3rd Party API, Payment, Email β is what this page is about.
What you do not control
| Property | Internal service | External provider |
|---|---|---|
| Latency | low, predictable, measured by you | higher, heavy tail, on the public internet |
| Availability | your SLO, your on-call | their SLA (credits, not your lost sales) |
| Limits | you set them | rate limits and quotas per API key |
| Changes | coordinated, contract-tested | versioned by them, deprecated on their calendar |
| Outcome of a call | known when the call returns | may be decided later (3-D Secure, bank review) |
| Failure visibility | your logs and traces | their status page, their error codes |
| Cost | infrastructure | often per call or per message |
Each row maps to a design rule:
- Isolate the provider behind an adapter (an anti-corruption layer, services): the provider's model, status names and SDK stop at one class.
- Every call has a timeout, and retries are safe (idempotency keys) β sections below.
- Protect yourself from the provider with a circuit breaker and a bulkhead per provider (reliability), so a slow provider cannot exhaust your threads (the
cascading-failurescenario). - Respect their limits: throttle on your side, honor
429andRetry-After. - Assume outcomes arrive asynchronously: model states explicitly, accept webhooks, reconcile.
- Observe each provider separately: latency, error rate by error code, rate-limit hits, cost.
Anatomy of a payment
Card payments are the hardest integration BoxOffice has, so we use them as the guide. The card number never touches BoxOffice: the app collects it with the provider's hosted fields and receives a token, which keeps BoxOffice out of most of the PCI DSS scope7. The payment service then creates a payment with that token.
sequenceDiagram
participant F as Fan (app)
participant O as order
participant P as payment
participant X as Payment provider
F->>X: card details in hosted fields β token tok_abc
F->>O: POST /v1/orders {holdId, token} + Idempotency-Key k-1
O->>P: charge order ord_9 (key ord_9)
P->>X: create payment 450.00 BRL, token, Idempotency-Key ord_9
X-->>P: status processing
P-->>O: PENDING
O-->>F: 202 Accepted β order ord_9 PENDING
Note over X: bank authorizes (maybe after 3-D Secure)
X->>P: webhook payment.succeeded (signed)
P->>O: PaymentSucceeded ord_9
O->>O: confirm hold β order CONFIRMED
O-->>F: push: your tickets are being issued Notice what is not in the picture: a synchronous "charge succeeded" answer on which checkout depends. The provider may answer processing, may ask the fan to authenticate with the bank (requires_action, 3-D Secure), or may simply time out. The definitive outcome often arrives later, by webhook. So BoxOffice models the payment as an explicit state machine:
stateDiagram-v2
[*] --> CREATED
CREATED --> PENDING : provider accepted the request
CREATED --> UNKNOWN : timeout β outcome not known
UNKNOWN --> PENDING : status query / webhook
PENDING --> REQUIRES_ACTION : 3-D Secure needed
REQUIRES_ACTION --> PENDING : fan authenticated
PENDING --> SUCCEEDED : webhook payment.succeeded
PENDING --> FAILED : webhook payment.failed
SUCCEEDED --> REFUNDED : hold expired / event cancelled The UNKNOWN state is the honest answer to "the call timed out": the request may or may not have reached the provider. Never treat a timeout as a failure (you would release the seats of a fan who was charged) nor as a success. Resolve it by querying the provider or waiting for the webhook β and make sure that retrying cannot charge twice.
Idempotency keys
An operation is idempotent if doing it twice has the same effect as doing it once. POST /payments is not β unless both sides agree on an idempotency key: a unique identifier of one logical operation, sent with every attempt of that operation. The server remembers the keys it has processed and, on a repeat, returns the stored result instead of executing again12.
BoxOffice needs keys in both directions:
- Inbound β fans and apps retry: a double click, a mobile app retrying on a flaky connection, the gateway retrying a
503. BoxOffice'sPOST /v1/ordersandPOST /v1/events/{id}/holdsrequire anIdempotency-Keyheader, generated by the client once per logical action (a UUID created when the fan presses Pay, reused on every retry of that press). - Outbound β BoxOffice retries the provider. Every call that creates a charge or a refund carries a key derived from BoxOffice's own id (
ord_9, orord_9:refund:1), so a retry after a timeout reuses it and the provider returns the original payment instead of creating a second one.
sequenceDiagram
participant P as payment
participant X as Payment provider
P->>X: create payment, Idempotency-Key ord_9
Note over X: charge created (pay_77)
X--xP: response lost (timeout)
Note over P: state UNKNOWN β retry with the SAME key
P->>X: create payment, Idempotency-Key ord_9
X-->>P: pay_77 succeeded (stored result, no new charge) Implementing it on your own API
The provider does this for you on its side; on BoxOffice's side you implement it. The core is a table whose primary key makes "first one wins" a database guarantee:
CREATE TABLE idempotency_keys (
client_id text NOT NULL, -- keys are scoped per caller
key text NOT NULL,
request_hash text NOT NULL, -- SHA-256 of method + path + body
status text NOT NULL, -- IN_PROGRESS | COMPLETED
response_code int,
response_body jsonb,
locked_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, key)
);
CREATE INDEX idempotency_keys_created_at ON idempotency_keys (created_at); -- purge after 24 h
@Component
class IdempotentExecutor {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
IdempotentExecutor(JdbcTemplate jdbc, TransactionTemplate tx) {
this.jdbc = jdbc;
this.tx = tx;
}
StoredResponse execute(String clientId, String key, String requestHash,
Supplier<StoredResponse> operation) {
// 1. Claim the key. The primary key guarantees that only one request wins.
int claimed = jdbc.update("""
INSERT INTO idempotency_keys (client_id, key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (client_id, key) DO NOTHING
""", clientId, key, requestHash);
if (claimed == 0) { // 2. Seen before.
Map<String, Object> row = jdbc.queryForMap("""
SELECT request_hash, status, response_code, response_body::text AS body
FROM idempotency_keys WHERE client_id = ? AND key = ?
""", clientId, key);
if (!requestHash.equals(row.get("request_hash")))
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"Idempotency-Key reused with a different request");
if ("IN_PROGRESS".equals(row.get("status")))
throw new ResponseStatusException(HttpStatus.CONFLICT,
"a request with this Idempotency-Key is still being processed");
return new StoredResponse((Integer) row.get("response_code"), (String) row.get("body"));
}
// 3. First time: run the operation and store its response in ONE transaction.
try {
return tx.execute(status -> {
StoredResponse response = operation.get();
jdbc.update("""
UPDATE idempotency_keys
SET status = 'COMPLETED', response_code = ?, response_body = ?::jsonb
WHERE client_id = ? AND key = ?
""", response.code(), response.body(), clientId, key);
return response;
});
} catch (RuntimeException e) { // nothing was committed:
jdbc.update("DELETE FROM idempotency_keys WHERE client_id = ? AND key = ?",
clientId, key); // let the client retry
throw e;
}
}
}
The decisions hidden in those lines are the ones an interviewer will probe:
| Case | Behavior | Why |
|---|---|---|
| Same key, same request, completed | return the stored response (same status code and body) | the client cannot tell a replay from the original |
| Same key, still in progress | 409 Conflict β "try again shortly" | two concurrent attempts must not both run |
| Same key, different request | 422 | a client bug; executing it would be surprising |
| Operation failed before committing | delete the key | nothing happened; a retry should run again |
Process crashed while IN_PROGRESS | after a timeout (locked_at older than, e.g., 1 min), allow a new attempt to take over | otherwise the key is stuck forever |
| Old keys | purge after 24 h (or the retry window you document) | storage; the IETF draft and providers use similar windows |
When the operation also calls the provider, step 3 is not purely local: the provider call cannot be rolled back with the transaction. That is why the outbound call carries its own idempotency key derived from the order id β if BoxOffice's transaction fails after the provider charged, the retry asks the provider again with the same key and gets the same payment back. For longer multi-step operations, Brandur Leach's "atomic phases and recovery points" design generalizes this3.
Keys must identify the logical operation
A key generated per HTTP attempt (a new UUID on each retry) protects nothing. A key derived from the content ("fan 42 buys seat C-14") blocks a fan who legitimately buys twice. Generate it when the user decides to act, and reuse it for every retry of that decision.
In the BoxOffice code: one key from order to the provider (lab 9)
In Hands-on 9 β Payment the key is derived from BoxOffice's own id, order-<orderId>, so every retry of the same order (Resilience4j in order, the gateway, a double click) carries the same key. payment is the adapter: it answers a known key from its table, calls the provider outside any database transaction (no connection is held while the provider thinks), forwards the same key, and stores the result under a UNIQUE constraint. It is simpler than the executor above: there is no IN_PROGRESS state, so two concurrent attempts with the same key can both reach the provider, and it is the provider's own deduplication by that key that keeps it to one charge. That is acceptable only because real providers deduplicate; for your own API, use the claim step.
public PaymentResponse charge(UUID orderId, java.math.BigDecimal amount) {
return http.post()
.uri("/payments")
.header("Idempotency-Key", "order-" + orderId) // same key on every retry
.body(new PaymentRequest(orderId, amount))
.retrieve()
.body(PaymentResponse.class);
}
public Result charge(String idempotencyKey, ChargeRequest req) {
Optional<PaymentOut> done = find(idempotencyKey);
if (done.isPresent()) {
return new Result(done.get(), false); // a retry: answer, do not charge again
}
// remote call OUTSIDE any database transaction: never hold a connection while waiting
PspClient.Charge charge = psp.charge(idempotencyKey, req.amount(), "order-" + req.orderId());
String status = "approved".equals(charge.status()) ? "AUTHORIZED" : "DECLINED";
jdbc.sql("""
INSERT INTO payments (id, idempotency_key, order_id, amount, status, provider_ref)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (idempotency_key) DO NOTHING
""")
.params(UUID.randomUUID(), idempotencyKey, req.orderId(), req.amount(), status, charge.id())
.update();
return new Result(find(idempotencyKey).orElseThrow(), true);
}
Retries and unknown outcomes
Not all failures are equal. What you may do next depends on whether the request could have had an effect:
| What happened | Could it have taken effect? | What to do |
|---|---|---|
| Connection refused, DNS failure, TLS handshake failed | no β nothing was sent | retry (with backoff) |
| Timeout after sending the request | unknown | retry with the same idempotency key, or query the status first |
5xx from the provider | unknown (possibly partial) | retry with the same key, with backoff and jitter |
429 Too Many Requests | no | wait for Retry-After, then retry; throttle yourself |
400, 401, 402 card declined, 422 | no, and it will not succeed | do not retry; surface the error |
Retries follow the rules from reliability: exponential backoff with full jitter, a small maximum number of attempts, at one layer only, and inside the caller's time budget. For a user-facing checkout that budget is short β a few seconds; after that, the payment becomes UNKNOWN/PENDING and the fan sees "we are confirming your payment", while a background job resolves it.
Webhooks
A webhook is an HTTP request that the provider sends to your URL when something happens on its side β payment.succeeded, payment.failed, charge.refunded, email.bounced. It is how asynchronous outcomes reach you without polling. Treat each delivery as an untrusted, possibly duplicated, possibly late message:
| Property of webhook delivery | Consequence for the receiver |
|---|---|
| Anyone on the internet can call your URL | verify the signature before trusting anything |
| A captured request can be sent again later | check the timestamp (reject old ones) |
At-least-once: retried until you answer 2xx, for hours or days | deduplicate by event id |
| No ordering guarantee | apply monotonic state transitions; ignore stale events |
| Your slow handler causes provider timeouts and retries | acknowledge fast, process asynchronously |
| Deliveries can be lost after the provider gives up | reconcile (next section) |
sequenceDiagram
participant X as Payment provider
participant H as Webhook receiver
participant DB as inbox table
participant Q as Queue
participant W as payment worker
X->>H: POST /webhooks/payments (signed)
H->>H: verify signature + timestamp
H->>DB: INSERT event (unique event id)
H->>Q: enqueue event id
H-->>X: 200 OK (within milliseconds)
X->>H: same event again (retry)
H->>DB: INSERT β duplicate, ignored
H-->>X: 200 OK
Q->>W: process event
W->>W: PENDING β SUCCEEDED (conditional update) Verifying signatures
Providers sign each delivery with a secret shared only with you, usually an HMAC-SHA256 over the raw body plus a timestamp and a message id. The Standard Webhooks specification, adopted by several providers, defines the headers webhook-id, webhook-timestamp and webhook-signature, and signs the string id.timestamp.body4; other providers use the same idea with their own header names (Stripe's Stripe-Signature, for instance5). A receiver in Python with FastAPI:
import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
from adapters import inbox, queue # the service's own persistence and queue adapters
app = FastAPI()
SECRET = base64.b64decode(os.environ["WEBHOOK_SECRET"].removeprefix("whsec_"))
TOLERANCE_S = 300 # reject deliveries older than 5 minutes
def verify(body: bytes, msg_id: str, timestamp: str, signatures: str) -> None:
if abs(time.time() - int(timestamp)) > TOLERANCE_S:
raise HTTPException(status_code=400, detail="timestamp outside tolerance") # replay
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode()
for candidate in signatures.split(): # several during secret rotation
version, _, value = candidate.partition(",")
if version == "v1" and hmac.compare_digest(value, expected): # constant time
return
raise HTTPException(status_code=401, detail="invalid signature")
@app.post("/webhooks/payments")
async def payment_webhook(
request: Request,
webhook_id: str = Header(...),
webhook_timestamp: str = Header(...),
webhook_signature: str = Header(...),
):
body = await request.body() # the RAW bytes: re-serialized JSON would not match
verify(body, webhook_id, webhook_timestamp, webhook_signature)
event = json.loads(body)
if await inbox.insert_if_absent(event_id=webhook_id, payload=event): # unique event id
await queue.publish("payment.provider-events", {"eventId": webhook_id})
return {"received": True} # 2xx fast; a worker does the real processing
Details that matter: verify over the raw bytes (parsing and re-serializing JSON changes whitespace and key order); compare with a constant-time function (hmac.compare_digest) so the comparison time does not leak the signature; accept several signatures during secret rotation; keep the handler tiny so it always answers within the provider's timeout.
Out of order and stale events
The worker applies each event as a conditional state transition, so a late or duplicated event cannot move a payment backwards:
UPDATE payments
SET status = 'SUCCEEDED',
provider_updated_at = :occurred_at
WHERE provider_payment_id = :provider_payment_id
AND status IN ('CREATED', 'UNKNOWN', 'PENDING', 'REQUIRES_ACTION');
-- 0 rows updated = already final (duplicate or stale event): acknowledge and ignore
Many teams also treat the webhook as a notification only ("something changed on pay_77") and fetch the current object from the provider's API before acting. It costs one call, but the state you act on is always the latest and authoritative one.
Webhooks or polling?
| Webhooks (push) | Polling (pull) | |
|---|---|---|
| Latency of the outcome | seconds | the polling interval |
| Load | one request per event | requests even when nothing changed |
| Needs a public endpoint | yes (signature, TLS, availability) | no |
| Missed updates | possible β needs reconciliation | none, if the query is complete |
| Typical use | primary channel for outcomes | fallback for stuck items, reconciliation |
Use both: webhooks as the fast path, and a sweeper that polls the provider for payments still UNKNOWN or PENDING after, say, 10 minutes.
When BoxOffice sends webhooks
Resellers subscribe to BoxOffice events (order.confirmed, event.cancelled). Now BoxOffice is the provider and must offer what it expects from others: signed deliveries with a unique id and timestamp, retries with exponential backoff for a documented period, a dead-letter store, a dashboard to replay deliveries, and the promise that delivery is at-least-once and unordered. A queue with one worker pool per subscriber keeps one slow reseller from delaying the rest.
Reconciliation
Idempotency and webhooks make each interaction safe. They do not guarantee that, at the end of the day, BoxOffice's records agree with the provider's: a webhook may be lost after the provider's retries ran out, a support agent may refund a payment in the provider's dashboard, a bug may skip a state update, and the provider deducts fees and chargebacks you only learn about from reports.
Reconciliation is the periodic job that compares the two sides and resolves differences β standard practice in any system that moves money6.
flowchart LR
report["Provider report<br/>or list API since T"] --> load["Load into<br/>provider_payments"]
ours[("payments<br/>(BoxOffice)")] --> match{"Match by<br/>provider id"}
load --> match
match -->|"equal"| ok["Matched β"]
match -->|"theirs SUCCEEDED,<br/>ours PENDING"| fix["Auto-fix:<br/>confirm or refund"]
match -->|"only in one side,<br/>amount differs"| human["Exception queue<br/>(finance on-call)"] SELECT coalesce(p.id, r.reference) AS payment,
p.status AS ours,
r.status AS theirs,
p.amount_cents AS our_amount,
r.amount_cents AS their_amount
FROM payments p
FULL OUTER JOIN provider_payments r
ON r.provider_payment_id = p.provider_payment_id
WHERE (p.created_at >= :day AND p.created_at < :day + interval '1 day'
OR r.created_at >= :day AND r.created_at < :day + interval '1 day')
AND (p.id IS NULL
OR r.provider_payment_id IS NULL
OR p.status <> r.status
OR p.amount_cents <> r.amount_cents);
| Mismatch | Likely cause | Resolution |
|---|---|---|
Provider succeeded, BoxOffice PENDING | lost webhook | confirm the order if the hold is still valid; otherwise refund automatically |
Provider refunded, BoxOffice SUCCEEDED | refund made in the provider's dashboard | cancel the tickets, update the order |
BoxOffice SUCCEEDED, provider has nothing | bug or test data in production | exception queue β investigate |
| Amounts differ | currency, fees, partial capture | exception queue; check the fee model |
Run it at two speeds: a near-real-time sweeper (every few minutes, for payments stuck in UNKNOWN/PENDING, by querying the provider's API) and a daily reconciliation against the provider's settlement report, which also checks fees and payouts against the bank. Larger systems record every money movement in a double-entry ledger so that the books always balance and every difference has an explanation.
Provider outages
Providers fail like everything else β and their outage arrives on your busiest day, because your busiest day is their busiest day too. Decide in advance, per provider, what BoxOffice does:
| Strategy | How | Fits | Cost |
|---|---|---|---|
| Fail fast, clearly | circuit breaker opens; checkout answers "payments temporarily unavailable" in milliseconds | payments when nothing else is possible | lost sales during the outage β but no hung threads, no cascading failure |
| Queue and retry later | accept the work, deliver when the provider is back | e-mail, push, analytics exports | delayed delivery; backlog to drain (rate limits!) |
| Fail over to a second provider | route by provider health; same port, two adapters | e-mail (stateless sends); payments for large businesses | two integrations, two reconciliations, card tokens are often provider-specific |
| Degrade the feature | hide the optional part | venue maps, recommendations, fraud score (fall back to rules) | reduced experience |
| Protect the user's state | extend seat holds while the payment provider is down | BoxOffice checkout | seats unavailable to others for longer |
flowchart LR
n["notification worker"] --> cb{"circuit breaker<br/>primary ESP"}
cb -->|"closed"| p["Primary e-mail provider"]
cb -->|"open"| s["Secondary e-mail provider"]
p -.->|"429 / 5xx"| retry["retry queue<br/>(backoff)"]
s -.->|"429 / 5xx"| retry
retry --> n The BoxOffice decisions:
- Payment provider down β the breaker opens after a burst of failures; new checkouts fail fast with a clear message, and active holds are extended by the outage duration so fans do not lose their seats while waiting. Payments already in flight stay
UNKNOWNand are resolved by the sweeper when the provider returns. (A second payment provider is on the roadmap, behind the samePaymentGatewayport.) - E-mail provider down or rate-limiting β messages wait in the queue; the notification worker throttles to the provider's limit; after a sustained outage, traffic fails over to a secondary provider. Ticket links remain available in the app, so a late e-mail is an inconvenience, not a lost sale.
- Venue-map API down β the event page renders without the interactive map (cached image fallback).
E-mail is an integration with its own rules
Sending e-mail reliably is more than an API call. Providers enforce rate limits per account; they report bounces (address does not exist) and complaints (marked as spam) by webhook, and BoxOffice must stop sending to those addresses (a suppression list) or the provider will suspend the account. Domain authentication with SPF, DKIM and DMARC decides whether messages reach the inbox at all. And transactional mail (tickets, password resets) should use a separate stream or subdomain from marketing mail, so that a bad campaign does not hurt ticket delivery.
Try it in the playground
Open the payment-system scenario: checkout calls a payment service that calls an external provider, with retries and idempotency.
- Raise the chaos level to high so the provider has outages and slow periods; watch checkout p99 and the error rate.
- Set retries without backoff on the payment edge: the provider receives a retry storm and stays down longer. Switch to exponential backoff with jitter.
- Add a timeout and a circuit breaker on the edge to the provider: checkout fails fast instead of piling up threads, and recovers when the provider does.
Security of integrations
- Secrets (API keys, webhook secrets) live in a secret manager, never in the repository; rotate them, and use restricted keys with only the permissions a service needs (network & security).
- Minimize PCI DSS scope: tokenize with the provider's hosted fields or payment page so that card numbers never cross BoxOffice's network.
- Egress control: calls to providers leave through a NAT gateway with fixed IPs (some providers allowlist them); deny other outbound traffic.
- Treat provider responses as input: validate them; a compromised or buggy provider must not be able to inject data into your system unchecked.
Trade-offs at a glance
| Decision | Option A | Option B |
|---|---|---|
| Confirming a payment | synchronous answer: simple UX, but checkout depends on the provider's latency and fails on timeouts | 202 + webhook: robust and honest about uncertainty, but needs states, push/poll in the UI and reconciliation |
| Learning outcomes | webhooks: fast, efficient, can be lost | polling: simple, complete, slow and wasteful |
| Provider redundancy | one provider: simpler, cheaper | two providers: survives outages, doubles integration and reconciliation work |
| Webhook processing | inline: simpler | inbox + queue: fast acks, retries under your control |
In the interview
The payment-system follow-ups are predictable β have the answers ready:
- "The call to the provider timed out. Was the customer charged?" β We don't know: state
UNKNOWN; retry with the same idempotency key or query the status; never charge twice, never assume failure. - "The user double-clicked Pay." β client-generated Idempotency-Key per logical action; the second request returns the first result.
- "How do you know the payment succeeded?" β signed webhook, verified, deduplicated by event id, applied as a monotonic state transition; plus a sweeper that polls stuck payments.
- "What if the webhook never arrives?" β reconciliation against the provider's API and settlement reports, at two speeds.
- "The provider is down." β circuit breaker, fail fast, extend holds, queue what can wait; a second provider if the business justifies it.
Best practices
- Give every layer a timeout shorter than its caller's β the caller must give up last, or it waits for work nobody will deliver. In BoxOffice: the gateway waits 5 s (lab 4),
orderwaits 1 s forpayment(lab 9). - Store the provider's id with every payment state β support, refunds and reconciliation all start from it. In BoxOffice: the
provider_refcolumn ofpayments(lab 9). - Test against a fake provider that can be slow, fail and answer twice β a stub behind a fault proxy turns "what if the provider takes 3 s?" into a test you can repeat. In BoxOffice: the
pspstub behind Toxiproxy in lab 9. - Watch each provider like one of your services β latency, error rate and rate-limit headroom per provider on a dashboard, plus its status page; an alert on the provider's p99 fires before checkout fails.
- Scope each provider credential to one service β only
paymentmay read the payment key, and rotating it is a routine, rehearsed operation (zero trust).
Common pitfalls
- No timeout on provider calls: the provider's slow day becomes your outage (
cascading-failure). - Retrying writes without an idempotency key: double charges, double refunds, duplicate e-mails.
- Treating a timeout as a failure and releasing the seats of a fan who was charged.
- Trusting unsigned webhooks, or verifying the signature over re-serialized JSON.
- Slow webhook handlers doing all the work inline: provider timeouts, retries and duplicates.
- Assuming webhook order: a late
processingevent overwritingsucceeded. - No reconciliation: small disagreements accumulate silently until the accounting does not close.
- Leaking the provider's model (status strings, ids, SDK types) throughout the code instead of one adapter.
- Ignoring rate limits and bounces: the e-mail provider suspends the account on sale day.
How it connects
flowchart LR
ADP["one adapter per provider<br/>ports and adapters"] --> RES["timeouts, circuit breaker,<br/>bulkhead β Reliability"]
IK["idempotency keys<br/>inbound and outbound"] --> RETRY["safe retries"]
TO["a timeout means UNKNOWN"] --> SM["payment state machine"]
RETRY --> SM
SM --> WH["webhooks: signature,<br/>dedup by event id"]
SM --> REC["reconciliation with<br/>the provider's records"]
WH --> ASYNC["ack fast, process async<br/>β Messaging"]
OUT["provider outages"] --> STRAT["fail fast, queue, fail over,<br/>degrade, extend holds"]
STRAT --> RES
IK -.-> DIST["idempotency<br/>β Distributed systems"] Palette components: payment (the provider behind the payment adapter) Β· email (rate limits, bounces, a secondary provider) Β· thirdparty (the venue-map API: degrade without it) Β· app (the adapter service) Β· sql (idempotency keys under a unique constraint, payment states) Β· queue (deliver later; process webhooks asynchronously) Β· scheduler (sweepers and reconciliation jobs) Β· serverless (a cheap webhook receiver). Scenarios: payment-system, cascading-failure, retry-storm, notification-system. Continues in: Reliability β circuit breaker, Distributed β idempotency, Distributed β transactions, Case study: payment system, Hands-on 9.
Key takeaways
- External providers add latency, limits, changes and asynchronous outcomes you do not control; isolate each one behind an adapter with timeouts, circuit breaker and bulkhead.
- Idempotency keys β inbound from clients, outbound to providers, derived from the logical operation β make retries safe; implement them with a unique key, stored responses, in-progress and mismatch handling.
- A timeout means UNKNOWN, not failed: model the payment as a state machine and resolve uncertainty by query, webhook or sweeper.
- Webhooks: verify the signature over raw bytes with a timestamp, deduplicate by event id, acknowledge fast, process asynchronously, apply monotonic transitions.
- Reconcile with the provider's records periodically; webhooks and retries are not enough on their own.
- Plan each provider outage in advance: fail fast, queue, fail over, degrade β and protect the user's state (extend holds).
-
The Idempotency-Key HTTP Header Field. IETF HTTPAPI working group, Internet-Draft.Β β©
-
Stripe. Idempotent requests. Stripe API reference.Β β©
-
LEACH, B. Implementing Stripe-like Idempotency Keys in Postgres. 2017.Β β©
-
Stripe. Receive Stripe events in your webhook endpoint (signatures, retries, event ordering).Β β©
-
XU, A.; LAM, S. System Design Interview β An Insider's Guide, Volume 2. ByteByteGo, 2022. Chapter "Payment System" (reconciliation, double-entry ledger, exactly-once with idempotency).Β β©
-
PCI Security Standards Council. PCI DSS (scope reduction by tokenization and hosted payment pages).Β β©