Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/paystack-webhook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Paystack webhook

Server-side credit granting for payments where the browser callback
(`POST /api/verify-payment`) never runs — e.g. the buyer pays and then
closes the tab. Previously such credits were only recoverable manually via
Restore; the webhook grants them automatically.

## Endpoint

```
POST https://<deployment-origin>/api/paystack-webhook
Content-Type: application/json
x-paystack-signature: <hex HMAC-SHA512 of the raw request body>
```

- The route is mounted with a raw-body parser **before** `express.json()`,
for this route only, so the exact bytes Paystack signed are verified.
- Signature = `HMAC-SHA512(rawBody, PAYSTACK_SECRET_KEY)`, compared with a
timing-safe compare (`server/secretCompare.js#timingSafeEqualString`).
- Missing or invalid signature → `400`. No `PAYSTACK_SECRET_KEY` configured → `503`.

## Events handled

| Paystack event | Behaviour |
|---|---|
| `charge.success` | Grant (see below) |
| `invoice.payment_succeeded` | Grant (see below) |
| any other event | `200 {ok:true, ignored:true}` — acknowledged so Paystack stops retrying |

For grantable events the handler:

1. Extracts `data.reference` and only accepts ContractCheq references
(`CC-…`, same pattern as `/api/verify-payment`); anything else is
`200 {ok:true, ignored:true}`.
2. **Re-verifies server-side** via the existing `verifyPaystack()` call
(`GET https://api.paystack.co/transaction/verify/<reference>`).
The webhook payload alone is never trusted.
3. Requires `data.status === 'success'`, `amount === PRICES[metadata.product]`
(single R19 / pack3 R39 / pack10 R99, in cents), and `currency === 'ZAR'`;
otherwise `402` and no grant.
4. Calls `store.saveGrant(reference, …)` with the verified customer email,
product, amount and credit count (1 / 3 / 10). `saveGrant` is write-once,
so replays never refill spent credits.
5. Returns `200 {ok:true, granted:true, grantId, product, credits, amount}`.

Transient failures (Paystack verify error, storage unavailable on Vercel)
return `502`/`503` so Paystack retries delivery; permanent validation
failures return `400`/`402`.

## Dedupe

Each delivery is logged as `[paystack-webhook] {"event":…,"eventId":…,"reference":…}`
(event id / reference only — never bodies, emails, or signatures).
An in-memory set of processed event ids (falling back to
`<event>:<reference>`, capped at 1000 entries) short-circuits exact replays
with `200 {ok:true, duplicate:true}`. This is best-effort on serverless
platforms (it does not survive cold starts); durability comes from the
write-once `saveGrant` described above.

## Paystack dashboard setup (manual, once)

> Do not point this at a live dashboard from automated tooling. Steps for a
> human with dashboard access:

1. Paystack Dashboard → **Settings → API Keys & Webhooks** (or
**Developers → Webhooks**, wording varies by dashboard version).
2. **Add a new endpoint** with the webhook URL — the production origin
above, incl. the `/api/paystack-webhook` path. Use the live/test URL
matching the mode (`PAYSTACK_SECRET_KEY`) configured on the deployment.
3. Select events: **`charge.success`** and **`invoice.payment_succeeded`**
(leave others unselected unless needed).
4. Save. Confirm the endpoint shows as active.
5. Make a real (or Paystack test-mode) payment with a `CC-…` reference and
confirm the grant appears without the browser callback — e.g. complete
payment in a private window, close it immediately, then check
`GET /api/credits` with the `x-payment-reference` header, or retry
`/api/verify-payment` (Restore) and confirm the credit count is unchanged
(the webhook already granted it, exactly once).
6. If the endpoint ever shows repeated failures, check server logs for
`[paystack-webhook]` lines and the `Invalid webhook signature` `400`s —
those mean the deployment's `PAYSTACK_SECRET_KEY` does not match the
Paystack environment the endpoint was registered in.

## Local smoke test (no Paystack interaction)

Generate a signature over an arbitrary payload with the test secret and
POST it locally; a wrong-secret signature must yield `400`:

```bash
SECRET=test-only
BODY='{"event":"charge.success","data":{"reference":"CC-local-smoke-0001"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $NF}')
curl -i -X POST http://localhost:3000/api/paystack-webhook \
-H 'Content-Type: application/json' -H "x-paystack-signature: $SIG" -d "$BODY"
```

With a non-matching deployment secret this returns `400`
`Invalid webhook signature`. A matching secret proceeds to server-side
Paystack verification (which will fail/`404` for a fake reference) — that
failure proves the signature check passed and the raw-body path is wired.

## Tests

`server/paystackWebhook.test.js` covers: valid signature grants, exact
replay dedupe, replay past dedupe cannot refill a spent credit, invalid
signature `400`, missing signature `400`, wrong amount `402` with no grant,
unsupported event ignored, and `invoice.payment_succeeded` granting pack3.
57 changes: 56 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const { validatePerspective } = require('./reportContext');
const DEMO_SAMPLES = require('./demoSamples');
const store = require('./store');
const { SITE_ORIGIN, canonicalHost } = require('./siteConfig');
const { isBearerAuthorized } = require('./secretCompare');
const { isBearerAuthorized, timingSafeEqualString } = require('./secretCompare');
const crypto = require('node:crypto');

