Afriex Integrations: Sandbox, Idempotency, and Webhook Simulation
Most payment integration bugs never show up in a manual test. You click through the happy path once, it works, you ship it. Then in production a network blip causes a retry, two webhook deliveries arrive out of order, or a duplicate request slips through - and now you're debugging a double payout at the worst possible time. 63% of async API failures stem from race conditions invisible to synchronous testing tools.
Payment APIs are almost entirely asynchronous - a transaction returns PENDING, then changes state later via webhook - which means the bugs that matter most are exactly the ones a quick manual test will never catch. This guide covers how to actually test an Afriex integration: sandbox setup, idempotency verification, and simulating webhook events without waiting for a real transaction to change state.
Set up your sandbox environment
Everything starts in the staging environment. No approval process, no waiting - sign up at business.afriex.com, grab your sandbox API key, and you have a fully working test environment immediately.
export const afriex = new AfriexSDK({
apiKey: process.env.AFRIEX_API_KEY!,
environment: "staging",
webhookPublicKey: process.env.AFRIEX_WEBHOOK_PUBLIC_KEY,
});
Keep a completely separate API key and webhook public key for staging versus production. Never test against production, even by accident - a hardcoded environment: "production" in a test file is a classic way to make a real transaction during a test run.
Testing idempotency: verify the effect, not just the response
A repeated 200 or 201 response is not enough evidence that idempotency works. An API can return a reassuring response while creating a duplicate record underneath. The test needs to check the actual downstream effect - was exactly one transaction created - not just what the response looked like.
Here's the pattern:
import { afriex } from "@/lib/afriex";
async function testIdempotency() {
const reference = `test-idem-${Date.now()}`;
const idempotencyKey = `idem-${reference}`;
const payload = {
type: "WITHDRAW" as const,
customerId: "test-customer-id",
destinationId: "test-payment-method-id",
sourceAmount: "10",
destinationAmount: "10",
sourceCurrency: "USD",
destinationCurrency: "NGN",
meta: { reference, idempotencyKey },
};
// Fire the same request twice with the same idempotency key
const first = await afriex.transactions.create(payload);
const second = await afriex.transactions.create(payload);
// The real assertion: both calls return the SAME transaction ID
console.assert(
first.transactionId === second.transactionId,
"Idempotency failed - two different transaction IDs were created"
);
// Confirm only one transaction actually exists for this reference
const allTransactions = await afriex.transactions.list({ type: "WITHDRAW" });
const matching = allTransactions.data.filter(
(t) => t.meta?.reference === reference
);
console.assert(
matching.length === 1,
`Expected exactly 1 transaction, found ${matching.length}`
);
}
This test proves the actual invariant that matters: repeated delivery of one logical write produces no more than one committed effect. Checking transactionId equality across both calls, then confirming only one record exists in the list, is what separates a real idempotency test from one that just checks for a 200.
Also test the negative case - the same key with a genuinely different payload should be rejected, not silently accepted as a new request or silently merged with the original:
async function testIdempotencyKeyReuse() {
const idempotencyKey = `idem-reuse-test-${Date.now()}`;
await afriex.transactions.create({
type: "WITHDRAW",
customerId: "test-customer-id",
destinationId: "test-payment-method-id",
sourceAmount: "10",
destinationAmount: "10",
sourceCurrency: "USD",
destinationCurrency: "NGN",
meta: { reference: "ref-1", idempotencyKey },
});
// Same key, different amount - this should be rejected, not silently accepted
try {
await afriex.transactions.create({
type: "WITHDRAW",
customerId: "test-customer-id",
destinationId: "test-payment-method-id",
sourceAmount: "50", // different amount
destinationAmount: "50",
sourceCurrency: "USD",
destinationCurrency: "NGN",
meta: { reference: "ref-2", idempotencyKey }, // same key
});
throw new Error("Expected an error for key reuse with different payload");
} catch (error) {
// This is the expected path
}
}
Run this in your CI pipeline against sandbox, not just once manually. A regression here is exactly the kind of bug that passes code review and fails in production three months later.
Simulating concurrent retries
The scenario that actually breaks systems in production: your disbursement worker calls transactions.create, the network times out before your code sees the response, your retry logic fires a second call with the same idempotency key - but what if both requests hit Afriex at nearly the same moment, not sequentially?
async function testConcurrentIdempotentRequests() {
const idempotencyKey = `idem-concurrent-${Date.now()}`;
const payload = {
type: "WITHDRAW" as const,
customerId: "test-customer-id",
destinationId: "test-payment-method-id",
sourceAmount: "20",
destinationAmount: "20",
sourceCurrency: "USD",
destinationCurrency: "NGN",
meta: {
reference: `concurrent-${Date.now()}`,
idempotencyKey,
},
};
// Fire both requests at the same time, not sequentially
const [resultA, resultB] = await Promise.all([
afriex.transactions.create(payload),
afriex.transactions.create(payload),
]);
console.assert(
resultA.transactionId === resultB.transactionId,
"Concurrent requests with the same idempotency key produced different transactions"
);
}
This is the test most teams skip because it requires deliberately racing two requests against each other, rather than just calling an endpoint twice in sequence. It is also the test that catches the bug a sequential test cannot - a race condition only shows up in reality when requests actually overlap.
Simulating webhooks without waiting for real transactions
Waiting for a real transaction to move through PENDING → PROCESSING → COMPLETED in sandbox to test your webhook handler is slow and unreliable. Afriex's SDK gives you a way to fire a real, signed webhook on demand against an existing sandbox entity.
// Trigger a real signed webhook event for an existing sandbox transaction
await afriex.webhooks.triggerTestWebhook({
event: "TRANSACTION.UPDATED",
entityId: "existing-sandbox-transaction-id",
});
Because this fires a genuinely signed payload to your configured webhook URL, your signature verification code runs exactly as it would with a real event - this is not a mocked payload, it is the same delivery mechanism Afriex uses in production.
To receive it locally, expose your dev server with a tunnel:
npx ngrok http 3000
Register the ngrok HTTPS URL as your webhook URL in the sandbox dashboard, then trigger events for every status your handler needs to process:
const statusesToTest = [
"TRANSACTION.CREATED",
"TRANSACTION.UPDATED",
// fire this with different underlying statuses
];
for (const event of statusesToTest) {
await afriex.webhooks.triggerTestWebhook({
event,
entityId: "existing-sandbox-transaction-id",
});
}
Test the handler against COMPLETED, FAILED, IN_REVIEW, and RETRY specifically. These four are the ones most often mishandled - IN_REVIEW and RETRY are not failures and should never trigger a failure notification, but a handler that has only been tested against the happy path will often get this wrong.
Testing webhook idempotency (not just transaction idempotency)
Afriex retries webhook delivery up to 12 times with exponential backoff. Your handler will receive the same event more than once by design. Test this explicitly:
async function testWebhookIdempotency(payload: WebhookPayload) {
// Process the same webhook payload twice
await processWebhookEvent(payload);
await processWebhookEvent(payload); // simulate a redelivery
// Assert the side effect only happened once
const notificationCount = await countNotificationsSent(
payload.data.transactionId
);
console.assert(
notificationCount === 1,
`Expected 1 notification, sent ${notificationCount}`
);
}
If your webhook handler sends an email or updates a balance on every delivery without checking whether it already processed that event, a retried webhook will double the side effect even though the underlying transaction only happened once.
What a full test suite for an Afriex integration should cover
| Test | What it proves |
|---|---|
| Same idempotency key, same payload, called twice | Returns the same transaction, no duplicate created |
| Same idempotency key, different payload | Rejected, not silently accepted |
| Same idempotency key, concurrent calls | Race condition does not produce two transactions |
| Webhook signature verification | Invalid signature is rejected before any processing |
| Webhook redelivery | Side effects (emails, balance updates) happen exactly once |
Each terminal status (COMPLETED, FAILED, REJECTED) |
Handler takes the correct action for each |
Non-terminal statuses (IN_REVIEW, RETRY, PROCESSING) |
Handler does not treat these as failures |
| Rate fetch before transaction creation | Displayed rate matches what the transaction actually uses |
Build this as an actual test file that runs in CI against sandbox, not a manual checklist you run once before a release. The bugs this catches are exactly the ones that are invisible until a specific, rare sequence of events happens in production - which is precisely when you don't want to be debugging them for the first time.
Comments
No comments yet. Start the discussion.