Qoyod
Pricing

Knowledge Base

Java Integration Examples for Fatoora (ZATCA) E-Invoicing

This guide is aimed at developers and technical integration teams building the connection to the Fatoora platform in Java as part of e-invoicing in Saudi Arabia. Its single subject: how to actually write Java code that authenticates against the authority’s APIs, computes the invoice hash, encodes the UBL payload, sends the request to clearance or reporting, then reads the response and handles errors. Every snippet here is copy-and-run ready once you set your organization’s own credentials.

Across all examples we rely on Java’s standard library with no external dependency. The HTTP client is the java.net.http.HttpClient bundled since version eleven, hashing is via java.security.MessageDigest, and encoding via java.util.Base64. This keeps the code clean and easy to drop into any Java or Spring project without adding heavy dependencies.

We assume you have already read the three conceptual guides this guide builds on. If not, start with the guide on Authentication in the Fatoora API to understand the Basic header structure, then the guide on API Endpoints for the Fatoora platform to learn the destinations and ports, then the guide on Invoice Submission via the API to understand the submission sequence. This guide turns those concepts into ready-to-use Java code.

A pivotal point we put before you from the start: the Fatoora APIs do not use OAuth. You will find no request for an access token before submission anywhere in the Java code here. You build the authentication header locally from the Cryptographic Stamp certificate (CSID) and its secret on every request. Keep this fact in mind, as it shapes the structure of the client you will write.

Structure of the integration project in Java

Before any line, arrange your system’s components in your mind. A Fatoora API integration in Java consists of four logical layers. The credentials layer reads the CSID and the secret from a secure source. The utilities layer builds the Basic header, computes the hash, and encodes UBL. The client layer sends the request via HttpClient. The response-handling layer reads the JSON and deals with errors and retries.

This separation between layers is not design luxury. Renewing the CSID certificate later becomes changing a value in a single layer. A change in the authority’s port becomes an edit in the client layer alone. Testing the hash construction becomes possible without sending an actual request. Build each layer in its own independent Java class from the start.

Pay close attention to naming the certificate precisely across all your code identifiers: CSID and not CCSI. The abbreviation means Cryptographic Stamp Identifier. The ordering mistake is common, and it confuses you when you search the authority’s documentation or review variable names in your code.

The integration path in Java
The steps the Java code executes to submit a compliant invoice.
1

Build the UBL file and sign it

2

Compute the SHA-256 hash

3

Base64 encoding and building the Basic header

4

POST to clearance or reporting

5

Read the response and save the result

The Java code wraps these steps in reusable functions.

Building the authentication header with Base64

Basic authentication in the HTTP protocol is simple at its core: you join the CSID to the secret with a colon, encode the resulting string with Base64, then place it in the Authorization header prefixed with the word Basic. What is new is that the “username” is the certificate identifier, and the “password” is its accompanying secret. The general form is as follows:

Authorization: Basic base64( CSID + ":" + secret )

In Java you build this value in two lines via java.util.Base64. Note that the colon is part of the input before encoding, not after, and that the encoding runs on the bytes with UTF-8 encoding explicitly to avoid the difference in the default encoding between environments:

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public final class AuthHeader {

    private AuthHeader() {}

    public static String basic(String csid, String secret) {
        String raw = csid + ":" + secret;
        String token = Base64.getEncoder()
                .encodeToString(raw.getBytes(StandardCharsets.UTF_8));
        return "Basic " + token;
    }
}

A fine detail many get wrong: the certificate identifier value is itself already Base64-encoded because it is binary certificate data. This means you are encoding something already encoded, i.e. two Base64 layers. The first layer is the certificate as you received it from the authority, and the second layer is for the full “identifier:secret” string as Basic authentication requires. Do not decode the identifier before building it into the header.

You call this function once when initializing the client, or before every request if you read credentials from a secrets store that changes. Do not request an access token from any endpoint beforehand. You build the header locally and send the request directly, and this is the essence of the practical difference between Basic and OAuth in a Java client’s design.

