<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Kodenova Engineering]]></title><description><![CDATA[Kodenova Engineering]]></description><link>https://kodenova.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 31 Oct 2026 17:30:10 GMT</lastBuildDate><atom:link href="https://kodenova.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Onboarding ZATCA CSIDs for SAP Business One When You Have More Than One Branch]]></title><description><![CDATA[I wrote this with help from an AI writing assistant and checked every ZATCA step against ZATCA's own published guidelines. The SAP Business One parts come…]]></description><link>https://kodenova.hashnode.dev/onboarding-zatca-csids-for-sap-business-one-when-you-have-more-than-one-branch</link><guid isPermaLink="true">https://kodenova.hashnode.dev/onboarding-zatca-csids-for-sap-business-one-when-you-have-more-than-one-branch</guid><category><![CDATA[e-invoicing]]></category><category><![CDATA[Saudi Arabia]]></category><category><![CDATA[ERP]]></category><category><![CDATA[zatca]]></category><category><![CDATA[sap business one]]></category><dc:creator><![CDATA[Zain Abbas]]></dc:creator><pubDate>Wed, 28 Oct 2026 08:00:01 GMT</pubDate><content:encoded><![CDATA[<p><em>I wrote this with help from an AI writing assistant and checked every ZATCA step against ZATCA's own published guidelines. The SAP Business One parts come from integration work.</em></p>
<p>Most ZATCA Phase 2 projects on SAP Business One don't stall on XML. They stall on onboarding: who generates the OTP, how many EGS units you actually have, and what happens when the second branch or the second company database turns up halfway through testing. This piece is about getting that part right the first time.</p>
<h2>What is a CSID, and why does SAP Business One need more than one?</h2>
<p>A Cryptographic Stamp Identifier (CSID) is the certificate ZATCA issues to each EGS unit, the "e-invoice generation solution" that signs and submits invoices. Each EGS unit gets its own CSID. In SAP Business One terms, every place that independently issues and signs invoices should be treated as its own EGS unit with its own CSID and its own invoice counter.</p>
<p>The detail people miss is that the CSID is tied to the EGS unit, not to the company. A single SAP Business One company database with three branches posting their own A/R invoices is usually not one EGS unit. If you register one unit and let three branches sign through it, you share one invoice counter (ICV) and one previous-invoice-hash chain across all three. That chain breaks the moment two branches post at the same time.</p>
<h2>How does the onboarding flow actually work?</h2>
<p>ZATCA's flow comes down to four steps: generate an OTP on the FATOORA portal, send a CSR with that OTP to get a compliance CSID, pass the compliance checks with sample documents, then exchange the compliance CSID for a production CSID. Only the production CSID can call the clearance and reporting APIs.</p>
<p>In more detail, from ZATCA's Detailed Technical Guideline and the FATOORA portal manual:</p>
<ol>
<li>Someone with the taxpayer's ERAD credentials logs in to FATOORA, opens "Onboard new solution unit/device" and generates OTP codes. You can request 1 to 100 codes at once. Each code is valid for one hour.</li>
<li>The EGS unit builds a CSR and calls the compliance endpoint (<code>/e-invoicing/core/compliance</code>) with the OTP in the header. The response gives a <code>binarySecurityToken</code>, a <code>secret</code> and a <code>requestID</code>. That token and secret are the compliance CSID.</li>
<li>Using the compliance CSID as Basic Auth, the unit sends sample documents to <code>/compliance/invoices</code>.</li>
<li>With all checks passed, the unit calls <code>/production/csids</code> with the <code>requestID</code> and receives the production CSID.</li>
</ol>
<p>If any compliance test fails or is skipped, the guideline says the unit has to start again from a new OTP and a new CSR. That one-hour OTP window is why I never let anyone generate codes until the integration is ready to fire the CSR immediately.</p>
<h2>What goes into the CSR, and where does it come from in SAP Business One?</h2>
<p>The CSR carries the taxpayer and unit identity: the 15-digit VAT number, organisation name, branch or location, industry, a unit serial number in the form <code>1-&lt;solution name&gt;|2-&lt;version&gt;|3-&lt;unique serial&gt;</code>, and the invoice type flag. Most of these already live in SAP Business One, so read them from there instead of retyping them.</p>
<p>Where I pull them from:</p>
<ul>
<li><strong>VAT number</strong>: <code>OADM.TaxIdNum</code> for a single-entity company, or the branch record in <code>OBPL</code> when Multiple Branches is enabled. <code>OBPL</code> has both <code>VATRegNum</code> and <code>TaxIdNum</code> (labelled "Branch Reg. No."), and localisations don't always fill the same one, so check which field actually holds the 15-digit VAT number in your database before you map it.</li>
<li><strong>Organisation name</strong>: <code>OADM.CompnyName</code> (or the legal name on the branch).</li>
<li><strong>Branch / location</strong>: <code>OBPL.BPLName</code> for the branch the EGS unit belongs to.</li>
<li><strong>Unique serial</strong>: something stable you control, for example database name plus branch ID. It must never be reused for a different unit.</li>
</ul>
<p>A quick query to see what you are working with before you count EGS units:</p>
<pre><code class="language-sql">SELECT T0."BPLId", T0."BPLName", T0."VATRegNum", T0."TaxIdNum", T0."MainBPL", T0."Disabled"
FROM OBPL T0
ORDER BY T0."BPLId";
</code></pre>
<p>If the VAT values are blank or inconsistent here, fix that in SAP Business One first. A CSR built on a wrong VAT number will happily get a compliance CSID and then fail you later.</p>
<h2>Which invoice type flag should you choose: 1000, 0100 or 1100?</h2>
<p>The flag tells ZATCA what the unit will issue. <code>1000</code> means standard (B2B) tax invoices only, <code>0100</code> means simplified (B2C) only, and <code>1100</code> means both. It also decides how many compliance samples you must pass: three for 1000 or 0100, six for 1100.</p>
<p>ZATCA's guideline and its developer community answers spell out the samples:</p>
<ul>
<li><strong>1000</strong>: standard invoice, standard credit note, standard debit note.</li>
<li><strong>0100</strong>: simplified invoice, simplified credit note, simplified debit note.</li>
<li><strong>1100</strong>: all six.</li>
</ul>
<p>On SAP Business One the safe default for a trading company is <code>1100</code>, because sooner or later someone issues a cash sale to a walk-in customer from the same database. Choosing <code>1000</code> to save three test documents and then discovering B2C sales after go-live means onboarding the unit again.</p>
<p>The sample documents should come out of SAP Business One itself, not from the SDK sample folder. Post a real A/R invoice (<code>OINV</code>), an A/R credit memo (<code>ORIN</code>) and a debit note the way your business actually raises it, for both a business partner with a VAT number in <code>OCRD.LicTradNum</code> and one without. The compliance step is the cheapest place to find out your mapping is wrong.</p>
<h2>How should multiple branches and multiple company databases be handled?</h2>
<p>Treat each independent signing point as its own EGS unit. One SAP Business One database with branches that post separately usually means one EGS unit per branch. Separate company databases always mean separate EGS units, even when they share a VAT group.</p>
<p>What that means in practice:</p>
<ul>
<li><strong>Per-unit storage.</strong> Keep the private key, production CSID, secret, ICV counter and last invoice hash per EGS unit, not per database. A user-defined table keyed by database and <code>BPLId</code> works well.</li>
<li><strong>Per-unit counters.</strong> The ICV and previous invoice hash must advance per unit. If two branches share them you will see hash-chain errors on whichever document lost the race.</li>
<li><strong>OTP batches.</strong> Generate OTPs only for the units you can onboard in the next hour. The portal lets you ask for up to 100, but unused codes simply expire.</li>
<li><strong>New branch later.</strong> A branch added after go-live is a new onboarding: new OTP, new CSR, new compliance samples. Put that in your branch-opening checklist so it doesn't surface as rejected invoices.</li>
</ul>
<h2>What about renewal, and testing before production?</h2>
<p>Renewal follows the same pattern. FATOORA has a separate "Renewing Existing CSID" option that issues OTPs for units whose certificates need renewing, and the unit sends a new CSR. Run the whole flow in the simulation environment first, using OTPs generated from the simulation portal.</p>
<p>Two habits that save trouble:</p>
<ul>
<li>Log the certificate expiry date for every unit and alert someone well before it. A lapsed certificate stops clearance for that branch on the day it lapses.</li>
<li>Keep simulation and production credentials in separate records. Mixing them up is one of the more common reasons a unit that "passed" in testing fails on day one.</li>
</ul>
<h2>A short checklist before you generate the first OTP</h2>
<ul>
<li>Count EGS units: one per independent signing point (branch or database).</li>
<li>Clean <code>OADM</code> and <code>OBPL</code> tax and name fields.</li>
<li>Pick the invoice type flag per unit (default <code>1100</code> unless you are sure).</li>
<li>Prepare real SAP Business One sample documents for every required type.</li>
<li>Build per-unit storage for keys, CSIDs, ICV and hash.</li>
<li>Rehearse in simulation, then onboard production within the OTP's one-hour window.</li>
</ul>
<hr />
<p><em>I'm Zain, founder of Kodenova. I build and support <a href="https://kodenova.com/e-invoicing/zatca-saudi-arabia/">ZATCA Phase 2 integration for SAP Business One</a>, including CSID onboarding for multi-branch setups. If you're working through this and get stuck on a specific step, the comments are open.</em></p>
]]></content:encoded></item><item><title><![CDATA[ZATCA Phase 2 in SAP Business One: Routing Each Document to Clearance or Reporting]]></title><description><![CDATA[Which B1 documents must wait for FATOORA, which can go out first, and how to build the queue so nothing slips past the 24-hour window.]]></description><link>https://kodenova.hashnode.dev/zatca-phase-2-in-sap-business-one-routing-each-document-to-clearance-or-reporting</link><guid isPermaLink="true">https://kodenova.hashnode.dev/zatca-phase-2-in-sap-business-one-routing-each-document-to-clearance-or-reporting</guid><category><![CDATA[SAP]]></category><category><![CDATA[ERP]]></category><category><![CDATA[e-invoicing]]></category><category><![CDATA[Saudi Arabia]]></category><category><![CDATA[SQL]]></category><dc:creator><![CDATA[Zain Abbas]]></dc:creator><pubDate>Tue, 27 Oct 2026 05:04:46 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>A note on how this was written: I drafted this article with help from an AI writing assistant and then checked and edited it myself. The design choices come from SAP Business One integration work; the ZATCA rules are quoted from ZATCA's own published guidelines on zatca.gov.sa.</p>
</blockquote>
<p>Most ZATCA Phase 2 conversations about SAP Business One start with the certificate (CSID) and the XML. The question that actually shapes the integration comes earlier: for each document B1 creates, does it have to be <strong>cleared</strong> by FATOORA before the customer sees it, or can it be <strong>reported</strong> afterwards? Get that routing wrong and you either block a cashier for no reason or hand a B2B customer an invoice that isn't legally valid yet.</p>
<h2>What is the difference between clearance and reporting in ZATCA Phase 2?</h2>
<p>Clearance applies to standard Tax Invoices (usually B2B) and their credit and debit notes: your system sends the invoice to FATOORA first, ZATCA validates and stamps it, and only then can you share it with the buyer. Reporting applies to Simplified Tax Invoices (usually B2C) and their notes: you stamp and hand it over immediately, then submit the XML within 24 hours.</p>
<p>ZATCA's Detailed Guideline is explicit on both points. On clearance, it says each tax invoice "must be cleared by the Authority as a prerequisite for sharing them with the buyers and for such Electronic Invoice to be regarded as legal and valid", and that clearance "is not applicable to Simplified Tax Invoices". On reporting, it says taxpayers "will be required to transmit all Simplified Tax Invoices to the FATOORA Portal within (24) hours from its issuance".</p>
<p>Two practical consequences for B1:</p>
<ul>
<li>A cleared invoice carries ZATCA's cryptographic stamp. A reported invoice carries <strong>your</strong> stamp, made with your own CSID, plus the Phase 2 QR code, before it is printed.</li>
<li>Clearance is synchronous from the user's point of view. Reporting is a background job.</li>
</ul>
<h2>Which SAP Business One documents need clearance, and which need reporting?</h2>
<p>In SAP Business One the routing decision belongs to the document, not to the document type. An A/R Invoice (OINV) can be a standard tax invoice for one customer and a simplified invoice for the next. The same goes for A/R Credit Memos (ORIN) and A/R Down Payment Invoices (ODPI). You classify each one when it is added, then route it.</p>
<p>The documents that usually fall in scope on the sales side:</p>
<table>
<thead>
<tr>
<th>B1 object</th>
<th>Table</th>
<th>ZATCA document</th>
</tr>
</thead>
<tbody><tr>
<td>A/R Invoice</td>
<td>OINV</td>
<td>Tax invoice (388)</td>
</tr>
<tr>
<td>A/R Credit Memo</td>
<td>ORIN</td>
<td>Credit note (381)</td>
</tr>
<tr>
<td>A/R Down Payment Invoice</td>
<td>ODPI</td>
<td>Prepayment invoice (386)</td>
</tr>
<tr>
<td>Debit notes (however your localization models them)</td>
<td>usually OINV</td>
<td>Debit note (383)</td>
</tr>
</tbody></table>
<p>Inside the XML, the invoice type code carries a <code>name</code> attribute whose first two characters say which flow it is: <code>01</code> for a standard tax invoice and <code>02</code> for a simplified one. That one attribute is what ties the B1 classification to the clearance or reporting API, so it should be set in exactly one place in your code.</p>
<p>A/R Reserve Invoices are the one I would flag in any B1 project. Teams use them for very different business reasons, and the answer on whether and when they are issued as e-invoices is a tax decision for the client's advisor, not something the integration should guess.</p>
<h2>How should B1 decide whether a document is standard or simplified?</h2>
<p>Classify on the buyer, not on the user's choice of form. A buyer with a valid 15-digit VAT number, or a business buyer that needs the full tax invoice details, goes the standard route. Walk-in and consumer sales go the simplified route. Store the result on the document header so it never gets recalculated later.</p>
<p>In B1 I keep three user-defined fields on each marketing document header (OINV, ORIN, ODPI):</p>
<ul>
<li><code>U_ZATCA_Type</code>: <code>STD</code> or <code>SMP</code>, set once when the document is added.</li>
<li><code>U_ZATCA_Status</code>: <code>PENDING</code>, <code>CLEARED</code>, <code>REPORTED</code>, <code>WARNING</code> or <code>REJECTED</code>.</li>
<li><code>U_ZATCA_UUID</code>: the UUID that went into the XML, so you can match FATOORA responses back to <code>DocEntry</code>.</li>
</ul>
<p>The buyer's VAT number normally sits in the business partner's Federal Tax ID (<code>OCRD.LicTradNum</code>), copied onto the document. A quick check that saves a lot of clearance rejections later is to list customers whose tax ID is missing or obviously malformed before go-live:</p>
<pre><code class="language-sql">SELECT T0."CardCode", T0."CardName", T0."LicTradNum"
FROM OCRD T0
WHERE T0."CardType" = 'C'
  AND T0."frozenFor" = 'N'
  AND (T0."LicTradNum" IS NULL
       OR LENGTH(T0."LicTradNum") &lt;&gt; 15
       OR T0."LicTradNum" NOT LIKE '3%3');