const PORT = Number(process.env.PORT) || 3000;
const MAX_FILE_MB = process.env.VERCEL ? 4 : (Number(process.env.MAX_FILE_MB) || 12);
Expand All @@ -38,6 +39,9 @@ app.use(canonicalHost);
app.use(helmet({ contentSecurityPolicy: false }));
app.use((_req,res,next)=>{res.set(require('./securityHeaders').securityHeaders());next();});
app.use('/api', (_req,res,next)=>{res.set('Cache-Control','no-store');next();});
// Paystack webhook needs the exact raw bytes for HMAC-SHA512 verification, so
// mount the raw parser for this route only, BEFORE express.json consumes it.
app.use('/api/paystack-webhook', express.raw({ type: 'application/json', limit: '1mb' }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));

Expand Down Expand Up @@ -461,6 +465,57 @@ app.post('/api/verify-payment', payLimiter, async (req,res,next)=> {
} catch (e) { next(e); }
});

// In-memory webhook event dedupe (best-effort on serverless: the write-once
// store.saveGrant below is the durable idempotency guarantee across restarts).
const seenWebhookEvents = new Set();
const MAX_SEEN_WEBHOOK_EVENTS = 1000;

app.post('/api/paystack-webhook', async (req,res,next)=> {
try {
if (!PAYSTACK_SECRET) return res.status(503).json({ error: 'Payments are not configured.' });
const signature = String(req.headers['x-paystack-signature'] || '');
if (!signature) return res.status(400).json({ error: 'Missing webhook signature' });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body || ''));
const expected = crypto.createHmac('sha512', PAYSTACK_SECRET).update(raw).digest('hex');
if (!timingSafeEqualString(signature, expected)) return res.status(400).json({ error: 'Invalid webhook signature' });
let payload;
try { payload = JSON.parse(raw.toString('utf8')); } catch (_) { return res.status(400).json({ error: 'Invalid webhook payload' }); }
const event = payload && typeof payload.event === 'string' ? payload.event : '';
const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {};
const eventId = payload && (payload.id ?? data.id) != null ? String(payload.id ?? data.id) : null;
const rawReference = String(data.reference || data.metadata?.reference || payload?.reference || '');
// Log event id only (never payload bodies, emails, or signatures) for dedupe/audit.
console.log('[paystack-webhook]', JSON.stringify({ event, eventId, reference: rawReference || null }));
if (!['charge.success', 'invoice.payment_succeeded'].includes(event)) {
return res.json({ ok: true, ignored: true, event: event || null });
}
const dedupeKey = eventId || (rawReference ? `${event}:${rawReference}` : null);
if (dedupeKey && seenWebhookEvents.has(dedupeKey)) return res.json({ ok: true, duplicate: true });
if (!/^CC-[A-Za-z0-9-]{10,100}$/.test(rawReference)) {
return res.json({ ok: true, ignored: true, reason: 'non-contractcheck reference' });
}
const payProbe = await storageProbe();
if (!payProbe.writable && process.env.VERCEL) return res.status(503).json({ error: 'Payments are unavailable until report storage is restored. Please try again later.' });
// Never trust the webhook payload alone: re-verify server-side with Paystack.
const body = await verifyPaystack(rawReference);
const verified = body.data || {};
if (verified.status !== 'success') return res.status(402).json({ error: 'Payment not successful' });
const purchased = verified.metadata?.product;
if (!Object.hasOwn(PRICES, purchased) || Number(verified.amount) !== PRICES[purchased] || verified.currency !== 'ZAR') {
return res.status(402).json({ error: 'Payment product mismatch' });
}
const customer = String(verified.customer?.email || '').toLowerCase().trim();
if (!customer) return res.status(402).json({ error: 'Payment customer email missing' });
const grant = await store.saveGrant(rawReference, { email: customer, product: purchased, amount: Number(verified.amount), credits: purchased === 'pack10' ? 10 : purchased === 'pack3' ? 3 : 1 });
if (dedupeKey) {
seenWebhookEvents.add(dedupeKey);
if (seenWebhookEvents.size > MAX_SEEN_WEBHOOK_EVENTS) seenWebhookEvents.delete(seenWebhookEvents.values().next().value);
}
await trackSafely('payment_verified', { product: purchased, amount: Number(verified.amount) });
res.json({ ok: true, granted: true, grantId: rawReference, product: purchased, credits: grant.remaining, amount: Number(verified.amount) });
} catch (e) { next(e); }
});

