惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
Visual Studio Blog
S
Schneier on Security
博客园 - 聂微东
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
月光博客
月光博客
美团技术团队
Last Week in AI
Last Week in AI
罗磊的独立博客
小众软件
小众软件
Hugging Face - Blog
Hugging Face - Blog
大猫的无限游戏
大猫的无限游戏
V
V2EX
博客园_首页
博客园 - 司徒正美
IT之家
IT之家
S
SegmentFault 最新的问题
宝玉的分享
宝玉的分享
雷峰网
雷峰网
量子位
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Tailwind CSS Blog
J
Java Code Geeks
博客园 - Franky
Apple Machine Learning Research
Apple Machine Learning Research
P
Privacy International News Feed
C
CERT Recently Published Vulnerability Notes
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
阮一峰的网络日志
阮一峰的网络日志
The Cloudflare Blog
AWS News Blog
AWS News Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
T
Threat Research - Cisco Blogs
Jina AI
Jina AI
V
Vulnerabilities – Threatpost
酷 壳 – CoolShell
酷 壳 – CoolShell
L
LINUX DO - 最新话题
N
News and Events Feed by Topic
H
Hacker News: Front Page
腾讯CDC
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
WordPress大学
WordPress大学
爱范儿
爱范儿
T
Tor Project blog
博客园 - 【当耐特】
T
The Exploit Database - CXSecurity.com
K
Kaspersky official blog
Latest news
Latest news
博客园 - 叶小钗
Know Your Adversary
Know Your Adversary
有赞技术团队
有赞技术团队

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Afriex Webhook Integration Guide: Signature Verification, Event Handling, and Production Best Practices
Victory Luck · 2026-05-25 · via DEV Community

When you create a transaction through the Afriex Business API, the response you get back is just the start. The transaction comes back with a status of PENDING. What happens after that — whether it moves to PROCESSING, COMPLETED, IN_REVIEW, or FAILED arrives through webhooks.

Most integration bugs in payment systems trace back to webhook handling, not the API calls themselves. Missed signature verification. Handlers that time out. Status updates applied twice. Fields read from parsed JSON instead of the raw body. These are the mistakes that cause payouts to look settled when they are not, or trigger duplicate notifications to your users.

This article covers how Afriex webhooks work, every event the system fires, how to verify signatures correctly, how to build a handler that holds up in production, and how to test locally before you go live.


What Afriex sends and when

Afriex fires a signed HTTP POST to your configured webhook URL whenever a resource changes. Three resource types generate events.

Transaction events are the ones you will interact with most. Every time a transaction is created or its status changes, Afriex fires either TRANSACTION.CREATED or TRANSACTION.UPDATED. Here's the full status vocabulary that a transaction moves through:

Status What it means
PENDING Transaction received, waiting to be processed
PROCESSING Actively being processed
COMPLETED Settled successfully
SUCCESS Alias for a settled transaction
FAILED Failed. Check meta for details
CANCELLED Cancelled before processing started
REFUNDED Funds returned to sender
IN_REVIEW Under manual review
REJECTED Rejected after review
RETRY Being automatically retried by the network
UNKNOWN Status could not be determined. Contact support

Customer events fire when a customer is created (CUSTOMER.CREATED), their details are updated (CUSTOMER.UPDATED), or they are deleted (CUSTOMER.DELETED). These are useful for keeping your local customer records in sync with Afriex.

Payment method events fire on creation (PAYMENT_METHOD.CREATED), update (PAYMENT_METHOD.UPDATED), and deletion (PAYMENT_METHOD.DELETED). If a payment method is deleted on the Afriex side, your application needs to know so it can prompt the user to attach a new one before the next payout.

There is also CHECKOUT_SESSION.CREATED for checkout flows.


Before you go live: allowlist the IP addresses

This step catches developers off guard. Before Afriex can deliver webhooks to your server, your firewall must allow inbound traffic from Afriex's IP addresses. Webhook requests from any other IP should be blocked regardless of whether the signature is valid.

Environment IP Address
Sandbox 34.234.189.210
Production 34.197.33.100