</code></pre>
<p>That is HANA syntax; on SQL Server use <code>LEN()</code> instead of <code>LENGTH()</code>. Saudi VAT numbers are 15 digits and start and end with 3, so anything this query returns either needs fixing or should be treated as a simplified-invoice customer.</p>
<p>One more rule worth enforcing: once <code>U_ZATCA_Type</code> is set, block changes to it with the B1 transaction notification (<code>SBO_SP_TransactionNotification</code>) or your add-on's validation. A document that silently flips from simplified to standard after printing is a mess to unwind.</p>
<h2>What does the clearance flow look like from an SAP B1 user's seat?</h2>
<p>The user adds the A/R Invoice, the integration builds and signs the XML, sends it to FATOORA's clearance endpoint, and waits. If ZATCA accepts it, the cleared XML and QR code come back and the print layout uses those. If ZATCA rejects it, the user sees the error before anyone has printed or emailed anything.</p>
<p>Design points that matter in practice:</p>
<ol>
<li><strong>Do not print from the B1 document alone.</strong> The Crystal or PLD layout should read the QR and status from the stored ZATCA response, and refuse to print a standard invoice whose status is not <code>CLEARED</code> or <code>WARNING</code>.</li>
<li><strong>Decide what happens on a rejection.</strong> The document already exists in B1 with its journal entry posted. In most designs the fix is a credit memo plus a corrected invoice, or a correction to master data and a resubmission, depending on the error. Write that rule down with the finance team before go-live, not during the first rejection.</li>
<li><strong>Timeouts are not rejections.</strong> If FATOORA does not answer, keep the document <code>PENDING</code> and retry. Do not resend under a new UUID, or you end up with two submissions for one invoice.</li>
<li><strong>Keep the chain intact.</strong> Every Phase 2 XML carries the previous invoice hash and an invoice counter for that EGS unit. If two users post at the same moment through one EGS unit, the hashing and counter assignment must be serialized. A single integration service per EGS unit is far easier to reason about than logic inside each client add-on.</li>
</ol>
<h2>How do you make sure simplified invoices are reported inside 24 hours?</h2>
<p>Treat reporting as a queue with an alarm, not a fire-and-forget call. Stamp and print the simplified invoice immediately, insert it as <code>PENDING</code>, and let a background service report it. Then run a check that flags anything still pending after a few hours, long before the 24-hour limit ZATCA sets.</p>
<p>A simple watchdog query I would schedule as a B1 alert or in the service's own monitor:</p>
<pre><code class="language-sql">SELECT 'OINV' AS "Obj", T0."DocEntry", T0."DocNum", T0."CardCode",
       T0."CreateDate", T0."CreateTS", T0."U_ZATCA_Status"
