Skip to content

Payment Integrations & Credential Encryption ​

This page is the runbook for payment provider credentials (Xendit, Stripe). It covers where they live, how they are encrypted, and what to do when an environment cannot read them.

Where credentials live ​

Provider config and credentials live in the payment_integrations table (credentials JSONB). Admins edit them in Web → Admin → Integrations → Payment Settings; the API exposes them under /v1/admin/payment-integrations.

  • Public keys (public_key, publishable_key) are stored plaintext.
  • Sensitive keys (secret_key, webhook_token, webhook_secret) are encrypted at rest with AES-256-GCM by @sudi/db (services/payment-credentials.ts).
  • Ciphertext is self-describing: enc:v1:<iv-hex>:<ciphertext-hex>.

Seeding never writes credentials. bun run db:seed is insert-only for payment integrations and settings (SUDI-222/233); it must not run as part of a deploy.

Encryption keys ​

Env varRole
CRYPTO_ENCRYPTION_KEYThe key — used to encrypt and to decrypt.

It must be 64 hex characters (32 bytes). The API validates the key at startup and logs an error when it is malformed. On Workers it also logs an error when the key is entirely absent, and refuses to store plaintext provider secrets without a valid key.

Encryption and decryption both use CRYPTO_ENCRYPTION_KEY. There is no secondary key.

Rule: every environment sharing a database must share the same key ​

The Neon database is shared across environments (dev/prod, and local when pointed at it). If two environments use different encryption keys, the ciphertext written by one cannot be decrypted by the other. This was the root cause of SUDI-234: a stored secret_key was unreadable, the connection test threw before using the freshly typed value, and the admin UI looked like the credentials had been replaced.

Set the same CRYPTO_ENCRYPTION_KEY on every environment that points at the same database.

Deploy provisioning (Workers) ​

The deploy workflows (deploy-prod.yml, deploy.yml) sync CRYPTO_ENCRYPTION_KEY to the API Worker from the repository secret before wrangler deploy. When that secret is not configured they verify the Worker already has it via wrangler secret list and fail the deploy if it is missing — so a keyless Worker can never ship silently again (SUDI-238).

Changing the key ​

  1. Set the same new CRYPTO_ENCRYPTION_KEY on every environment.
  2. Re-save each provider in the admin dashboard — a non-blank submitted secret is re-encrypted with the current key (blank fields keep the stored value).
  3. Old ciphertext can only be read with the key that wrote it; once re-saved, the row is encrypted with the current key.

Key fingerprint & parity guard (SUDI-243) ​

Every save records payment_integrations.key_fingerprint — SHA-256(rawKey)[:12] of the key that encrypted the secret. The admin Payment Settings page compares it to the current environment's key and shows a red banner on mismatch, and the API rejects a save that would preserve ciphertext it cannot decrypt (CREDENTIALS_KEY_MISMATCH) instead of writing a broken row. The connection Test also warns when the stored key (not the freshly submitted one) is undecryptable — so "save + test" can no longer look healthy while checkout is broken.

Check parity at any time:

bash
bun run payment:key:check

It exits non-zero when the local key differs from the fingerprint recorded in the shared database (and, optionally, from deployed diagnostics endpoints when PAYMENT_KEY_CHECK_ENDPOINTS and PAYMENT_ADMIN_TOKEN are set). Both deploy workflows run it after syncing the Worker secret, so a deploy that would ship a mismatched key fails instead of breaking checkout later.

Re-key recovery ​

When stored credentials were encrypted with an old key you still possess, re-key them in place instead of re-entering every provider secret:

bash
CRYPTO_ENCRYPTION_KEY=<new> bun run payment:rekey --from <oldKey>
# preview only:
CRYPTO_ENCRYPTION_KEY=<new> bun run payment:rekey --from <oldKey> --dry-run

The tool decrypts each enc:v1: value with the old key, re-encrypts it with the current key, and updates key_fingerprint. Values it cannot decrypt are left untouched and reported (exit 1) — ciphertext is never destroyed.

Recovery: "credentials could not be decrypted" ​

Symptoms:

  • Admin connection test fails even with a correct key.
  • Checkout/webhook returns XENDIT_CREDENTIALS_UNDECRYPTABLE or "Xendit checkout not configured".
  • API logs: Stored Xendit credentials could not be decrypted.

Recovery:

  1. Re-enter the credentials in the admin dashboard and click Save Configuration. Saving re-encrypts the submitted secrets with the current key.
  2. If the stored ciphertext was written with a key that is no longer available, it is unrecoverable — enter fresh credentials from the provider dashboard.

The connection test always prefers submitted values over stored ones, so a stale stored secret can never block testing newly entered keys. Unreadable ciphertext is preserved on save (never silently destructive) and omitted on read.

