top of page

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:

  1. Load org payment gateway config

  2. If gateway === syntch:

    • instantiate SyntchGateway

    • tokenize raw card if needed

    • call processPayment()

    • use the real response from /v2/transactions/bcp

  3. Only mark the donation successful if processPayment().success === true

  4. Save:

    • real transactionId

    • authCode

    • cardBrand

    • last4

    • raw gateway response

  5. Remove any placeholder transaction IDs like:

    • txn_pending_TIMESTAMP

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

  1. Authenticate

  2. Tokenize

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

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