Accept a donation from your own site
If your donate button already works like this, you are one function body away from taking donations on local rails in Colombia and Mexico, and by card from anywhere in the world.
// Your front end today. This does not change.fetch('/api/donations/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, currency, needId, isAnonymous })}) .then(async (r) => { const { url } = await r.json(); window.location.assign(url); }) .catch(() => toast.error('No pudimos abrir el checkout'));That contract — your server mints a hosted checkout session, the browser redirects to it — is the same contract Vaki exposes. So the migration is not an integration project. It is a new body for one route.
What actually changes
Section titled “What actually changes”| Before | After | |
|---|---|---|
| Front end | POST /api/donations/checkout → redirect to url | No change. Zero lines. |
| Your route | Calls your current provider, returns { url } | Calls Vaki, returns { url } |
| Payment methods | Whatever your provider offers | PSE and cards in Colombia, PayPal in Mexico, cards worldwide |
| PCI scope | None (hosted checkout) | None (hosted checkout) |
| Your donation record | Created up front, reconciled later | Unchanged — pass its id as external_reference |
The reason your front end does not change is that Vaki returns a url and
nothing else is required to complete a donation. Keep treating it as opaque and
redirecting to it, and the front end stays untouched through our checkout changes
too.
Why this is worth doing
Section titled “Why this is worth doing”If your donors are in Latin America and your current checkout offers only cards and US consumer rails, a donor without an international card cannot complete the flow at all, and a donor with one pays the cross-border and FX spread. That is not a conversion-optimisation problem, it is a coverage problem: the money never had a path.
Vaki’s checkout offers the local rail alongside cards — PSE for Colombian donors, PayPal for Mexican ones — priced and settled in the cause’s own currency, while a donor anywhere in the world can still give by card. Same button, more ways to pay, one flat 5% with no separate processing fee charged to the donor. See what a donor can pay with for exactly what is live today.
1. Replace the route body
Section titled “1. Replace the route body”Before — the shape almost every integration has:
// app/api/donations/checkout/route.js — BEFOREexport async function POST(req) { const { amount, currency, needId, isAnonymous } = await req.json();
const donation = await db.donations.create({ amount, currency, needId, status: 'pending' });
const session = await provider.checkout.sessions.create({ /* provider-specific fields */ });
return Response.json({ url: session.url, donationId: donation.id });}After — the same route, calling Vaki:
// app/api/donations/checkout/route.js — AFTERconst VAKI_API = 'https://api.vaki.cohttps://public-api-staging.vaki.co/v1';
export async function POST(req) { const { amount, currency, needId, isAnonymous } = await req.json();
// 1. Your own record still comes first. Nothing about that changes. const donation = await db.donations.create({ amount, currency, needId, status: 'pending' });
// 2. Map your need to the Vaki cause it funds. const need = await db.needs.findById(needId);
// 3. One call to Vaki. const res = await fetch(`${VAKI_API}/checkout_links`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.VAKI_API_KEY}`, 'Content-Type': 'application/json', // Your donation id makes the retry of this route safe. 'Idempotency-Key': `donation-${donation.id}` }, body: JSON.stringify({ vaki: need.vakiKey, amount, // integer; COP has no minor unit currency, // 'COP' | 'USD' external_reference: donation.id, // comes back to you on the payment anonymous: Boolean(isAnonymous), success_url: `https://example.org/gracias?d=${donation.id}`, cancel_url: 'https://example.org/donar', // Accepted and stored today; delivery ships next. Set it now. callback_url: 'https://example.org/api/vaki/callback', metadata: { need_id: needId } }) });
if (!res.ok) { const problem = await res.json(); // application/problem+json console.error('vaki_checkout_link_failed', { code: problem.code, // branch on this status: problem.status, instance: problem.instance, // the request path that failed detail: problem.detail // prose; log it, never parse it }); await db.donations.update(donation.id, { status: 'failed' }); return Response.json({ error: 'checkout_unavailable' }, { status: 502 }); }
const link = await res.json();
// 4. Persist Vaki's id. This is the handle you poll and reconcile with. await db.donations.update(donation.id, { vakiCheckoutLinkId: link.id });
// 5. Same response shape as before. return Response.json({ url: link.url, donationId: donation.id });}That is the migration. Steps 1, 2, 4 and 5 are your existing code; step 3 is the new part.
2. Confirm the donation
Section titled “2. Confirm the donation”Poll the link when the donor lands on your success_url.
export async function GET(_req, { params }) { const donation = await db.donations.findById(params.id);
const res = await fetch(`${VAKI_API}/checkout_links/${donation.vakiCheckoutLinkId}`, { headers: { Authorization: `Bearer ${process.env.VAKI_API_KEY}` } });
const link = await res.json();
// 'open' | 'completed' | 'expired' | 'cancelled' if (link.status === 'completed' && donation.status !== 'paid') { await db.donations.update(donation.id, { status: 'paid' }); }
return Response.json({ status: link.status });}A donor can close the tab between paying and being redirected, so the
success_url visit is a hint, not a guarantee. Also sweep links that are still
open after a few minutes with a background job, and stop as soon as a link
reaches a terminal status.
// Worker: settle the stragglers. Runs every couple of minutes.const pending = await db.donations.findMany({ status: 'pending', vakiCheckoutLinkId: { not: null }, createdAt: { gt: hoursAgo(48) } // links expire; don't poll forever});
for (const donation of pending) { const link = await getCheckoutLink(donation.vakiCheckoutLinkId); if (link.status === 'completed') { await db.donations.update(donation.id, { status: 'paid' }); } else if (link.status === 'expired' || link.status === 'cancelled') { await db.donations.update(donation.id, { status: 'abandoned' }); }}When webhook delivery ships you delete this worker and handle
checkout_link.completed at the callback_url you already set. Nothing else
about the integration changes — which is the point of setting callback_url now.
3. Handle the errors that actually happen
Section titled “3. Handle the errors that actually happen”Map problem codes to behaviour once, in one place, rather than checking status
codes at each call site.
code | Status | What it means | What to do |
|---|---|---|---|
validation_failed | 400 / 422 | A field is the wrong shape. Usually a decimal amount. | Read errors[], fix the payload. Never retry unchanged. |
unauthorized | 401 | Missing, wrong, revoked or expired key | Page a human. Do not retry. |
forbidden | 403 | Key lacks checkout_links:write, or an IP allowlist blocked you | Ask Vaki to widen the key or drop the allowlist. |
vaki_not_found | 422 here | The vaki key does not resolve to a cause | Your need→cause mapping is stale. |
rate_limit_exceeded | 429 | Too many requests | Back off exponentially with jitter and retry. |
not_implemented | 501 | The route is routed but not implemented yet | Check the changelog. Do not retry. |
internal_error | 500 | Vaki-side failure | Retry with the same Idempotency-Key. |
Every error body is a problem document. Two habits worth
having from day one: branch on code, never on title, and log instance
together with your own request id — instance is the request path, so on its
own it tells support which route failed but not which call.
4. Ship it
Section titled “4. Ship it”A migration checklist you can actually work through:
-
VAKI_API_KEYin your secret manager, not in the repo, not in the client bundle. - Route body replaced; front end untouched.
-
Idempotency-Keyderived from your donation id, so a double-submit or a route retry cannot create two links. -
external_referenceset to your donation id. -
chl_…id persisted before you return the URL. -
callback_urlset, even though nothing is delivered to it yet. -
success_urlandcancel_urlpoint at real pages. - Polling on
success_urlplus a background sweep for closed tabs. - One end-to-end run against a
draftcause, with a small amount and an email that has never donated on Vaki (why). - Confirmed
status: "completed"and your record flipped to paid. - Error mapping in place,
instancein your logs.
What you keep
Section titled “What you keep”Your donation records, your need or campaign model, your thank-you page, your front end, your analytics. Vaki replaces the payment leg and nothing else — which is why this is reversible. If it does not work for you, the old route body is still in your git history.
- Fees — what 5% covers, and what the donor is charged
- Idempotency — the exact replay semantics
- Rate limits — and what to do about a launch spike
- Known limitations — read before you demo