Qoyod
Pricing

Knowledge Base

Retry Logic for Fatoora Platform E-Invoicing Integration

When you build an integration with the Fatoora platform, success in an ideal environment is not the metric. The real metric is how your system behaves when the network drops for a few seconds, when the platform returns a transient server error, or when your request rate exceeds the allowed limit. In these moments the integrity of your invoices is decided: is the attempt retried so the invoice arrives, or does the request fail silently and the customer discovers the gap days later? Retry Logic is the layer that turns a fragile integration into one that holds up in production.

This guide explains when to retry and when to hold back, how to build exponential backoff with jitter, how to preserve non-duplication of clearance by reusing the same unique identifier (UUID), and how to handle requests that fail permanently via a dead-letter queue. The goal is for the developer to walk away with a model directly applicable to the Clearance API and the Reporting API.

This article is part of the Developer Hub within Qoyod E-Invoicing. It assumes you have read Fatoora platform API endpoints and know the difference between the Clearance API and the Reporting API, and it assumes familiarity withFatoora platform API error handling the error response structure and the rules of Submitting the invoice for clearance and reporting. Retry Logic builds directly on top of these two layers.

Why you need deliberate retry logic

The network is not perfect, servers fail sometimes, and rate limits exist for a reason. In an e-invoicing integration, a small percentage of requests will fail for transient reasons that have nothing to do with the validity of the invoice. The invoice is sound, the data is correct, but the request did not arrive or the response did not return. Here lies the problem: a transient failure is temporary by nature, and a simple resend is enough to make it succeed.

The developer who treats every failure as the end of the road loses valid invoices because of a two-second outage. And the developer who retries on every error without distinction floods the platform with duplicate requests, hits rate limits, and may create duplicate clearance invoices at the Authority. The solution is between these two extremes: logic that retries only on what deserves it, at a pace that gradually spreads out, while guaranteeing that resending does not produce duplication.

The strategy begins with a single question at every failure: is this error transient or permanent? A transient error is resolvable by retrying because its cause is temporary. A permanent error will not change no matter how many times you resend because its cause is in the data itself. Mixing the two categories is the most common mistake in invoicing integrations, and its result is either losing valid invoices or flooding the platform with requests doomed to fail.

Where the retry layer sits in the issuance flow

Retry logic is a middleware layer that sits between the invoice-creation logic and the HTTP client that connects to the Fatoora platform. When a request fails, the error does not return directly to the business logic. It first passes through the decision layer that classifies the error, then decides: immediate retry, wait then retry, or permanent handoff to the dead-letter queue.

When to retry and when not to
The retry decision by response type.
Response Decision
2xx success No retry needed
4xx logical error Do not retry, fix first
429 / 5xx / timeout Retry with exponential backoff
Request errors are not retried; server errors and congestion are retried with backoff.

Classifying responses: what deserves a retry

The first and most important decision is classification. Do not retry on every failure, only on transient failures. The following table summarizes how you handle each category of responses coming from the Clearance and Reporting APIs:

Case Category Retry? Reason
500 / 502 / 503 / 504 Platform server error Yes The problem is in the platform, not in your invoice. The error is temporary and usually resolves within seconds.
429 Too Many Requests Rate limit exceeded Yes, respecting the header The request is valid but the pace is high. Wait for the duration specified by the Retry-After header.
Connection timeout / network drop Network error Yes, carefully The request may have arrived without the response reaching you. Retry with the same UUID to avoid duplication.
400 Bad Request Malformed request No The request structure is wrong. Resending the same data will inevitably fail.
401 Unauthorized Authentication failed No (renew first) An expired CSID certificate or an invalid token. Renew the credential then send as a new request, not as a blind retry.
422 Unprocessable Entity Validation rules failed No The invoice failed in validationResults. Fix the data first, because resending without change repeats the failure.

