Payment Provider Integration Guide¶
Single-tenant Rust core with plugin-based, opt-in payment providers.
Overview¶
Sawabona provides a flexible, plugin-based payment provider architecture supporting multiple payment processors. The system uses a runtime plugin architecture where each payment provider is a separate, independently deployable crate.
Architecture¶
- Each provider is a separate
sawabona-payment-*crate - Providers registered at runtime via
ProviderRegistry - Binary size: ~15MB (core only, providers optional)
- Provider selection: Runtime via registry
- Reduced binary bloat and improved modularity
Supported Payment Providers¶
| Provider | Best For | Regions | Webhook signature scheme |
|---|---|---|---|
| Stripe | Global, high volume | Worldwide | HMAC-SHA256 over t.body, Stripe-Signature |
| Paddle | SaaS, merchant-of-record (tax) | Worldwide / EU | HMAC-SHA256 over ts:body, Paddle-Signature |
| Flutterwave | African markets | Africa | verif-hash shared-secret compare |
| PayStack | African startups | Africa | HMAC-SHA512 over body, x-paystack-signature |
| Mollie | European ISVs, SEPA/iDEAL | Europe | none — the payment is read back over an authenticated call |
Sawabona only ships providers whose inbound-webhook authenticity can be verified with a documented, reproducible scheme (no guessed cryptography). Adyen, Braintree, MercadoPago and PagSeguro were intentionally not shipped for this reason (sales-gated/ID-gated onboarding and/or webhook schemes that could not be proven from public docs).
Architecture¶
Plugin-Based Design¶
The new architecture separates concerns into:
- sawabona-core - Core abstractions and traits (no provider implementations)
- sawabona-payments/ - Workspace containing all provider crates
payment-stripe/- Stripe provider implementationpayment-flutterwave/- Flutterwave provider implementationpayment-paystack/- PayStack provider implementationpayment-paddle/- Paddle (Paddle Billing) provider implementationpayment-mollie/- Mollie provider implementation
Core Components¶
- PaymentProviderTrait - Common interface for all providers (in sawabona-core)
- PaymentProvider Enum - Provider type identification (in sawabona-core)
- ProviderConfig - Configuration data structure (in sawabona-core)
- ProviderFactory - Factory trait for creating provider instances (in sawabona-core)
- ProviderRegistry - Dynamic provider registration at runtime (in sawabona-core)
Key Methods¶
pub trait PaymentProviderTrait: Send + Sync {
async fn create_subscription(...) -> Result<SubscriptionData>;
async fn cancel_subscription(...) -> Result<SubscriptionData>;
async fn update_subscription(...) -> Result<SubscriptionData>;
async fn verify_webhook(...) -> Result<bool>;
async fn process_webhook(...) -> Result<WebhookEvent>;
async fn get_health(...) -> Result<HealthStatus>;
async fn sync_webhook(...) -> Result<WebhookEvent>;
async fn verify_payment(...) -> Result<SubscriptionData>;
}
Automatic License Creation from Webhooks¶
When a payment webhook event is received and verified, Sawabona can automatically create a license for the customer. This is the primary business flow and is provider-aware — it works for every shipped provider, not only Stripe:
- Customer pays via the provider
- The provider sends a license-creating event (see the per-provider table below)
- Sawabona verifies the webhook signature
- The handler extracts the provider-specific price/plan identifier from the
payload (or an explicit
sawabona_price_id/sawabona_plan_idmetadata override) - Reverse-lookup via that provider's product-mapping table finds the Sawabona product
- An active plan for that product is selected
- A license key is generated and the license is created in the database
- The webhook response includes the new
license_id
The idempotency gate fails closed for license-creating events (a DB error
returns 503 so the provider retries) and the decision of which events create
a license is keyed on (provider, event_type) — a Stripe-shaped event type can
never trigger issuance for a different provider.
Per-provider license-creating events and identifiers¶
| Provider | License-creating event types | Identifier extracted (native location → mapping table) |
|---|---|---|
| Stripe | checkout.session.completed, invoice.payment_succeeded |
data.object.lines.data[0].pricing.price_details.price (API ≥ 2025-11) or …price.id (legacy) / metadata.stripe_price_id → stripe_product_mappings.stripe_price_id |
| Paddle | transaction.completed, subscription.created, subscription.activated |
data.items[0].price.id / custom_data.sawabona_price_id → paddle_product_mappings.paddle_price_id |
| PayStack | charge.success, subscription.create |
data.plan.plan_code / metadata.sawabona_plan_id → paystack_product_mappings.paystack_plan_id |
| Flutterwave | charge.completed |
data.plan / meta.sawabona_plan_id → flutterwave_product_mappings.flutterwave_plan_id |
| Mollie | payment.paid (synthesised — see below) |
metadata.sawabona_plan_id → mollie_product_mappings.mollie_price_id |
What the engine does for an ISV's own account¶
| Stripe | Paddle | Mollie | PayStack | Flutterwave | |
|---|---|---|---|---|---|
| Key proved before storing | ✅ | ✅ GET /event-types |
✅ GET /methods |
✅ GET /balance |
✅ GET /balances |
| Webhook intake registered | ✅ | ✅ POST /notification-settings |
n/a — told per payment | dashboard, one URL per account | dashboard, one URL per environment |
| Billing portal | ✅ | ✅ POST /customers/{id}/portal-sessions |
✗ | ✅ GET /subscription/{code}/manage/link |
✗ |
A portal is not keyed the same way everywhere. Stripe and Paddle open it for
a CUSTOMER; PayStack opens it for a SUBSCRIPTION, because its page exists to
repair the card behind one. create_billing_portal_session therefore takes
both identifiers — the caller has them, and passing both spares a provider from
returning a subscription code out of customer_for_subscription and calling it
a customer.
Where registration is a dashboard field, webhook_setup_url() names the
page and the admin response carries it. "Not supported" is not an instruction:
it tells an ISV a step exists without saying where, which turns two minutes into
a search.
Paddle's notification secret is readable exactly once, at creation. The engine lists destinations before creating one for that reason: registering the same URL twice hands back a NEW secret while the old destination keeps delivering with the old one, and every event then fails its signature. A creation that returns no secret is an error rather than a success — the destination would deliver events nobody could verify, with a secret that cannot be read again.
Mollie has no account-level webhook. webhookUrl travels on each payment,
so ensure_webhook_endpoint has nothing to create and answers AlreadyPresent
— after checking the stored URL is the expected one, because a configuration
pointing elsewhere would take money and report it to another engine.
Whose account collects¶
Every *_config table carries tenant_id, every provider has a
get_*_config_for(pool, tenant), and the webhook intake is
/api/v1/webhooks/{provider}/t/{tenant}. An ISV that has supplied their own
credentials collects on their own account for their own products; everyone else
falls back to the engine owner's row, which is what every deployment had before
per-tenant credentials existed.
Written through PUT /api/v1/admin/payments/tenants/{tenant}/{provider}, read
back through GET /api/v1/admin/payments/tenants/{tenant}.
That path used to end in /stripe. The read side was generic from the
start, so the engine could already collect into an ISV's Flutterwave account and
there was simply no way to tell it the credentials — a capability an ISV cannot
configure is not delivered.
And there was no read at all. Five write paths, one delete, and nothing that answered "which providers does this tenant have?" — so a console could store a provider and could not show it, and nothing could count them. The listing returns the provider, whether it is enabled, and whether a signing secret is stored. No secret, encrypted or otherwise: a caller entitled to ask what is configured is not a caller entitled to read a key.
The engine states no limit on how many an ISV may run. What a hosted deployment
sells is its own commercial policy and belongs to the gateway in front — on
Kionowo's, one provider below Harmony, refused with plan_provider_limit
before the write reaches here. A self-hosted engine has no such gateway and no
such limit, which is the correct default: the engine applies, it does not
decide.
Money going back¶
Cancellation was handled from the start; reversal was not. A customer who paid, took their licence and then charged the payment back kept working software indefinitely.
| Provider | Reversal events | The original payment is at |
|---|---|---|
| Stripe | charge.refunded, charge.dispute.created |
data.object.payment_intent, or data.object.charge on a dispute |
| Paddle | adjustment.created — only action: refund + status: approved, or action: chargeback |
data.transaction_id |
| PayStack | refund.processed, charge.dispute.create |
data.transaction_reference (several depths tried) |
| Flutterwave | refund.completed — off by default, their support must enable it per account |
data.charge_id |
| Mollie | payment.refunded — synthesised, see below |
data.id |
data.id on a reversal is the refund, not the payment. Every provider but
Mollie sends the id of the refund or adjustment at the top of the object.
Reading it the way the license-creating path does would look up a reference no
licence was ever recorded against, find nothing, and log "no license is linked
to this payment" — true, and completely misleading. extract_reversed_purchase_id
exists for exactly this.
A refunded Mollie payment is still paid. Mollie changes no status when
money goes back; the same payment simply grows an amountRefunded. The
provider crate classifies the refund BEFORE the status and stamps the refunded
figure in minor units, so a redelivery after a refund cannot read as a fresh
sale.
What happens. The licences the payment bought are revoked, not suspended. Suspension is for a customer who stopped paying — they had the thing, the funding lapsed, and paying again brings it back. A reversal says the sale is undone: the money is with the buyer and the licence should not exist. Revocation also tombstones the key so it cannot be reissued.
A partial refund revokes nothing. Sending a fifth of a payment back as
goodwill is not the sale coming apart. Only a reversal covering the whole
payment acts, decided from two sources in order: the payload where the provider
states both figures (Stripe puts amount and the RUNNING amount_refunded on
the same charge, so two partial refunds that finish the job are recognised),
otherwise the original payment read back through payment_receipt. When
neither is available nothing happens and an operator is told — which is where
refunds already were.
A chargeback skips that check: the card network does not reverse a fraction
on the vendor's behalf. chargeback_warning is deliberately NOT acted on — the
dispute may still be won, and cutting a customer off over their bank's advance
notice punishes them for something that has not happened.
license_purchases — what payment bought each licence¶
license_subscriptions is only written when there IS a subscription, so every
one-off sale (prepaid period, mobile-money wallet) recorded nothing and no
reversal could reach it. license_purchases is written for every licence,
in the same transaction that mints it. The index is deliberately not unique:
one payment can mint several keys, and a refund has to reach all of them.
Product-mapping tables¶
Each provider maps its own price/plan identifier to a Sawabona product. The table name and identifier column differ per provider (see above); e.g. for Stripe:
INSERT INTO stripe_product_mappings (id, product_id, stripe_price_id, created_at)
VALUES (gen_random_uuid(), '<sawabona-product-uuid>', 'price_xxx', NOW());
and for Paddle:
INSERT INTO paddle_product_mappings (id, product_id, paddle_price_id, created_at)
VALUES (gen_random_uuid(), '<sawabona-product-uuid>', 'pri_xxx', NOW());
A payment whose identifier has no row in the corresponding mapping table is
verified and logged, but issues no license (the response carries a null
license_id).
One price, several products — a bundle¶
Map the same price to each member, each with its own plan. Nothing else is declared: the bundle is the set of rows.
for member in nazelo pimatika litatoli; do
curl -sS -X PUT -H "X-API-Key: $ADMIN" -H 'Content-Type: application/json' \
-d "{\"provider\":\"stripe\",\"price_id\":\"price_trust_harmony\",
\"plan\":\"${member}_harmony\",\"billing_interval\":\"month\"}" \
"$ENGINE/api/v1/admin/products/${member}/prices"
done
One payment then mints one licence per member, in one transaction: three products paid for and two delivered is worse than a retry, because nothing says which one is missing. Every member is minted on the same term, in the same environment, for the same tenant.
What holds it together afterwards is the payment, not a bundle id.
license_purchases already links every licence a payment minted and is
deliberately not unique, so a refund reaches all three. A separate bundle
reference would be a second thing to keep true.
Three consequences worth knowing before you configure one:
- Every member must pin a plan. A mapping without one fails the whole purchase rather than delivering the rest — the engine never guesses a plan, and a partial bundle is a customer who has to notice what is missing.
- The receipt is emitted once, on the first member. One payment moved, so
three receipts would be three claims about it. Each member still gets its own
license.created, which is what each product's gateway needs to deliver a key. - A member may not be mapped twice. Widening the rule to "one price, many products" kept the rest of it: one price buys a given product once, or a checkout could bill an amount nobody chose.
Subscription lifecycle: renewals, cancellation, and the kill-switch¶
A purchase is not a one-off. The same subscription emits several events over its life, and each has a different consequence.
One purchase, many events¶
Every event of one subscription shares a business idempotency key built from the provider's subscription id (not the id of the event's own object — an invoice or checkout session id differs on every event, and keying on those would mint a license per event and another on every renewal). A second event for a subscription that already minted is acknowledged as a duplicate and mints nothing. Renewals are therefore silent: they keep the existing license, they do not create a second one.
Ending a subscription¶
| Provider | Event treated as "this subscription is over" |
|---|---|
| Stripe | customer.subscription.deleted |
| Paddle | subscription.canceled |
| PayStack | subscription.disable |
| Flutterwave | subscription.cancelled |
| Mollie | (none — no subscription is ever sold through it; see below) |
A failed payment is not an ending. Providers retry a card for days (dunning) and most of those recover; cutting a customer off on the first failure would punish the majority to catch the minority. Only the provider's own terminal event counts.
When such an event arrives:
- Every license that subscription funded is suspended. The scope comes from
the
license_subscriptionstable, written in the same transaction as the mint — never from the tenant, so a customer who cancels one product keeps the others they still pay for. An already-revoked license is left revoked. - If one of those licenses is for the platform product, the kill-switch below fires as well.
The kill-switch (ISV stops paying)¶
An ISV subscribes to the platform, then ships products protected by it. If their subscription lapses, their products must stop being protected immediately — otherwise they keep selling protection they no longer pay for.
Cancelling the subscription that funded their platform-product license
(SAWABONA_PLATFORM_PRODUCT_SLUG, default sawabona) sets is_active = false
on every product owned by that tenant. License validation refuses any
license whose product is inactive, so all of that ISV's customers stop
validating at once — one flag upstream rather than a mass revocation nobody
could undo. Paying again restores exactly the products the switch turned off
(tracked by products.deactivated_by_billing), leaving a product an operator
deliberately retired retired.
Offline signed tokens already issued still live to their expiry. That is inherent to offline validation, not a gap in the switch.
Product ownership is a prerequisite. The switch selects products by
products.tenant_id, which is stamped from the request's tenant context when
the product is created. A product created without one belongs to the default
tenant and no ISV cut-off will ever reach it — so when onboarding an ISV, make
sure their products are created under their tenant, and re-assign any that
already exist:
UPDATE products SET tenant_id = '<isv-tenant-slug>', updated_at = NOW()
WHERE slug IN ('their-product-a', 'their-product-b');
The slug must be the SAME one the checkout stamps on the payment metadata
(sawabona_tenant), because that is what the minted license is attributed to.
Provider dashboard: which events to subscribe to¶
The engine cannot act on an event the provider never sends. A webhook endpoint that lists only the payment events will mint licenses correctly and never cut anybody off — the failure is silent. For Stripe, the endpoint needs all three:
Verify with:
curl -s https://api.stripe.com/v1/webhook_endpoints -u "$STRIPE_SECRET_KEY:" \
| python3 -c "import json,sys; [print(e['url'], e['enabled_events']) for e in json.load(sys.stdin)['data']]"
Provider payload drift¶
Providers move fields between API versions, and a moved field looks exactly like a missing one: the engine answers 503, the provider retries, and eventually gives up — a paid purchase that mints nothing. Stripe API 2025-11 moved three fields on the invoice object at once:
| Was | Is (≥ 2025-11) |
|---|---|
data.object.lines.data[0].price.id |
data.object.lines.data[0].pricing.price_details.price |
data.object.subscription_details |
data.object.parent.subscription_details |
data.object.subscription |
data.object.parent.subscription_details.subscription |
Both shapes are read (an endpoint pinned to an older API version still sends the legacy one). When a webhook resolves no price or no tenant, compare the real payload against these paths first:
Configuration¶
Environment Variables¶
# Encryption key for API secrets (32 bytes, standard or URL-safe base64)
SAWABONA_ENCRYPTION_KEY=your-base64-encoded-32-byte-key
# Stripe (auto-seeded into stripe_config table on API startup)
SAWABONA_STRIPE_SECRET_KEY=sk_test_...
SAWABONA_STRIPE_WEBHOOK_SECRET=whsec_...
# Other providers (auto-seeded into their *_config table on startup when the
# provider is registered via SAWABONA_PAYMENT_PROVIDERS and its API key is set)
SAWABONA_PADDLE_API_KEY=...
SAWABONA_PADDLE_WEBHOOK_SECRET=...
SAWABONA_FLUTTERWAVE_SECRET_KEY=...
SAWABONA_FLUTTERWAVE_WEBHOOK_SECRET=...
SAWABONA_PAYSTACK_SECRET_KEY=...
SAWABONA_PAYSTACK_WEBHOOK_SECRET=...
SAWABONA_PAYSTACK_PUBLIC_KEY=...
SAWABONA_MOLLIE_API_KEY=...
SAWABONA_MOLLIE_NOTIFICATION_URL=https://api.example.com/api/v1/webhooks/mollie
Any provider can also be initialized (or have an empty config row created) from
the admin API — PUT /api/v1/admin/payments/providers/{provider} creates the
row when one does not yet exist, so env-seeding is optional.
Auto-Seeding from Environment¶
On API startup the server seeds DB config from environment variables for every
registered provider — not Stripe alone. The flow is symmetric: after the
registered providers are resolved (from SAWABONA_PAYMENT_PROVIDERS, default
stripe), sawabona-api/src/lib.rs calls the matching per-provider seed helper
in sawabona-core/src/db/repositories/payment_repository.rs
(seed_stripe_config_from_env, seed_paddle_config_from_env,
seed_flutterwave_config_from_env, seed_paystack_config_from_env,
seed_mollie_config_from_env).
Each seed helper, for its provider:
- No-ops if the provider's primary secret env var is unset/empty (see table below).
- No-ops if a config row already exists (it never overwrites a configured provider).
- Otherwise encrypts the secret(s) at rest (AES-256-GCM, keyed by
SAWABONA_ENCRYPTION_KEY, key-versioned) and upserts the row withenabled = true. - Logs "<Provider> config seeded successfully from environment variables".
Seeding is best-effort: a failure for one provider is logged and does not block startup or other providers. A provider that is not registered is never seeded, even if its env vars are set.
| Provider | Primary secret (gates seeding) | Additional env vars |
|---|---|---|
| Stripe | SAWABONA_STRIPE_SECRET_KEY |
SAWABONA_STRIPE_WEBHOOK_SECRET |
| Paddle | SAWABONA_PADDLE_API_KEY |
SAWABONA_PADDLE_WEBHOOK_SECRET |
| Flutterwave | SAWABONA_FLUTTERWAVE_SECRET_KEY |
SAWABONA_FLUTTERWAVE_WEBHOOK_SECRET |
| PayStack | SAWABONA_PAYSTACK_SECRET_KEY |
SAWABONA_PAYSTACK_WEBHOOK_SECRET, SAWABONA_PAYSTACK_PUBLIC_KEY |
| Mollie | SAWABONA_MOLLIE_API_KEY |
SAWABONA_MOLLIE_NOTIFICATION_URL (no webhook secret — Mollie does not sign) |
ProviderConfig also carries two non-secret extras — notification_url (for a
provider that is told where to call back, or whose signature covers the URL as
well as the body) and environment (sandbox / production, which can also
select the API base URL). No provider shipped today uses them; they are kept
because the next one likely will. Webhook secrets and provider-specific extras
are optional at seed time; the secret/token in the first column is what gates
whether seeding runs at all.
This eliminates the need for manual CLI-based provider registration during development. Any provider can equally be initialized from the admin API (see above), so env-seeding is optional.
Database Configuration¶
Provider configurations are stored in provider-specific tables (each stores an encrypted API key/token and webhook secret):
stripe_configpaddle_configflutterwave_configpaystack_configmollie_config— also storesnotification_url; nowebhook_secret, because Mollie does not sign
API Endpoints¶
List Providers¶
Get Provider Config¶
Update Provider Config¶
PUT /api/v1/admin/payments/providers/{provider}
Body: {
"enabled": true,
"api_key": "new-key",
"webhook_secret": "new-secret"
}
Test Provider Health¶
Handle Webhooks¶
Webhook Verification¶
Stripe¶
- Header:
Stripe-Signature(format:t=<timestamp>,v1=<signature>) - Algorithm: HMAC-SHA256
- Signed content:
{timestamp}.{payload}(timestamp first, then the raw body) - Replay protection: events outside a 5-minute timestamp tolerance are rejected
- License issuance: see the per-provider table above
Paddle¶
- Header:
Paddle-Signature(format:ts=<unix>;h1=<hex>) - Algorithm: HMAC-SHA256 over
{ts}:{raw_body}, 5-minute tolerance
Mollie¶
- Header: none. Mollie sends no signature, by design.
- Body:
application/x-www-form-urlencoded, carrying one parameter —id=tr_…. Every other provider posts JSON, so the provider decodes its own body (PaymentProviderTrait::decode_webhook) rather than the handler assuming one content type for all. - Verification: the payment is read back over an authenticated
GET /v2/payments/{id}. The status is deliberately not transmitted in the webhook — you believe the API, not the request body. A forged delivery can at most make the engine re-read a payment it already knows about. - Event type: synthesised from the status the fetch returns (
payment.paid/payment.failed/payment.pending). Mollie states none. - The event id is the payment id, not a per-delivery id — Mollie has no such thing. Mollie calls back on every status change, and the idempotency guard then mints a licence once per payment rather than once per call.
- Mollie explicitly recommends against IP allow-listing; its addresses change.
PayStack¶
- Header:
x-paystack-signature - Algorithm: HMAC-SHA512 over the raw body (lowercase hex)
Flutterwave¶
- Header:
verif-hash - Scheme: constant-time equality of the header against the configured
secret_hash(Flutterwave does not sign the body)
Security Features¶
- Encryption at Rest - API keys encrypted with AES-256-GCM
- Constant-Time Comparison - Prevents timing attacks on signature verification
- Webhook Verification - All webhooks verified before processing
- Health Checks - Provider connectivity validation
- Error Handling - Comprehensive error types and logging
Testing¶
Run payment provider tests:
E2E Stripe Payment Test¶
Full-circle test: Stripe payment webhook → license creation → device activation.
# Requires: API server running on port 8888 with Stripe env vars
SAWABONA_SERVER__PORT=8888 cargo run --bin sawabona-api &
cargo test --package sawabona-core --test e2e_stripe_payment_test -- --nocapture
The test:
1. Creates a product + plan via admin API
2. Inserts a stripe_product_mapping (stripe price → product)
3. Self-signs a invoice.payment_succeeded webhook payload
4. POSTs to /api/v1/webhooks/stripe
5. Verifies license was created and is active
6. Activates a device on the payment-created license via geometric proof
Getting Started with Providers¶
Using Starter Templates¶
Each provider has a starter template to help you get started quickly:
--provider takes one of five: stripe, paddle, mollie, paystack,
flutterwave. Nothing else. Adyen, MercadoPago, PagSeguro and Braintree were
removed when Sawabona narrowed to providers with a verifiable webhook scheme,
and a migration drops their tables — this page went on offering
--provider adyen, which the CLI refuses.
These templates need a checkout of this repository beside them. Their
Cargo.toml reaches sawabona-core by relative path, and the crates are not on
crates.io, so a reader who has only the template cannot build it. That is a
deliberate position, not an oversight: see the publishing decision in the launch
runbook. Until it changes, these are examples for someone who already has the
engine.
Template Structure¶
Each starter template includes:
- Cargo.toml - Dependencies by path into this repository, and its own
[workspace]so cargo does not try to make it a member of the engine's - src/main.rs - Example usage demonstrating provider registration
- .env.example - Environment variable configuration template
- README.md - Quick start guide and documentation
Manual Provider Integration¶
To manually integrate a provider into your application:
- Add the provider crate to your
Cargo.toml:
- Register the provider at runtime:
use sawabona_payment_stripe::StripeFactory;
use sawabona_core::services::ProviderRegistry;
let registry = ProviderRegistry::new();
registry.register(Box::new(StripeFactory::new(config)));
- Use the provider:
let provider = registry.get_provider(PaymentProvider::Stripe)?;
let subscription = provider.create_subscription(customer_id, price_id, metadata).await?;
No More Feature Flags¶
The plugin architecture eliminates the need for compile-time feature flags. Providers are now:
- Independently deployable - Each provider is a separate crate
- Optionally included - Only include providers you need
- Runtime configurable - Enable/disable providers without recompilation
- Modular - Providers don't depend on each other
Error Handling¶
Common error types:
PaymentProviderError- Provider-specific errorsWebhookVerificationFailed- Signature verification failureProviderNotConfigured- Missing provider configurationInternalError- Encryption or system errors
Best Practices¶
- Always verify webhook signatures before processing
- Use environment variables for sensitive configuration
- Implement health checks before processing payments
- Log all payment operations for audit trails
- Use constant-time comparison for signature verification
- Encrypt API keys before storing in database