app.post('/api/analyze', uploadLimiter, upload.single('file'), async (req, res) => {
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
Expand Down
113 changes: 113 additions & 0 deletions server/paystackWebhook.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

for (const key of ['VERCEL', 'BLOB_STORE_ID', 'BLOB_READ_WRITE_TOKEN', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_TABLE']) delete process.env[key];
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-webhook-'));
process.env.PAYSTACK_SECRET_KEY = 'test-webhook-secret';
const app = require('./index');
const store = require('./store');

const SECRET = 'test-webhook-secret';
const sign = (raw) => crypto.createHmac('sha512', SECRET).update(raw).digest('hex');

test('Paystack webhook: HMAC-verified, idempotent grants', async () => {
const server = app.listen(0);
await new Promise((r) => server.once('listening', r));
const base = 'http://127.0.0.1:' + server.address().port;
const realFetch = global.fetch;
// Mutable verified-transaction fixture so the wrong-amount case can diverge.
let verified = {
status: 'success', amount: 1900, currency: 'ZAR',
customer: { email: 'buyer@example.test' }, metadata: { product: 'single' },
};
let verifyCalls = 0;
const post = async (raw, signature) => {
const headers = { 'Content-Type': 'application/json' };
if (signature !== undefined) headers['x-paystack-signature'] = signature;
const r = await realFetch(base + '/api/paystack-webhook', { method: 'POST', headers, body: raw });
return { status: r.status, body: await r.json() };
};
const payloadFor = (reference, event = 'charge.success') => JSON.stringify({
event, data: { reference, amount: 1900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'single' } },
});
try {
global.fetch = async (url, options) => {
if (String(url).startsWith('https://api.paystack.co/')) {
verifyCalls++;
return { ok: true, json: async () => ({ status: true, data: verified }) };
}
return realFetch(url, options);
};

// 1. Valid signature grants credits.
const goodRaw = payloadFor('CC-webhook-grant-0001');
const good = await post(goodRaw, sign(goodRaw));
assert.equal(good.status, 200, JSON.stringify(good.body));
assert.equal(good.body.granted, true);
assert.equal(good.body.credits, 1);
assert.equal(good.body.amount, 1900);
assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 1);

// 2. Replay (same event) is idempotent: duplicate flag, no refill, no extra verify call needed for grant.
const callsBeforeReplay = verifyCalls;
const replay = await post(goodRaw, sign(goodRaw));
assert.equal(replay.status, 200);
assert.equal(replay.body.duplicate, true);
assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 1);
assert.equal(verifyCalls, callsBeforeReplay, 'dedupe should short-circuit before re-verify');

// 2b. Replay past the in-memory dedupe (fresh event id, same reference) still
// cannot refill: spend the credit first, then the write-once saveGrant must
// return the existing grant with remaining=0, not a fresh 1.
await store.useCredit('CC-webhook-grant-0001', 'report-spend-1');
const replay2Raw = JSON.stringify({ event: 'charge.success', id: 999001, data: { reference: 'CC-webhook-grant-0001', amount: 1900, currency: 'ZAR', metadata: { product: 'single' } } });
const replay2 = await post(replay2Raw, sign(replay2Raw));
assert.equal(replay2.status, 200);
assert.equal(replay2.body.granted, true);
assert.equal(replay2.body.credits, 0, 'write-once grant must not refill a spent credit');
assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 0);

// 3. Invalid signature -> 400, no grant.
const badRaw = payloadFor('CC-webhook-badsig-0001');
const bad = await post(badRaw, '0'.repeat(128));
assert.equal(bad.status, 400);
assert.equal(await store.getGrant('CC-webhook-badsig-0001'), null);

// 4. Missing signature -> 400, no grant.
const missing = await post(payloadFor('CC-webhook-nosig-0001'));
assert.equal(missing.status, 400);
assert.equal(await store.getGrant('CC-webhook-nosig-0001'), null);

// 5. Wrong amount (server-side verify disagrees with PRICES) -> 402, no grant.
verified = { ...verified, amount: 100 };
const wrongRaw = payloadFor('CC-webhook-wrongamt-01');
const wrong = await post(wrongRaw, sign(wrongRaw));
assert.equal(wrong.status, 402);
assert.match(wrong.body.error, /product mismatch/);
assert.equal(await store.getGrant('CC-webhook-wrongamt-01'), null);
verified = { status: 'success', amount: 1900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'single' } };

// 6. Unsupported event is acknowledged (200, ignored) so Paystack stops retrying it.
const otherRaw = payloadFor('CC-webhook-other-ev-001', 'transfer.success');
const other = await post(otherRaw, sign(otherRaw));
assert.equal(other.status, 200);
assert.equal(other.body.ignored, true);
assert.equal(await store.getGrant('CC-webhook-other-ev-001'), null);

// 7. invoice.payment_succeeded also grants (pack3 pricing via server verify).
verified = { status: 'success', amount: 3900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'pack3' } };
const invRaw = JSON.stringify({ event: 'invoice.payment_succeeded', data: { reference: 'CC-webhook-invoice-001', amount: 3900, currency: 'ZAR', metadata: { product: 'pack3' } } });
const inv = await post(invRaw, sign(invRaw));
assert.equal(inv.status, 200, JSON.stringify(inv.body));
assert.equal(inv.body.granted, true);
assert.equal(inv.body.credits, 3);
} finally {
global.fetch = realFetch;
server.close();
fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true });
}
});
Loading