Recurring billing wiring (Xendit Subscriptions, SUDI-247) ​

Two entry points for creating a subscription (How subscriptions work):

  1. Xendit Checkout UI (used) — create a Payment Session of type SUBSCRIPTION (services/xendit-recurring-client.tscreateSubscriptionSession), redirect the user to the hosted Payment Link to link their method, then listen for webhooks: session completed → payment token webhook → plan active.
  2. Existing payment token — create the plan directly with a stored payment_token_id (Phase 2 attaches saved vault methods this way).

Recovery for failed cycles (Recover failed payments):

  • Multiple payment methods — up to 5 channels per plan, attempted sequentially in priority order. Our vault (user_payment_methods) mirrors this: isDefault first, then priority ascending.
  • Retry configuration — set at plan creation; Xendit retries all attached channels per cycle and reports via cycle webhooks.
  • Payment Link fallback — a one-time link lets the user settle a failed cycle manually without replacing the saved methods.

PCI-safe vault policy ​

user_payment_methods stores only Xendit gateway references (xendit_payment_method_id, xendit_customer_id, payment_token_id) plus display-masked data (brand, last4, masked_account, exp_month/year from payment_method.activated callbacks, e.g. maskedBankAccountNumber: XXXXXX1234). Never store PANs or sensitive authentication data — gateway tokenization keeps card data out of our PCI DSS scope (scope-reduction via tokenization + masking).

Migration drift note (pre-existing) ​

bun run db:generate currently diffs stale DROPs for exchange_accounts/orders/portfolio_snapshots/positions/trade_history — those tables exist in 0000_snapshot.json but no longer in src/ (trading schema removed without a migration). 0004_add_user_payment_methods.sql was therefore trimmed to the additive vault statements only. Do NOT apply a generated migration containing those DROPs without a deliberate, user-approved cleanup task.

Xendit webhook configuration (sandbox + live) ​

The API exposes exactly two Xendit webhook endpoints. Both verify the x-callback-token header against the configured webhook_token (payment_integrations.credentials, DB-first, then the XENDIT_WEBHOOK_TOKEN env var).

EndpointPurpose
POST /v1/webhooks/xenditOne-time payments (invoice.paid, payment.succeeded, SETTLED) — airdrop + plan activation
POST /v1/webhooks/xendit-recurringRecurring subscriptions (payment_session.completed, payment_method.activated, payment_token.*, recurring.plan.*, recurring.cycle.*)

URLs to set in the Xendit dashboard ​

Sandbox (dev) uses https://dev.api.sudigital.com; live uses https://api.sudigital.com. Only these five rows are consumed:

Xendit rowSandbox URLLive URL
INVOICES → Invoices paidhttps://dev.api.sudigital.com/v1/webhooks/xendithttps://api.sudigital.com/v1/webhooks/xendit
PAYMENT SESSION → Payment Session Completed…/v1/webhooks/xendit-recurring…/v1/webhooks/xendit-recurring
PAYMENT METHOD V2 → Payment method…/v1/webhooks/xendit-recurring…/v1/webhooks/xendit-recurring
PAYMENT TOKENS V3 → Payment Token Status…/v1/webhooks/xendit-recurring…/v1/webhooks/xendit-recurring
RECURRING → Recurring…/v1/webhooks/xendit-recurring…/v1/webhooks/xendit-recurring

All other rows (FVA, Disbursement, Payouts v2/v3, Payout Links, Retail outlets, Cards, Direct Debit, XenPlatform, Report, Payment Requests V2/V3, Refunds, E-Wallets, Paylater, QR, Payment Session Expired, Bill Payments) are not consumed — leave them unset.

Verification token ​

Copy the Verification Token shown on the Xendit Webhooks page (sandbox and live differ) into Web → Admin → Integrations → Payment Settings → Xendit → Webhook Verification Token (and/or XENDIT_WEBHOOK_TOKEN on sudi-api / sudi-api-dev). Every enabled webhook must send the same x-callback-token.

Payload-shape gotcha ​

Xendit payloads carry extra fields (user_id, business_id, created, …) and the newer webhooks wrap the body as { event, data }. Both body schemas use additionalProperties: true so only the fields we read are validated — a strict Elysia t.Object rejected every real payload with 400 before the handler (and token check) ran. Keep it tolerant when editing those routes.

Testing ​

Use the dashboard Test button per row. Expected: 200 {"received":true} (invoice) or 200 {"success":true} (recurring). Interpret failures as: 401 → callback token mismatch; 400 → the deployed API lacks the tolerant schemas (redeploy).

One backend. Three products. One token.