If you have a job queue entry in Business Central that talks to an external system (pushing an export, calling a payment gateway, posting to a shipping API) there’s a failure mode that’s easy to miss until it bites you in production: the AL transaction can roll back after the external call has already succeeded.
The problem in plain terms
Imagine to have a business process (handled via job queue) where you do some data processing inside BC and then you need to post data to an external system.
AL wraps most operations in an implicit transaction. If a job queue handler throws an error, Business Central rolls back every database change made in that run (that’s the whole point of a transaction, it’s supposed to leave you in a clean, consistent state).
But an HTTP call to an external system is not part of that transaction. Once the external system has processed the request, there is no “undo” Business Central can trigger automatically. If your handler calls the external API first and only updates local records afterward, a failure in the second half leaves you with:
- An external system that has already created the export, shipment, or payment.
- A BC database that has rolled back and has no record it ever happened.
The job queue is built to retry failed entries automatically when started with the next iteration, so the same outbox row gets picked up again, and the same request gets sent again. Now you have a duplicate export, a duplicate shipment, a duplicate payment (because from BC’s point of view, nothing succeeded yet).
The anti-pattern is a specific, recognizable sequence in a job queue handler:
- Read an Business Central record.
- Call
HttpClient.Post(or any other side-effecting API) with no stable request identifier the external system understands. - Update or delete the local row.
- Any error in step 3 (or later) causes the row to be retried, and step 2 fires again.
Here’s what that looks like in AL (pseudo code):
table 50112 "Queued Export Bad"{ DataClassification = CustomerContent; fields { field(1; "Entry No."; Integer) { AutoIncrement = true; } field(2; Payload; Text[250]) { } } keys { key(PK; "Entry No.") { Clustered = true; } }}codeunit 50112 "Queued Export Worker Bad"{ TableNo = "Job Queue Entry"; trigger OnRun() var QueuedExport: Record "Queued Export Bad"; Client: HttpClient; Content: HttpContent; Response: HttpResponseMessage; begin if not QueuedExport.FindFirst() then exit; Content.WriteFrom(QueuedExport.Payload); Client.Post('https://example.local/exports', Content, Response); if not Response.IsSuccessStatusCode() then Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); // If this local step fails, the external export exists but this row is retried. UpdateLocalStatus(); FinalizeExport(QueuedExport); end; local procedure UpdateLocalStatus() begin end; local procedure FinalizeExport(var QueuedExport: Record "Queued Export Bad") begin QueuedExport.Delete(); end;}
The comment right before UpdateLocalStatus() says it all: if that local step (or the delete right after it) fails for any reason, the export has already happened on the external side, but BC still thinks the row is pending. Next run, it’s sent again.
It’s worth noting one thing this pattern does not need: a Processed flag set right after the HTTP call. That might look like a fix, but it isn’t one. If a later AL error rolls back that flag along with everything else, the row looks unprocessed again even though the external call already went through. The flag lives inside the same transaction as the rest of the failure, so it rolls back with it.
The fix: a stable idempotency key the external system enforces
The reliable fix is to give the external system a stable request ID that exists before the call is made — and to make the external service itself responsible for deduplicating on that ID.
In Business Central, the natural source for that ID is the outbox row’s own SystemId. It’s a GUID, it’s assigned when the row is created, and it doesn’t change across retries of the same row (exactly what an idempotency key needs to be).
table 50112 "Queued Export Good"{ DataClassification = CustomerContent; fields { field(1; "Entry No."; Integer) { AutoIncrement = true; } field(2; Payload; Text[250]) { } } keys { key(PK; "Entry No.") { Clustered = true; } }}codeunit 50112 "Queued Export Worker Good"{ TableNo = "Job Queue Entry"; trigger OnRun() var QueuedExport: Record "Queued Export Good"; Client: HttpClient; Content: HttpContent; ContentHeaders: HttpHeaders; JsonPayload: JsonObject; RequestBody: Text; Response: HttpResponseMessage; begin if not QueuedExport.FindFirst() then exit; JsonPayload.Add('idempotencyKey', Format(QueuedExport.SystemId)); JsonPayload.Add('payload', QueuedExport.Payload); JsonPayload.WriteTo(RequestBody); Content.WriteFrom(RequestBody); Content.GetHeaders(ContentHeaders); ContentHeaders.Clear(); ContentHeaders.Add('Content-Type', 'application/json'); Client.Post('https://example.local/exports', Content, Response); if not Response.IsSuccessStatusCode() then Error('Export failed with HTTP status %1.', Response.HttpStatusCode()); // The external service must atomically create a record only when idempotencyKey // does not exist. When the key already exists, it must return the existing record // without repeating the side effect. UpdateLocalStatus(); QueuedExport.Delete(); end; local procedure UpdateLocalStatus() begin end;}
In this second example two things changed, and both matter:
SystemIdtravels in the request body asidempotencyKey. It exists before the job queue ever calls the handler, and it’s the same value on every retry of the same row (so the external system always sees the same key for the same logical attempt).- The outbox row is only deleted after the external call succeeds. If anything fails before that point, the row survives and gets retried. But because the key hasn’t changed, the retry is now safe.
The part your own AL code can’t guarantee is the other half of the contract: the external service must enforce uniqueness on idempotencyKey. When it sees the same key twice, it has to return the existing record instead of creating a new one. That’s a requirement on the API you’re calling, not something you can code around locally. If the external side doesn’t support it, you’ll need a genuinely idempotent operation (an upsert by business key, for example) instead.
What about deletes?
Everything so far covers the “create” case, where the risk is a duplicate export, shipment, or payment. Deletion introduces a second angle worth calling out separately, because there are actually two different things that can be deleted, and they behave differently.
Deleting the resource on the external system. A DELETE call is usually idempotent by nature: removing something that’s already gone leaves the system in the same end state as removing it the first time. The catch is in how your AL code interprets the response on a retry. The external system will typically answer the first call with 200/204, and a repeated call (after the resource is already gone) with 404. Treating that 404 as a failure defeats the whole purpose:
Client.Delete('https://example.local/exports/' + Format(QueuedExport.SystemId), Response);if Response.HttpStatusCode() in [200, 204] then QueuedExport.Delete() // deleted successfullyelse if Response.HttpStatusCode() = 404 then QueuedExport.Delete() // already deleted on a previous run — still a successelse Error('Delete failed with HTTP status %1.', Response.HttpStatusCode());
A 404 on retry isn’t an error here, but it’s confirmation that the desired end state (the resource no longer exists) was already reached. Erroring out on it would just put the row back into an endless retry loop.
Deleting the local outbox row is the other half, and it’s the reason the ordering in the fixed example above is not arbitrary. QueuedExport.Delete() only happens after the external call has succeeded, never before. If you deleted the outbox row first and the external call afterward failed, an AL rollback would restore the row, but you’d have lost the record of whether the external call had actually been attempted. Deleting the local row last means that any failure leaves the row in place with its SystemId intact, so the retry has the same idempotency key it would have had on the first attempt.
Put together: for the call going out, make a repeat-safe response code (like 404 on delete) count as success; for the row staying in BC, only remove it once the external side has confirmed the effect actually took place.
The takeaway:
Don’t assume a rolled-back AL transaction means “nothing happened.” For anything that crosses into an external system, the database rollback and the real-world effect are two separate things, and the job queue’s retry behavior guarantees that gap gets exercised sooner or later. Attach a stable ID (SystemId or other key) is usually right there waiting to be used (before you make the call, not after the first duplicate shows up in production).
