If you are integrating ZATCA Phase 2 (Saudi Arabia's Fatoora e-invoicing) and you have seen this:
{
"type": "ERROR",
"code": "signed-properties-hashing",
"category": "CERTIFICATE_ERRORS",
"message": "Invalid signed properties hashing, SignedProperties with id='xadesSignedProperties'"
}
...after your invoice sailed through /compliance/invoices, this post is for you. It is the single most confusing failure mode in the whole integration, and the fix is not what the error suggests.
The trap: SignedProperties exists in two byte-shapes
The XAdES SignedProperties block is referenced twice in your signed document:
-
ds:Reference URI="#xadesSignedProperties"carries a digest of the block. - The block itself is embedded inside
ds:Object > xades:QualifyingProperties.
The natural assumption is that both refer to the same bytes. They do not.
The hashed shape carries namespace declarations and starts at column 0:
<xades:SignedProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Id="xadesSignedProperties">
<xades:SignedSignatureProperties>
<xades:SigningTime>2026-08-07T02:14:33</xades:SigningTime>
<xades:SigningCertificate>
<xades:Cert>
<xades:CertDigest>
<ds:DigestMethod xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
The embedded shape carries no namespace declarations (they are inherited from ancestors) and its root element is indented to column 32:
<xades:SignedProperties Id="xadesSignedProperties">
<xades:SignedSignatureProperties>
Embed the hashed shape verbatim - the intuitive thing to do - and the gateway rejects with signed-properties-hashing, even though your indentation "looks right".
The second half of the trap: the digest encoding
The digest is not the raw SHA-256 bytes in base64. It is base64 of the hex string:
const crypto = require('crypto');
// hashedShape = the namespaced, column-0 variant above
const propsDigest = Buffer.from(
crypto.createHash('sha256').update(Buffer.from(hashedShape, 'utf8')).digest('hex'),
'utf8'
).toString('base64');
If you used .digest('base64') you produced a valid-looking digest that ZATCA will never match. The same convention applies to the certificate digest.
Why compliance checks did not catch it
This is the part that costs people days: /compliance/invoices does not verify this digest at all. Only /invoices/reporting/single (and clearance) does.
So the workflow that feels safe - pass all four compliance sample documents, obtain your production CSID, celebrate - proves nothing about this block. Your first real invoice is the first time anything checks it.
Rule of thumb: compliance-green is not reporting-green. The only proof your signing is correct is an accepted reporting call.
The fix
Generate both shapes from one function so they can never drift:
const signedPropsBlock = (withNamespaces) => {
const xades = withNamespaces ? ' xmlns:xades="http://uri.etsi.org/01903/v1.3.2#"' : '';
const ds = withNamespaces ? ' xmlns:ds="http://www.w3.org/2000/09/xmldsig#"' : '';
const lead = withNamespaces ? '' : ' '.repeat(32);
return \`\${lead}<xades:SignedProperties\${xades} Id="xadesSignedProperties">
<xades:SignedSignatureProperties>
<xades:SigningTime>\${signingTime}</xades:SigningTime>
</xades:SignedProperties>\`;
};
const hashed = signedPropsBlock(true); // digest this one
const embedded = signedPropsBlock(false); // put this one in the document
Two more details that bite here:
-
SigningTimemust have no Z suffix and no milliseconds - the validator reformats it as%Y-%m-%dT%H:%M:%S. - The indentation inside the template is part of the contract. The validator rebuilds this block from a fixed template and compares digests, so "prettifying" your XML output breaks it.
And while you are here: the hash scope
A related failure that produces a different opaque rejection - the invoice hash itself is SHA-256 over the canonical XML without the XML declaration (C14N drops it) and without three elements: ext:UBLExtensions, cac:Signature, and the AdditionalDocumentReference whose ID is QR.
When you insert those blocks after hashing, they must add zero new text nodes. The verifier strips element subtrees but keeps surrounding whitespace - so a single newline between the inserted QR reference and cac:Signature changes the canonical form and breaks the hash. Butt them directly together, with no whitespace between the two elements.
Nine more of these
ZATCA's real contract lives in its gateway's behavior, not its documentation. I hit ten undocumented rules like this while shipping a POS integration - each discovered through a live rejection, none caught by 256 green local tests. Things like: each environment demanding a different CSR certificate template (wrong one gives a bare Invalid Request before your CSR is even read), the device serial belonging in the surname OID because ZATCA's own OpenSSL config wrote SN=, and secp256k1 working in Node but throwing UNKNOWN_GROUP inside Electron.
I documented all ten, free and in full: github.com/mousah20/zatca-phase2-traps
If you just need the QR part, the TLV encoder (all 9 Phase-2 tags, byte-correct for Arabic seller names) is on npm as zatca-qr-tlv, MIT licensed.
And if you would rather not spend a month on this at all, I packaged the entire integration - onboarding, signing, QR, reporting and clearance - as a production-proven Node.js SDK with a live test you can run against ZATCA's sandbox in two minutes.
Not affiliated with ZATCA. Behavior described as observed in August 2026.
Top comments (0)