Idempotent verification requests: avoiding duplicate checks and duplicate charges
Give every logical verification one requestId, save it before you send the request, and reuse it on every retry. A repeated requestId returns the existing verification instead of creating and charging a second one.

Charles Archibong, Co-founder
· 6 min read

Key takeaways
- A repeated metadata.requestId returns the existing verification instead of creating a new one.
- Generate the requestId from your own record and store it before the first attempt, not inside the retry loop.
- Retry 429, 502, 503, 504 and connection failures with backoff; treat other 4xx responses as bugs to fix.
- A 202 only means queued. A failed verification is an outcome to handle, not an error to retry.
To retry a verification request safely, send the same metadata.requestId every time you retry it. Myaza Trust treats requestId as your idempotency key: if a request arrives with a requestId your organisation has already used, the API returns the existing verification rather than creating a second one, and you are not charged twice.
That single field only protects you if you handle it correctly. The requestId has to be generated once per logical verification, saved before the first attempt, and reused verbatim. Generate it inside your retry loop and every retry becomes a brand new verification. The rest of this article covers where to create it, which responses to retry, and which "failures" are not failures at all.
What goes wrong without idempotency?
The classic case is a timeout after success. Your server sends POST /api/kyc/verify, the API accepts it and queues the verification, and the response is lost on the way back: a dropped mobile connection, a load balancer timeout, a pod restart. From your side, the request failed. From ours, it succeeded.
If your code retries with a fresh identifier, the platform sees a new request and creates a second verification. In Production each completed verification is billed to your credit balance, according to the environments documentation, so the lost response has just cost you a second check. It also leaves two verification IDs attached to one applicant, and your reconciliation now has to decide which one is real.
With the same requestId, the retry returns the original verification, and the lost response costs nothing.
How does requestId work on the verify endpoint?
It lives inside the request's metadata object. From the create verification reference:
{
"country": "NG",
"idType": "bvn",
"idNumber": "12345678901",
"externalUserId": "user_42",
"metadata": { "requestId": "order_1001", "loanId": "loan_20191" }
}The first submission returns 202 Accepted:
{
"verificationId": "ver_01j9...",
"status": "processing",
"externalUserId": "user_42",
"metadata": { "loanId": "loan_20191" }
}A later submission with the same requestId returns that verification, in whatever state it has reached, for example { "verificationId": "ver_01j9...", "status": "approved" }. externalUserId and your metadata come back exactly as stored, so the retry and the original agree.
Keys are scoped to your organisation. If a requestId collides with one belonging to another organisation, the API returns 403 with { "error": "Forbidden" } rather than disclosing anything about that verification.
Where should you generate the requestId?
From your own record, and before you send anything. A pattern that holds up:
When a customer starts identity verification, create a row in your own database for that attempt (an application, an onboarding step, a loan).
Derive the
requestIdfrom that row, for examplekyc-application-8841, and store it on the row.Send the request with that stored value.
On any retry, read the value back from the row. Never compute a new one.
Three rules follow:
Do not use a random UUID generated per HTTP call. That is a unique identifier for the call, which is the opposite of what you need.
Namespace it. A bare
1001is likely to collide with your own other records over time, and a cross-organisation collision returns403. A prefix such askyc-application-makes the value unambiguous.Mint a new one only for a genuinely new verification. If a customer is declined and you decide they may start again from scratch, that is a new logical verification with a new
requestId. Reusing the old one would simply return the declined verification.
If a reviewer sends a verification back so the applicant can redo some steps, you do not need a new requestId at all. A verification that is sent back or verified again keeps its ID, and the resubmission appears as a new attempt on it, as the verification result reference describes.
When the SDK submits for you
Most integrations do not call /verify directly. The web, React Native and Flutter SDKs make the call when the applicant presses submit. They accept metadata with your requestId (metadata={{ requestId: "order_1001" }} in React Native, metadata: {'requestId': 'order_1001'} in Flutter), so the same principle applies: pass a value derived from your own record, not one generated fresh each time the screen mounts.
Which responses should you retry?
The errors documentation is explicit, and it maps to a short table.
Response | Retry? | What to do |
|---|---|---|
| No | Done. Track the |
| Yes | Wait for the |
| Yes | Exponential backoff with jitter, same |
Connection reset, DNS failure, timeout | Yes | Same as above. The request may have succeeded, which is exactly why the |
| Not blindly | Keep the request details and contact support if it persists. |
| No | Fix the payload. Read |
| No | Check the key and its environment. |
| No | Production needs an approved business profile. |
| No | Your |
The documented limit is 100 requests per 15 minutes per client, with RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers on responses. Reading RateLimit-Remaining lets a bulk job slow down before it is refused, rather than after. The rate limits page notes that limits may differ between Sandbox and Production and can be adjusted for your account.
What does a safe retry loop look like?
A minimal Node.js version, using the global fetch. The body is serialised once, outside the loop, so every attempt sends identical bytes with the same requestId.
const VERIFY_URL = 'https://trust.myaza.app/api/kyc/verify';
const RETRYABLE = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random());
export async function submitVerification(body, apiKey, maxAttempts = 5) {
if (!body.metadata?.requestId) throw new Error('metadata.requestId is required');
const payload = JSON.stringify(body);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let res;
try {
res = await fetch(VERIFY_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: payload,
});
} catch (networkError) {
if (attempt === maxAttempts) throw networkError;
await sleep(backoff(attempt));
continue;
}
if (res.ok) return res.json(); // 202 for a new verification, or the existing one on a replay
if (!RETRYABLE.has(res.status) || attempt === maxAttempts) {
const detail = await res.json().catch(() => ({}));
throw Object.assign(new Error(detail.error ?? `HTTP ${res.status}`), { status: res.status, detail });
}
const resetSeconds = Number(res.headers.get('RateLimit-Reset'));
await sleep(res.status === 429 && resetSeconds > 0 ? resetSeconds * 1000 : backoff(attempt));
}
}Note what is absent: no retry on 400, 401 or 403, and no new identifier anywhere. The caller supplies a requestId it has already stored.
Is a failed verification something to retry?
No, and confusing the two is a common source of duplicates. A 202 only means the request was queued. The verification can still end declined because a face did not match, or because the ID was not found in the government database. Those are outcomes delivered by webhook or by polling, each with a reason and a stable reasonCode. They are not HTTP errors.
If your retry logic watches outcomes and resubmits on a decline, it will either get the same declined verification back (same requestId) or create a second, paid verification of the same bad input (new requestId). Neither helps. Branch on the reasonCode instead: ask for a new photo on document_blurry, for a corrected number on identity_not_found, and send the person through a new or redone verification deliberately.
Does the webhook side need idempotency too?
Yes. The same webhook delivery may arrive more than once, because a delivery is retried until your endpoint acknowledges it. The webhooks documentation says to deduplicate on the X-Myaza-Delivery header, or on verificationId. Record what you have processed and make the handler a no-op the second time. Idempotent sending and idempotent receiving together are what make the flow exactly-once from your customer's point of view.
Checklist
One
requestIdper logical verification, derived from your own record and namespaced.Stored before the first attempt; read back, never regenerated, on retry.
Passed through the SDK's
metadatawhen the SDK submits for you.Retries on
429,502,503,504and network failures only, with backoff and theRateLimit-Resetvalue.Outcomes such as
declinedhandled byreasonCode, never by resubmitting.Webhook handler deduplicated on
X-Myaza-DeliveryorverificationId.
The full request contract is in the create verification reference, and the developer hub links the rest of the API.
Sources

Charles Archibong
Co-founder
Charles Archibong co-founded Myaza Trust. He writes about identity verification, financial technology, and the practical work of building trusted digital services.


