Adding Booking Deposits to a Multi-Tenant SaaS with PayPal, Square, and Stripe - Including Apple Pay

Dmytro Bondarchuk|May 9, 2026|24 min read|No comments

Timeli.sh lets service businesses collect deposits, full prepayments, and in-person payments at the time of booking. Adding that feature meant integrating three payment processors - PayPal, Square, and Stripe - behind a single abstraction so each organization could connect whichever processor they already use. I also needed Apple Pay to work through both PayPal and Square, which turned out to be the most involved part of the whole thing.

This post covers the architecture, the per-processor specifics, and a detailed breakdown of how Apple Pay was added to each one.


The architecture: a pluggable payment processor interface

Timeli.sh uses an app store model where every external integration - email, SMS, calendar, payments - is a connected app. Each app implements a typed interface, and the booking system talks to whatever payment app the organization has installed.

For payment processors, that interface looks roughly like this:

// packages/types/src/apps/payment/index.ts
export interface IPaymentProcessor {
createPaymentIntent(params: CreatePaymentIntentParams): Promise<PaymentIntent>;
capturePayment(intentId: string): Promise<Payment>;
refundPayment(paymentId: string, amount?: number): Promise<Refund>;
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
// Optional - only implemented by processors that support Apple Pay
getApplePayDomainAssociation?(app: ConnectedApp): Promise<string | null>;
}
TypeScript

The getApplePayDomainAssociation method is optional on purpose. Not every processor supports Apple Pay, and the routing layer that serves /.well-known/apple-developer-merchantid-domain-association checks for the method's existence before calling it:

// apps/web/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts
export async function GET() {
const servicesContainer = await getServicesContainer();
const { defaultApps } =
await servicesContainer.configurationService.getConfigurations("defaultApps");
const paymentAppId = defaultApps?.paymentAppId;
if (!paymentAppId) {
return new NextResponse(null, { status: 404 });
}
const { app, service } =
await servicesContainer.connectedAppsService.getAppService<IPaymentProcessor>(
paymentAppId,
);
if (!service.getApplePayDomainAssociation) {
return new NextResponse(null, { status: 404 });
}
const content = await service.getApplePayDomainAssociation(app);
if (!content) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(content, {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
TypeScript

This means the /.well-known/ route is always registered - it's a static Next.js route - but it dynamically delegates to whichever payment app is configured. If the organization is using Stripe (which doesn't go through this route for Apple Pay), it returns 404. If they're using PayPal or Square, the processor-specific file is served.


PayPal

PayPal's JavaScript SDK supports Apple Pay natively through its paypal.Buttons and paypal.ApplePay APIs. The overall booking payment flow uses PayPal's hosted card fields for card payments and hooks into ApplePaySession for Apple Pay.

Setting up PayPal orders

For card payments and standard checkout, I create a PayPal order server-side when the customer reaches the payment step, then capture it after they authorize:

// packages/app-store/src/apps/paypal/service.ts (simplified)
export class PaypalConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
const client = this.getClient(params.app);
const order = await client.orders.create({
intent: params.captureOnCreate ? "CAPTURE" : "AUTHORIZE",
purchase_units: [
{
amount: {
currency_code: params.currency,
value: params.amount.toFixed(2),
},
description: params.description,
custom_id: params.metadata?.appointmentId,
},
],
});
return {
id: order.id,
status: order.status,
clientSecret: order.id, // PayPal uses orderId as the client reference
};
}
async capturePayment(orderId: string) {
const order = await this.client.orders.capture(orderId);
return this.mapOrderToPayment(order);
}
}
TypeScript

PayPal doesn't have a clientSecret concept like Stripe does - the orderId serves as the client-side reference. The booking form uses the PayPal JS SDK to render the card fields hosted by PayPal and passes the orderId on completion.

Adding Apple Pay to PayPal

Apple Pay on PayPal requires three things that are easy to miss in the docs:

The domain association file must be served at /.well-known/apple-developer-merchantid-domain-association with no redirects and no file extension.

