# FIX Session Layer in C#: Logon, Heartbeats, Sequence Numbers and Resend

In [part 1](https://dev.to/sergiy_lutsak_hft/fix-44-in-c-from-scratch-build-checksum-and-parse-orders-without-a-library-10pg) we built a FIX 4.4 order by hand, framed a TCP stream and parsed an ExecutionReport. That is enough to understand a log file. It is not enough to stay connected: send that order to a real broker without a session around it and you get silence, then a dropped socket.

The session layer is everything that is not about trading: who we are, are you still there, which message number are we on, and what did I miss while I was away. It is about 200 lines of code and most of the production incidents.

Same disclosure as before: I build [HFT Forex Copier](https://hftforexcopier.com/) and other trading infrastructure at HFT Software, where a FIX session that recovers cleanly at 3 a.m. matters more than one that is a microsecond faster.

The code below reuses `FixWriter`, `FixReader` and `FixField` from part 1. As before it is plain C# 7 with no packages, so it runs on .NET Framework 4.8 and on current .NET.

## What the session layer is responsible for

Seven administrative message types do all the work:

| MsgType | Name | Job |
| --- | --- | --- |
| `A` | Logon | Authenticate, agree the heartbeat interval, optionally reset sequence numbers |
| `0` | Heartbeat | "I am alive", sent when nothing else was sent for one interval |
| `1` | TestRequest | "Are you alive?", the other side must answer with a Heartbeat echoing `112` |
| `2` | ResendRequest | "I missed messages from number N, send them again" |
| `4` | SequenceReset | "Skip ahead to number N", used to answer a ResendRequest without replaying |
| `3` | Reject | "Your message broke session rules" |
| `5` | Logout | Orderly goodbye |

Underneath all of it sits one rule: every message in each direction carries `34=MsgSeqNum`, the numbers increase by exactly one, and both sides remember where they are, across disconnects. The authoritative text is the [FIX Session Layer specification](https://www.fixtrading.org/standards/fix-session-layer-online/).

## Sequence numbers must survive a restart

If your process restarts and starts again from 1 while the broker expects 5,812, the broker will refuse the logon. So the two counters live on disk, not in a field:

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;

public interface ISeqStore
{
    int NextOut { get; set; }   // sequence number of the next message we send
    int NextIn { get; set; }    // sequence number we expect to receive next
}

public sealed class FileSeqStore : ISeqStore
{
    readonly string _path;
    int _out = 1, _in = 1;

    public FileSeqStore(string path)
    {
        _path = path;
        if (File.Exists(path))
        {
            string[] p = File.ReadAllText(path).Split(':');
            _out = int.Parse(p[0], CultureInfo.InvariantCulture);
            _in = int.Parse(p[1], CultureInfo.InvariantCulture);
        }
    }

    public int NextOut { get { return _out; } set { _out = value; Save(); } }
    public int NextIn { get { return _in; } set { _in = value; Save(); } }

    void Save()
    {
        File.WriteAllText(_path, _out.ToString(CultureInfo.InvariantCulture) + ":" +
                                 _in.ToString(CultureInfo.InvariantCulture));
    }
}
```

Rewriting a tiny file on every message is fine for an order session that sends a few messages a second. For a market data session you would batch or memory-map it. Many retail brokers sidestep the problem by asking you to log on with `141=Y` (ResetSeqNumFlag), which restarts both sides at 1 on every connection. That is simpler, and it also means anything you missed while disconnected is gone, so you must reconcile open orders and positions yourself after each logon.

## Outbound side: Logon and a clock you can control

Two design choices make this class testable. The session never touches a socket, it calls a `send` delegate. And it never reads the system clock, it calls an injected `utcNow`. With those two seams you can run a full day of heartbeats in a unit test in a millisecond.

```csharp
public sealed class FixSession
{
    readonly string _sender, _target;
    readonly ISeqStore _seq;
    readonly Action<string> _send;        // writes one complete message to the socket
    readonly Func<DateTime> _utcNow;      // injected clock: the session never calls DateTime.UtcNow
    int _heartBtInt = 30;
    DateTime _lastSent, _lastReceived;
    bool _testRequestPending, _resendPending;

    public bool LoggedOn { get; private set; }
    public event Action<byte[], int, int> ApplicationMessage;   // fills, rejects, market data
    public event Action<string> Disconnected;

    public FixSession(string sender, string target, ISeqStore seq,
                      Action<string> send, Func<DateTime> utcNow)
    {
        _sender = sender; _target = target; _seq = seq; _send = send; _utcNow = utcNow;
        _lastSent = _lastReceived = utcNow();
    }

    public void Logon(string username, string password, int heartBtInt, bool resetSeqNum,
                      params KeyValuePair<int, string>[] dialectHeader)
    {
        _heartBtInt = heartBtInt;
        if (resetSeqNum) { _seq.NextOut = 1; _seq.NextIn = 1; }
        var f = new List<KeyValuePair<int, string>>(dialectHeader);
        f.Add(F(98, "0"));                                                // EncryptMethod: none
        f.Add(F(108, heartBtInt.ToString(CultureInfo.InvariantCulture))); // HeartBtInt, seconds
        if (resetSeqNum) f.Add(F(141, "Y"));                              // ResetSeqNumFlag
        f.Add(F(553, username));
        f.Add(F(554, password));
        Send("A", f);
    }

    public void SendApplication(string msgType, List<KeyValuePair<int, string>> fields)
    {
        if (!LoggedOn) throw new InvalidOperationException("Not logged on");
        Send(msgType, fields);
    }

    void Send(string msgType, List<KeyValuePair<int, string>> fields)
    {
        int seq = _seq.NextOut;
        _seq.NextOut = seq + 1;                 // persist BEFORE the bytes leave
        _lastSent = _utcNow();
        _send(FixWriter.Build(msgType, _sender, _target, seq, _lastSent, fields));
    }
```

The order inside `Send` is deliberate. If the process dies after the counter is saved but before the bytes leave, the next message arrives with a number one higher than the broker expects. The broker asks for a resend, we answer with a gap fill, and life goes on. The opposite order can reuse a number the broker has already seen, and a sequence number that is too low is a fatal error.

The `dialectHeader` parameter exists because brokers differ. The cTrader FIX API, for example, runs separate price and trade sessions and tells them apart by `57=TRADE` or `57=QUOTE` in the header, as its [specification](https://help.ctrader.com/fix/specification/) describes. That is the kind of detail a product like our [MT4 to cTrader copier](https://hftforexcopier.com/mt4-to-ctrader-copier/) has to get right per venue, and it is why the first document to read is always the broker's own rules of engagement.

## Heartbeats and the dead line problem

TCP will not tell you promptly that the other side has vanished. A pulled cable or a frozen gateway can leave a socket looking healthy for minutes. FIX solves it at the application level:

```csharp
    // call once a second
    public void OnTimer()
    {
        if (!LoggedOn) return;
        DateTime now = _utcNow();
        double quiet = (now - _lastReceived).TotalSeconds;

        if (_testRequestPending && quiet >= 2 * _heartBtInt)
        {
            Drop("No reply to TestRequest");
            return;
        }
        if (!_testRequestPending && quiet >= 1.2 * _heartBtInt)
        {
            _testRequestPending = true;
            Send("1", Fields(F(112, "TEST-" + now.ToString("HHmmss", CultureInfo.InvariantCulture))));
            return;
        }
        if ((now - _lastSent).TotalSeconds >= _heartBtInt)
            Send("0", Fields());
    }
```

Three rules. If we sent nothing for one interval, send a Heartbeat. If we heard nothing for a bit more than one interval, send a TestRequest. If that also goes unanswered, declare the line dead and reconnect. The 1.2 and 2 multipliers are my choice; the specification only asks for the interval plus a reasonable allowance for transmission time.

With a 30 second interval, worst case detection takes about a minute. If a minute of not knowing whether your orders are live is too long, negotiate a shorter `108`.

## Inbound side: the sequence check comes first

Every inbound message goes through the same gate before its type matters:

```csharp
    // one framed, checksum-verified message (see part 1)
    public void OnMessage(byte[] buf, int start, int length)
    {
        string msgType = null, testReqId = null;
        int seqNum = 0, beginSeqNo = 0, newSeqNo = 0;
        bool possDup = false, gapFill = false;

        int pos = start, end = start + length;
        FixField f;
        while (FixReader.TryRead(buf, ref pos, end, out f))
        {
            switch (f.Tag)
            {
                case 35: msgType = Encoding.ASCII.GetString(buf, f.Offset, f.Length); break;
                case 34: seqNum = ToInt(buf, f); break;
                case 43: possDup = buf[f.Offset] == (byte)'Y'; break;
                case 112: testReqId = Encoding.ASCII.GetString(buf, f.Offset, f.Length); break;
                case 7: beginSeqNo = ToInt(buf, f); break;
                case 36: newSeqNo = ToInt(buf, f); break;
                case 123: gapFill = buf[f.Offset] == (byte)'Y'; break;
            }
        }

        _lastReceived = _utcNow();
        _testRequestPending = false;

        // SequenceReset in Reset mode ignores MsgSeqNum by definition
        if (msgType == "4" && !gapFill) { _seq.NextIn = newSeqNo; return; }

        int expected = _seq.NextIn;
        if (seqNum > expected)
        {
            // We missed something. Ask once for everything from the gap onwards (16=0 = "to infinity").
            if (!_resendPending)
            {
                _resendPending = true;
                Send("2", Fields(F(7, expected.ToString(CultureInfo.InvariantCulture)), F(16, "0")));
            }
            return;                               // do not process out-of-order messages
        }
        if (seqNum < expected)
        {
            if (possDup) return;                  // a resend of something we already have
            Drop("MsgSeqNum too low: expected " + expected + ", got " + seqNum);
            return;
        }

        _seq.NextIn = (msgType == "4") ? newSeqNo : expected + 1;
        _resendPending = false;

        switch (msgType)
        {
            case "A": LoggedOn = true; break;
            case "0": break;                                          // Heartbeat
            case "1": Send("0", Fields(F(112, testReqId))); break;    // TestRequest -> Heartbeat
            case "2": AnswerResendRequest(beginSeqNo); break;
            case "4": break;                                          // GapFill, already applied
            case "5": Drop("Logout from counterparty"); break;
            default:
                var handler = ApplicationMessage;
                if (handler != null) handler(buf, start, length);
                break;
        }
    }
```

The three branches are the heart of FIX recovery:

*   **Too high** means we missed messages. We ask for them and, importantly, we do not process the message that revealed the gap. A fill delivered out of order is how position state gets corrupted. It will come again, flagged `43=Y`.
    
*   **Too low without** `43=Y` means the two sides disagree about history. There is no safe automatic fix, so the session drops and a human looks at it.
    
*   **Exactly right** advances the counter, and only then does the message type matter.
    

## Answering a ResendRequest without replaying orders

When the broker asks us to resend, the literal answer is to replay every message in the range. For an order flow that is dangerous: a NewOrderSingle that was relevant forty seconds ago is not an order you want executed now. The common policy is to replay nothing and cover the whole range with a single SequenceReset in gap fill mode:

```csharp
    // Policy: never replay old orders. Cover the whole requested range with one GapFill.
    void AnswerResendRequest(int beginSeqNo)
    {
        DateTime now = _utcNow();
        string ts = now.ToString("yyyyMMdd-HH:mm:ss.fff", CultureInfo.InvariantCulture);
        var f = Fields(F(43, "Y"),                       // PossDupFlag
                       F(122, ts),                       // OrigSendingTime, required with 43=Y
                       F(123, "Y"),                      // GapFillFlag
                       F(36, _seq.NextOut.ToString(CultureInfo.InvariantCulture)));  // NewSeqNo
        _lastSent = now;
        // A GapFill carries the sequence number of the first message it replaces.
        _send(FixWriter.Build("4", _sender, _target, beginSeqNo, now, f));
    }

    void Drop(string reason)
    {
        LoggedOn = false;
        var handler = Disconnected;
        if (handler != null) handler(reason);
    }

    static int ToInt(byte[] buf, FixField f)
    {
        int v = 0;
        for (int i = f.Offset; i < f.Offset + f.Length; i++) v = v * 10 + (buf[i] - '0');
        return v;
    }

    static KeyValuePair<int, string> F(int tag, string value)
    {
        return new KeyValuePair<int, string>(tag, value);
    }

    static List<KeyValuePair<int, string>> Fields(params KeyValuePair<int, string>[] items)
    {
        return new List<KeyValuePair<int, string>>(items);
    }
}
```

Two details are easy to get wrong. The gap fill does not consume a new sequence number: it is sent with the number of the first message it replaces. And it must carry `43=Y` together with `122=OrigSendingTime`, or a strict counterparty will reject it.

After a gap fill you still owe yourself a reconciliation: ask the broker for order status and compare it with what you believe is open.

## Running a whole day in a millisecond

Here is the payoff of injecting the clock and the transport. This scenario plays the broker, moves time forward by hand and prints both directions:

```csharp
public static class Scenario
{
    static DateTime _clock = new DateTime(2026, 9, 23, 8, 0, 0, DateTimeKind.Utc);
    static int _brokerSeq = 1;

    static KeyValuePair<int, string> F(int tag, string value) { return new KeyValuePair<int, string>(tag, value); }

    // Builds a message "from the broker" and feeds it to the session, as the socket reader would.
    static void FromBroker(FixSession s, string msgType, int seq, params KeyValuePair<int, string>[] fields)
    {
        string raw = FixWriter.Build(msgType, "BROKER", "CLIENT1", seq, _clock, fields);
        Console.WriteLine("  <- " + raw.Replace('\x01', '|'));
        byte[] bytes = Encoding.ASCII.GetBytes(raw);
        s.OnMessage(bytes, 0, bytes.Length);
    }

    static void Tick(FixSession s, int seconds)
    {
        for (int i = 0; i < seconds; i++) { _clock = _clock.AddSeconds(1); s.OnTimer(); }
    }

    public static void Main()
    {
        string path = Path.Combine(Path.GetTempPath(), "client1-broker.seq");
        if (File.Exists(path)) File.Delete(path);

        var session = new FixSession("CLIENT1", "BROKER", new FileSeqStore(path),
            raw => Console.WriteLine("  -> " + raw.Replace('\x01', '|')), () => _clock);
        session.ApplicationMessage += (b, o, n) => Console.WriteLine("  ** application message delivered, " + n + " bytes");
        session.Disconnected += reason => Console.WriteLine("  !! disconnected: " + reason);

        Console.WriteLine("1. Logon");
        session.Logon("12345", "secret", 30, true, F(57, "TRADE"), F(50, "COPIER"));
        FromBroker(session, "A", _brokerSeq++, F(98, "0"), F(108, "30"), F(141, "Y"));

        Console.WriteLine("2. Thirty quiet seconds");
        Tick(session, 30);

        Console.WriteLine("3. Broker sends TestRequest");
        FromBroker(session, "1", _brokerSeq++, F(112, "PING-7"));

        Console.WriteLine("4. A fill arrives with MsgSeqNum 5 while we expect 3");
        FromBroker(session, "8", 5, F(11, "ORD-0001"), F(150, "F"), F(39, "2"));
        Console.WriteLine("5. Broker gap-fills 3..4, then resends the fill");
        FromBroker(session, "4", 3, F(43, "Y"), F(122, "20260923-08:00:30.000"), F(123, "Y"), F(36, "5"));
        FromBroker(session, "8", 5, F(43, "Y"), F(122, "20260923-08:00:30.000"), F(11, "ORD-0001"), F(150, "F"), F(39, "2"));

        Console.WriteLine("6. Broker asks us to resend from 2");
        FromBroker(session, "2", 6, F(7, "2"), F(16, "0"));

        Console.WriteLine("7. The line goes silent");
        Tick(session, 36);
        Tick(session, 24);

        Console.WriteLine("8. Restart: sequence numbers survive");
        var store = new FileSeqStore(path);
        Console.WriteLine("  next out = " + store.NextOut + ", next in = " + store.NextIn);
    }
}
```

And the output, exactly as the program prints it:

```text
1. Logon
  -> 8=FIX.4.4|9=114|35=A|49=CLIENT1|56=BROKER|34=1|52=20260923-08:00:00.000|57=TRADE|50=COPIER|98=0|108=30|141=Y|553=12345|554=secret|10=185|
  <- 8=FIX.4.4|9=74|35=A|49=BROKER|56=CLIENT1|34=1|52=20260923-08:00:00.000|98=0|108=30|141=Y|10=211|
2. Thirty quiet seconds
  -> 8=FIX.4.4|9=56|35=0|49=CLIENT1|56=BROKER|34=2|52=20260923-08:00:30.000|10=128|
3. Broker sends TestRequest
  <- 8=FIX.4.4|9=67|35=1|49=BROKER|56=CLIENT1|34=2|52=20260923-08:00:30.000|112=PING-7|10=231|
  -> 8=FIX.4.4|9=67|35=0|49=CLIENT1|56=BROKER|34=3|52=20260923-08:00:30.000|112=PING-7|10=231|
4. A fill arrives with MsgSeqNum 5 while we expect 3
  <- 8=FIX.4.4|9=79|35=8|49=BROKER|56=CLIENT1|34=5|52=20260923-08:00:30.000|11=ORD-0001|150=F|39=2|10=249|
  -> 8=FIX.4.4|9=65|35=2|49=CLIENT1|56=BROKER|34=4|52=20260923-08:00:30.000|7=3|16=0|10=001|
5. Broker gap-fills 3..4, then resends the fill
  <- 8=FIX.4.4|9=98|35=4|49=BROKER|56=CLIENT1|34=3|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|123=Y|36=5|10=135|
  <- 8=FIX.4.4|9=110|35=8|49=BROKER|56=CLIENT1|34=5|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|11=ORD-0001|150=F|39=2|10=014|
  ** application message delivered, 133 bytes
6. Broker asks us to resend from 2
  <- 8=FIX.4.4|9=65|35=2|49=BROKER|56=CLIENT1|34=6|52=20260923-08:00:30.000|7=2|16=0|10=002|
  -> 8=FIX.4.4|9=98|35=4|49=CLIENT1|56=BROKER|34=2|52=20260923-08:00:30.000|43=Y|122=20260923-08:00:30.000|123=Y|36=5|10=134|
7. The line goes silent
  -> 8=FIX.4.4|9=56|35=0|49=CLIENT1|56=BROKER|34=5|52=20260923-08:01:00.000|10=129|
  -> 8=FIX.4.4|9=72|35=1|49=CLIENT1|56=BROKER|34=6|52=20260923-08:01:06.000|112=TEST-080106|10=245|
  !! disconnected: No reply to TestRequest
8. Restart: sequence numbers survive
  next out = 7, next in = 7
```

Read step 4 twice. The fill with number 5 is received and deliberately not delivered. Only after the broker fills the gap in step 5 does the application see it, once, in order. Step 6 shows our gap fill going out with `34=2`, the first number the broker asked for, and `36=5`, where we really are.

## What this sketch leaves out

It is a teaching session, not a product. A production one also needs a logon timeout, Logout with a reason and a wait for the reply, Reject (`35=3`) handling, validation of `49`, `56` and `52` on inbound messages, a reconnect policy with backoff, and thread safety between the socket reader, the timer and the order sender. [QuickFIX/n](https://quickfixn.org/) does all of that, and after writing the 200 lines above you will know exactly what its log is telling you.

If you would rather watch a FIX session than write one, the same lifecycle is visible from the user side in a FIX client: FIX API Terminal has a plain-language walkthrough of [how FIX API trading works](https://fixapiterminal.com/en/fix-api-trading-guide/). For a non-developer colleague, [What is FIX API](https://hftforexcopier.com/what-is-fix-api/) covers the why. And if the question is whether the extra work over a MetaTrader bridge pays off for latency-sensitive strategies, I compared the two routes in [FIX API vs MT4 bridge](https://hftarbitrageplatform.com/en/fix-api-vs-mt4-bridge/).

## Checklist

*   Persist both sequence numbers, and save before you send.
    
*   Never process a message that arrives with a number higher than expected.
    
*   Treat a number lower than expected without `43=Y` as fatal.
    
*   Answer a ResendRequest with a GapFill unless you have a reason to replay, and never replay orders.
    
*   Send the GapFill with the first replaced number, `43=Y` and `122`.
    
*   Detect dead lines with TestRequest, do not trust TCP to tell you.
    
*   Reconcile orders and positions after every logon and after every gap.
    
*   Inject the clock and the transport so all of this can be tested without a broker.
    

* * *

*I am Sergiy Lutsak (I also publish as Sergey Luts). I have been building high-frequency trading systems since 2000. More about my work:* [*author page*](https://hftforexcopier.com/sergey-luts-high-frequency-trading-systems-developer/)*.*

*This article is about software engineering. It is not investment advice, and trading leveraged products carries a high risk of loss.*
