Please replace the current Syntch integration with a real end-to-end implementation based on the Syntch sandbox Swagger.
What we confirmed from the Syntch sandbox Swagger UI:
- Base API URL: https://syntch-sandbox.simpay.net/api
- OpenAPI URL: https://syntch-sandbox.simpay.net/api/openapi
- Auth endpoint: POST /Authenticate
- Tokenization endpoints:
- POST /merchants/{merchantKey}/tokens/cards
- POST /merchants/{merchantKey}/tokens
- GET /merchants/{merchantKey}/tokens/cards
- Transaction endpoints:
- POST /transactions
- POST /v2/transactions/bcp ← this is the one we should use for card donations
- QuickPayments exists, but for GiveHub I want the standard merchant token + card transaction flow unless Swagger/testing proves otherwise.
Critical fixes needed:
1. Do NOT default sandbox to https://syntch-sandbox.simpay.net
It must be https://syntch-sandbox.simpay.net/api
2. Do NOT guess auth routes like /auth, /token, /login, etc.
Use POST /Authenticate exactly.
3. Do NOT stop after tokenization.
After tokenization, run the actual payment through POST /v2/transactions/bcp
4. Do NOT generate fake transaction IDs like txn_pending_TIMESTAMP for sandbox/live gateway tests.
5. Only mark the donation paid after Syntch returns a successful gateway response.
Please implement the following.
==================================================
1) syntch.ts
==================================================
Use this as the Syntch gateway provider implementation skeleton and wire it into the existing provider interface.
```ts
import type {
IGatewayProvider,
ProcessPaymentRequest,
ProcessPaymentResponse,
CreateRecurringRequest,
CreateRecurringResponse,
CancelRecurringRequest,
CancelRecurringResponse,
TokenizeCardRequest,
TokenizeCardResponse,
GatewayConfig,
} from "../types";
interface SyntchAuthCache {
token: string;
fetchedAt: number;
expiresAt: number;
}
const authCache = new Map<string, SyntchAuthCache>();
const AUTH_TTL_MS = 55 * 60 * 1000;
function cacheKey(config: GatewayConfig): string {
return [
config.baseUrl || "",
config.username || "",
config.merchantKey || "",
String(config.isSandbox ?? true),
].join("::");
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function mask(value?: string | null): string {
if (!value) return "";
if (value.length <= 4) return "*".repeat(value.length);
return `${"*".repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
}
async function safeReadText(res: Response): Promise<string> {
try {
return await res.text();
} catch {
return "";
}
}
function parseMaybeJson(text: string): any {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function getRequired(config: GatewayConfig, key: keyof GatewayConfig): string {
const value = config[key];
if (!value || typeof value !== "string" || !value.trim()) {
throw new Error(`Missing required Syntch config value: ${String(key)}`);
}
return value.trim();
}
export class SyntchGateway implements IGatewayProvider {
getBaseUrl(config: GatewayConfig): string {
if (config.baseUrl && String(config.baseUrl).trim()) {
return trimTrailingSlash(String(config.baseUrl).trim());
}
return config.isSandbox === false
? "https://syntch.simpay.net/api"
: "https://syntch-sandbox.simpay.net/api";
}
private async request(
config: GatewayConfig,
path: string,
init: RequestInit & { skipAuth?: boolean } = {}
): Promise<any> {
const baseUrl = this.getBaseUrl(config);
const url = `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(init.headers as Record<string, string> | undefined),
};
if (!init.skipAuth) {
const token = await this.authenticate(config);
headers["Authorization"] = `Bearer ${token}`;
}
const startedAt = Date.now();
try {
const res = await fetch(url, {
...init,
headers,
});
const elapsedMs = Date.now() - startedAt;
const rawText = await safeReadText(res);
console.log("[syntch] http", {
url,
method: init.method || "GET",
status: res.status,
ok: res.ok,
elapsedMs,
responsePreview: rawText?.slice(0, 1000),
});
if (!res.ok) {
throw new Error(
`Syntch request failed (${res.status}) ${url}: ${rawText || res.statusText}`
);
}
return parseMaybeJson(rawText);
} catch (error: any) {
const elapsedMs = Date.now() - startedAt;
console.error("[syntch] network_or_request_error", {
url,
method: init.method || "GET",
elapsedMs,
error: error?.message || String(error),
});
throw error;
}
}
async authenticate(config: GatewayConfig): Promise<string> {
const key = cacheKey(config);
const cached = authCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
const username = getRequired(config, "username");
const password =
(config as any).password?.trim?.() ||
(config as any).apiKey?.trim?.() ||
(config as any).transactionKey?.trim?.();
if (!password) {
throw new Error("Missing required Syntch config value: password/apiKey/transactionKey");
}
const url = `${this.getBaseUrl(config)}/Authenticate`;
const body = {
username,
password,
};
console.log("[syntch] authenticate_request", {
url,
usernameLast4: mask(username),
});
const startedAt = Date.now();
let res: Response;
let rawText = "";
try {
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
rawText = await safeReadText(res);
} catch (error: any) {
console.error("[syntch] authenticate_network_error", {
url,
elapsedMs: Date.now() - startedAt,
error: error?.message || String(error),
});
throw new Error(`Syntch authenticate network failure: ${error?.message || String(error)}`);
}
console.log("[syntch] authenticate_response", {
url,
status: res.status,
elapsedMs: Date.now() - startedAt,
responsePreview: rawText.slice(0, 1000),
});
if (!res.ok) {
throw new Error(`Syntch authenticate failed (${res.status}): ${rawText || res.statusText}`);
}
const parsed = parseMaybeJson(rawText) || {};
const token =
parsed?.token ||
parsed?.accessToken ||
parsed?.access_token ||
parsed?.jwt ||
parsed?.data?.token ||
parsed?.result?.token;
if (!token || typeof token !== "string") {
throw new Error(
`Syntch authenticate succeeded but no token field was found. Response: ${rawText}`
);
}
authCache.set(key, {
token,
fetchedAt: Date.now(),
expiresAt: Date.now() + AUTH_TTL_MS,
});
return token;
}
async tokenizeCard(
request: TokenizeCardRequest,
config: GatewayConfig
): Promise<TokenizeCardResponse> {
const merchantKey = getRequired(config, "merchantKey");
const body = {
number: request.cardNumber,
expirationMonth: request.expMonth,
expirationYear: request.expYear,
cvv: request.cvv,
cardHolderName:
request.cardholderName ||
[request.firstName, request.lastName].filter(Boolean).join(" ").trim(),
billingAddress: request.address1
? {
address1: request.address1,
address2: request.address2 || "",
city: request.city || "",
state: request.state || "",
postalCode: request.zip || "",
countryCode: request.country || "US",
}
: undefined,
};
const response = await this.request(
config,
`/merchants/${encodeURIComponent(merchantKey)}/tokens/cards`,
{
method: "POST",
body: JSON.stringify(body),
}
);
const token =
response?.token ||
response?.cardToken ||
response?.data?.token ||
response?.data?.cardToken ||
response?.result?.token;
if (!token) {
throw new Error(
`Syntch card tokenization succeeded but no token was returned: ${JSON.stringify(response)}`
);
}
return {
success: true,
token,
rawResponse: response,
};
}
async processPayment(
request: ProcessPaymentRequest,
config: GatewayConfig
): Promise<ProcessPaymentResponse> {
const merchantKey = getRequired(config, "merchantKey");
let token = request.token;
if (!token && request.cardNumber) {
const tokenized = await this.tokenizeCard(
{
cardNumber: request.cardNumber,
expMonth: request.expMonth,
expYear: request.expYear,
cvv: request.cvv,
cardholderName: request.cardholderName,
firstName: request.firstName,
lastName: request.lastName,
address1: request.address1,
address2: request.address2,
city: request.city,
state: request.state,
zip: request.zip,
country: request.country,
} as TokenizeCardRequest,
config
);
token = tokenized.token;
}
if (!token) {
throw new Error("Syntch requires either a previously tokenized card token or raw card fields.");
}
const amount = Number(request.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error(`Invalid Syntch payment amount: ${request.amount}`);
}
const payload = {
merchantKey,
amount,
currency: request.currency || "USD",
transactionType: request.transactionType || "sale",
token,
orderNumber:
request.orderId ||
request.reference ||
request.invoiceNumber ||
`donation_${Date.now()}`,
invoiceNumber: request.invoiceNumber || request.orderId || undefined,
description: request.description || "GiveHub Donation",
customer: {
firstName: request.firstName || "",
lastName: request.lastName || "",
email: request.email || "",
phone: request.phone || "",
},
billingAddress: request.address1
? {
address1: request.address1,
address2: request.address2 || "",
city: request.city || "",
state: request.state || "",
postalCode: request.zip || "",
countryCode: request.country || "US",
}
: undefined,
metadata: {
orgId: request.orgId || "",
donationId: request.donationId || "",
source: "GiveHub",
},
};
const response = await this.request(config, "/v2/transactions/bcp", {
method: "POST",
body: JSON.stringify(payload),
});
const approved =
response?.approved === true ||
response?.success === true ||
response?.isApproved === true ||
response?.status === "approved" ||
response?.status === "success" ||
response?.result === "approved" ||
response?.responseCode === "00" ||
response?.resultCode === "00";
const transactionId =
response?.transactionId ||
response?.id ||
response?.txnId ||
response?.pnRef ||
response?.referenceNumber ||
response?.data?.transactionId;
const authCode =
response?.authCode ||
response?.authorizationCode ||
response?.data?.authCode;
const cardBrand =
response?.cardBrand ||
response?.brand ||
response?.cardType ||
response?.data?.cardBrand;
const last4 =
response?.last4 ||
response?.cardLast4 ||
response?.maskedPan?.slice?.(-4) ||
response?.data?.last4;
return {
success: !!approved,
transactionId: transactionId || "",
authCode: authCode || "",
cardBrand: cardBrand || "",
last4: last4 || "",
rawResponse: response,
errorMessage: approved
? undefined
: response?.message ||
response?.error ||
response?.errorMessage ||
"Syntch transaction was not approved",
};
}
async createRecurring(
_request: CreateRecurringRequest,
_config: GatewayConfig
): Promise<CreateRecurringResponse> {
throw new Error("Syntch recurring billing is not yet implemented in GiveHub.");
}
async cancelRecurring(
_request: CancelRecurringRequest,
_config: GatewayConfig
): Promise<CancelRecurringResponse> {
throw new Error("Syntch recurring cancellation is not yet implemented in GiveHub.");
}
}
==================================================
2) IMPORTANT wiring changes in donation/create.ts
Please update donation/create.ts so it does this:
-
Load org payment gateway config
-
If gateway === syntch:
-
instantiate SyntchGateway
-
tokenize raw card if needed
-
call processPayment()
-
use the real response from /v2/transactions/bcp
-
-
Only mark the donation successful if processPayment().success === true
-
Save:
-
real transactionId
-
authCode
-
cardBrand
-
last4
-
raw gateway response
-
-
Remove any placeholder transaction IDs like:
-
txn_pending_TIMESTAMP
-
-
Return the real gateway failure message to the UI if declined or errored
Pseudo-flow:
if (gateway.provider === "syntch") {
const syntch = new SyntchGateway();
const paymentResult = await syntch.processPayment(
{
amount: donation.amount,
currency: "USD",
token: input.cardToken,
cardNumber: input.cardNumber,
expMonth: input.expMonth,
expYear: input.expYear,
cvv: input.cvv,
cardholderName: input.cardholderName,
firstName: donor.firstName,
lastName: donor.lastName,
email: donor.email,
phone: donor.phone,
address1: donor.address1,
address2: donor.address2,
city: donor.city,
state: donor.state,
zip: donor.zip,
country: donor.country,
orderId: donation.id,
invoiceNumber: donation.id,
description: `GiveHub donation for org ${org.id}`,
orgId: org.id,
donationId: donation.id,
},
gatewayConfig
);
if (!paymentResult.success) {
throw new Error(paymentResult.errorMessage || "Syntch payment failed");
}
transactionId = paymentResult.transactionId;
authCode = paymentResult.authCode;
brand = paymentResult.cardBrand;
last4 = paymentResult.last4;
gatewayResponse = paymentResult.rawResponse;
}
==================================================
3) log exactly what is happening
Please add diagnostic logging for Syntch:
-
exact baseUrl used
-
exact auth URL used
-
exact tokenization URL used
-
exact transaction URL used
-
HTTP status
-
raw response body
-
whether the failure occurred before HTTP response existed
-
elapsed time for each call
Needed log labels:
-
[syntch] authenticate_request
-
[syntch] authenticate_response
-
[syntch] tokenization_request
-
[syntch] tokenization_response
-
[syntch] transaction_request
-
[syntch] transaction_response
-
[syntch] network_or_request_error
Please never log full PAN or CVV.
==================================================
4) working test curl commands
Please use these as sanity checks from the backend environment or a terminal where outbound access is allowed.
A. Auth test
curl -X POST "https://syntch-sandbox.simpay.net/api/Authenticate" \
-H "Content-Type: application/json" \
-d '{
"username": "YOUR_SYNTCH_USERNAME",
"password": "YOUR_SYNTCH_PASSWORD"
}'
Expected:
-
HTTP 200
-
response containing some token/session/auth field
B. Card tokenization test
Replace YOUR_BEARER_TOKEN and YOUR_MERCHANT_KEY after the auth call succeeds.
curl -X POST "https://syntch-sandbox.simpay.net/api/merchants/YOUR_MERCHANT_KEY/tokens/cards" \
-H "Authorization: Bearer YOUR_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"number": "4111111111111111",
"expirationMonth": "12",
"expirationYear": "2028",
"cvv": "123",
"cardHolderName": "Test Donor",
"billingAddress": {
"address1": "123 Main St",
"city": "Acworth",
"state": "GA",
"postalCode": "30101",
"countryCode": "US"
}
}'
Expected:
-
HTTP 200/201
-
response containing a token/cardToken field
C. Card transaction test
Replace YOUR_CARD_TOKEN after tokenization succeeds.
curl -X POST "https://syntch-sandbox.simpay.net/api/v2/transactions/bcp" \
-H "Authorization: Bearer YOUR_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantKey": "YOUR_MERCHANT_KEY",
"amount": 25.00,
"currency": "USD",
"transactionType": "sale",
"token": "YOUR_CARD_TOKEN",
"orderNumber": "givehub_test_001",
"invoiceNumber": "givehub_test_001",
"description": "GiveHub Sandbox Donation",
"customer": {
"firstName": "Test",
"lastName": "Donor",
"email": "test@example.com",
"phone": "5555551212"
},
"billingAddress": {
"address1": "123 Main St",
"city": "Acworth",
"state": "GA",
"postalCode": "30101",
"countryCode": "US"
},
"metadata": {
"source": "GiveHub",
"environment": "sandbox"
}
}'
Expected:
-
HTTP 200/201
-
response containing a real transaction identifier and approval/decline fields
==================================================
5) payload format for /v2/transactions/bcp
Please start with this payload shape for GiveHub card donations:
{
"merchantKey": "YOUR_MERCHANT_KEY",
"amount": 25.00,
"currency": "USD",
"transactionType": "sale",
"token": "CARD_TOKEN_FROM_TOKENIZATION",
"orderNumber": "DONATION_OR_ORDER_ID",
"invoiceNumber": "DONATION_OR_ORDER_ID",
"description": "GiveHub Donation",
"customer": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phone": "5555551212"
},
"billingAddress": {
"address1": "123 Main St",
"address2": "",
"city": "Acworth",
"state": "GA",
"postalCode": "30101",
"countryCode": "US"
},
"metadata": {
"orgId": "ORG_ID",
"donationId": "DONATION_ID",
"source": "GiveHub"
}
}
If Swagger’s “Try it out” shows different required field names, please align the implementation to the exact OpenAPI schema for:
-
POST /Authenticate
-
POST /merchants/{merchantKey}/tokens/cards
-
POST /v2/transactions/bcp
But the flow itself should remain:
-
Authenticate
-
Tokenize
-
Charge with /v2/transactions/bcp
==================================================
6) one more important note
If the current Leap environment still throws “fetch failed” before any HTTP status comes back, then the issue is outbound connectivity/TLS/DNS from the hosted runtime, not the Syntch credentials or payload.
Please explicitly distinguish:
-
network failure before response
vs -
HTTP error response from Syntch
I want the UI/backend logs to say which one happened.
==================================================
7) acceptance criteria
This is complete only when:
-
Syntch sandbox auth succeeds from Leap backend
-
card tokenization succeeds
-
/v2/transactions/bcp is actually called
-
donation is only marked paid on approved response
-
real transaction ID is stored
-
failure messages are returned cleanly
-
no fake txn_pending_* IDs remain in the Syntch flow
And if you want, paste this underneath it too:
```text
One more thing: after you implement it, please also add a temporary debug endpoint or script to test these 3 steps independently:
1. authenticate
2. tokenize
3. charge
That will make Syntch certification much faster.