The file content is different for sandbox vs production - PayPal generates environment-specific files from their Developer Dashboard.

You have to register each domain manually in the PayPal dashboard before Apple Pay buttons will render on that domain.

Because timeli.sh is multi-tenant and organizations use custom domains, I can't hardcode a domain in PayPal's dashboard. The domain association file just needs to be reachable - Apple fetches it during their verification process when the merchant registers the domain. So the file itself is baked into the app as a constant, and the route serves it on demand.

The sandbox and production files are different hex-encoded blobs:

// packages/app-store/src/apps/paypal/apple-pay.ts
export const APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX =
"7B22..."; // hex-encoded sandbox file from PayPal dashboard
export const APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION =
"7B22..."; // hex-encoded production file from PayPal dashboard
TypeScript

The service selects the right one based on the app's configuration:

// packages/app-store/src/apps/paypal/service.ts
async getApplePayDomainAssociation(app: ConnectedApp): Promise<string | null> {
const config = app.config as PaypalAppConfig;
const hex = config.environment === "production"
? APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION
: APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX;
// Decode hex to binary string for serving
return Buffer.from(hex, "hex").toString("binary");
}
TypeScript

The content type in the route is text/plain rather than application/octet-stream because of how Next.js streams the response - I tested both and text/plain worked reliably while the binary content type triggered decoding issues in some environments. Apple's verifier doesn't actually enforce a specific content type; it cares that the file is there and unmodified.

The Apple Pay session flow on the frontend

On the booking form, I use PayPal's JS SDK to manage the Apple Pay session. The critical steps are: check if Apple Pay is available, initialize the session, handle merchant validation, and complete the payment on authorization.

// packages/app-store/src/apps/paypal/form.tsx (simplified)
const ApplePaySection: React.FC<{ currency: string; amount: number }> = ({
currency,
amount,
}) => {
const orgConfig = useOrgConfig();
const initApplePay = async () => {
const applePayConfig = await window.paypal.Applepay().config();
if (!applePayConfig.isEligible) {
return; // Device or account doesn't support Apple Pay
}
const session = new ApplePaySession(3, {
countryCode: applePayConfig.countryCode,
currencyCode: currency,
merchantCapabilities: applePayConfig.merchantCapabilities,
supportedNetworks: applePayConfig.supportedNetworks,
requiredBillingContactFields: ["postalAddress", "name"],
total: {
label: (orgConfig as any).businessName ?? "Checkout",
type: "final",
amount: amount.toFixed(2),
},
});
session.onvalidatemerchant = async (event) => {
const validationData = await window.paypal
.Applepay()
.validateMerchant({ validationUrl: event.validationURL });
session.completeMerchantValidation(validationData.merchantSession);
};
session.onpaymentauthorized = async (event) => {
const orderId = await createPaypalOrder(amount, currency);
await window.paypal.Applepay().confirmOrder({
orderId,
token: event.payment.token,
billingContact: event.payment.billingContact,
});
session.completePayment(ApplePaySession.STATUS_SUCCESS);
onPaymentComplete(orderId);
};
session.begin();
};
return (
<apple-pay-button
buttonstyle="black"
type="buy"
locale="en-US"
onClick={initApplePay}
/>
);
};
TypeScript

The requiredBillingContactFields: ["postalAddress", "name"] line was added in a follow-up fix. Without it, PayPal's confirmOrder call was failing for some cards because the billing address was missing from the token payload. Apple Pay doesn't send billing contact info unless you explicitly request it in the payment request object - it's opt-in, not default.


Square

Square's Web Payments SDK has Apple Pay support built in at a higher level than PayPal. Rather than managing an ApplePaySession directly, you initialize Square's payments.applePay(paymentRequest) object and it handles the session lifecycle internally. The tradeoff is slightly less control but significantly less boilerplate.

Setting up Square card payments

Square uses a tokenization model: the customer's card details are entered into Square-hosted fields, Square returns a nonce, and I use that nonce server-side to create a payment:

