supabase
Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
tipalti-integration-specialist
Tipalti payment integration guide for payee onboarding, payment processing, webhooks, and tax compliance. Use when implementing payment features.
Full skill instructions
Use this skill when implementing Tipalti payment integrations, including payee onboarding, payment processing, webhooks, and tax compliance for Ballee.
Tipalti is a global payables automation platform. Fever already uses Tipalti heavily for payments.
| Role | Entity | Responsibility |
|---|---|---|
| Payer | Fever | Owns Tipalti account, funds flow from Fever to dancers |
| Orchestrator | Ballee | Integrates with Fever's Tipalti via API credentials |
| Payees | Dancers | Onboard to Fever's Tipalti payee portal via Ballee UI |
Money Flow: Fever → Tipalti → Dancers (Ballee never touches funds)
| Product | Purpose | Use Case in Ballee |
|---|---|---|
| Mass Payments | Pay contractors/freelancers globally | Pay dancers for performances |
| AP Automation | Invoice processing & vendor payments | Receive payments from clients (Fever) |
Key Principle: Ballee stores tipalti_payee_id references only - NO bank account numbers, tax IDs, or sensitive PII. All payment data lives in Tipalti (Fever's account).
# Environment variables required
TIPALTI_API_URL=https://api.sandbox.tipalti.com # Sandbox
TIPALTI_API_URL=https://api.tipalti.com # Production
TIPALTI_PAYER_NAME=ballee
TIPALTI_API_KEY=your_api_key
TIPALTI_API_SECRET=your_api_secret
TIPALTI_WEBHOOK_SECRET=your_webhook_secret
Tipalti uses HMAC-SHA256 for request authentication:
import crypto from 'crypto';
function signRequest(
payerName: string,
payeeId: string,
timestamp: number,
secret: string
): string {
const payload = `${payerName}${payeeId}${timestamp}`;
return crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
For embedded iFrame security, use GetDynamicKey:
interface DynamicKeyResponse {
key: string; // Use to sign query string
token: string; // Include in iFrame URL as ?token=xxx
expiresAt: number;
}
async function getDynamicKey(payeeId: string): Promise<DynamicKeyResponse> {
const timestamp = Math.floor(Date.now() / 1000);
const signature = signRequest(PAYER_NAME, payeeId, timestamp, API_SECRET);
// Call Tipalti GetDynamicKey API
const response = await fetch(`${API_URL}/v9/PayeeFunctions.asmx/GetDynamicKey`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
payerName: PAYER_NAME,
idap: payeeId,
timestamp,
key: signature
})
});
return response.json();
}
| Method | Best For | Implementation |
|---|---|---|
| iFrame | User self-service | Embed Tipalti's hosted UI |
| API | Backend automation | UpdateOrCreatePayeeInfo SOAP call |
| CSV Upload | Bulk migration | Tipalti admin portal |
// packages/features/tipalti/src/components/tipalti-iframe.tsx
'use client';
interface TipaltiIframeProps {
payeeId: string;
onComplete: () => void;
height?: number;
}
export function TipaltiIframe({ payeeId, onComplete, height = 600 }: TipaltiIframeProps) {
const [iframeUrl, setIframeUrl] = useState<string | null>(null);
useEffect(() => {
const loadIframe = async () => {
const { token } = await getDynamicKeyAction(payeeId);
const url = new URL(process.env.NEXT_PUBLIC_TIPALTI_IFRAME_URL!);
url.searchParams.set('idap', payeeId);
url.searchParams.set('payer', process.env.NEXT_PUBLIC_TIPALTI_PAYER_NAME!);
url.searchParams.set('token', token);
setIframeUrl(url.toString());
};
loadIframe();
}, [payeeId]);
// Listen for postMessage from Tipalti
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== process.env.NEXT_PUBLIC_TIPALTI_IFRAME_ORIGIN) return;
if (event.data.type === 'TIPALTI_ONBOARDING_COMPLETE') {
onComplete();
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onComplete]);
if (!iframeUrl) return <Spinner />;
return (
<iframe
src={iframeUrl}
width="100%"
height={height}
frameBorder="0"
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
);
}
interface CreatePayeeInput {
email: string;
firstName: string;
lastName: string;
country: string;
externalId: string; // Your user ID
}
async function createPayee(input: CreatePayeeInput): Promise<string> {
const timestamp = Math.floor(Date.now() / 1000);
const idap = `dancer_${input.externalId}`; // Generate Tipalti payee ID
const response = await tipaltiSoapClient.call('UpdateOrCreatePayeeInfo', {
payerName: PAYER_NAME,
idap,
timestamp,
key: signRequest(PAYER_NAME, idap, timestamp, API_SECRET),
skipNulls: 1,
item: {
Email: input.email,
FirstName: input.firstName,
LastName: input.lastName,
Country: input.country,
ExternalId: input.externalId,
PayeeEntityType: 'Individual'
}
});
return idap;
}
Before processing payments, verify payee is "Payable":
type PayeeStatus =
| 'Active' // Can receive payments
| 'Pending' // Onboarding incomplete
| 'RequiresReview' // Manual review needed
| 'Blocked' // Cannot receive payments
| 'Inactive';
async function getPayeeStatus(payeeId: string): Promise<{
status: PayeeStatus;
paymentMethodStatus: string;
taxFormStatus: string;
}> {
const response = await tipaltiClient.call('GetExtendedPayeeDetails', {
payerName: PAYER_NAME,
idap: payeeId,
timestamp: Math.floor(Date.now() / 1000),
key: signRequest(...)
});
return {
status: response.PayeeStatus,
paymentMethodStatus: response.PaymentMethodStatus,
taxFormStatus: response.TaxFormStatus
};
}
interface PaymentInput {
payeeId: string;
amount: number;
currency: string;
invoiceRefNumber: string;
description: string;
}
async function submitPayment(input: PaymentInput): Promise<string> {
// First verify payee is payable
const status = await getPayeeStatus(input.payeeId);
if (status.status !== 'Active') {
throw new Error(`Payee not payable: ${status.status}`);
}
const response = await tipaltiClient.call('ProcessPayments', {
payerName: PAYER_NAME,
timestamp: Math.floor(Date.now() / 1000),
key: signRequest(...),
payments: [{
Idap: input.payeeId,
Amount: input.amount,
Currency: input.currency,
RefCode: input.invoiceRefNumber,
Description: input.description
}]
});
return response.PaymentId;
}
| Status | Description | Action |
|---|---|---|
Submitted | Payment sent to Tipalti | Wait for processing |
Processing | Being processed | Monitor via webhook |
Completed | Successfully paid | Update invoice as paid |
Failed | Payment failed | Check error, retry or notify |
Cancelled | Cancelled before processing | Mark as cancelled |
| Event Type | Trigger | Use Case |
|---|---|---|
payee.onboarding.completed | Payee finishes iFrame setup | Mark dancer as payment-ready |
payee.payment_method.updated | Bank details changed | Audit log |
payment.status.changed | Payment status update | Sync invoice status |
payment.completed | Payment successful | Mark invoice as paid |
payment.failed | Payment failed | Alert admin, retry |
tax_form.submitted | W9/W8-BEN submitted | Update tax status |
tax_form.approved | Tax form validated | Clear for payments |
// apps/web/app/api/webhooks/tipalti/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(request: Request) {
const signature = request.headers.get('X-Tipalti-Signature');
const body = await request.text();
// Verify signature
const expectedSignature = crypto
.createHmac('sha256', process.env.TIPALTI_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expectedSignature)
)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(body);
// Store event for audit
await supabase.from('tipalti_webhook_events').insert({
event_type: payload.eventType,
payload
});
// Process event
switch (payload.eventType) {
case 'payment.completed':
await handlePaymentCompleted(payload);
break;
case 'payment.failed':
await handlePaymentFailed(payload);
break;
case 'payee.onboarding.completed':
await handleOnboardingComplete(payload);
break;
}
return NextResponse.json({ received: true });
}
async function handlePaymentCompleted(payload: any) {
// Update payment request
await supabase
.from('tipalti_payment_requests')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
tipalti_metadata: payload
})
.eq('tipalti_payment_id', payload.paymentId);
// Update invoice
const { data: paymentRequest } = await supabase
.from('tipalti_payment_requests')
.select('invoice_id')
.eq('tipalti_payment_id', payload.paymentId)
.single();
if (paymentRequest) {
await supabase
.from('invoices')
.update({
payment_status: 'paid',
paid_at: new Date().toISOString()
})
.eq('id', paymentRequest.invoice_id);
}
}
Tipalti can auto-generate invoices for payees (dancers), reducing admin work:
interface SelfBillingInvoice {
payeeId: string;
invoiceNumber: string;
invoiceDate: string;
dueDate: string;
amount: number;
currency: string;
lineItems: Array<{
description: string;
quantity: number;
unitPrice: number;
}>;
}
async function createSelfBillingInvoice(invoice: SelfBillingInvoice): Promise<string> {
const response = await tipaltiClient.call('CreateOrUpdateInvoices', {
payerName: PAYER_NAME,
timestamp: Math.floor(Date.now() / 1000),
key: signRequest(...),
invoices: [{
Idap: invoice.payeeId,
InvoiceRefCode: invoice.invoiceNumber,
InvoiceDate: invoice.invoiceDate,
InvoiceDueDate: invoice.dueDate,
TotalAmount: invoice.amount,
Currency: invoice.currency,
LineItems: invoice.lineItems.map(li => ({
Description: li.description,
Quantity: li.quantity,
UnitPrice: li.unitPrice
}))
}]
});
return response.InvoiceId;
}
| Form | For | Countries |
|---|---|---|
| W-9 | US persons | USA |
| W-8BEN | Non-US individuals | International |
| W-8BEN-E | Non-US entities | International |
tax_form.submitted → tax_form.approvedTipalti handles year-end 1099 generation for US payees:
Tipalti handles currency conversion automatically:
// Pay a French dancer in EUR
await submitPayment({
payeeId: 'dancer_123',
amount: 500,
currency: 'EUR', // Dancer receives EUR
invoiceRefNumber: 'INV-2024-001',
description: 'Performance fee - Swan Lake Dec 15'
});
Supported currencies: USD, EUR, GBP, CAD, AUD, CHF, and 100+ more
Exchange rate locking: Rates locked at time of payment submission
CREATE TABLE tipalti_payees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) UNIQUE,
tipalti_payee_id TEXT NOT NULL UNIQUE,
onboarding_status TEXT NOT NULL DEFAULT 'pending'
CHECK (onboarding_status IN ('pending', 'started', 'completed', 'requires_review')),
payment_method_status TEXT DEFAULT 'not_set'
CHECK (payment_method_status IN ('not_set', 'pending', 'verified', 'rejected')),
tax_form_status TEXT DEFAULT 'not_submitted'
CHECK (tax_form_status IN ('not_submitted', 'submitted', 'approved', 'rejected')),
country_code TEXT,
currency TEXT,
last_synced_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tipalti_payment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
tipalti_payee_id TEXT NOT NULL,
tipalti_payment_id TEXT UNIQUE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'EUR',
status TEXT NOT NULL DEFAULT 'pending_approval'
CHECK (status IN (
'pending_approval', 'approved', 'submitted',
'processing', 'paid', 'failed', 'cancelled'
)),
submitted_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
failure_reason TEXT,
tipalti_metadata JSONB DEFAULT '{}'::jsonb,
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
| Error | Cause | Resolution |
|---|---|---|
PAYEE_NOT_FOUND | Invalid payee ID | Check tipalti_payee_id mapping |
PAYEE_NOT_PAYABLE | Onboarding incomplete | Redirect to iFrame |
INVALID_SIGNATURE | Auth failure | Check API keys, timestamp |
INSUFFICIENT_FUNDS | Payer balance low | Add funds to Tipalti account |
DUPLICATE_PAYMENT | RefCode already used | Use unique invoice numbers |
INVALID_CURRENCY | Currency not supported | Check payee's supported currencies |
async function submitPaymentWithRetry(
input: PaymentInput,
maxRetries = 3
): Promise<string> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await submitPayment(input);
} catch (error) {
if (error.code === 'RATE_LIMITED' && attempt < maxRetries) {
await sleep(1000 * attempt); // Exponential backoff
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
TIPALTI_API_URL=https://api.sandbox.tipalti.com
Use prefix test_ for sandbox payees:
test_dancer_001test_dancer_002Handles the full Supabase workflow from schema changes to deployment, with built-in security guardrails that catch common traps like RLS...
Comprehensive guides and best practices for Neon Serverless Postgres, covering setup, connection methods, authentication, and platform APIs.
Guide for setting up and using Firebase Authentication. Use this skill when the user's app requires user sign-in, user management, or secure data access using auth rules.
A skill to evaluate how secure Firestore security rules are. Use this when Firestore security rules are updated to ensure that the generated rules are extremely secure and robust.
Official skill for integrating Firebase AI Logic (Gemini API) into web applications. Covers setup, multimodal inference, structured output, and security.
Complete Better Auth server and client setup with database adapters, session management, plugins, and security configuration.
Deploy and manage projects on Vercel using token-based authentication. Use when working with Vercel CLI using access tokens rather than interactive login — e.g. "deploy to vercel", "set up vercel", "add environment variables to vercel".
Complete Cloudflare platform integration with decision trees for compute, storage, AI, networking, security, and infrastructure-as-code.
Run Azure compliance and security audits with azqr plus Key Vault expiration checks. Covers best-practice assessment, resource review, policy/compliance validation, and security posture checks. WHEN: compliance scan, security audit, BEFORE running azqr (compliance cli tool), Azure best practices, Key Vault expiration check, expired certificates, expiring secrets, orphaned resources, compliance assessment.
Send, read, and manage Gmail messages, drafts, labels, and account settings.
Comprehensive website auditing across 230+ rules in 21 categories including SEO, performance, security, and accessibility.
Shared authentication, CLI syntax, and output formatting patterns for gws Google Workspace commands.
Discover your celebrity doppelgänger with StarByFace!
GeoSpy: Pricing, Features, FAQs, and Alternatives for AI Teams
Detect AI-generated voices to protect against audio fraud.
Ensure Your Content's Originality with AI Plagiarism Checker
Upscale images by 400% without quality loss
Protect your social media from copyright disputes
Accurately Detect AI-Generated Content with TheChecker.AI
Chrome extension that detects and flags AI-generated content