Most Senegalese SMEs integrating Wave into their site copy a docs snippet, drop in an endpoint, and call it done. Three months later, the first double-charge lands: the user clicked twice, the webhook fired twice, the order shipped twice. This guide ships the production version.
TL;DR
- Wave Business webhook is not idempotent by default — you handle dedup server-side.
- Always verify the HMAC signature before writing to the DB.
- Store Wave's
event_idin apayment_eventstable with a unique constraint: second receipt = no-op.- Wave retry policy: 5 attempts over 24h, exponential backoff. Always return 200 OK, even on dedup.
Target architecture
`
[Wave POS / Wave App] → [Wave Backend] → POST /api/webhooks/wave
↓
- Verify HMAC signature
- Parse JSON
- Check event_id in DB → idempotent?
- If new → write payment + commit
- Always respond 200 OK
`
Step 1 — Next.js App Router endpoint
`ts
// app/api/webhooks/wave/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
const WAVE_WEBHOOK_SECRET = process.env.WAVE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('wave-signature') ?? '';
const expected = crypto
.createHmac('sha256', WAVE_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return NextResponse.json({ error: 'invalid_signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
const existing = await prisma.paymentEvent.findUnique({
where: { externalId: event.id },
});
if (existing) return NextResponse.json({ ok: true, deduped: true });
await prisma.$transaction([
prisma.paymentEvent.create({
data: {
externalId: event.id,
provider: 'wave',
type: event.type,
payload: event,
},
}),
prisma.order.update({
where: { id: event.metadata.order_id },
data: { status: 'paid', paidAt: new Date() },
}),
Need a professional website?
Kolonell builds websites that attract clients, optimized for the Sénégalese market. Free quote in 2 minutes.
]);
return NextResponse.json({ ok: true });
}
`
Step 2 — payment_events table (Prisma)
`prisma
model PaymentEvent {
id String @id @default(cuid())
externalId String @unique
provider String
type String
payload Json
receivedAt DateTime @default(now())
@@index([provider, type])
}
`
The @unique constraint on externalId is your safety net: even if app logic lets a duplicate slip, Postgres rejects the insert.
Step 3 — testing without Wave sandbox
Wave does not offer a stable public sandbox in Senegal as of May 2026. Two options:
Option A — local fixture + curl:
`bash
SECRET="your_local_secret"
BODY='{"id":"evt_test_123","type":"merchant.payment_received","amount":15000,"currency":"XOF","metadata":{"order_id":"ord_42"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST http://localhost:3000/api/webhooks/wave \
-H "Content-Type: application/json" \
-H "wave-signature: $SIG" \
-d "$BODY"
`
Option B — ngrok + real Wave test number: create a 100 XOF order, pay, verify DB trace.
Common mistakes (Kolonell audit of 23 sites in 2025)
| # | Mistake | Consequence | Fix |
|---|---|---|---|
| 1 | No HMAC check | Endpoint exposed, fake payments possible | Verify signature first |
| 2 | Idempotency by order_id instead of event_id | Double-handling on init+completed events for same order | Dedup by event_id |
| 3 | 500 response on already-processed event | Wave retries 5×, log spam, alerts broken | Return 200 OK on dedup |
| 4 | Body read after middleware parse | Invalid signature (body mutated) | Read req.text() raw |
| 5 | No DB timeout | Webhook blocks >10s, Wave times out, useless retry | Index DB + short transaction |
FAQ
Q: How long does Wave wait for a response?
A: 10 seconds. Past that, Wave treats the event as failed and retries.
Q: Does Wave webhook send duplicates?
A: Yes, on retry after timeout. That is exactly why idempotency is mandatory.
Q: Do I need a queue (Redis/BullMQ)?
A: For <1000 payments/day, a synchronous endpoint with a Postgres transaction is enough. Beyond that, offload to a queue.
Our standard integration at Kolonell
For a typical e-commerce client (200 orders/month fashion shop), production-ready Wave integration takes 47 minutes:
- 12 min: API routes + payment_events table
- 15 min: HMAC signature + idempotency
- 10 min: fixture + real-order tests
- 10 min: wire into order workflow (confirmation email, status update)
Read our companion Orange Money tutorial →
Mohamed Bah
Fondateur, Kolonell
Passionate about digital and entrepreneurship in Africa, Mohamed has been helping Sénégalese businesses with their digital transformation since 2020. Founder of Kolonell, he believes every SME deserves a professional and accessible online présence.