Add these to your firewall or security group allowlist. Without this, Afriex webhook requests will be silently blocked before they reach your handler.


Setting up your endpoint

In your Afriex dashboard, go to Developers then the Webhooks tab. Paste your endpoint URL and save. Your webhook public key is on the same screen — copy it and store it as an environment variable. You will need it for signature verification.

Staging and production use different public keys. Make sure you are using the correct one for each environment.


Signature verification

Every webhook Afriex sends includes an x-webhook-signature header. This is a Base64-encoded RSA-SHA256 signature of the raw request body, signed with Afriex's private key. You verify it using the public key from your dashboard.

Two things to get right here that developers frequently get wrong:

Verify against the raw body, not parsed JSON. If you parse the body to JSON first and then try to verify the signature against the re-serialized string, it will fail. The signature was computed against the exact bytes Afriex sent. Any transformation — even a whitespace difference — breaks it.

Reject the request immediately if verification fails. Do not process the payload. Do not log it as a real event. Return 400 or 401 and stop.

Here is the correct verification implementation:

import crypto from "crypto";

function verifyWebhookSignature(
  signature: string,
  rawBody: string | Buffer,
  publicKey: string
): boolean {
  try {
    const verifier = crypto.createVerify("RSA-SHA256");
    verifier.update(rawBody);
    return verifier.verify(publicKey, signature, "base64");
  } catch {
    return false;
  }
}

Enter fullscreen mode Exit fullscreen mode

In a Next.js API route, you need to read the raw body before any parsing happens:

// src/app/api/webhooks/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-webhook-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  // Read raw body before any parsing
  const rawBody = await req.text();

  const isValid = verifyWebhookSignature(
    signature,
    rawBody,
    process.env.AFRIEX_WEBHOOK_PUBLIC_KEY!
  );

  if (!isValid) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  // handle payload
}

Enter fullscreen mode Exit fullscreen mode

In a Fastify application, you need to preserve the raw body before Fastify's JSON parser consumes it:

// Register this plugin before routes
fastify.addContentTypeParser(
  "application/json",
  { parseAs: "string" },
  (req, body, done) => {
    try {
      const parsed = JSON.parse(body as string);
      // Attach raw string to request for webhook verification
      (req as any).rawBody = body;
      done(null, parsed);
    } catch (err) {
      done(err as Error, undefined);
    }
  }
);

Enter fullscreen mode Exit fullscreen mode


The payload structure

Every Afriex webhook follows the same envelope:

{
  "event": "TRANSACTION.UPDATED",
  "data": { ... }
}

Enter fullscreen mode Exit fullscreen mode

The event field tells you what happened. The data field contains the resource. Here is what each resource type looks like:

Transaction payload

{
  "event": "TRANSACTION.UPDATED",
  "data": {
    "transactionId": "69d60071ab82306f11b03393",
    "status": "COMPLETED",
    "type": "WITHDRAW",
    "sourceAmount": "3.28847",
    "sourceCurrency": "USD",
    "destinationAmount": "5000",
    "destinationCurrency": "NGN",
    "destinationId": "690df3281c11eea59108fcaf",
    "customerId": "69528240ba52c13b669fb239",
    "meta": {
      "reference": "ref-withdraw-001",
      "idempotencyKey": "idem-withdraw-001"
    },
    "createdAt": "2026-04-08T07:14:57.444Z",
    "updatedAt": "2026-04-08T07:15:30.000Z"
  }
}

Enter fullscreen mode Exit fullscreen mode

Customer payload

{
  "event": "CUSTOMER.UPDATED",
  "data": {
    "customerId": "698b0440cba7ec3daee9163d",
    "name": "John Smith",
    "email": "johnsmith@gmail.com",
    "phone": "+2348012345678",
    "countryCode": "NG",
    "meta": {},
    "createdAt": "2026-02-10T10:11:12.415Z",
    "updatedAt": "2026-02-11T15:30:45.123Z"
  }
}

Enter fullscreen mode Exit fullscreen mode

Payment method payload