FROM OINV T0
WHERE T0."U_ZATCA_Type" = 'SMP'
  AND T0."U_ZATCA_Status" IN ('PENDING', 'REJECTED')
UNION ALL
SELECT 'ORIN', T0."DocEntry", T0."DocNum", T0."CardCode",
       T0."CreateDate", T0."CreateTS", T0."U_ZATCA_Status"
FROM ORIN T0
WHERE T0."U_ZATCA_Type" = 'SMP'
  AND T0."U_ZATCA_Status" IN ('PENDING', 'REJECTED');
</code></pre>
<p><code>CreateTS</code> in B1 is stored as an integer time (HHMMSS), so combine it with <code>CreateDate</code> in the service code rather than in SQL if you want an exact age in hours. The point of the query is that a non-empty result is somebody's job today.</p>
<p>Things that cause late reporting in real B1 environments are rarely exotic: the integration service stopped after a Windows update, the certificate expired, a branch posted offline, or one rejected document blocked the queue behind it. Report documents independently, keep processing after a single failure, and send the failures to a person, not only to a log file.</p>
<h2>Do warnings from FATOORA need action?</h2>
<p>Yes, even though the document is accepted. FATOORA can accept a document with warnings, and ZATCA's guideline lists "accepted with one or more warning(s)" as a separate outcome from a clean pass. Store the warning text against the document, review the pattern weekly, and fix the root cause in master data or mapping before it turns into errors.</p>
<p>In B1 that usually means setting <code>U_ZATCA_Status = 'WARNING'</code>, keeping the full response in a user-defined table keyed by object type and <code>DocEntry</code>, and giving finance a simple query over it. Rounding on line-level VAT and incomplete buyer address data are the kind of things that show up here first.</p>
<h2>Before you build: a short checklist</h2>
<ul>
<li>Agree with finance and the tax advisor which customers are standard and which are simplified, and encode that on the business partner.</li>
<li>Clean <code>LicTradNum</code> and buyer addresses for every standard-invoice customer.</li>
<li>One integration service per EGS unit, serializing hash and counter assignment.</li>
<li>Print layouts read the ZATCA response, never the raw B1 document.</li>
<li>A written rule for rejected cleared invoices, and a watchdog for simplified invoices that are not yet reported.</li>
<li>Validate sample XML from each document type in the sandbox before production onboarding. Kodenova has a free <a href="https://kodenova.com/tools/zatca-phase-2-invoice-validator/">ZATCA Phase 2 invoice XML, QR and hash validator</a> that is handy for checking test output.</li>
</ul>
<h2>About the author</h2>
<p>I'm Zain Abbas, founder of Kodenova. I build ZATCA Phase 2 integrations for SAP Business One, along with other SAP B1 add-ons and Service Layer integrations. If you're planning the routing for your own B1 company and want a second pair of eyes, you can reach me at <a href="mailto:hello@kodenova.com">hello@kodenova.com</a>.</p>
<p><em>Sources: ZATCA, "E-Invoicing Detailed Guideline" (sections 2.15, 2.16, 4.2.2, 6.4) and "E-invoicing Detailed Technical Guidelines" (section 4.3.1), both on zatca.gov.sa; ZATCA, "What is E-invoicing?" and "Roll-out phases" pages, checked 27 Oct 2026.</em></p>
]]></content:encoded></item><item><title><![CDATA[SAP Business One Service Layer $batch: Atomic Multi-Document Writes Without Losing Your Mind]]></title><description><![CDATA[How change sets, Content-ID references and the multipart format actually behave, with a small Python client you can adapt.]]></description><link>https://kodenova.hashnode.dev/sap-business-one-service-layer-batch-atomic-multi-document-writes-without-losing-your-mind</link><guid isPermaLink="true">https://kodenova.hashnode.dev/sap-business-one-service-layer-batch-atomic-multi-document-writes-without-losing-your-mind</guid><category><![CDATA[OData]]></category><category><![CDATA[Python]]></category><category><![CDATA[ERP]]></category><category><![CDATA[SAP]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Zain Abbas]]></dc:creator><pubDate>Thu, 22 Oct 2026 04:13:30 GMT</pubDate><content:encoded><![CDATA[<p>If you integrate with SAP Business One, sooner or later you need two or three writes to succeed or fail together: create a sales order and its invoice, or add a business partner and the first order for it. Doing that as separate HTTP calls leaves you with half-finished data when the second call fails.</p>
<p>The SAP Business One Service Layer solves this with OData batch requests and change sets. This post walks through the format, the parts that trip people up, and a minimal Python client. Drafted with AI assistance and reviewed for accuracy by Kodenova. Everything below was checked against SAP's "Working with SAP Business One Service Layer" guide and the SAP Learning lesson "Performing Batch Operations in Service Layer API".</p>
<h2>What is a Service Layer $batch request?</h2>
<p>A batch request groups several Service Layer operations into one HTTP POST to the <code>$batch</code> endpoint, for example <code>POST https://&lt;host&gt;:50000/b1s/v2/$batch</code>. The body is a multipart/mixed MIME message with a boundary you choose. The Service Layer processes the sub-requests in order, top to bottom.</p>
<p>Each sub-request is its own MIME part with two required headers:</p>
<pre><code>Content-Type: application/http
Content-Transfer-Encoding: binary
</code></pre>
<p>After a blank line comes a normal HTTP request line (<code>GET</code>, <code>POST</code>, <code>PATCH</code>, ...), optional headers, a blank line, and the JSON body.</p>
<h2>What does a change set add?</h2>
<p>A change set is a nested multipart block inside the batch that behaves as one atomic unit: if any sub-request inside it fails, the whole change set is rolled back. Change sets cannot contain GET requests or other change sets. They are what you want for "all or nothing" document creation.</p>
<p>On the <code>/b1s/v2</code> (OData v4) endpoint, the SAP guide says the <code>Content-ID</code> header is mandatory for every sub-request in a change set, whether you reference it or not. On <code>/b1s/v1</code> (OData v3) it is only needed when you reference it.</p>
<h2>How do I reference a document created earlier in the same change set?</h2>
<p>Give the creating sub-request a <code>Content-ID</code>, then use <code>$&lt;Content-ID&gt;</code> in a later sub-request of the same change set. In a URL, <code>$1</code> stands for the URI of the entity created by Content-ID 1, so <code>PATCH $1</code> updates it and <code>POST $1/Cancel</code> cancels it. SAP's own examples also use <code>$1</code> inside JSON bodies, for example as a <code>BaseEntry</code>.</p>
<p>Here is the order-plus-invoice example from the SAP Learning lesson, trimmed and with line breaks restored:</p>
<pre><code>POST https://localhost:50000/b1s/v2/$batch
Content-Type: multipart/mixed;boundary=batch_36522ad7