The core rule: 5xx errors, network timeouts, and 429 are candidates for retry because the invoice is valid and the problem is transient. Logical 4xx errors (400, 401, and 422) are not candidates for automatic retry because their cause is in the request itself, and it will not change no matter how many times you resend. The distinction here is not a technical detail but the first line of defense against wasting resources and against flooding the platform.

The 429 error is a special case worth a pause. It belongs to the 4xx category but is retryable, provided you respect the Retry-After header if present. Do not treat it as a logical error and do not ignore the duration the platform specifies, because resending immediately after a 429 prolongs the block period instead of resolving it. The details of the time windows and limits deserve an independent review within the Developer Hub.

The network timeout is the hardest case to classify, because it is ambiguous by nature. When the response is cut off, you do not know whether the request reached the platform and was actually processed, or dropped along the way. This is precisely where reusing the same unique identifier becomes a necessity, not an option, as we will detail in the duplication-prevention section.

Separate classification from handling

Classification decides whether we retry. Handling decides how. Mixing the two produces tangled code that is hard to maintain. The cleanest approach is to build a single classification function that returns an explicit decision, then build the handling logic on top of that decision:

function classify(response, networkError) {
  if (networkError === "timeout" || networkError === "connection_reset") {
    return { retry: true, reason: "network" };
  }
  const code = response.statusCode;
  if (code >= 500 && code <= 504)        return { retry: true,  reason: "server" };
  if (code === 429)                       return { retry: true,  reason: "rate_limit" };
  if (code === 401)                       return { retry: false, reason: "auth" };   // renew then send as a new request
  if (code === 400 || code === 422)       return { retry: false, reason: "logical" };
  if (code >= 200 && code < 300)          return { retry: false, reason: "success" };
  return { retry: false, reason: "unknown" };  // the safe default: do not retry
}

Note the last assumption. Any unknown code is classified by default as non-retryable. This conservative stance protects you from infinite retry loops on cases you did not anticipate. It is safest to log the unknown code and hand it to manual review rather than assume it is transient.

Exponential backoff with jitter

Once you decide to retry, the next question is: when? Resending immediately is a common mistake, because the transient cause has usually not been resolved yet. Repeated sending without waiting is called a "retry storm": hundreds of clients retry at the same instant, prolonging the platform outage instead of waiting for it to recover.

The standard solution is exponential backoff. Instead of a fixed interval between attempts, the interval doubles with each attempt: one second, then two, then four, then eight, and so on. This gives the platform an increasing window to recover, and reduces pressure gradually instead of dumping it all at once.

Exponential backoff with jitter
How attempts spread out to avoid overwhelming the server.
Exponential backoff

Attempt 1: after one second

Attempt 2: after two seconds

Attempt 3: after 4 seconds

Attempt 4: after 8 seconds

Add jitter to each interval

Doubling the interval with jitter prevents attempts from synchronizing.

But exponential backoff alone is not enough. If the platform goes down and a thousand clients retry after exactly one second, then after exactly two seconds, they hit the platform in synchronized waves. The solution is to add jitter: instead of waiting exactly two seconds, wait a random duration within a window around two seconds. This scatters the requests along the time axis and prevents them from synchronizing.

function backoffDelay(attempt) {
  const base = 1000;          // one second in milliseconds
  const cap  = 32000;         // cap of 32 seconds for any attempt
  const exp  = Math.min(cap, base * Math.pow(2, attempt));  // 1s, 2s, 4s, 8s...
  // full jitter: a random value between zero and the exponential bound
  return Math.floor(Math.random() * exp);
}

This pattern is known as "full jitter": the duration is a random value between zero and the current exponential bound. It is the most effective at dissipating retry storms because it distributes requests across the entire time window. Note also the cap: no matter how high the attempt number, the interval never exceeds 32 seconds, so the durations do not become long with no practical meaning.

Maximum number of attempts

Retrying is not endless. Each request has a maximum number of attempts, after which it is handed to the dead-letter queue instead of spinning in an eternal loop. The appropriate number depends on the nature of the API, but the practical rule is that four to six attempts cover most transient failures. With exponential backoff starting from one second and doubling, five attempts span roughly thirty seconds in total, which is a sufficient window for most temporary server failures to recover.

