Not every merchant has a developer. Not every payment needs an API integration. Payment links solve both problems: a shareable URL that any merchant can create from the dashboard and send to any customer via WhatsApp, email, SMS, or social media.
The concept is simple. The implementation has more nuance than you might expect.
How Payment Links Work
A payment link is a URL like https://pay.0fee.dev/pl_abc123xyz that, when visited, presents the customer with a checkout page. The merchant configures the amount, currency, and required customer fields when creating the link. The customer fills in their details, selects a payment method, and completes the payment.
Merchant creates link --> Shares URL --> Customer visits
--> Fills details --> Selects payment method --> Pays
--> Transaction created --> Invoice generated --> Merchant notifiedCreating a Payment Link
The creation API accepts the payment parameters and returns a shareable URL:
pythonclass PaymentLinkCreate(BaseModel):
amount: Decimal
currency: str = "USD"
description: Optional[str] = None
payment_reference: Optional[str] = None
# Customer data requirements
collect_name: bool = True
collect_email: bool = True
collect_phone: bool = True
name_required: bool = False
email_required: bool = False
phone_required: bool = True
# Expiry
expires_at: Optional[datetime] = None
max_uses: Optional[int] = None # None = unlimited
# Redirect
success_url: Optional[str] = None
cancel_url: Optional[str] = None
@router.post("/api/payment-links") async def create_payment_link( data: PaymentLinkCreate, app_id: UUID = Depends(get_current_app), db: AsyncSession = Depends(get_db), ): """Create a shareable payment link.""" link_id = generate_link_id() # pl_ + 16 alphanumeric chars BLANK link = PaymentLink( id=link_id, app_id=app_id, amount=data.amount, currency=data.currency, description=data.description, payment_reference=data.payment_reference or f"PL-{link_id[-8:].upper()}", collect_name=data.collect_name, collect_email=data.collect_email, collect_phone=data.collect_phone, name_required=data.name_required, email_required=data.email_required, phone_required=data.phone_required, expires_at=data.expires_at, max_uses=data.max_uses, use_count=0, status="active", success_url=data.success_url, cancel_url=data.cancel_url, ) BLANK db.add(link) await db.commit() BLANK return { "id": link_id, "url": f"https://pay.0fee.dev/{link_id}", "amount": float(data.amount), "currency": data.currency, "status": "active", } ```
Customer Data Collection
The payment link checkout page collects customer information based on the merchant's configuration. Three fields are available: first/last name, email, and phone number.
svelte<script lang="ts">
import { User, Mail, Phone } from 'lucide-svelte';
let { link } = $props<{ link: PaymentLinkData }>();
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let errors = $state<Record<string, string>>({});
function validate(): boolean {
errors = {};
if (link.collect_name && link.name_required) {
if (!firstName.trim()) errors.firstName = 'First name is required';
if (!lastName.trim()) errors.lastName = 'Last name is required';
}
if (link.collect_email && link.email_required) {
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Invalid email address';
}
}
if (link.collect_phone && link.phone_required) {
if (!phone.trim()) {
errors.phone = 'Phone number is required';
}
}
return Object.keys(errors).length === 0;
}
</script>
<form onsubmit|preventDefault={handleSubmit} class="space-y-4">
{#if link.collect_name}
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">
First Name {link.name_required ? '*' : ''}
</label>
<div class="relative">
<User class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
type="text"
bind:value={firstName}
placeholder="John"
class="w-full pl-9 pr-3 py-2 rounded-lg border
{errors.firstName ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}"
/>
</div>
{#if errors.firstName}
<p class="text-red-500 text-xs mt-1">{errors.firstName}</p>
{/if}
</div>
<div>
<label class="block text-sm font-medium mb-1">
Last Name {link.name_required ? '*' : ''}
</label>
<input
type="text"
bind:value={lastName}
placeholder="Doe"
class="w-full px-3 py-2 rounded-lg border
{errors.lastName ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}"
/>
{#if errors.lastName}
<p class="text-red-500 text-xs mt-1">{errors.lastName}</p>
{/if}
</div>
</div>
{/if}
{#if link.collect_email}
<div>
<label class="block text-sm font-medium mb-1">
Email {link.email_required ? '*' : ''}
</label>
<div class="relative">
<Mail class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
type="email"
bind:value={email}
placeholder="[email protected]"
class="w-full pl-9 pr-3 py-2 rounded-lg border
{errors.email ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}"
/>
</div>
{#if errors.email}
<p class="text-red-500 text-xs mt-1">{errors.email}</p>
{/if}
</div>
{/if}
{#if link.collect_phone}
<div>
<label class="block text-sm font-medium mb-1">
Phone {link.phone_required ? '*' : ''}
</label>
<div class="relative">
<Phone class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
type="tel"
bind:value={phone}
placeholder="+225 07 00 00 00"
class="w-full pl-9 pr-3 py-2 rounded-lg border
{errors.phone ? 'border-red-500' : 'border-gray-200 dark:border-gray-700'}"
/>
</div>
{#if errors.phone}
<p class="text-red-500 text-xs mt-1">{errors.phone}</p>
{/if}
</div>
{/if}
</form>The payment_data Structure Fix (Session 024)
In Session 024, we discovered and fixed a critical bug in how customer data was stored. The original implementation scattered customer fields across the transaction record in inconsistent ways:
python# BEFORE (broken): Fields stored inconsistently
transaction.customer_email = data.email # Sometimes here
transaction.metadata["email"] = data.email # Sometimes here
transaction.payment_data = {"phone": data.phone} # And sometimes hereThe fix consolidated everything into a structured payment_data field:
python# AFTER (fixed): All customer data in payment_data
class PaymentData(BaseModel):
"""Structured customer data for a transaction."""
customer_first_name: Optional[str] = None
customer_last_name: Optional[str] = None
customer_email: Optional[str] = None
customer_phone: Optional[str] = None
customer_address: Optional[str] = None
# Payment method details
payment_method: Optional[str] = None
provider: Optional[str] = None
provider_method_code: Optional[str] = None
# Source
source: str = "payment_link" # or "api", "checkout", "add_funds"
# On the transaction model class Transaction(Base): payment_data: Mapped[dict] = mapped_column(JSON, default=dict) ```
This fixed bugs where invoice generation would fail because the customer email was in metadata instead of payment_data, or the customer name was split across two different fields.
Auto-Generated Email for Phone-Only Customers
Many African customers pay via mobile money and provide only a phone number. But invoices and receipts need an email address. We solve this by generating a deterministic email from the phone number:
pythonimport hashlib
def generate_customer_email(phone: str) -> str:
"""Generate a deterministic email from a phone number.
Example: +2250700000000 -> [email protected]
"""
# Normalize phone number
clean_phone = phone.replace(" ", "").replace("-", "").replace("+", "")
# MD5 hash of normalized phone
phone_hash = hashlib.md5(clean_phone.encode()).hexdigest()[:8]
return f"{phone_hash}@mail.0fee.dev"This auto-generated email serves as a placeholder in the system. It is not used for actual email delivery -- that goes through SMS or WhatsApp. But it provides a consistent identifier for linking transactions, invoices, and customer records.
Invoice Creation From Payment Links
When a payment link transaction completes, an invoice is automatically generated:
pythonasync def create_invoice_from_payment_link(
link: PaymentLink,
transaction: Transaction,
db: AsyncSession,
):
"""Auto-generate an invoice when a payment link is paid."""
app = await db.get(App, link.app_id)
app_settings = await get_app_settings(link.app_id, db)
payment_data = transaction.payment_data or {}
# Generate invoice reference using the payment link's reference
reference = await generate_invoice_reference(
payment_reference=link.payment_reference,
app_slug=app.slug,
db=db,
)
customer_email = (
payment_data.get("customer_email")
or generate_customer_email(payment_data.get("customer_phone", ""))
)
invoice = Invoice(
app_id=link.app_id,
transaction_id=transaction.id,
reference=reference,
merchant_name=app_settings.company_name or app.name,
merchant_address=app_settings.company_address,
merchant_email=app_settings.company_email,
merchant_phone=app_settings.company_phone,
merchant_logo_url=app_settings.company_logo_url,
merchant_tax_id=app_settings.tax_id,
customer_name=f"{payment_data.get('customer_first_name', '')} {payment_data.get('customer_last_name', '')}".strip() or None,
customer_email=customer_email,
customer_phone=payment_data.get("customer_phone"),
subtotal=transaction.source_amount,
tax_amount=Decimal("0"),
total=transaction.source_amount,
currency=transaction.source_currency,
items=[
{
"description": link.description or "Payment",
"quantity": 1,
"unit_price": float(transaction.source_amount),
"amount": float(transaction.source_amount),
}
],
status="paid",
issued_at=datetime.utcnow(),
paid_at=transaction.completed_at,
)
db.add(invoice)
await db.commit()
return invoiceProvider Method Code Lookup
When a customer selects a payment method on the payment link checkout, we need to map it to the correct provider method code. This lookup bridges the user-friendly method name to the provider's internal identifier:
pythonasync def resolve_payment_method(
method_slug: str,
country: str,
currency: str,
db: AsyncSession,
) -> dict:
"""Resolve a user-selected method to provider routing info."""
method = await db.execute(
select(PaymentMethod).where(
PaymentMethod.slug == method_slug,
PaymentMethod.country == country,
PaymentMethod.currency == currency,
PaymentMethod.active == True,
)
)
method = method.scalar_one_or_none()
if not method:
raise HTTPException(
status_code=400,
detail=f"Payment method '{method_slug}' not available in {country}"
)
return {
"provider": method.provider,
"method_code": method.provider_method_code,
"method_name": method.display_name,
"min_amount": method.min_amount,
"max_amount": method.max_amount,
}For example, "Orange Money" in Ivory Coast maps to provider cinetpay with method code OM. The same "Orange Money" in Senegal might map to a different provider with a different code. The lookup ensures the correct routing.
Currency Conversion at Payment Time
Payment links store amounts in the merchant's preferred currency, but the customer may pay in a different currency depending on their country and selected payment method:
pythonasync def process_payment_link_checkout(
link_id: str,
customer_data: dict,
payment_method: str,
customer_country: str,
db: AsyncSession,
):
"""Process the checkout for a payment link."""
link = await db.get(PaymentLink, link_id)
if not link or link.status != "active":
raise HTTPException(status_code=404, detail="Payment link not found or expired")
if link.max_uses and link.use_count >= link.max_uses:
raise HTTPException(status_code=400, detail="Payment link usage limit reached")
# Resolve payment method
method_info = await resolve_payment_method(
payment_method, customer_country, get_country_currency(customer_country), db
)
# Convert amount if currencies differ
customer_currency = get_country_currency(customer_country)
if customer_currency != link.currency:
rate = await get_exchange_rate(link.currency, customer_currency)
customer_amount = link.amount * rate
else:
customer_amount = link.amount
# Handle zero-decimal currencies
if customer_currency in ZERO_DECIMAL_CURRENCIES:
customer_amount = int(round(customer_amount))
# Create transaction
transaction = await create_transaction(
app_id=link.app_id,
source_amount=customer_amount,
source_currency=customer_currency,
payment_reference=link.payment_reference,
payment_method=payment_method,
provider=method_info["provider"],
provider_method_code=method_info["method_code"],
payment_data={
"customer_first_name": customer_data.get("first_name"),
"customer_last_name": customer_data.get("last_name"),
"customer_email": customer_data.get("email")
or generate_customer_email(customer_data.get("phone", "")),
"customer_phone": customer_data.get("phone"),
"source": "payment_link",
"payment_link_id": link_id,
},
db=db,
)
# Increment use count
link.use_count += 1
await db.commit()
return transactionThe Dashboard View
Merchants manage payment links from a dedicated section in the dashboard:
svelte<script lang="ts">
import { Link, Copy, ExternalLink, Trash2, ToggleLeft, ToggleRight }
from 'lucide-svelte';
let { links } = $props<{ links: PaymentLink[] }>();
async function copyLink(url: string) {
await navigator.clipboard.writeText(url);
// Show toast notification
}
async function toggleLink(id: string, active: boolean) {
await fetch(`/api/payment-links/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: active ? 'active' : 'inactive' }),
});
}
</script>
<div class="space-y-3">
{#each links as link}
<div class="bg-white dark:bg-gray-800 rounded-lg border
border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Link class="w-5 h-5 text-blue-500" />
<div>
<div class="font-medium">
{link.description || link.payment_reference}
</div>
<div class="text-sm text-gray-500">
{link.currency} {link.amount.toLocaleString()}
-- {link.use_count} payment{link.use_count !== 1 ? 's' : ''}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => copyLink(link.url)}
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
title="Copy link"
>
<Copy class="w-4 h-4" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
title="Open link"
>
<ExternalLink class="w-4 h-4" />
</a>
<button
onclick={() => toggleLink(link.id, link.status !== 'active')}
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
title="{link.status === 'active' ? 'Deactivate' : 'Activate'}"
>
{#if link.status === 'active'}
<ToggleRight class="w-5 h-5 text-green-500" />
{:else}
<ToggleLeft class="w-5 h-5 text-gray-400" />
{/if}
</button>
</div>
</div>
</div>
{/each}
</div>What We Learned
- Payment links are the no-code on-ramp. Merchants who would never integrate an API can start accepting payments within minutes using payment links. It is the lowest-friction path to revenue.
- Customer data structure must be rigid from day one. The Session 024 fix for
payment_datainconsistency cost us time that should have been avoided with a strict schema upfront.
- Phone-only customers are the norm in Africa. The auto-generated email solution is a pragmatic workaround that keeps the system functional without forcing customers to have email addresses.
- Currency conversion at payment time, not at link creation. Rates change daily. A link created on Monday should convert at Tuesday's rate when the customer pays on Tuesday.
- Multi-use links are surprisingly popular. Merchants use them for recurring services, donation pages, and event tickets. The
max_usesfield turned a simple feature into a versatile tool.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.