// packages/app-store/src/apps/square/service.ts (simplified)
export class SquareConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
// Square doesn't have a pre-authorization intent like Stripe.
// We create a placeholder and return metadata the client needs.
return {
id: crypto.randomUUID(),
locationId: params.app.config.locationId,
clientSecret: null, // Square uses nonces, not client secrets
};
}
async capturePayment(nonce: string, params: CaptureParams) {
const client = this.getClient(params.app);
const response = await client.paymentsApi.createPayment({
sourceId: nonce,
idempotencyKey: crypto.randomUUID(),
amountMoney: {
amount: BigInt(Math.round(params.amount * 100)),
currency: params.currency as string,
},
note: params.description,
referenceId: params.metadata?.appointmentId,
locationId: params.app.config.locationId,
});
return this.mapPaymentToResult(response.result.payment!);
}
}
TypeScript

Square amounts are in the smallest currency unit as a BigInt, which is different from PayPal (decimal string) and Stripe (integer cents). I wrap all three in the createPaymentIntent / capturePayment interface so the booking form doesn't need to know.

Adding Apple Pay to Square

Square's Apple Pay setup has two parts: domain registration and the frontend SDK integration.

For domain registration, Square has an API endpoint (POST /v2/apple-pay/domains) that handles the verification handshake with Apple. You call it with your domain, Square fetches the domain association file from your /.well-known/ endpoint, and Apple marks the domain as verified. The domain association file that Square expects at /.well-known/ is Square's own file, which the Web Payments SDK generates and tells you where to download in the developer console.

Because of the same multi-tenant issue as PayPal - organizations use custom domains - I couldn't just put a static file on disk. The Square domain association file is served through the same getApplePayDomainAssociation interface method, with Square's version of the file stored as a constant in the app package:

// packages/app-store/src/apps/square/service.ts
async getApplePayDomainAssociation(app: ConnectedApp): Promise<string | null> {
const config = app.config as SquareAppConfig;
// Square provides one domain association file per environment
return config.environment === "production"
? SQUARE_APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION
: SQUARE_APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX;
}
TypeScript

On the frontend, Square's SDK handles the Apple Pay session once I give it a payment request object:

// packages/app-store/src/apps/square/form.tsx (simplified)
const initializeApplePay = async (payments: Square.Payments, amount: number) => {
const paymentRequest = payments.paymentRequest({
countryCode: "US",
currencyCode: "USD",
requiredBillingContactFields: ["postalAddress"],
total: {
amount: amount.toFixed(2),
label: orgConfig.businessName ?? "Checkout",
},
});
const applePay = await payments.applePay(paymentRequest);
return applePay;
};
// In the payment handler:
const handleApplePay = async () => {
const result = await applePay.tokenize();
if (result.status === "OK") {
await captureSquarePayment(result.token);
}
};
TypeScript

Square's tokenize() call manages the entire ApplePaySession lifecycle - opening the sheet, validating the merchant against Square's endpoint (POST /v2/apple-pay/validate-merchant), and completing the session. All I do is hand it the payment request and wait for the token.

The thing that burned me here was domain registration timing. Square verifies the domain at registration time by fetching the /.well-known/ file immediately. So the file has to be live and reachable before you click "Register" in the Square dashboard - unlike PayPal, where the file just needs to be there when a buyer tries to pay. I had the route deployed but hadn't flushed the CDN cache on the /.well-known/ path, and Square's verifier got a 404. Twenty minutes of debugging before I realized the old cached response was still being served.


Stripe

Stripe's payment integration uses Payment Intents and the Stripe Elements UI library for card entry. The Stripe integration doesn't go through the getApplePayDomainAssociation interface because Stripe handles Apple Pay domain verification differently - through their dashboard and a Stripe-hosted mechanism rather than a manually served file. Apple Pay just works through Stripe's Payment Request Button element once the domain is configured in the Stripe dashboard.

The booking flow with Stripe:

// packages/app-store/src/apps/stripe/service.ts (simplified)
export class StripeConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
const stripe = this.getClient(params.app);
const intent = await stripe.paymentIntents.create({
amount: Math.round(params.amount * 100),
currency: params.currency.toLowerCase(),
capture_method: params.captureOnCreate ? "automatic" : "manual",
description: params.description,
metadata: {
appointmentId: params.metadata?.appointmentId ?? "",
},
});
return {
id: intent.id,
clientSecret: intent.client_secret!,
};
}
async capturePayment(intentId: string) {
const intent = await this.stripe.paymentIntents.capture(intentId);
return this.mapIntentToPayment(intent);
}
}
TypeScript

Stripe's clientSecret is sent to the frontend to initialize Stripe Elements. The Payment Request Button element (which renders an Apple Pay or Google Pay button depending on the device) picks it up from there and handles the rest.


Deposit vs full payment

One of the requirements was supporting deposits - charging a percentage or fixed amount upfront and leaving the rest to be collected at appointment time. All three processors support this, but they model it differently.

PayPal: create the order with intent: "AUTHORIZE" instead of "CAPTURE". The authorization holds the funds without settling them. Capture happens later via the admin dashboard when the appointment is completed.

Square: create the payment with autocomplete: false. Square holds the payment authorization. Complete it later with a separate completePayment call on the payments API.

Stripe: create the PaymentIntent with capture_method: "manual". Capture later with stripe.paymentIntents.capture(intentId).

The abstraction in IPaymentProcessor exposes a captureOnCreate boolean, and each processor maps it to their own mechanism:

export interface CreatePaymentIntentParams {
amount: number;
currency: string;
description: string;
captureOnCreate: boolean; // false = deposit/authorize, true = full immediate charge
metadata?: Record<string, string>;
}
TypeScript

Refunds work the same way - each processor has its own refund API, all mapped to a single refundPayment(paymentId, amount?) call. Partial refunds (for when a deposit is forfeited) pass an explicit amount; full refunds omit it.


The multi-tenant problem: whose credentials?

This is the other major thing the docs don't cover. Standard payment integrations assume you own the Stripe/PayPal/Square account. In a multi-tenant SaaS, each organization connects their own payment account. Money flows from the booking customer directly to the organization's account, not through timeli.sh.

The way I handle this is that each connected app stores the organization's API credentials (encrypted at rest), and every API call uses those credentials rather than any platform-level credentials:

private getClient(app: ConnectedApp): SquareClient {
const config = app.config as SquareAppConfig;
return new SquareClient({
accessToken: config.accessToken, // org's own Square credentials
environment:
config.environment === "production"
? SquareEnvironment.Production
: SquareEnvironment.Sandbox,
});
}
TypeScript

This means timeli.sh is never in the payment flow at all. There's no platform fee collected through the payment processor at the app level. Billing for the timeli.sh subscription (via Polar) is completely separate from billing for the organization's actual bookings (via Square/PayPal/Stripe).


What I would do differently

Square before PayPal for Apple Pay. Square's Web Payments SDK abstracts Apple Pay far more cleanly. If I had started with Square, I would have understood the session lifecycle better before diving into PayPal's lower-level implementation.

Cache-busting the /.well-known/ route from the start. Both PayPal and Square make live HTTP requests to this file during verification. Any CDN caching on that path will burn you. The Next.js route should include explicit no-cache headers:

return new NextResponse(content, {
headers: {
"Content-Type": "text/plain",
"Cache-Control": "no-store",
},
});
TypeScript

Testing Apple Pay earlier in the dev cycle. Apple Pay can't be tested on localhost or over HTTP. Both processors require HTTPS and a real (or tunneled) domain. I set up an ngrok tunnel for local testing, but it would have saved time to have that in place from day one rather than discovering it when I first tried to test an end-to-end payment.


The relevant commits

The core payment app implementation landed in commit 51b2e75, which includes Square, Stripe, the IPaymentProcessor interface, webhooks, and the billing portal.

Apple Pay for PayPal was added in commit ab02669 and fixed in c48ce5d (splitting the sandbox/production files into separate constants) and a4f0dd6 (adding requiredBillingContactFields to the payment request).

