Websites11 min read

Wave Business webhook: site integration + idempotency in 47 minutes (2026 tutorial)

Mohamed Bah·Fondateur, Kolonell
May 5, 2026
Share:
Wave Business webhook: site integration + idempotency in 47 minutes (2026 tutorial)

Wave Business webhook: site integration + idempotency in 47 minutes (2026 tutorial)

Websites

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_id in a payment_events table 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)

#MistakeConsequenceFix
1No HMAC checkEndpoint exposed, fake payments possibleVerify signature first
2Idempotency by order_id instead of event_idDouble-handling on init+completed events for same orderDedup by event_id
3500 response on already-processed eventWave retries 5×, log spam, alerts brokenReturn 200 OK on dedup
4Body read after middleware parseInvalid signature (body mutated)Read req.text() raw
5No DB timeoutWebhook blocks >10s, Wave times out, useless retryIndex 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 →

Tags:#Wave#Webhook#Payments#Senegal#Next.js#Tutorial
Share:

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.