The reason for specifying UTF-8 encoding explicitly in getBytes is worth clarifying. If you let Java choose the system default encoding, the output may differ between a Linux server and a Windows development machine, producing a different token that the server rejects with a 401 on one environment but not another. Fixing StandardCharsets.UTF_8 eliminates this hidden source of hard-to-trace errors that work locally and fail in production.

Keep the AuthHeader class final with a private constructor, as it is a container for a static function with no state. This pattern for utility classes prevents pointlessly creating an instance of it, and makes clear to whoever reads the code that its job is to build a value, not to carry state. Apply the same pattern to the rest of the utility classes such as InvoiceHash and UblEncoder.

Computing the invoice hash with SHA-256

Every invoice in phase two carries a hash (invoiceHash) computed over its UBL document with the SHA-256 algorithm. This hash is part of the hash chain that links each invoice to its predecessor, and it is sent to the authority within the payload. In Java you compute it via java.security.MessageDigest with no external library.

The output required by the authority is a SHA-256 hash Base64-encoded, not in hex format. Pay attention to this point, because computing the hash then converting it to hex instead of Base64 is a common mistake that makes the authority reject the invoice despite the computation itself being correct. The following snippet computes the hash over the raw bytes of the UBL document and returns it in Base64 format:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public final class InvoiceHash {

    private InvoiceHash() {}

    public static String sha256Base64(String ublXml) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(
                    ublXml.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(
                    "SHA-256 is not available in this environment", e);
        }
    }
}

An essential point in computing the hash: compute it over the document in the exact same form you will send it, byte for byte. Any extra whitespace, or a different newline, or a different element ordering after computing the hash, produces a hash that does not match the sent document, so the authority rejects it. Fix the document’s normalization before hashing, then do not touch it afterward.

The NoSuchAlgorithmException here is practically theoretical, because SHA-256 is guaranteed in every standard Java environment. But catching it is mandatory because the signature enforces it. Turn it into an unchecked exception with a clear message instead of ignoring it, even if you are confident it will never occur.

The MessageDigest object is not thread-safe, because it carries internal state that accumulates with each digest call. This is why we create a new instance of it inside the function every time instead of sharing a single instance. If you need higher performance when issuing thousands of invoices, use a ThreadLocal per thread instead of a shared instance, but never share a single instance between threads.

Remember that the first invoice’s hash in each environment takes a fixed starting value, then links each subsequent invoice to its predecessor’s hash to form a connected chain. This linkage makes any tampering with a middle invoice detectable, because it breaks the chain. Your code computes the hash, but managing its sequence and storing the previous hash is the responsibility of your system’s storage layer, so design it to store the last invoice’s hash for each environment.

Request body fields
The three fields in the clearance or reporting request body.
JSON request body

invoiceHash: the invoice’s SHA-256 hash (Base64)

uuid: the invoice’s unique identifier

invoice: the invoice in Base64 encoding

The three fields are derived from the invoice file itself.

Encoding the UBL document with Base64

The clearance and reporting APIs require you to send the UBL document Base64-encoded inside the JSON payload, not as raw XML text. The reason is that XML contains characters that may break the JSON structure, so encoding guarantees safe transport of the whole document. In Java, encoding is a single line via the same Base64 we used for authentication:

import java.nio.charset.StandardCharsets;
import java.util.Base64;

public final class UblEncoder {

    private UblEncoder() {}

    public static String encode(String ublXml) {
        return Base64.getEncoder()
                .encodeToString(ublXml.getBytes(StandardCharsets.UTF_8));
    }
}

This encoded document is the value of the invoice field in the request payload. Alongside it you send the invoiceHash value you computed in the previous step, and the invoice’s unique identifier uuid. Note that the hash is computed over the original document before encoding it with Base64, not over the encoded document. This is an ordering many get wrong, producing a mismatch.

To build the request body in JSON without an external library, a simple text template suffices because the fields are known and limited. If your project uses a JSON library such as Jackson, use it, but for clarity here we build the string manually with value escaping:

public static String buildBody(String uuid,
                               String invoiceHash,
                               String encodedUbl) {
    return """
        {
          "uuid": "%s",
          "invoiceHash": "%s",
          "invoice": "%s"
        }
        """.formatted(uuid, invoiceHash, encodedUbl);
}

The text block available since version fifteen of Java makes building a JSON template readable. If you are on an older version, use String.format with normal concatenation. The result is the same: a JSON payload carrying the identifier, the hash, and the encoded document, ready to send.

Sending the request via HttpClient

Now we assemble the layers into an actual request. The java.net.http.HttpClient client is the core component. You build an HttpRequest carrying the full endpoint, the Authorization header from the basic function, the mandatory Accept-Version header with the value V2, the Content-Type header with the value application/json, then the request body. The following snippet sends a B2B invoice to the clearance endpoint:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class FatooraClient {

    private static final String CLEARANCE_URL =
        "https://gw-fatoora.zatca.gov.sa/e-invoicing/core/invoices/clearance/single";

    private final HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(20))
            .build();

    private final String authorization;

    public FatooraClient(String csid, String secret) {
        this.authorization = AuthHeader.basic(csid, secret);
    }

    public HttpResponse<String> clearInvoice(String body) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(CLEARANCE_URL))
                .timeout(Duration.ofSeconds(60))
                .header("Authorization", authorization)
                .header("Accept-Version", "V2")
                .header("Content-Type", "application/json")
                .header("Accept", "application/json")
                .header("Accept-Language", "ar")
                .header("Clearance-Status", "1")
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        return client.send(request,
                HttpResponse.BodyHandlers.ofString());
    }
}

Note the Clearance-Status header with the value 1 specific to clearance requests, which asks the authority to clear the invoice immediately before handing it to the buyer. Reporting requests for B2C invoices, on the other hand, go to the reporting/single endpoint without this header, because reporting happens within 24 hours after issuance, not before delivery.

Make the Accept-Version and Content-Type headers a fixed part of building every request, exactly as they are here. Forgetting Accept-Version on a single endpoint is a common cause of hard-to-trace errors, because the gateway may respond with a default version that does not match your expectations. Fix it in the client layer, not in each call.

Reuse a single HttpClient instance throughout the application’s lifetime. Creating a new client for each request wastes resources and opens surplus connections. The client is thread-safe, so inject it as a singleton or a bean in Spring. We set the connection timeout to twenty seconds and the full request timeout to sixty seconds, and these are reasonable values for the authority’s gateway.

The headers deserve a pause, as every request carries six headers each with a role. Authorization carries Basic authentication with the CSID certificate and is mandatory. Accept-Version with the value V2 specifies the API version and is also mandatory. Content-Type with the value application/json tells the gateway the payload is JSON. Accept with the same value requests a JSON response. Accept-Language with the value ar specifies the language of error messages and is optional. Clearance-Status with the value 1 is specific to clearance requests, not reporting.

The difference between the clearance endpoint and the reporting endpoint is reflected in the code by a single variable: the URI address and the presence or absence of the Clearance-Status header. To avoid duplicating the FatooraClient class twice, make the address and the special headers parameters you pass to the function, so the same client serves both endpoints. This keeps the authentication and sending logic unified, and changes only the destination based on the invoice type.

Reading the response and parsing JSON

After sending, you receive a response carrying a status code and a body in JSON format. The first step is always to check the status code before diving into the body. A 200 code means processing succeeded, a 401 code means authentication failed, a 400 code means an error in the payload structure, and a 5xx code means a temporary server problem that calls for a retry.

A successful clearance response carries the clearance status, the signed invoice, and the validation results. Its shape is as follows:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "clearanceStatus": "CLEARED",
  "clearedInvoice": "<Base64-signed-XML>",
  "validationResults": {
    "status": "PASS",
    "warningMessages": [],
    "errorMessages": []
  }
}

To read these values in Java without an external library, a simple extraction of the required fields suffices. For a serious project use Jackson or Gson to fully parse the JSON into an object, but for clarity we present the status-check function relying on the HTTP code first, then extracting the clearance status:

import java.net.http.HttpResponse;

public class ResponseHandler {

    public void handle(HttpResponse<String> response) {
        int status = response.statusCode();
        String body = response.body();

        if (status == 200) {
            // success: parse clearedInvoice and validationResults with a JSON library
            System.out.println("Clearance succeeded, save the signed invoice");
        } else if (status == 401) {
            throw new AuthException("Authentication failed: " + body);
        } else if (status == 400) {
            throw new ValidationException("Invoice structure error: " + body);
        } else if (status >= 500) {
            throw new RetryableException("Temporary server error: " + status);
        } else {
            throw new IllegalStateException(
                    "Unexpected code " + status + ": " + body);
        }
    }
}

Distinguishing between exception types here is deliberate. AuthException and ValidationException are permanent errors that are not resolved by retrying, so handling them requires correcting the credentials or the invoice structure. RetryableException alone calls for a retry, because its cause is temporary in the server. This classification drives the retry logic in the next step.

Setting Accept-Language to ar makes the error messages in the response body in Arabic, which is useful for support teams. Always read the response body on failure, as it carries the precise message that explains the cause, not the status code alone.

The validationResults field deserves a careful read even on success. Clearance may succeed with a 200 code but the field carries a non-empty warningMessages, i.e. warnings that do not prevent acceptance but point to matters worth handling before they turn into errors. Log these warnings and review them periodically, as ignoring them today may mean a rejection tomorrow when the authority tightens a rule that was a warning.

The clearedInvoice field in the success response carries the invoice signed by the authority, Base64-encoded. This is the approved version that you save and hand to the buyer, not the version you sent. Decode it with Base64.getDecoder to obtain the signed XML, then store it linked to the invoice in your system. Confusing the sent version with the approved version is a mistake that loses you the authority’s official signature.

Exception handling and retries

The network is not guaranteed, and the authority’s gateway may sometimes respond with a 5xx code or the timeout may expire. A serious integration client does not give up at the first temporary failure, but retries with increasing spacing (exponential backoff). Note a decisive rule: retry only on temporary errors, not on authentication errors or invoice structure errors, because resending a wrong invoice will not succeed no matter how many times you repeat it.

The following snippet wraps the send in retry logic that handles HttpTimeoutException and the 5xx server codes, and increases the wait interval between attempts:

import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

public class RetryingSender {

    private static final int MAX_ATTEMPTS = 3;

    private final FatooraClient client;

    public RetryingSender(FatooraClient client) {
        this.client = client;
    }

    public HttpResponse<String> sendWithRetry(String body)
            throws Exception {
        Exception last = null;

        for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
            try {
                HttpResponse<String> response =
                        client.clearInvoice(body);

                int status = response.statusCode();
                if (status < 500) {
                    return response; // 2xx or 4xx: do not retry
                }
                last = new RetryableException("server " + status);

            } catch (HttpTimeoutException e) {
                last = e; // timeout is retryable
            }

            long waitMs = (long) Math.pow(2, attempt) * 1000L;
            Thread.sleep(waitMs); // 2s then 4s then 8s
        }

        throw new IllegalStateException(
                "Sending failed after " + MAX_ATTEMPTS + " attempts", last);
    }
}

Note that the condition status less than 500 returns the response immediately. This includes the successful 200 code and the 400 and 401 codes, because these two are permanent errors handled by the caller that are not resolved by repetition. Only 5xx codes and the timeout lead to a new attempt. This distinction protects you from flooding the gateway with repeated wrong requests.

The increasing spacing (the first attempt after two seconds, then four, then eight) gives the server time to recover instead of bombarding it with successive requests. For a serious deployment, add simple randomness (jitter) to the wait interval to avoid synchronizing the attempts of several clients at the same moment. An upper limit of three attempts is reasonable, as beyond that is usually a problem that calls for intervention, not waiting.

Response-handling decision tree
How the code behaves according to the status code.
Status code Action
2xx Save the approved result
400/401 Permanent error: correct, do not retry
5xx / timeout Retry with exponential backoff
Request errors are not retried, and server errors are retried with backoff.

Assembling the full path from start to finish

We have now reached assembling the layers into a single integrated path. This snippet takes a UBL document, computes its hash, encodes it, builds the payload, then sends it with retries, then handles the response. It is the complete picture of what we explained piece by piece:

public class IntegrationFlow {

    public static void main(String[] args) throws Exception {
        // 1) credentials from a secrets store, not from code
        String csid   = System.getenv("FATOORA_CSID");
        String secret = System.getenv("FATOORA_SECRET");

        // 2) the normalized UBL document (pre-signed)
        String ublXml = loadSignedUbl();
        String uuid   = "3cf5ee18-ee25-44ea-a444-2c37ba7f28be";

        // 3) hash then encode then payload
        String invoiceHash = InvoiceHash.sha256Base64(ublXml);
        String encodedUbl  = UblEncoder.encode(ublXml);
        String body = FatooraClient.buildBody(uuid, invoiceHash, encodedUbl);

        // 4) sending with retries
        FatooraClient client = new FatooraClient(csid, secret);
        RetryingSender sender = new RetryingSender(client);
        var response = sender.sendWithRetry(body);

        // 5) handling the response
        new ResponseHandler().handle(response);
    }

    private static String loadSignedUbl() {
        // read the signed XML from a file or from a UBL generator in your system
        return "...";
    }
}

The order of the steps here is not interchangeable. The hash is computed over the normalized signed document before encoding it with Base64. Encoding runs after hashing. The payload is built from both outputs together. Sending comes last. Any mixing of this order, especially computing the hash over an encoded or unnormalized document, produces a mismatch the authority rejects.

Note reading the credentials from environment variables via System.getenv, not from constants in the code. This is not a cosmetic detail, but a fundamental security rule we return to in the next section. The signed invoice returned in the response is what you save and hand to the buyer, not the document you sent.

This example handles a single invoice, but a production system issues many invoices. Do not make main process one invoice then exit; instead read the ready invoices from a work queue and process them one by one. The single HttpClient client serves them all because it is thread-safe. This design separates invoice generation from sending, so if the gateway goes down momentarily the invoices remain in the queue until it recovers, without losing any of them.

Also separate UBL generation and signing from this path. The role of the sending code is to take a ready signed document and deliver it to the authority, not to build the invoice. This separation makes each layer testable on its own: you test UBL construction with known documents, and you test sending with ready documents, without mixing two logics in a single class that is hard to maintain.

Securing the credentials in Java code

Credentials in Basic authentication are fixed and do not expire automatically before renewing the certificate, unlike the short-lived access token in OAuth. This makes leaking them more dangerous, because whoever obtains them can impersonate your machine until you notice and reissue the certificate. Strict security in Java code is not optional.

The first rule: do not write the CSID or the secret as String constants in the code, and do not push them to the code repository. This is the most common source of leaks. Read them at runtime from protected environment variables, or from a cloud secrets store, or from a configuration file outside the code that is not tracked in git.

The second rule: do not log the Authorization header in system logs. Many Java frameworks log the full headers when debugging, so credentials leak into the log files unintentionally. Add an explicit masking rule for this header in the logging configuration, and review what your code prints before deployment.

The third rule: separate the simulation credentials from production into two different keys, not in the same value swapped by a variable. The simulation and production APIs use two completely separate certificates, and sending one environment’s certificate to the other environment’s address produces a rejection with a 401 code even though the data is “correct” in itself. Pair the address destination and the credentials in a single configuration.

The fourth rule: at any suspicion of a leak, reissue the certificate immediately from the authority without waiting for confirmation. Reissuance invalidates the old data and grants you a new pair, so even if the old secret is in another party’s hands it becomes worthless. Design your code to read the credentials from a central, updatable source, so renewal becomes changing a value in a single place, not redeploying the whole system.

How Qoyod helps you integrate with the Fatoora platform

Everything we explained in this guide, from building the Basic header with Base64 to computing the SHA-256 hash, encoding UBL, sending via HttpClient, and retrying, is managed by Qoyod’s e-invoicing system on your behalf. You do not write the AuthHeader class nor compute the hash manually; instead you issue your invoice from the Qoyod interface and the system handles the connection with the authority in the background.

Specifically, Qoyod does the following at the technical integration level:

  • It manages the Cryptographic Stamp certificate (CSID) automatically, from requesting the compliance certificate via the OTP, to generating the production certificate after passing compliance.
  • It builds the Authorization header in Basic form for every request automatically, so you do not deal with Base64 encoding nor with the secret directly.
  • It computes the invoice hash, signs it cryptographically, embeds the QR code, and stores the hash chain for compliance verification.
  • It performs immediate clearance for B2B invoices and reporting within 24 hours for B2C invoices, with error handling and retries internally.
  • It separates the simulation environment from the production environment, and manages certificate renewal as it nears expiry.

With this, development teams focus on the business logic in their system, while the integration layer with the authority remains Qoyod’s responsibility. This eliminates the need to build and maintain a direct Java integration with the Fatoora platform from scratch, and saves your team from maintaining the certificate, the headers, and the signing with every update from the authority.

Start today

Let Qoyod handle the integration with the Fatoora platform

Do not build a Java integration from scratch. Issue your phase-two compliant e-invoices, and let Qoyod manage the certificate, the hashing, the encoding, and the connection with the authority in the background.

Try Qoyod free for 14 days

Frequently asked questions about integration in Java

Do I need an external library to integrate with the Fatoora platform in Java?

No for the core functions. The java.net.http.HttpClient client, the java.security.MessageDigest hashing, and the java.util.Base64 encoding are all in the standard library since version eleven. You may prefer a JSON library such as Jackson to parse the response into an object, but authentication, hashing, encoding, and sending do not need any external dependency.

How do I build the Authorization header in Java exactly?

You join the CSID to the secret with a colon, then encode the resulting string with Base64 over UTF-8 bytes via Base64.getEncoder, then prefix it with the word Basic. The colon is part of the input before encoding, not after. Build the value once when initializing the client and do not request an access token from any endpoint before it.

Do I compute the invoice hash in hex or Base64?

In Base64. You compute the hash with the SHA-256 algorithm via MessageDigest over the bytes of the normalized UBL document, then encode the resulting bytes with Base64, not with hex. Computing the hash correctly then converting it to hex is a common mistake that makes the authority reject the invoice despite the computation being sound.

When do I retry sending the invoice in Java?

On temporary errors only: the 5xx server codes and the HttpTimeoutException. Retry with increasing spacing (two seconds then four then eight) up to three attempts. Do not retry on a 401 code (authentication failure) nor 400 (structure error), because both are permanent errors not resolved by repetition but by correcting the credentials or the invoice.

Do the Fatoora APIs use OAuth in Java code?

No. The invoice issuance APIs use Basic authentication built on the CSID certificate and its secret, not OAuth with temporary access tokens. In Java code you do not request an access token from any endpoint before sending; instead you build the Authorization header locally and send the request directly. This simplifies the client’s design and eliminates managing the token’s lifecycle.

Why do I read the CSID and the secret from environment variables and not from the code?

Because Basic credentials are fixed and do not expire automatically, so leaking them allows impersonating your machine for a long time. Writing them as String constants in the code and pushing them to the code repository is the most common source of leaks. Read them at runtime via System.getenv or from an encrypted secrets store, and do not log the Authorization header in the logs.

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.