Skip to content

Accept a donation from your own site

If your donate button already works like this, you are one function body away from taking donations through Colombian payment rails.

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

BeforeAfter
Front endPOST /api/donations/checkout → redirect to urlNo change. Zero lines.
Your routeCalls your current provider, returns { url }Calls Vaki, returns { url }
Payment methodsWhatever your provider offersPSE, Nequi, Bre-B, bank transfer, cards
PCI scopeNone (hosted checkout)None (hosted checkout)
Your donation recordCreated up front, reconciled laterUnchanged — 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.

If your donors are Colombian and your current checkout offers 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 PSE, Nequi, Bre-B and local bank transfer alongside cards, priced in COP, settling in Colombia. Same button, more ways to pay, one flat 5% with no separate processing fee charged to the donor.

Before — the shape almost every integration has:

// app/api/donations/checkout/route.js — BEFORE
export 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 — AFTER
const VAKI_API = 'https://api.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.

Poll the link when the donor lands on your success_url.

app/api/donations/[id]/status/route.js
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.

Map problem codes to behaviour once, in one place, rather than checking status codes at each call site.

codeStatusWhat it meansWhat to do
validation_failed400 / 422A field is the wrong shape. Usually a decimal amount.Read errors[], fix the payload. Never retry unchanged.
unauthorized401Missing, wrong, revoked or expired keyPage a human. Do not retry.
forbidden403Key lacks checkout_links:write, or an IP allowlist blocked youAsk Vaki to widen the key or drop the allowlist.
vaki_not_found422 hereThe vaki key does not resolve to a causeYour need→cause mapping is stale.
rate_limit_exceeded429Too many requestsBack off exponentially with jitter and retry.
not_implemented501The route is routed but not implemented yetCheck the changelog. Do not retry.
internal_error500Vaki-side failureRetry 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 idinstance is the request path, so on its own it tells support which route failed but not which call.

A migration checklist you can actually work through:

  • VAKI_API_KEY in your secret manager, not in the repo, not in the client bundle.
  • Route body replaced; front end untouched.
  • Idempotency-Key derived from your donation id, so a double-submit or a route retry cannot create two links.
  • external_reference set to your donation id.
  • chl_… id persisted before you return the URL.
  • callback_url set, even though nothing is delivered to it yet.
  • success_url and cancel_url point at real pages.
  • Polling on success_url plus a background sweep for closed tabs.
  • One end-to-end run against a draft cause, 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, instance in your logs.

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.