async function submitWithRetry(invoice) {
  const maxAttempts = 5;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const response = await sendToFatoora(invoice);     // the same UUID every time
    const decision = classify(response, response.networkError);

    if (decision.reason === "success") return response;
    if (!decision.retry) {
      // logical or auth error: no point retrying
      throw new PermanentError(decision.reason, response);
    }
    // transient error: wait then resend the same invoice with the same identifier
    if (attempt < maxAttempts - 1) {
      await sleep(backoffDelay(attempt));
    }
  }
  // all attempts exhausted: hand off to the dead-letter queue
  await deadLetterQueue.push(invoice);
  throw new RetriesExhaustedError(invoice.uuid);
}

Pay attention to the decision sequence in the loop. First we check for success and exit immediately. Then we check whether the error is non-retryable and throw a permanent error without waiting. Finally, if the error is transient, we wait per the backoff then repeat. When attempts are exhausted, the request goes to the dead-letter queue and is not lost.

Preventing duplication: reusing the same unique identifier

Here is the most dangerous part of retry logic, and the most neglected. When the network drops after sending a clearance request, the request may have reached the platform and been processed, but the response did not return to you. If you retry with a new unique identifier, you are asking the Authority to clear a second invoice for the same operation. The result: two certified invoices for one transaction, an accounting and tax error that is hard to correct later.

The golden rule: every attempt to send the same invoice uses the same unique identifier (UUID) and the same chained hash value. The unique identifier is generated once when the invoice is created, not at every send attempt. This way the platform treats repeated attempts as a single request, and if it had processed the first request it returns the same result instead of creating a new clearance.

Duplication prevention via the unique identifier
How the unique identifier guarantees the invoice is not duplicated.
1

Send with a fixed UUID

2

Network drop

3

Retry with the same UUID = no duplication

Resending with the same identifier returns the same result without duplication.

This principle is called idempotency. An idempotent request is one that produces the same result whether it is sent once or five times. Retry logic depends on it entirely: without it, every retry is a risk of creating a duplicate, and the cure becomes more dangerous than the disease.

In practice, store the unique identifier with the invoice in your database before the first send. At every attempt, read the stored identifier and do not generate a new one. And if you receive a 200 response for a request you thought had failed (because the timeout was cut off in the previous attempt), this confirms that the reuse worked as intended: the platform matched the identifier and returned the certified invoice without duplication.

Distinguishing clearance from reporting in retries

Retry behavior differs slightly between the two APIs. The Clearance API (for B2B tax invoices) is synchronous: it waits for the Authority's certification before handing the invoice to the buyer, so retrying here blocks the operation until it succeeds or is exhausted. The Reporting API (for simplified B2C invoices) is asynchronous: the invoice is handed to the buyer immediately then reported to the Authority within 24 hours, so retrying happens in the background without holding up the customer at the point of sale.

This difference affects the queue design. Clearance invoices deserve faster, more urgent attempts because the buyer is waiting. Reporting invoices tolerate slower backoff and a wider window, as long as reporting is completed within the twenty-four-hour deadline imposed by the Authority.

The dead-letter queue: the final safety net

What happens to the invoice after all attempts are exhausted? It must not disappear silently, nor keep spinning in the loop forever. The answer is the dead-letter queue: a separate store to which permanently failed requests are handed, to be reviewed and processed manually or automatically later.

The dead-letter queue serves three purposes. First, it prevents invoice loss: every failed invoice is saved in a known place from which it does not disappear. Second, it separates failure from the main production line: the failure of a single invoice does not halt the rest of the issuance. Third, it provides a reference for analysis: an accumulation of a particular type of failure in the queue reveals a structural problem worth addressing.

