Back to 0fee
0fee

Payment Links: Shareable URLs for Any Payment

How 0fee.dev payment links work: shareable URLs, customer data collection, currency conversion, and invoice generation. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 10 min 0fee
payment-linkscheckoutno-code

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.

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 notified

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 here

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

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 invoice

Provider 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 transaction

The 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

  1. 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.
  1. Customer data structure must be rigid from day one. The Session 024 fix for payment_data inconsistency cost us time that should have been avoided with a strict schema upfront.
  1. 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.
  1. 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.
  1. Multi-use links are surprisingly popular. Merchants use them for recurring services, donation pages, and event tickets. The max_uses field 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles