Please add Syntch to this new software using the same architecture we finalized in GiveHub.
Goal
Add production-ready Syntch card processing with:
-
server-side authentication
-
server-side tokenization
-
one-time payments
-
recurring payments
-
proxy support for production
-
per-org config
-
clean fallback between direct and proxied routing
1) Data / Config Model
Store Syntch in the org’s payment gateway config with these fields at the top level:
{ "payment_gateway": "syntch", "payment_gateway_config": { "username": "PROD_API_USERNAME", "password": "PROD_API_PASSWORD", "merchantKey": "PROD_MERCHANT_KEY", "paymentMode": "production", "baseUrl": "https://syntch-proxy-production.up.railway.app", "processorId": "", "isSandbox": false } }
Important rules
-
merchantKey is not the auth credential.
-
Syntch auth uses username + password first.
-
merchantKey is used later for tokenization / transaction routes.
-
baseUrl must be optional and must be honored when present.
-
baseUrl must be top-level, not nested.
2) Admin UI / Settings Screen
Add a Syntch settings form with these fields:
-
Sandbox Mode toggle
-
Base URL
-
Username
-
Password
-
Merchant Key
-
Processor ID (optional)
Behavior
-
If Base URL is blank:
-
use default sandbox/prod URL based on mode
-
-
If Base URL is populated:
-
use it exactly as entered
-
-
In production, we should be able to enter:
Labeling
Make it clear in UI:
-
Username = API auth username
-
Password = API auth password
-
Merchant Key = merchant key, not API key
3) Gateway Routing Rules
Implement these exact defaults:
const SANDBOX_BASE_URL = "https://syntch-sandbox.simpay.net/api"; const PRODUCTION_BASE_URL = "https://syntch.simpay.net/api";
Critical rule
Do not make the Railway proxy the default production URL.
Correct logic:
function getBaseUrl(config: GatewayConfig): string { if (config.baseUrl) return String(config.baseUrl).replace(/\/$/, ""); const mode = (config.paymentMode as string | undefined) ?? ""; if (mode === "production" || config.isSandbox === false) { return PRODUCTION_BASE_URL; } return SANDBOX_BASE_URL; }
Why
-
direct production default should be real Syntch
-
proxy should only be used when config.baseUrl is explicitly set
4) Proxy Support
If config.baseUrl is set, inject the proxy secret at runtime:
if (config.baseUrl) { config.proxySecret = syntchProxySecret(); }
Requirements
-
Encore secret name: SyntchProxySecret
-
Railway env var: PROXY_SECRET
-
These must match exactly
Header
Send:
headers["x-proxy-secret"] = config.proxySecret as string;
Proxy expectations
Railway should:
-
validate x-proxy-secret
-
forward all paths to SYNTCH_TARGET
-
production target:
5) Parse Config Safely
Create / update parseGatewayConfig so it preserves all fields and explicitly keeps baseUrl.
Use logic like:
export function parseGatewayConfig(raw: unknown): GatewayConfig { if (!raw) return {}; const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; const config: GatewayConfig = { ...parsed, baseUrl: (parsed as any).baseUrl, }; return config; }
Important
The DB driver may return JSON as:
-
string
-
object
Support both.
6) Authentication Flow
Implement authenticate(config) that:
-
resolves base URL with getBaseUrl(config)
-
POSTs to:
-
${baseUrl}/Authenticate
-
-
sends:
-
username
-
password
-
-
caches bearer token
-
retries if needed
Request body
const authBody = { username: config.username || "", password: config.password || config.apiKey || config.transactionKey || "", };
Headers
const authHeaders: Record<string, string> = { "Content-Type": "application/json", Accept: "application/json", }; if (config.proxySecret) { authHeaders["x-proxy-secret"] = config.proxySecret as string; }
Token cache
Cache bearer token for about 55 minutes.
7) Tokenization Endpoint
Add a public backend endpoint:
POST /payment/syntch-tokenize
Request:
{ "orgId": "5", "cardNumber": "4111111111111111", "expiryMonth": "12", "expiryYear": "30", "cvv": "123", "nameOnCard": "Test User", "billingZip": "30101" }
Endpoint behavior
-
validate org
-
verify org uses payment_gateway = syntch
-
load org payment gateway config
-
parse config
-
inject proxy secret if baseUrl exists
-
call gw.tokenizeCardServerSide(...)
-
return:
-
success
-
token
-
last4
-
cardType
-
error
-
Important
Do not tokenize in the frontend with Syntch JS.
Use server-side tokenization only.
8) Tokenization Gateway Logic
Implement tokenization path exactly like this pattern:
const path = `/merchants/${merchantKeyStr}/tokens/cards`;
Body:
const body = { MerchantKey: isNaN(merchantKeyInt) ? merchantKeyStr : merchantKeyInt, CardNumber: cardNumber.replace(/\s/g, ""), ExpirationDate: `${mm}${yy}`, NameOnCard: nameOnCard, TokenFormat: "Uid", };
If ZIP exists:
body.PostalCode = billingAddress.postalCode;
Success response
Extract:
-
Token
-
Last4
-
CardBrand / CardType
This is already how the working implementation behaves.
9) One-Time Charge Processing
Implement transaction processing using:
POST /v2/transactions/bcp
Body should include:
-
merchantKey
-
amount
-
transactionType: "sale"
-
token
-
orderNumber
-
invoiceNumber
-
customer object
-
billingAddress
-
metadata
Pattern used in working version:
const body = { merchantKey, amount: Number(request.amount.toFixed(2)), TotalAmount: Number(request.amount.toFixed(2)), currency: "USD", transactionType: "sale", token: request.paymentMethodToken, orderNumber, invoiceNumber, invoiceData: { invoiceNumber, invoiceDate: new Date().toISOString().split("T")[0], TotalAmount: Number(request.amount.toFixed(2)), }, description: request.description || "Donation", customer: { firstName: request.donorFirstName, lastName: request.donorLastName, email: request.donorEmail, phone: request.metadata?.phone || "", }, billingAddress: { address1: request.metadata?.address1 || "", address2: request.metadata?.address2 || "", city: request.metadata?.city || "", state: request.metadata?.state || "", postalCode: request.metadata?.postalCode || "", countryCode: request.metadata?.countryCode || "US", }, metadata: { orgId: request.organizationId, source: "AppNameHere", }, };
10) Approval / Decline Handling
Implement approval detection like the working version:
-
approve if:
-
approved === true
-
success === true
-
status === "approved"
-
responseCode === "00"
-
etc.
-
If not approved:
-
surface the processor message if present
-
otherwise use:
-
Syntch declined (HTTP ${status})
-
Important
Syntch may return HTTP 201 with a declined payload.
Treat that as a decline, not a success.
That exact issue was encountered in prod and must be handled. The working version already has decline parsing logic.
11) Recurring Payments
Implement recurring using customer + contract flow.
Step A: find or create customer
Cache customer_key by:
-
org_id
-
merchant_key
-
donor_email
If not found:
POST /customers
Step B: create contract
Use:
POST /merchants/{merchantKey}/customers/{customerKey}/contracts
Body should include:
-
CustomerKey
-
ContractId
-
Status = "Active"
-
Token
-
TokenFormat = "Uid"
-
BillAmount
-
BillingPeriod
-
BillingInterval
-
StartDate
-
Description
-
EmailAddress
Map frequency:
-
weekly -> Weekly
-
monthly -> Monthly
-
yearly -> Annually
This recurring contract flow is already present in the working implementation.
12) Cancel Recurring
Implement:
DELETE /merchants/{merchantKey}/customers/{customerKey}/contracts/{contractKey}
If customer key is not directly provided:
-
look it up from cached syntch_customers
13) Retry / Token Eviction Logic
In generic JSON request helper:
-
authenticate first
-
send bearer token
-
if response is 401 or 403:
-
evict cached token
-
re-auth once
-
retry request
-
The working gateway already does this and it should be copied.
14) Required Logging
Add masked logs in these places:
After config parse
console.log("SYNTCH RUNTIME CONFIG", { orgId: req.orgId, gateway: row.payment_gateway, baseUrl: config.baseUrl, paymentMode: config.paymentMode, isSandbox: config.isSandbox, username: config.username ? `${String(config.username).slice(0, 3)}***${String(config.username).slice(-2)}` : "(empty)", passwordPresent: !!(config.password || config.apiKey || config.transactionKey), merchantKey: config.merchantKey ? String(config.merchantKey) : "(empty)", processorId: config.processorId || "(empty)", });
Before auth fetch
console.log("SYNTCH AUTH INPUT", { baseUrl, authUrl, username: config.username ? `${String(config.username).slice(0, 3)}***${String(config.username).slice(-2)}` : "(empty)", passwordPresent: !!(config.password || config.apiKey || config.transactionKey), merchantKey: config.merchantKey ? String(config.merchantKey) : "(empty)", hasProxySecret: !!config.proxySecret, });
Before tokenization fetch
console.log("SYNTCH GATEWAY DEBUG:", { baseUrl: config.baseUrl, hasProxySecret: !!config.proxySecret, finalUrl, });
Logging rules
-
never log raw password
-
never log full PAN
-
mask card number to last4 only
-
keep logs removable later
These logs were critical in isolating the routing, auth, and merchantKey issues.
15) Frontend Flow
Frontend payment flow should be:
-
detect gateway = syntch
-
call /payment/syntch-tokenize
-
if tokenization succeeds:
-
call normal payment endpoint with returned token
-
-
if recurring:
-
send isRecurring: true
-
send recurring frequency
-
backend handles contract creation logic
-
Frontend should not attempt Syntch JS tokenization.
16) Validation / UX
Add:
-
card number validation
-
exp validation
-
CVV validation
-
ZIP validation
-
friendly decline messaging
Store and pass:
-
last4
-
cardType
17) Required Secrets / Environment Variables
Encore
-
SyntchProxySecret
Railway proxy
-
PROXY_SECRET
-
SYNTCH_TARGET=https://syntch.simpay.net/api
Important
PROXY_SECRET and SyntchProxySecret must match exactly.
18) Production Test Checklist
Please test in this order:
Auth / tokenization
-
verify Syntch token is returned
-
verify last4 + card type return
One-time payment
-
Visa
-
non-recurring
-
$5 or $10
-
confirm approval / decline parsing
Recurring
-
monthly
-
confirm contract creation
-
confirm subscription ID stored
Decline handling
-
confirm processor decline message surfaces correctly
Cancel recurring
-
confirm contract delete works
19) Common Mistakes to Avoid
Do not repeat these mistakes:
-
Do not use Railway proxy as the default production URL
-
Do not store baseUrl nested under another object
-
Do not confuse Merchant Key with API username/password
-
Do not assume API Key = Merchant Key
-
Do not do frontend tokenization for Syntch
-
Do not trust DB JSON shape without logging parsed config
-
Do not assume HTTP 201 means success for Syntch transaction processing
-
Do not forget that auth uses username/password first, merchantKey second
20) Definition of Done
This integration is complete when:
-
org can save Syntch config in admin UI
-
tokenization succeeds via backend
-
one-time charges process
-
recurring charges create contracts
-
cancellations work
-
proxy routing works when baseUrl is set
-
direct routing works when baseUrl is blank
-
declines are handled cleanly
-
logs are masked and safe
Please follow this architecture exactly so we don’t repeat the same proxy/auth/baseUrl mistakes from the previous build.
