YeetCode
Guides

Yoco Pricing & Access Packages

Configure access packages, understand the checkout flow, and manage access expiry for South African payments.

This guide covers how the Yoco access-package system works in depth. For initial setup, see Connect Yoco to Convex.

How Access Packages Work

Unlike subscriptions, Yoco uses a "buy once, access for X days" model. Customers purchase a time-limited access package. When it expires, they buy again.

Customer picks package → Convex creates checkout → Yoco-hosted checkout

                                                   Customer pays (card or EFT)

Yoco sends webhook → Convex verifies (HMAC-SHA256) → Creates yocoCheckouts record

                                                   DB trigger fires

                                              Access granted (accessGrants table)

                                              Reminder emails at 14/7/1/0 days

Configuring Access Packages

Access packages live in config.yaml under the accessPackages key:

accessPackages:
  - id: "3-month"
    name: "3 Month Access"
    durationDays: 90
    priceInCents: 29900       # R299.00
    currency: "ZAR"
  - id: "6-month"
    name: "6 Month Access"
    durationDays: 180
    priceInCents: 49900       # R499.00
    currency: "ZAR"
  - id: "lifetime"
    name: "Lifetime Access"
    durationDays: 36500       # ~100 years
    priceInCents: 99900       # R999.00
    currency: "ZAR"

Field Reference

FieldDescription
idUnique identifier — stored in checkout metadata, used for access grant lookup
nameDisplay name shown on pricing cards and in emails
durationDaysHow many days of access the package grants
priceInCentsPrice in cents (29900 = R299.00)
currencyCurrency code (always "ZAR" for Yoco)

Pricing Card Generation

The marketing site transforms these packages into pricing cards automatically. The transformation logic lives in apps/marketing/components/yoco-pricing/pricing.data.ts and computes:

  • Icons based on duration — clock for short packages, rocket for medium, sparkles for lifetime
  • Descriptions like "Full access for 90 days" or "Pay once, own it forever"
  • Badges — "Best Value" for 6-month, "Forever" for lifetime
  • Feature lists — base features plus extras for higher tiers
  • CTA text — "Get Started" or "Get Lifetime Access"

To customize this logic (e.g., change which package is highlighted or adjust descriptions), edit apps/marketing/components/yoco-pricing/pricing.data.ts.

Checkout Flow

Creating a Checkout Session

When a customer clicks a pricing card:

  1. The frontend calls yoco.checkoutMutations.createSession with:

    • packageId — matches an accessPackages[].id from config
    • successUrl, cancelUrl, failureUrl — redirect URLs after checkout
  2. The backend action:

    • Verifies the user is authenticated
    • Looks up the package by ID to get priceInCents and currency
    • Creates a checkout via the Yoco SDK with metadata.clerkUserId and metadata.packageId
    • Returns a redirectUrl to the Yoco-hosted checkout page
  3. The frontend redirects the customer to Yoco's checkout

Payment Methods

Yoco supports:

  • Card payments — Visa, Mastercard
  • Instant EFT — Direct bank transfer (South African banks)

The payment method is selected by the customer on Yoco's hosted checkout page. Your backend receives the payment method type in the webhook payload.

Webhook Processing

Signature Verification

Yoco signs webhooks with HMAC-SHA256. Your backend verifies the signature using the YOCO_WEBHOOK_SECRET:

  1. Extract the webhook-signature header
  2. Compute HMAC-SHA256 of the raw request body using the secret
  3. Compare the computed signature with the header value
  4. Reject the request if they don't match

This is handled automatically by the webhook handler in apps/convex-backend/yoco/webhookEvents.ts.

Event Types

EventWhenWhat Happens
payment.succeededCustomer payment confirmedCreates yocoCheckouts record → DB trigger grants access
payment.failedPayment declined or failedCreates yocoCheckouts record with status: "failed"

Access Granting

When a payment.succeeded webhook is processed:

  1. The handler creates a yocoCheckouts record with the payment details
  2. A database trigger detects the new record and schedules access granting
  3. The access granting logic:
    • Looks up the packageId from the checkout metadata
    • Finds the matching package in config.accessPackages
    • Calculates expiresAt = now + (durationDays * 86_400_000ms)
    • Creates an accessGrants record

Duration Stacking

If a customer buys again while they still have active access, the new duration is added to their current expiry date, not calculated from today. This rewards early renewal:

Current expiry: March 15
Customer buys 3-month package on March 1
New expiry: June 13 (March 15 + 90 days, not March 1 + 90 days)

Database Schema

Access grants are tracked in the accessGrants table:

accessGrants
├── userId           — Convex user ID
├── packageId        — Which package was purchased (e.g., "6-month")
├── yocoCheckoutId   — Link to the yocoCheckouts record
├── grantedAt        — When access was granted
├── expiresAt        — When access expires
└── remindersSent    — Array of reminder IDs already sent (e.g., ["14d", "7d"])

Checking Access

Backend Query

// apps/convex-backend/yoco/accessQueries.ts
const access = await ctx.runQuery(api.yoco.accessQueries.hasAccess);
// Returns: { hasAccess: boolean, expiresAt?: number, packageId?: string }

Frontend Usage

The Yoco pricing cards check access status to show the right CTA:

const access = useQuery(api.yoco.accessQueries.hasAccess);

if (access?.hasAccess) {
  // Show "Extend Access" (disabled) or current expiry info
} else {
  // Show "Get Started" button that initiates checkout
}

Use this query to gate features behind access — any component can check hasAccess to conditionally render content.

Expiry Reminders

A cron job periodically checks for expiring access and sends reminder emails. Four reminders are sent:

ReminderWhenEmail Content
14d14 days before expiry"Your access expires soon"
7d7 days before expiry"Your access expires in a week"
1d1 day before expiry"Your access expires tomorrow"
0dAt expiry"Your access has expired"

Each reminder is tracked in the remindersSent array on the accessGrants record, preventing duplicate sends. The cron queries for grants expiring within each window and sends emails for any reminder not yet delivered.

Cron Setup

The reminder cron is defined in apps/convex-backend/crons.ts:

crons.interval(
  "check expiring access",
  { hours: 2 },
  internal.yoco.accessReminders.checkExpiringAccess,
  {},
);

It runs every 2 hours. Adjust the interval based on how time-sensitive your reminders need to be.

Email Template

Reminder emails use the access-expiring template from packages/email/emails/access-expiring.tsx. The template uses values from config.yaml (app name, URLs) and includes:

  • Days remaining (or "has expired")
  • Package name
  • Link to purchase again

Yoco-Specific Considerations

South Africa Only

Yoco is a South African payment provider. Your business must be registered in South Africa to use Yoco. Customers pay in ZAR. International customers should use the Polar subscription system instead.

No Customer Portal

Unlike Polar, Yoco doesn't provide a customer portal for managing billing. Customers simply buy a new package when their access expires. The marketing site shows their current access status on the pricing cards.

No Refunds via API

Yoco refunds must be processed through the Yoco Business Portal, not via API. If you need automated refund handling, consider adding a manual review workflow.

Test Mode

Yoco provides test API keys (prefixed with sk_test_). Use these during development. Test card numbers are available in the Yoco developer docs.

On this page