diff --git a/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs
new file mode 100644
index 00000000..bd19bd9c
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using System.Text.Json;
+
+namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
+
+///
+/// over a real : POSTs the recipe payload to
+/// {baseUrl}/api/DelmiaRecipeDownload with the X-API-Key header. A reachable server
+/// (any status) is ; a connection/timeout failure is .
+///
+internal sealed class HttpRecipeSender(HttpClient http, string apiKey) : IRecipeSender
+{
+ private const string MethodPath = "/api/DelmiaRecipeDownload";
+
+ public async Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
+ {
+ var url = baseUrl.TrimEnd('/') + MethodPath;
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.TryAddWithoutValidation("X-API-Key", apiKey);
+ var json = JsonSerializer.Serialize(payload, NotifierJsonContext.Default.RecipeDownload);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ using var response = await http.SendAsync(request, ct);
+ var status = (int)response.StatusCode;
+ RecipeDownloadResult? body = null;
+ if (response.IsSuccessStatusCode)
+ {
+ var responseText = await response.Content.ReadAsStringAsync(ct);
+ body = TryParse(responseText);
+ }
+
+ return new AttemptOutcome(AttemptKind.Connected, status, body, null);
+ }
+ catch (HttpRequestException ex)
+ {
+ return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, ex.Message);
+ }
+ catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
+ {
+ // No external cancellation requested → this is an HttpClient.Timeout, i.e. the node never answered.
+ return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "timeout: " + ex.Message);
+ }
+ }
+
+ private static RecipeDownloadResult? TryParse(string responseText)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(responseText, NotifierJsonContext.Default.RecipeDownloadResult);
+ }
+ catch (JsonException)
+ {
+ return null; // unparseable body on a 2xx → treated as failure by the loop
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs
new file mode 100644
index 00000000..dbda04df
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs
@@ -0,0 +1,62 @@
+using System.Net;
+using System.Text;
+using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
+
+namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
+
+public class HttpRecipeSenderTests
+{
+ private sealed class StubHandler(Func responder) : HttpMessageHandler
+ {
+ public HttpRequestMessage? LastRequest { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ return Task.FromResult(responder(request));
+ }
+ }
+
+ private static readonly RecipeDownload Payload =
+ new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" };
+
+ [Fact]
+ public async Task ConnectFailure_maps_to_ConnectFailed()
+ {
+ var handler = new StubHandler(_ => throw new HttpRequestException("refused"));
+ using var http = new HttpClient(handler);
+ var sender = new HttpRecipeSender(http, "sbk_x");
+ var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
+ Assert.Equal(AttemptKind.ConnectFailed, outcome.Kind);
+ Assert.Equal(0, outcome.StatusCode);
+ }
+
+ [Fact]
+ public async Task Success_200_parses_body_and_sends_header_and_path()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"Result\":true,\"ResultText\":\"\"}", Encoding.UTF8, "application/json"),
+ });
+ using var http = new HttpClient(handler);
+ var sender = new HttpRecipeSender(http, "sbk_x");
+ var outcome = await sender.SendAsync("http://a/", Payload, CancellationToken.None);
+ Assert.Equal(AttemptKind.Connected, outcome.Kind);
+ Assert.Equal(200, outcome.StatusCode);
+ Assert.NotNull(outcome.Body);
+ Assert.True(outcome.Body!.Result);
+ Assert.Equal("sbk_x", handler.LastRequest!.Headers.GetValues("X-API-Key").Single());
+ Assert.Equal("http://a/api/DelmiaRecipeDownload", handler.LastRequest.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task Non2xx_is_Connected_with_status()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ using var http = new HttpClient(handler);
+ var sender = new HttpRecipeSender(http, "sbk_x");
+ var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
+ Assert.Equal(AttemptKind.Connected, outcome.Kind);
+ Assert.Equal(500, outcome.StatusCode);
+ }
+}