{
  "event": "PAYMENT_METHOD.DELETED",
  "data": {
    "paymentMethodId": "69f87b0dcc0ee96511560796",
    "channel": "BANK_ACCOUNT",
    "customerId": "6922e4520a53e858ab42efa8",
    "institution": {
      "institutionCode": "058",
      "institutionName": "GTBank"
    },
    "accountName": "John Smith",
    "accountNumber": "1234567890",
    "countryCode": "NG"
  }
}

Enter fullscreen mode Exit fullscreen mode


Building a production-grade handler

A webhook handler has one job: acknowledge receipt fast, then process asynchronously. Afriex expects a 2xx response within about 5 seconds. If your handler does database writes, sends emails, or calls other APIs synchronously before returning, you will hit that window under any real load.

The pattern that holds up:

  1. Verify signature
  2. Return 200 immediately
  3. Push the raw payload to a queue
  4. Process in a background worker
// Lean handler — verify, acknowledge, enqueue
export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-webhook-signature");
  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  const rawBody = await req.text();
  const isValid = verifyWebhookSignature(
    signature,
    rawBody,
    process.env.AFRIEX_WEBHOOK_PUBLIC_KEY!
  );

  if (!isValid) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  // Enqueue for async processing — do not process inline
  await queue.add("webhook", { payload: JSON.parse(rawBody) });

  return NextResponse.json({ received: true });
}

Enter fullscreen mode Exit fullscreen mode

Handle each event correctly

Not every status requires the same response. Here is a decision map for transaction events:

async function handleTransactionEvent(payload: TransactionWebhookPayload) {
  const { transactionId, status } = payload.data;

  // Update your database first
  await updateTransactionStatus(transactionId, status);

  switch (status) {
    case "PROCESSING":
      // Informational — no user-facing action needed
      break;

    case "COMPLETED":
    case "SUCCESS":
      // Terminal success — notify user, update UI state
      await sendPayoutConfirmationEmail(transactionId);
      break;

    case "IN_REVIEW":
      // Compliance hold — notify user that payout is under review
      // Do not mark as failed. Wait for further updates.
      await notifyPayoutUnderReview(transactionId);
      break;

    case "RETRY":
      // Network is retrying automatically — no action needed
      // Do not alarm the user
      break;

    case "FAILED":
    case "REJECTED":
      // Terminal failure — notify user, allow them to retry
      await sendPayoutFailedAlert(transactionId, status);
      break;

    case "UNKNOWN":
      // Indeterminate — log and alert your team to investigate
      await alertTeam(`Transaction ${transactionId} reached UNKNOWN status`);
      break;
  }
}

Enter fullscreen mode Exit fullscreen mode

The IN_REVIEW and RETRY statuses are the ones most developers handle incorrectly. IN_REVIEW is not a failure — it is a compliance hold that will resolve into COMPLETED or REJECTED. If you mark it as failed and notify the user, you will have unhappy users chasing payouts that are actually in progress. RETRY means the network is handling it automatically. No action needed on your end.

Make your handler idempotent

Afriex retries webhook delivery up to 12 times with exponential backoff. Your handler will receive the same event more than once. That is by design. Your code needs to handle it gracefully.

The simplest approach: store a record of processed webhook event IDs and skip any you have already handled.

async function processWebhookEvent(payload: WebhookPayload) {
  const eventId = `${payload.event}:${payload.data.transactionId}:${payload.data.updatedAt}`;

  // Check if we have already processed this exact event
  const alreadyProcessed = await db
    .select()
    .from(processedWebhooks)
    .where(eq(processedWebhooks.eventId, eventId))
    .limit(1);

  if (alreadyProcessed.length > 0) {
    // Already handled — acknowledge and return
    return;
  }

  // Process the event
  await handleTransactionEvent(payload);

  // Mark as processed
  await db.insert(processedWebhooks).values({ eventId, processedAt: new Date() });
}

Enter fullscreen mode Exit fullscreen mode

For the idempotency key you can use a combination of event type, resource ID, and updatedAt timestamp. This way, the same status update arriving twice is treated as a duplicate and skipped, but a genuine status change on the same transaction (e.g., PROCESSING followed by COMPLETED) is treated as two distinct events.