Field What is stored Benefit
Invoice identifier (UUID) The original unique identifier Ensures reprocessing uses the same identifier so it does not produce duplication.
Request payload The full invoice body as sent Enables resending without rebuilding it from scratch.
Last response The status code and the last error body Identifies the cause of the permanent failure and guides manual handling.
Attempt counter How many attempts occurred and when Reveals whether the problem is a long transient one or a recurring pattern.
Timestamp The time of the first and last attempt Links the failure to known platform outage windows.

The practical rule: do not reprocess from the dead-letter queue automatically and without limits. The request reached the queue because it failed five times, so resending it immediately usually fails a sixth time. It is best to schedule periodic reprocessing (for example, every hour) with a maximum number of retry cycles, and a human alert when a request exceeds this limit, since it then becomes a case that deserves manual inspection rather than automatic retry.

Do not confuse the dead-letter queue with logical errors

Another common mistake: pushing invoices that failed with a 422 error (validation failure) into the dead-letter queue awaiting retry. This is wrong, because the invoice will never pass no matter how many times it is retried as long as its data is wrong. The dead-letter queue is for exhausted transient failures, not for logical failures. Invoices with logical errors go to a different path: they are shown to the user to correct the data, then sent as an amended invoice with a new identifier, not as a retry with an old identifier.

A full scenario: from the drop to recovery

Let us tie the pieces together in a real-world example that any production integration goes through. A merchant issues a B2B tax invoice worth SAR 12,500 and sends it to the Clearance API. At that moment, the platform experiences a temporary load spike and returns a 503 code, then the network drops on the next attempt before the response arrives. How does sound retry logic handle this sequence?

When the invoice is created, the system generates one unique identifier (UUID) and stores it in the database with a "sending" status. The first attempt returns 503, so the logic classifies it as a transient server error that is retryable. The system waits a random duration within the one-second window per the exponential backoff, then resends with the same identifier.

The second attempt hits a network drop: the request reaches the platform and is actually processed, but the response is lost along the way, so the system records a timeout. Here the role of identifier reuse appears. Had the system generated a new identifier now, it would have asked the Authority to clear a second invoice for the same transaction. But it keeps the original identifier, waits within the two-second window, then resends.

The third attempt succeeds and returns 200, but the surprise is that the platform returns the very same certified invoice it had processed in the disconnected second attempt. Because the identifier did not change, the platform matched the request with what it had processed earlier and did not create a new clearance. The system stores the certified invoice and the QR code, updates the status to "certified," and the operation completes with one invoice, not two.

This sequence sums up the three principles together: classification distinguished the transient error from the permanent one, exponential backoff gave the platform a window to recover, and identifier reuse prevented duplication despite the mid-operation drop. Had any of the three principles been dropped, the scenario would have ended with a lost invoice or a duplicate one.

Monitoring and logging: what you cannot see you cannot fix

Retry logic works in the background by nature, and this is a silent danger. Requests are retried and succeed without anyone noticing, until a recurring transient failure turns into a structural problem no one sees. That is why logging is an integral part of the logic, not an optional add-on.

Log for every attempt: the invoice identifier, the attempt number, the response code, the classification reason, and the wait duration applied. This log answers crucial questions during an investigation: does the rate of 503s rise at a particular hour? Is a specific invoice retried more than others? Is the dead-letter queue approaching capacity? Without this data, you discover the problem from a customer complaint, not from a monitoring dashboard.

Monitor three key metrics. First: the retry rate, that is, the percentage of requests that needed at least one attempt after the first. A sudden rise indicates a failure in the platform or in your network. Second: the dead-letter queue arrival rate, that is, the percentage of requests that exhausted all their attempts. Any rise here is an alarm that deserves immediate inspection. Third: the recovery time, that is, the duration from the first failure to success or permanent handoff.

Automatic alerting builds on top of these metrics. Set a threshold for the dead-letter queue arrival rate, and when the system exceeds it, send an alert to the concerned team. The goal is to know about the problem before the customer does, so you fix it while it is a small transient failure and not an accumulated invoice crisis.