--batch_36522ad7
Content-Type: multipart/mixed;boundary=changeset_77162fcd

--changeset_77162fcd
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 1

POST Orders

{"CardCode":"C20000","DocDueDate":"2024-11-04",
 "DocumentLines":[{"ItemCode":"A00001","Quantity":"2","TaxCode":"A2","UnitPrice":"300"}]}
--changeset_77162fcd
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 2

POST Invoices

{"CardCode":"C20000","DocDueDate":"2024-11-04",
 "DocumentLines":[{"BaseType":17,"BaseEntry":$1,"BaseLine":0,"Quantity":"1"}]}
--changeset_77162fcd--
--batch_36522ad7--
</code></pre>
<p>Note that <code>"BaseEntry":$1</code> is not valid JSON. The Service Layer substitutes it before parsing, so don't run the body through a JSON serializer after inserting the placeholder. Build the string yourself.</p>
<h2>What does the response look like, and how do I know it worked?</h2>
<p>If the batch format is valid, the Service Layer returns 200 OK on v2 (202 Accepted on v1) even when a sub-request failed. The real results are inside the multipart response body, one HTTP status line per sub-response. You must parse those; checking only the outer status code will hide failures.</p>
<p>Two rules from the SAP guide matter here:</p>
<ol>
<li>The batch stops executing at the first failed sub-request.</li>
<li>If a change set fails, you get one error response for the whole change set, not one per sub-request.</li>
</ol>
<p>A malformed body (missing boundary, wrong line endings) returns a 400 with a message like "Incomplete Batch Request Body!".</p>
<h2>A minimal Python client</h2>
<p>This uses <code>requests</code>. A <code>requests.Session</code> keeps the <code>B1SESSION</code> cookie from <code>/Login</code>, and the <code>ROUTEID</code> cookie if your Service Layer runs behind its load balancer, so every call stays on the same node.</p>
<pre><code class="language-python">import re
import uuid
import requests