All payment app code lives in packages/app-store/src/apps/ in the timeli.sh repository.Adding Booking Deposits to a Multi-Tenant SaaS with PayPal, Square, and Stripe - Including Apple Pay

Timeli.sh lets service businesses collect deposits, full prepayments, and in-person payments at the time of booking. Adding that feature meant integrating three payment processors - PayPal, Square, and Stripe - behind a single abstraction so each organization could connect whichever processor they already use. I also needed Apple Pay to work through both PayPal and Square, which turned out to be the most involved part of the whole thing.

This post covers the architecture, the per-processor specifics, and a detailed breakdown of how Apple Pay was added to each one.


The architecture: a pluggable payment processor interface

Timeli.sh uses an app store model where every external integration - email, SMS, calendar, payments - is a connected app. Each app implements a typed interface, and the booking system talks to whatever payment app the organization has installed.

For payment processors, that interface looks roughly like this:

// packages/types/src/apps/payment/index.ts
export interface IPaymentProcessor {
createPaymentIntent(params: CreatePaymentIntentParams): Promise<PaymentIntent>;
capturePayment(intentId: string): Promise<Payment>;
refundPayment(paymentId: string, amount?: number): Promise<Refund>;
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
// Optional - only implemented by processors that support Apple Pay
getApplePayDomainAssociation?(app: ConnectedApp): Promise<string | null>;
}
TypeScript

The getApplePayDomainAssociation method is optional on purpose. Not every processor supports Apple Pay, and the routing layer that serves /.well-known/apple-developer-merchantid-domain-association checks for the method's existence before calling it:

// apps/web/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts
export async function GET() {
const servicesContainer = await getServicesContainer();
const { defaultApps } =
await servicesContainer.configurationService.getConfigurations("defaultApps");
const paymentAppId = defaultApps?.paymentAppId;
if (!paymentAppId) {
return new NextResponse(null, { status: 404 });
}
const { app, service } =
await servicesContainer.connectedAppsService.getAppService<IPaymentProcessor>(
paymentAppId,
);
if (!service.getApplePayDomainAssociation) {
return new NextResponse(null, { status: 404 });
}
const content = await service.getApplePayDomainAssociation(app);
if (!content) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(content, {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
TypeScript

This means the /.well-known/ route is always registered - it's a static Next.js route - but it dynamically delegates to whichever payment app is configured. If the organization is using Stripe (which doesn't go through this route for Apple Pay), it returns 404. If they're using PayPal or Square, the processor-specific file is served.


PayPal

PayPal's JavaScript SDK supports Apple Pay natively through its paypal.Buttons and paypal.ApplePay APIs. The overall booking payment flow uses PayPal's hosted card fields for card payments and hooks into ApplePaySession for Apple Pay.

Setting up PayPal orders

For card payments and standard checkout, I create a PayPal order server-side when the customer reaches the payment step, then capture it after they authorize:

// packages/app-store/src/apps/paypal/service.ts (simplified)
export class PaypalConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
const client = this.getClient(params.app);
const order = await client.orders.create({
intent: params.captureOnCreate ? "CAPTURE" : "AUTHORIZE",
purchase_units: [
{
amount: {
currency_code: params.currency,
value: params.amount.toFixed(2),
},
description: params.description,
custom_id: params.metadata?.appointmentId,
},
],
});
return {
id: order.id,
status: order.status,
clientSecret: order.id, // PayPal uses orderId as the client reference
};
}
async capturePayment(orderId: string) {
const order = await this.client.orders.capture(orderId);
return this.mapOrderToPayment(order);
}
}
TypeScript

PayPal doesn't have a clientSecret concept like Stripe does - the orderId serves as the client-side reference. The booking form uses the PayPal JS SDK to render the card fields hosted by PayPal and passes the orderId on completion.

Adding Apple Pay to PayPal

Apple Pay on PayPal requires three things that are easy to miss in the docs:

The domain association file must be served at /.well-known/apple-developer-merchantid-domain-association with no redirects and no file extension.

The file content is different for sandbox vs production - PayPal generates environment-specific files from their Developer Dashboard.