Common mistakes in retry logic

Before we conclude, here are the mistakes that recur in invoicing integrations and how to avoid them:

Mistake Result The right approach
Retrying on a 422 error Repeated failure with no benefit and resource consumption Fix the invoice data, then send it as an amended invoice, not as a retry.
Generating a new unique identifier on every attempt A duplicate clearance invoice at the Authority Generate the identifier once at creation and reuse it on every send.
Immediate resend without waiting A request storm that prolongs the platform outage Apply exponential backoff with jitter between attempts.
Attempts with no maximum An infinite loop that consumes resources Set a maximum then hand the request to the dead-letter queue.
Ignoring the Retry-After header on 429 Prolonging the block period instead of resolving it Respect the duration the platform specifies precisely.
Silent retry without logging Recurring failures no one sees Log every attempt and monitor its rates.

How Qoyod helps you with retry logic

The greatest practical benefit of understanding retry logic is that Qoyod applies it on your behalf in its direct integration with the Fatoora platform, so you do not need to build this layer from scratch:

  • Automatic clearance and reporting with built-in retry logic: Qoyod handles sending every B2B invoice for instant clearance and every B2C invoice for reporting within 24 hours, and automatically retries on transient failures without any intervention from you.
  • Built-in duplication prevention: Qoyod stores the unique identifier (UUID) and the hash chain for each invoice and reuses them on any resend, so clearance is not duplicated at the Authority.
  • CSID certificate management: Qoyod manages the Cryptographic Stamp Identifier (CSID) certificate automatically and handles its renewal, so your invoices do not stop because of an expired certificate.
  • Saving the hash chain for verification: Qoyod stores the invoice hash chain to ensure sequence integrity, which is the pillar that duplication prevention relies on during retries.

Note what Qoyod does not do and what remains your responsibility: filing the tax return and paying the tax are done through the Authority's portal by the client, and registering the CSID certificate with the Authority remains a step the client performs with guidance from Qoyod. Qoyod handles the issuance, clearance, and reporting layer, not the final filing of the return.

A checklist for sound retry logic

Before you consider the retry layer production-ready, review these points:

  • Classify before you retry: Retry only on 5xx, 429, and network timeouts. Do not retry on 400, 401, and 422.
  • Exponential backoff with jitter: Double the interval on each attempt, add jitter to scatter requests, and set an upper cap on the duration.
  • A maximum number of attempts: Four to six attempts are enough. After that, hand off to the dead-letter queue.
  • The same unique identifier on every attempt: Generate the UUID once at creation, and reuse it on every send to prevent duplication.
  • Respect the Retry-After header: On a 429, wait the duration the platform specifies, not one of your own.
  • A dead-letter queue: Store exhausted requests with their full context instead of leaving them to disappear.
  • Separate logical failure from transient failure: Logical errors go to correction, not to the dead-letter queue.
  • Monitor and log: Log every attempt and its reason to reveal recurring patterns before they escalate.

Retry logic is not an engineering luxury but a basic condition for an invoicing integration that holds up in production. The difference between an integration that loses invoices at the first drop and one that recovers automatically is exactly this layer: precise classification, deliberate backoff, tight duplication prevention, and a final safety net. If you build your direct integration with the Fatoora platform, make this layer a priority, not an afterthought. And if you want to skip building it entirely, Qoyod applies it on your behalf within its certified integration.

Start today

E-invoicing that recovers automatically from network failures

Qoyod handles clearance and reporting with the Fatoora platform, and automatically retries on transient failures with the same unique identifier, so your invoices are neither duplicated nor lost.

Start your free trial with Qoyod

Guides

Continue your learning journey

Explore the rest of Qoyod’s guides, or start applying what you’ve learned.

Live webinars hosted by the Qoyod team to help you use the software easily and answer your questions.

Discover Qoyod’s latest updates, ongoing improvements, and new features in one place.

Our team is ready to help you and provide instant support for any issue you face, around the clock.