Skip to content

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

Headline "Retry without paying twice" beside an illustration of dashed retry arcs converging on a single check mark, on a deep indigo gradient.

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:

  1. When a customer starts identity verification, create a row in your own database for that attempt (an application, an onboarding step, a loan).

  2. Derive the requestId from that row, for example kyc-application-8841, and store it on the row.

  3. Send the request with that stored value.

  4. 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 1001 is likely to collide with your own other records over time, and a cross-organisation collision returns 403. A prefix such as kyc-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

202 Accepted

No

Done. Track the verificationId.

429 Too Many Requests

Yes

Wait for the RateLimit-Reset seconds, then retry with the same requestId.

502, 503, 504

Yes

Exponential backoff with jitter, same requestId.

Connection reset, DNS failure, timeout

Yes

Same as above. The request may have succeeded, which is exactly why the requestId matters.

500

Not blindly

Keep the request details and contact support if it persists. pricing_not_configured will not fix itself on retry.

400 Invalid request body

No

Fix the payload. Read message.

401 Invalid API key

No

Check the key and its environment.

403 business_not_approved

No

Production needs an approved business profile.

403 Forbidden

No

Your requestId collides with another organisation's. Your generation scheme is not unique enough.

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 requestId per 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 metadata when the SDK submits for you.

  • Retries on 429, 502, 503, 504 and network failures only, with backoff and the RateLimit-Reset value.

  • Outcomes such as declined handled by reasonCode, never by resubmitting.

  • Webhook handler deduplicated on X-Myaza-Delivery or verificationId.

The full request contract is in the create verification reference, and the developer hub links the rest of the API.

Sources

Charles Archibong

About the author

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.

  • Headline "Webhook signatures and retries" beside an illustration of a code window with angle brackets, on a deep indigo gradient.

    Developers

    Verifying webhook signatures and handling retries correctly

    Verify the V2 signature over the raw request bytes, reject timestamps outside the replay window, record the event ID under a unique constraint before doing any work, and return 2xx only once the event is durably stored.

  • Headline "Status in, product state out" beside an illustration of three rising tiers, on a deep indigo gradient.

    Developers

    Mapping verification statuses to your product's states

    Drive your product from status, the outcome, and store checkStatus beside it as the explanation. Map each of the ten status values to one state in your own system, and take the latest status from verification.status_updated or polling.

Build your product.We'll handle the rest.

Identity and compliance, end to end, built to global standards, priced for founders.

Idempotent verification requests and safe retries · Myaza Trust