You have to register each domain manually in the PayPal dashboard before Apple Pay buttons will render on that domain.

Because timeli.sh is multi-tenant and organizations use custom domains, I can't hardcode a domain in PayPal's dashboard. The domain association file just needs to be reachable - Apple fetches it during their verification process when the merchant registers the domain. So the file itself is baked into the app as a constant, and the route serves it on demand.

The sandbox and production files are different hex-encoded blobs:

// packages/app-store/src/apps/paypal/apple-pay.ts
export const APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX =
"7B22..."; // hex-encoded sandbox file from PayPal dashboard
export const APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION =
"7B22..."; // hex-encoded production file from PayPal dashboard
TypeScript

The service selects the right one based on the app's configuration:

// packages/app-store/src/apps/paypal/service.ts
async getApplePayDomainAssociation(app: ConnectedApp): Promise<string | null> {
const config = app.config as PaypalAppConfig;
const hex = config.environment === "production"
? APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION
: APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX;
// Decode hex to binary string for serving
return Buffer.from(hex, "hex").toString("binary");
}
TypeScript

The content type in the route is text/plain rather than application/octet-stream because of how Next.js streams the response - I tested both and text/plain worked reliably while the binary content type triggered decoding issues in some environments. Apple's verifier doesn't actually enforce a specific content type; it cares that the file is there and unmodified.

The Apple Pay session flow on the frontend

On the booking form, I use PayPal's JS SDK to manage the Apple Pay session. The critical steps are: check if Apple Pay is available, initialize the session, handle merchant validation, and complete the payment on authorization.

// packages/app-store/src/apps/paypal/form.tsx (simplified)
const ApplePaySection: React.FC<{ currency: string; amount: number }> = ({
currency,
amount,
}) => {
const orgConfig = useOrgConfig();
const initApplePay = async () => {
const applePayConfig = await window.paypal.Applepay().config();
if (!applePayConfig.isEligible) {
return; // Device or account doesn't support Apple Pay
}
const session = new ApplePaySession(3, {
countryCode: applePayConfig.countryCode,
currencyCode: currency,
merchantCapabilities: applePayConfig.merchantCapabilities,
supportedNetworks: applePayConfig.supportedNetworks,
requiredBillingContactFields: ["postalAddress", "name"],
total: {
label: (orgConfig as any).businessName ?? "Checkout",
type: "final",
amount: amount.toFixed(2),
},
});
session.onvalidatemerchant = async (event) => {
const validationData = await window.paypal
.Applepay()
.validateMerchant({ validationUrl: event.validationURL });
session.completeMerchantValidation(validationData.merchantSession);
};
session.onpaymentauthorized = async (event) => {
const orderId = await createPaypalOrder(amount, currency);
await window.paypal.Applepay().confirmOrder({
orderId,
token: event.payment.token,
billingContact: event.payment.billingContact,
});
session.completePayment(ApplePaySession.STATUS_SUCCESS);
onPaymentComplete(orderId);
};
session.begin();
};
return (
<apple-pay-button
buttonstyle="black"
type="buy"
locale="en-US"
onClick={initApplePay}
/>
);
};
TypeScript

The requiredBillingContactFields: ["postalAddress", "name"] line was added in a follow-up fix. Without it, PayPal's confirmOrder call was failing for some cards because the billing address was missing from the token payload. Apple Pay doesn't send billing contact info unless you explicitly request it in the payment request object - it's opt-in, not default.


Square

Square's Web Payments SDK has Apple Pay support built in at a higher level than PayPal. Rather than managing an ApplePaySession directly, you initialize Square's payments.applePay(paymentRequest) object and it handles the session lifecycle internally. The tradeoff is slightly less control but significantly less boilerplate.

Setting up Square card payments

Square uses a tokenization model: the customer's card details are entered into Square-hosted fields, Square returns a nonce, and I use that nonce server-side to create a payment:

// packages/app-store/src/apps/square/service.ts (simplified)
export class SquareConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
// Square doesn't have a pre-authorization intent like Stripe.
// We create a placeholder and return metadata the client needs.
return {
id: crypto.randomUUID(),
locationId: params.app.config.locationId,
clientSecret: null, // Square uses nonces, not client secrets
};
}
async capturePayment(nonce: string, params: CaptureParams) {
const client = this.getClient(params.app);
const response = await client.paymentsApi.createPayment({
sourceId: nonce,
idempotencyKey: crypto.randomUUID(),
amountMoney: {
amount: BigInt(Math.round(params.amount * 100)),
currency: params.currency as string,
},
note: params.description,
referenceId: params.metadata?.appointmentId,
locationId: params.app.config.locationId,
});
return this.mapPaymentToResult(response.result.payment!);
}
}
TypeScript

Square amounts are in the smallest currency unit as a BigInt, which is different from PayPal (decimal string) and Stripe (integer cents). I wrap all three in the createPaymentIntent / capturePayment interface so the booking form doesn't need to know.

Adding Apple Pay to Square

Square's Apple Pay setup has two parts: domain registration and the frontend SDK integration.

For domain registration, Square has an API endpoint (POST /v2/apple-pay/domains) that handles the verification handshake with Apple. You call it with your domain, Square fetches the domain association file from your /.well-known/ endpoint, and Apple marks the domain as verified. The domain association file that Square expects at /.well-known/ is Square's own file, which the Web Payments SDK generates and tells you where to download in the developer console.

Because of the same multi-tenant issue as PayPal - organizations use custom domains - I couldn't just put a static file on disk. The Square domain association file is served through the same getApplePayDomainAssociation interface method, with Square's version of the file stored as a constant in the app package:

// packages/app-store/src/apps/square/service.ts
async getApplePayDomainAssociation(app: ConnectedApp): Promise<string | null> {
const config = app.config as SquareAppConfig;
// Square provides one domain association file per environment
return config.environment === "production"
? SQUARE_APPLE_PAY_DOMAIN_ASSOCIATION_PRODUCTION
: SQUARE_APPLE_PAY_DOMAIN_ASSOCIATION_SANDBOX;
}
TypeScript

On the frontend, Square's SDK handles the Apple Pay session once I give it a payment request object:

// packages/app-store/src/apps/square/form.tsx (simplified)
const initializeApplePay = async (payments: Square.Payments, amount: number) => {
const paymentRequest = payments.paymentRequest({
countryCode: "US",
currencyCode: "USD",
requiredBillingContactFields: ["postalAddress"],
total: {
amount: amount.toFixed(2),
label: orgConfig.businessName ?? "Checkout",
},
});
const applePay = await payments.applePay(paymentRequest);
return applePay;
};
// In the payment handler:
const handleApplePay = async () => {
const result = await applePay.tokenize();
if (result.status === "OK") {
await captureSquarePayment(result.token);
}
};
TypeScript

Square's tokenize() call manages the entire ApplePaySession lifecycle - opening the sheet, validating the merchant against Square's endpoint (POST /v2/apple-pay/validate-merchant), and completing the session. All I do is hand it the payment request and wait for the token.

The thing that burned me here was domain registration timing. Square verifies the domain at registration time by fetching the /.well-known/ file immediately. So the file has to be live and reachable before you click "Register" in the Square dashboard - unlike PayPal, where the file just needs to be there when a buyer tries to pay. I had the route deployed but hadn't flushed the CDN cache on the /.well-known/ path, and Square's verifier got a 404. Twenty minutes of debugging before I realized the old cached response was still being served.


Stripe

Stripe's payment integration uses Payment Intents and the Stripe Elements UI library for card entry. The Stripe integration doesn't go through the getApplePayDomainAssociation interface because Stripe handles Apple Pay domain verification differently - through their dashboard and a Stripe-hosted mechanism rather than a manually served file. Apple Pay just works through Stripe's Payment Request Button element once the domain is configured in the Stripe dashboard.

The booking flow with Stripe:

