top of page

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

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:

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:

  1. resolves base URL with getBaseUrl(config)

  2. POSTs to:

    • ${baseUrl}/Authenticate

  3. sends:

    • username

    • password

  4. caches bearer token

  5. 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:

  1. detect gateway = syntch

  2. call /payment/syntch-tokenize

  3. if tokenization succeeds:

    • call normal payment endpoint with returned token

  4. 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

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:

  1. Do not use Railway proxy as the default production URL

  2. Do not store baseUrl nested under another object

  3. Do not confuse Merchant Key with API username/password

  4. Do not assume API Key = Merchant Key

  5. Do not do frontend tokenization for Syntch

  6. Do not trust DB JSON shape without logging parsed config

  7. Do not assume HTTP 201 means success for Syntch transaction processing

  8. 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.

Givehub.com

GiveHub.com
Acworth, GA 30101

United States
Email: sales@givehub.com
Phone: 866-933-7048

 

MISSION STATEMENT

 

To offer a robust and a superior product suite to help non-profits and churches increase giving and operate more effectively and efficiently with cutting edge technology. 

 

Yours in Christ, 
GiveHub.com Team

  • Facebook Social Icon
  • LinkedIn Social Icon
  • Instagram Social Icon

© 2026 GiveHub.com - All rights reserved - Support - Book a Demo

bottom of page