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 daysConfiguring 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
| Field | Description |
|---|---|
id | Unique identifier — stored in checkout metadata, used for access grant lookup |
name | Display name shown on pricing cards and in emails |
durationDays | How many days of access the package grants |
priceInCents | Price in cents (29900 = R299.00) |
currency | Currency 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:
-
The frontend calls
yoco.checkoutMutations.createSessionwith:packageId— matches anaccessPackages[].idfrom configsuccessUrl,cancelUrl,failureUrl— redirect URLs after checkout
-
The backend action:
- Verifies the user is authenticated
- Looks up the package by ID to get
priceInCentsandcurrency - Creates a checkout via the Yoco SDK with
metadata.clerkUserIdandmetadata.packageId - Returns a
redirectUrlto the Yoco-hosted checkout page
-
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:
- Extract the
webhook-signatureheader - Compute HMAC-SHA256 of the raw request body using the secret
- Compare the computed signature with the header value
- 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
| Event | When | What Happens |
|---|---|---|
payment.succeeded | Customer payment confirmed | Creates yocoCheckouts record → DB trigger grants access |
payment.failed | Payment declined or failed | Creates yocoCheckouts record with status: "failed" |
Access Granting
When a payment.succeeded webhook is processed:
- The handler creates a
yocoCheckoutsrecord with the payment details - A database trigger detects the new record and schedules access granting
- The access granting logic:
- Looks up the
packageIdfrom the checkout metadata - Finds the matching package in
config.accessPackages - Calculates
expiresAt = now + (durationDays * 86_400_000ms) - Creates an
accessGrantsrecord
- Looks up the
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:
| Reminder | When | Email Content |
|---|---|---|
14d | 14 days before expiry | "Your access expires soon" |
7d | 7 days before expiry | "Your access expires in a week" |
1d | 1 day before expiry | "Your access expires tomorrow" |
0d | At 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.