SL = "https://sl.example.local:50000/b1s/v2"
CRLF = "\r\n"

s = requests.Session()
s.verify = "/etc/ssl/certs/sl-ca.pem"   # don't use verify=False outside a lab
s.post(f"{SL}/Login", json={
    "CompanyDB": "SBODEMOGB", "UserName": "manager", "Password": "***"
}).raise_for_status()


def sub_request(method, path, body=None, content_id=None):
    head = ["Content-Type: application/http",
            "Content-Transfer-Encoding: binary"]
    if content_id is not None:
        head.append(f"Content-ID: {content_id}")
    req = [f"{method} {path}"]
    if body is None:
        req += ["", ""]                 # end of headers + empty body
    else:
        req += ["Content-Type: application/json", "", body]
    return CRLF.join(head + [""] + req)


def changeset_batch(parts):
    b, c = f"batch_{uuid.uuid4()}", f"changeset_{uuid.uuid4()}"
    lines = [f"--{b}", f"Content-Type: multipart/mixed;boundary={c}", ""]
    for p in parts:
        lines += [f"--{c}", p]
    lines += [f"--{c}--", f"--{b}--", ""]
    return b, CRLF.join(lines)


order = ('{"CardCode":"C20000","DocumentLines":'
         '[{"ItemCode":"A00001","Quantity":"2"}]}')
invoice = ('{"CardCode":"C20000","DocumentLines":'
           '[{"BaseType":17,"BaseEntry":$1,"BaseLine":0}]}')

boundary, body = changeset_batch([
    sub_request("POST", "Orders", order, content_id=1),
    sub_request("POST", "Invoices", invoice, content_id=2),
])

r = s.post(f"{SL}/$batch", data=body.encode("utf-8"),
           headers={"Content-Type": f"multipart/mixed;boundary={boundary}"})
r.raise_for_status()

statuses = [int(x) for x in re.findall(r"^HTTP/1\.1 (\d{3})", r.text, re.M)]
if not statuses or any(code &gt;= 400 for code in statuses):
    raise RuntimeError(f"Batch failed: {statuses}\n{r.text[:2000]}")
print("Created:", statuses)   # e.g. [201, 201]
</code></pre>
<p>The status-line regex is deliberately simple. For production, parse the multipart response properly so you can read the error JSON and the <code>Location</code> headers of created documents.</p>
<h2>Which mistakes break batch requests most often?</h2>
<p>The usual culprits are LF instead of CRLF line endings, a missing blank line between MIME headers and the request line, a GET placed inside a change set, a missing Content-ID on v2, and treating the outer 200 as success. Fix those five and most "Incomplete Batch Request Body" errors disappear.</p>
<p>A few design notes from real integrations:</p>
<ul>
<li><strong>Keep change sets small.</strong> One business transaction per change set (order plus invoice, BP plus first order). A 300-document change set holds locks longer and makes one bad line roll back everything.</li>
<li><strong>Plan for the unknown outcome.</strong> If the connection drops after the Service Layer committed but before you read the response, a blind retry creates duplicates. Put your external reference in a field such as <code>NumAtCard</code> or a user-defined field and query for it before retrying.</li>
<li><strong>Mind the session.</strong> A long-running batch job should handle an expired session (the default <code>SessionTimeout</code> is 30 minutes) by logging in again and re-checking state, not by resending everything.</li>
<li><strong>Don't use batch for bulk reads.</strong> For large reads, normal paged GET requests with <code>$select</code> and <code>$filter</code> are easier to reason about.</li>
</ul>
<h2>When should I not use $batch?</h2>
<p>Skip batching when operations are independent and can fail separately, for example syncing 500 unrelated item prices. Separate calls with retries give clearer error handling there. Use change sets when partial success would leave inconsistent business data.</p>
<hr />
<p>I founded Kodenova, where I build SAP Business One integrations, including B1Codex, our own connector that reads and writes through the Service Layer and DI API. If batching is part of your integration work, more of what we do is here: <a href="https://kodenova.com/?utm_source=hashnode&amp;utm_medium=social&amp;utm_campaign=daily-20261022">https://kodenova.com/?utm_source=hashnode&amp;utm_medium=social&amp;utm_campaign=daily-20261022</a></p>
]]></content:encoded></item></channel></rss>