Handle customer and payment method events

async function handleCustomerEvent(payload: CustomerWebhookPayload) {
  const { event, data } = payload;

  if (event === "CUSTOMER.UPDATED") {
    // Keep your local record in sync
    await updateLocalCustomer(data.customerId, {
      name: data.name,
      email: data.email,
    });
  }

  if (event === "CUSTOMER.DELETED") {
    // Mark the customer as removed in your DB
    await markCustomerDeleted(data.customerId);
  }
}

async function handlePaymentMethodEvent(payload: PaymentMethodWebhookPayload) {
  const { event, data } = payload;

  if (event === "PAYMENT_METHOD.DELETED") {
    // Remove from your DB and flag the customer as needing a new payout method
    await removePaymentMethod(data.paymentMethodId);
    await flagCustomerNeedsPaymentMethod(data.customerId);
  }
}

Enter fullscreen mode Exit fullscreen mode


Retry behavior

Afriex retries failed webhook deliveries up to 12 times. The schedule:

30s → 1m → 2m → 4m → 8m → 16m → 32m → 1h → 2h → 4h → 8h → 16h

Enter fullscreen mode Exit fullscreen mode

A delivery is considered failed if your endpoint returns a non-2xx status or does not respond within about 5 seconds. This means your handler timing out is treated the same as a hard error — Afriex will retry.

Two practical implications. First, your handler must respond quickly (within 5 seconds) regardless of what processing needs to happen — hence the enqueue-and-return pattern above. Second, you should never rely solely on webhooks for reconciliation. Build a polling fallback: periodically call GET /api/v1/transaction/:id for transactions that have been in a non-terminal status for longer than expected. Webhooks are the fast path. The API is the source of truth.


Testing locally

Afriex provides a sandbox-only endpoint for firing real signed webhooks against your local handler without needing to manufacture underlying activity. You create an entity (a customer, payment method, or transaction) in sandbox, then call the trigger endpoint with the entity ID and the event name you want to test.

curl --request POST \
  --url https://sandbox.api.afriex.com/api/v1/webhooks/trigger \
  --header 'Content-Type: application/json' \
  --header 'x-api-key: your-sandbox-api-key' \
  --data '{
    "event": "TRANSACTION.UPDATED",
    "entityId": "69d60071ab82306f11b03393"
  }'

Enter fullscreen mode Exit fullscreen mode

Afriex will send a real signed webhook to your configured callback URL using that entity as the payload. Because it is a real signed payload, your signature verification code runs exactly as it would in production.

To receive it locally, expose your dev server with ngrok:

npx ngrok http 3000

Enter fullscreen mode Exit fullscreen mode

Register the HTTPS URL ngrok gives you as your webhook URL in the Afriex sandbox dashboard, then fire the trigger. You can test every event type this way — CUSTOMER.CREATED, PAYMENT_METHOD.DELETED, TRANSACTION.UPDATED with any status — against a real entity in sandbox.

The trigger endpoint returns 403 Forbidden in production, so there is no risk of accidentally firing test webhooks against your live environment.


Checklist before going live

  • [ ] Afriex IP addresses added to your server allowlist (34.197.33.100 for production)
  • [ ] Webhook public key stored as an environment variable, not hardcoded
  • [ ] Signature verification runs against raw body bytes, not parsed JSON
  • [ ] Handler returns 2xx within 5 seconds
  • [ ] Processing happens asynchronously after acknowledgement
  • [ ] All 11 transaction statuses handled explicitly — including IN_REVIEW, RETRY, and UNKNOWN
  • [ ] Handler is idempotent — safe to receive the same event multiple times
  • [ ] PAYMENT_METHOD.DELETED event triggers a flag in your database
  • [ ] Polling fallback implemented for transactions stuck in non-terminal status
  • [ ] Tested all event types using the sandbox trigger endpoint

The full webhook reference is at docs.afriex.com/api-reference/endpoint/webhooks/introduction. The transaction API reference, including the full request and response schema, is at docs.afriex.com/api-reference/endpoint/transactions/create.