// packages/app-store/src/apps/stripe/service.ts (simplified)
export class StripeConnectedApp implements IPaymentProcessor {
async createPaymentIntent(params: CreatePaymentIntentParams) {
const stripe = this.getClient(params.app);
const intent = await stripe.paymentIntents.create({
amount: Math.round(params.amount * 100),
currency: params.currency.toLowerCase(),
capture_method: params.captureOnCreate ? "automatic" : "manual",
description: params.description,
metadata: {
appointmentId: params.metadata?.appointmentId ?? "",
},
});
return {
id: intent.id,
clientSecret: intent.client_secret!,
};
}
async capturePayment(intentId: string) {
const intent = await this.stripe.paymentIntents.capture(intentId);
return this.mapIntentToPayment(intent);
}
}
TypeScript

Stripe's clientSecret is sent to the frontend to initialize Stripe Elements. The Payment Request Button element (which renders an Apple Pay or Google Pay button depending on the device) picks it up from there and handles the rest.


Deposit vs full payment

One of the requirements was supporting deposits - charging a percentage or fixed amount upfront and leaving the rest to be collected at appointment time. All three processors support this, but they model it differently.

PayPal: create the order with intent: "AUTHORIZE" instead of "CAPTURE". The authorization holds the funds without settling them. Capture happens later via the admin dashboard when the appointment is completed.

Square: create the payment with autocomplete: false. Square holds the payment authorization. Complete it later with a separate completePayment call on the payments API.

Stripe: create the PaymentIntent with capture_method: "manual". Capture later with stripe.paymentIntents.capture(intentId).

The abstraction in IPaymentProcessor exposes a captureOnCreate boolean, and each processor maps it to their own mechanism:

export interface CreatePaymentIntentParams {
amount: number;
currency: string;
description: string;
captureOnCreate: boolean; // false = deposit/authorize, true = full immediate charge
metadata?: Record<string, string>;
}
TypeScript

Refunds work the same way - each processor has its own refund API, all mapped to a single refundPayment(paymentId, amount?) call. Partial refunds (for when a deposit is forfeited) pass an explicit amount; full refunds omit it.


The multi-tenant problem: whose credentials?

This is the other major thing the docs don't cover. Standard payment integrations assume you own the Stripe/PayPal/Square account. In a multi-tenant SaaS, each organization connects their own payment account. Money flows from the booking customer directly to the organization's account, not through timeli.sh.

The way I handle this is that each connected app stores the organization's API credentials (encrypted at rest), and every API call uses those credentials rather than any platform-level credentials:

private getClient(app: ConnectedApp): SquareClient {
const config = app.config as SquareAppConfig;
return new SquareClient({
accessToken: config.accessToken, // org's own Square credentials
environment:
config.environment === "production"
? SquareEnvironment.Production
: SquareEnvironment.Sandbox,
});
}
TypeScript

This means timeli.sh is never in the payment flow at all. There's no platform fee collected through the payment processor at the app level. Billing for the timeli.sh subscription (via Polar) is completely separate from billing for the organization's actual bookings (via Square/PayPal/Stripe).


What I would do differently

Square before PayPal for Apple Pay. Square's Web Payments SDK abstracts Apple Pay far more cleanly. If I had started with Square, I would have understood the session lifecycle better before diving into PayPal's lower-level implementation.

Cache-busting the /.well-known/ route from the start. Both PayPal and Square make live HTTP requests to this file during verification. Any CDN caching on that path will burn you. The Next.js route should include explicit no-cache headers:

return new NextResponse(content, {
headers: {
"Content-Type": "text/plain",
"Cache-Control": "no-store",
},
});
TypeScript

Testing Apple Pay earlier in the dev cycle. Apple Pay can't be tested on localhost or over HTTP. Both processors require HTTPS and a real (or tunneled) domain. I set up an ngrok tunnel for local testing, but it would have saved time to have that in place from day one rather than discovering it when I first tried to test an end-to-end payment.

blogposttimeli.shpaypalsquarestripepaymentsapple pay

Comments

No comments yet. Be the first to comment.

Add comment

Contact me

Email

dmytro@bondarchuk.me
© 2026 Dmytro BondarchukCreated usingTimeli.sh